在C++算法竞赛中,处理区间问题(比如求区间和、区间最值、区间修改)时,有两个结构总是如影随形: 树状数组(Binary Indexed Tree, BIT) 和 线段树(Segment Tree) 。初学者往往陷入纠结:这俩到底有什么区别?我什么时候用树状数组,什么时候用线段树?为什么有人说树状数组能做的线段树都能做,反之则不然?
本文将带你从原理、代码实现、功能边界、常数性能等多个维度,彻底理清这对“孪生兄弟”。全文约2000字,配有C++代码片段,适合已经学过二者但总是混淆的读者。
一、从名字说起:它们长什么样?
树状数组:紧凑的二进制魔法
树状数组利用整数的二进制分解思想,用一个数组 BIT[] 来维护前缀信息。它的核心操作是 lowbit(x) = x & -x,通过不断加减 lowbit 实现查询和更新。
线段树:二叉树的全能战士
线段树把区间递归地分成左右两半,每个节点存储对应区间的聚合信息(和、最大值、最小值等)。它是一棵平衡二叉树,通常用数组存储(类似堆)。
一句话总结 :树状数组是“专用工具”,线段树是“瑞士军刀”。
二、核心操作对比:代码见真章
我们以最常见的“区间求和 + 单点修改”为例,对比两者的代码。
树状数组实现
int BIT[ N] , n;
void add ( int i, int delta) {
while ( i <= n) {
BIT[ i] += delta;
i += i & - i;
}
}
int sum ( int i) {
int s = 0 ;
while ( i > 0 ) {
s += BIT[ i] ;
i -= i & - i;
}
return s;
}
int range_sum ( int l, int r) {
return sum ( r) - sum ( l - 1 ) ;
}
简洁至极。建树只需对原数组每个位置调用 add(i, a[i]),O(n log n)。
线段树实现(不含懒标记)
int tree[ 4 * N] , a[ N] ;
void build ( int p, int l, int r) {
if ( l == r) { tree[ p] = a[ l] ; return ; }
int mid = ( l + r) / 2 ;
build ( p* 2 , l, mid) ;
build ( p* 2 + 1 , mid+ 1 , r) ;
tree[ p] = tree[ p* 2 ] + tree[ p* 2 + 1 ] ;
}
void update ( int p, int l, int r, int pos, int val) {
if ( l == r) { tree[ p] = val; return ; }
int mid = ( l + r) / 2 ;
if ( pos <= mid) update ( p* 2 , l, mid, pos, val) ;
else update ( p* 2 + 1 , mid+ 1 , r, pos, val) ;
tree[ p] = tree[ p* 2 ] + tree[ p* 2 + 1 ] ;
}
int query ( int p, int l, int r, int ql, int qr) {
if ( ql <= l && r <= qr) return tree[ p] ;
int mid = ( l + r) / 2 , res = 0 ;
if ( ql <= mid) res += query ( p* 2 , l, mid, ql, qr) ;
if ( qr > mid) res += query ( p* 2 + 1 , mid+ 1 , r, ql, qr) ;
return res;
}
明显更冗长。但它的优势在区间修改时会体现出来。
三、功能边界:树状数组不能做什么?
很多同学听说“线段树能做的树状数组都能做”,这是不准确的。准确的说法是: 对于可减的运算(如加法、异或),树状数组可以通过差分技巧实现区间加、区间查;对于不可减的运算(如最大值、最小值),树状数组很难处理区间查询 。具体来说:
结论 :树状数组本质上是前缀和思想的扩展,适合处理 前缀可合并且可逆 (即存在逆运算,比如加法的逆是减法)的信息。而线段树只需要结合律(可合并),不要求可逆,因此适用范围广得多。
四、性能与常数:写代码时的实际感受
时间常数
在 n = 2e5 且操作次数 2e5 的极限数据下,树状数组能轻松跑进 0.1 秒,而线段树可能需要 0.3~0.5 秒。常数敏感型题目(如卡常的 OI 题)中,树状数组是首选。
空间消耗
编码复杂度
五、实战选型指南:我到底该用哪个?
根据题目需求,按以下流程决策:
是否只需要前缀查询(或单点查询)和单点更新? → 树状数组足够,没必要上线段树。
是否涉及区间修改和区间查询,且运算为加法(或其它可减运算)? → 可以用“树状数组维护差分”的扩展(两个BIT),代码仍比线段树简洁。推荐学一下这个技巧。
查询运算不可减(如最大值、最小值),或有区间赋值操作? → 直接线段树,树状数组无能为力。
需要同时维护多个信息(如区间和、区间最大子段和)? → 线段树。
对常数时间要求极高,且功能树状数组刚好满足? → 树状数组。
题目只是“区间加+区间求和”模板题? → 两者皆可,但树状数组更短,推荐树状数组;如果你想练习线段树,也可以。
一个经典误区
Q: “我用线段树做区间求和,过了,但换树状数组是不是更快?” A: 是的,但前提是你需要重新写差分树状数组。而如果你已经写了线段树,且不卡常,没必要重构。
六、进阶:树状数组的“超纲”用法
很多选手不知道,树状数组其实能做一些“看起来很线段树”的事情:
1. 区间加 + 区间求和(无需线段树)
维护两个BIT: B1 和 B2。区间加 [l, r] 时:
add(B1, l, delta); add(B1, r+1, -delta);
add(B2, l, delta*(l-1)); add(B2, r+1, -delta*r);
前缀查询 sum(i) = sum(B1, i)*i - sum(B2, i)。区间和再用前缀和相减。代码量仍然远小于线段树。
2. 权值树状数组求第k小
在值域上建立树状数组(值域离散化),每个位置存储该值出现的次数。求第k小时,在BIT上二分(倍增法),复杂度 O(log V)。这是动态求第k小的简洁解法。
3. 二维树状数组
扩展成二维,支持子矩阵和、单点更新。二维线段树代码极其复杂,而二维BIT依然简洁。
七、线段树的“不可替代”场景
尽管树状数组能“曲线救国”实现部分区间操作,但有些场合线段树是唯一选择:
区间取模、区间开方 :这类操作无法通过差分简单实现,需要线段树的“暴力剪枝”思想(每个数开方几次后会变成1,记录最大值)。
区间赋值 + 各种查询 :懒标记必须支持覆盖操作,树状数组无法做到。
维护复杂信息 :比如区间最大连续子段和,需要节点存储前缀最大、后缀最大、总和、答案四个值,pushUp需要自定义合并逻辑——这正是线段树的拿手好戏。
八、总结:两张表帮你记住区别
一句话建议 :
如果你的需求是“单点改、区间查”或“区间加、单点查”,无脑树状数组。
如果涉及到“区间最值”、“区间赋值”或复杂合并信息,别挣扎,上线段树。
如果不确定,可以先写出树状数组,发现功能不够再换成线段树——毕竟重构代码也是学习过程。
希望这篇文章能让你彻底分清这对“孪生兄弟”。下次遇到区间问题,不要再凭感觉盲目选择,而是要理性分析: 我的运算可逆吗?我需要懒标记吗?卡常数吗? 回答完这三个问题,答案自然浮出水面。