树状数组全解:动态前缀和、第k小与逆序对实战指南
在做排名统计、在线排行榜或者比赛计分系统时,经常会遇到这样一个需求:数据在不断变化,我们却要反复询问“当前前 k 大的数是什么”“当前有多少对逆序关系”这类问题。初学者通常第一反应是维护一个数组,每次修改后重新排序,或者两层循环去统计。数据量小的时候没问题,一旦数据量来到十万、百万级别,O(n²) 的复杂度会立刻让人崩溃。
树状数组(Binary Indexed Tree,简称 BIT)就是解决这类“动态前缀统计”问题的经典数据结构。它代码量极短、常数极小,却能以 O(log n) 的复杂度完成单点修改和前缀查询,还能配合“树状数组上二分”实现动态第 k 小,以及通过离散化高效求解逆序对问题。很多刚接触算法的读者只看教程时觉得明白,但一到自己写题就卡住,主要原因是没有把“树状数组到底存了什么”这件事彻底搞清楚。
这篇文章会围绕三个核心问题展开:树状数组的原理和基础模板是什么;如何在树状数组上二分求动态第 k 小;如何用树状数组实现逆序对计数,并扩展到更进阶的带权逆序对场景。同时会给出完整可运行的代码、验证方式、常见错误和工程实践建议,帮助你把这套数据结构真正变成自己的工具。
1. 这篇文章真正要解决的问题
在算法竞赛和日常开发中,有一类问题非常普遍:我们有一个可变的数组,需要频繁执行两类操作——修改某个位置的值,以及查询某个前缀区间的和。最朴素的做法是直接维护原始数组,修改是 O(1) 的,但查询前缀和需要遍历,是 O(n) 的。另一类做法是维护前缀和数组,查询是 O(1) 的,但每次修改都要重建整个前缀和数组,又变成 O(n) 了。
如果操作次数是 m,数组长度是 n,前缀和数组的 O(n) 修改成本和朴素数组的 O(n) 查询成本,整体时间复杂度就是 O(n*m)。当 n 和 m 都接近 10⁵ 甚至 10⁶ 时,显然无法接受。
树状数组的意义在于,它把单点修改和前缀查询都做到了 O(log n)。它不是最快的,线段树也能做到同样的复杂度,但树状数组的代码量只有线段树的五分之一左右,常数也小得多,在大多数只需要“单点修改 + 前缀查询”的场景下,树状数组都是更优先的选择。
具体能解决什么问题呢?最典型的三类场景:
- 单点加值、区间求和:比如股票每日价格变动,求某段时间内的总变动。
- 动态第 k 小:权值树状数组上二分,比如实时排行榜中的第 k 名。
- 逆序对计数:统计一个数组中逆序对的数量,这是归并排序之外最常用的解法。
这篇文章面向的读者是:已经掌握基础数组、循环和二分查找,但还没系统学过树状数组,或者学过代码但理解不深的算法学习者;以及工作中需要处理动态排名、频次统计、逆序对相关任务的工程师。读完这篇文章,你会彻底弄懂树状数组的二进制原理,能直接套用模板写出自己的代码,并且知道在什么场景下选择树状数组而不是线段树。
2. 树状数组核心原理:从 lowbit 开始
树状数组的实现非常精巧,核心概念只有一个:lowbit。
lowbit(x) 表示 x 的二进制表示中,最低位的 1 所对应的数值。比如 x = 6,二进制是 110,最低位的 1 在第二位,对应数值是 2,所以 lowbit(6) = 2。计算方式为:
为什么 x & (-x) 能取到最低位的 1?因为负数在计算机中以补码形式存储,-x 等于 ~x + 1。x 与 -x 进行按位与运算后,高位全部变为 0,只有最低位的那个 1 会被保留下来。
树状数组在逻辑上维护一个数组 a[1..n],但它真正存储的是另一个数组 c[1..n]。c[i] 的含义是:从 a[i] 开始,向前数 lowbit(i) 个元素,这些元素的和。换句话说:
举个例子:如果 i = 8,lowbit(8) = 8,那么 c[8] 就是从 a[1] 到 a[8] 的和。如果 i = 6,lowbit(6) = 2,那么 c[6] = a[5] + a[6]。
这种设计带来一个非常好的性质:查询前缀和时,我们可以从下标 i 开始,不断把 i 减去 lowbit(i),累加对应的 c[i],直到 i 变为 0。修改某个位置的值时,从下标 i 开始,不断把 i 加上 lowbit(i),更新对应位置 c[i],直到超过数组长度 n。
以查询前 7 个元素的和为例:
- 初始 i = 7,加上 c[7](c[7] 包含 a[7])
- i = 7 - lowbit(7) = 7 - 1 = 6,加上 c[6](c[6] 包含 a[5] 和 a[6])
- i = 6 - lowbit(6) = 6 - 2 = 4,加上 c[4](c[4] 包含 a[1] 到 a[4])
- i = 4 - lowbit(4) = 4 - 4 = 0,结束
总共累加了 3 个 c 数组的值,覆盖了 a[1] 到 a[7] 的所有元素。由于二进制分解的性质,查询和修改的操作次数都等于二进制中 1 的个数,最坏情况下是 O(log n)。
树状数组通常被称为 Fenwick 树,由 Peter Fenwick 在 1994 年提出。它和线段树的区别在于:线段树可以处理区间最值、区间求和、区间修改等多种操作,但代码长、常数大;树状数组只适合处理“可以前缀相减”的操作,比如求和、异或、乘法,但代码极短、速度极快,在很多单点修改+区间查询的场景下是首选。
2.1 树状数组能处理什么操作
不是所有操作都能用树状数组。核心限制在于:
- 支持的操作:前缀和、前缀异或、前缀最大值(部分场景有技巧)。
- 不支持的操作:区间取最小值、区间修改后单点查询(需要差分配合才能支持一部分)。
如果问题需要“区间查询最值”或者“区间修改区间查询”,直接去写线段树,不要硬套树状数组。
2.2 树状数组 vs 朴素数组 vs 前缀和数组
| 方案 | 单点修改 | 前缀查询 | 区间查询 | 代码量 |
|---|---|---|---|---|
| 朴素数组 | O(1) | O(n) | O(n) | 极少 |
| 前缀和数组 | O(n) | O(1) | O(1) | 极少 |
| 树状数组 | O(log n) | O(log n) | O(log n) | 短 |
| 线段树 | O(log n) | O(log n) | O(log n) | 长 |
从表中可以看出,树状数组不是任何单一场景下的最优解,但它是所有场景下的均衡解,这让它在处理“查询和修改交替出现”的动态问题时非常合适。
3. 树状数组基础代码实现
这一节给出最基础的树状数组模板。我们以 C++ 为主要语言,同时给出 Python 版本,方便不同技术栈的读者对照。
3.1 C++ 基础模板
核心逻辑分析:
add函数从待修改下标开始,不断加 lowbit,更新所有覆盖该位置的 c 数组项。sum函数从查询下标开始,不断减 lowbit,累加所有组成前缀和的 c 数组项。- 下标从 1 开始,这是树状数组最常见的写法,因为 0 的 lowbit 是死循环。
rangeSum(l, r)用前缀和相减实现,前提是维护的操作满足可减性。
3.2 Python 基础模板
Python 版本逻辑完全一致,适合快速验证算法思路。实际比赛或项目中如果对性能要求高,建议用 PyPy 运行,或者直接使用 C++。
3.3 验证方法
把代码保存对应文件后,运行:
预期输出:
如果输出与预期不符,先检查下标是否从 1 开始,以及 add 和 sum 中 while 循环的条件是否写反。
4. 树状数组上二分:动态第 k 小的实现
很多人在学会树状数组基础操作后,会遇到一个很自然的问题:如果数组在不断变化,我想快速找到“当前所有数字中第 k 小的数是多少”,该怎么做?
一种直观的方法是用权值线段树,线段树的每个叶子节点代表一个值,内部节点维护区间内数字的个数,然后在线段树上二分查找。这个思路完全正确,但代码量较大。树状数组其实也支持类似的操作,这就是“树状数组上二分”。
4.1 原理:二进制逼近
假设我们维护了一个权值树状数组,其中 c[i] 表示某个值域区间内数字出现的次数。要找第 k 小的数,我们可以从高到低枚举二进制位,尝试构造答案。
核心思想是:我们想知道“前缀和最早达到 k 的下标”。令 pos = 0,cur = 0,从 log2(n) 这个最高位开始,每次尝试把 pos 加上 2^bit,如果新的 pos 不超过 n,并且 cur + c[pos] < k,说明答案至少比 pos 大,可以安全地把 pos 移过去,同时累加 cur。
这个做法的正确性依赖于树状数组的性质:c[pos] 存储的是一段长度为 lowbit(pos) 的区间和。当我们在二进制位上逐步逼近时,每次考虑的区间都是当前已经确认的答案区间的下一段。这个过程本质上是在二进制树上进行二分,复杂度为 O(log n)。
4.2 C++ 完整代码
这里的关键点:
maxLog取值为数组长度的二进制位数,一般从20到30足够,取决于值域大小。- 循环中
cur + c[nxt] < k的条件意味着我们找的是“前缀和首次达到 k 的位置”,最终pos + 1就是答案。 - 删除操作等价于
add(pos, -1),前提是该值确实存在于集合中。
4.3 和线段树二分的区别
线段树二分的思路是递归或迭代地在树上走,每层只走一个方向,时间复杂度也是 O(log n)。树状数组上二分的优势在于代码更短、常数更小,但它要求值域已知且在可接受的数组大小范围内。如果值域达到 10⁹ 级别,需要先离散化,或者改用平衡树。
4.4 一个典型应用:动态中位数
假设我们有一个数据流,每次插入一个数字,就要求输出当前所有数字的中位数。用两个树状数组上二分可以做到:维护集合总数 total,第 (total+1)/2 小就是中位数。每次插入后调用一次 kth((total+1)/2),复杂度 O(log n),可以很好地处理在线场景。也可以直接用两个堆来做,但是对“动态删除任意值”的需求,树状数组上二分明显更灵活。
5. 逆序对计数:从朴素到树状数组的进阶
逆序对问题是树状数组最经典的应用之一。给定一个数组 a,如果 i < j 且 a[i] > a[j],则称 (i, j) 是一对逆序对。求逆序对数量,最简单的是两层循环 O(n²),但数据量一旦上来就会超时。
5.1 朴素逆序对做法
复杂度 O(n²),对于 n=10⁵ 完全不可行。
5.2 树状数组求逆序对:离散化 + 权值统计
树状数组求逆序对的核心思路是:从左到右扫描数组,把每个元素“当作值域下标”插入权值树状数组。每插入一个元素 a[i] 之前,查询当前树状数组中比 a[i] 大的元素个数,这个数量就是以 a[i] 为第二个元素构成的逆序对数量。
如果数组元素范围很大,比如 10⁹,不能直接开这么大的数组,所以要先把数组元素离散化。离散化就是把数值映射到 1 到 n 的排名上,但我们需要保留原数值之间的相对大小关系。对逆序对问题而言,映射后的值大小关系和原数组保持一致即可。
运行验证:
预期输出:
代码关键解释:
lower_bound找到元素在有序数组中的排名,重复元素映射到同一个排名,这样相等元素不会错误地构成逆序对。- 每次循环先查询“已插入元素中大于当前元素的个数”,再把自己插入进去。
- 总逆序对数量可能超过 int 范围,所以用
long long存储答案。
5.3 逆序对 v2:进一步扩展的场景
很多文章会在学完基础逆序对之后,把“逆序对 v2”定义为更复杂的变体,通常有几种拓展方向:
第一种是带权逆序对。原始问题中每个元素只算一个,但如果每个元素带一个权重,要求计算所有逆序对的权重之和,或者统计权重大于某个阈值的逆序对数量。此时树状数组不再是单纯计数,而是维护两棵:一棵维护数量,一棵维护权重和,查询时再做组合计算。
第二种是动态逆序对。每次在某个位置插入或删除一个数字,查询当前数组的逆序对总数。这需要离线处理,通常会用到 CDQ 分治或者带修改的树状数组,不过单纯用树状数组维护当前已经插入的所有元素,也可以处理“只插入不删除”的动态过程。
第三种是二维偏序下的逆序对。例如平面上有若干点,每个点有 x 和 y 两个属性,问有多少对点满足 x1 < x2 但 y1 > y2。这时先按 x 排序,再对 y 序列计算逆序对,本质上就转化成了树状数组逆序对问题。
从练习角度,建议把“基础逆序对”理解透,再尝试以下进阶题:
- 求逆序对数量对 10⁹+7 取模的结果。
- 在值域很大且元素可重复时,验证离散化逻辑是否正确。
- 使用树状数组上二分,求“第 k 小的逆序对”这类组合问题。
5.4 一个结合树状数组上二分的进阶例子
考虑这样一个问题:给定一个长度为 n 的排列,要求对每个位置 i,输出前 i 个数中第 k 小的数。这是一个典型的“权值树状数组 + 树上二分”问题。从左到右扫描,每次把 a[i] 插入权值树状数组,然后调用 kth(k) 输出结果。整个过程在线完成,时间复杂度 O(n log n)。
运行结果:
这个例子把树状数组的基础操作、离散化理解、上二分技巧串联了起来。自己动手跑一遍,对比每一步的输出,会很有帮助。
6. 运行结果与效果验证
在动手练习时,建议按下面的顺序验证你的实现是否可靠。
6.1 基础操作验证
- 构建一个 n=8 的树状数组,插入 0 到 7。
- 验证
sum(7)、rangeSum(3,6)。 - 修改某个位置后,验证前缀和是否同步更新。
你可以在代码中加一段随机测试:随机生成数组,对比树状数组区间查询结果和暴力遍历结果,如果某一次不一致,说明实现有误。
6.2 树状数组上二分验证
- 先构造一个已知集合,例如
{1, 3, 3, 5, 7}。 - 分别查询第 1、2、3、4、5 小,和预期结果对比。
- 插入、删除元素后,再重新查询,确认结果正确。
- 特别注意:查询第 k 小时,k 必须在当前集合大小范围内,否则结果不可预期。
6.3 逆序对验证
- 使用完全逆序数组
{n, n-1, ..., 1},逆序对数量应该是 n*(n-1)/2,例如{5,4,3,2,1}对应 10。 - 使用包含重复元素的数组,比如
{3,1,4,1,5,9,2,6},确认重复元素之间不会错误计数。 - 再写一个 O(n²) 的暴力函数做对拍,随机生成上千组小规模数据,验证树状数组版本和暴力版本结果一致。这是算法学习中最有效的验证方法。
6.4 复杂度分析验证
- 对 n=10⁵ 的数据量分别测试朴素 O(n²) 和树状数组 O(n log n) 的耗时。
- 一般 O(n²) 在数据量到 10⁴ 以上时就会明显变慢,而树状数组在 10⁵ 甚至 10⁶ 级别仍然很快。
7. 常见问题与排查思路
7.1 问题排查表
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
add 后查询结果不变 |
下标从 0 开始,导致死循环或越界 | 检查初始化时下标是否为 1 | 统一从 1 开始,或用 pos + 1 |
sum 返回结果小于预期 |
sum 的 while 条件写成 pos >= 0,导致死循环或越界 |
调试打印每次 pos 的值 |
while 条件写成 pos > 0 |
| 树状数组上二分超时 | maxLog 没算准,循环次数过多 |
打印 maxLog 值 |
maxLog = floor(log2(n)) 或 ceil(log2(n)) 内 |
| 逆序对结果偏大 | 没有处理重复元素,把相等元素当作逆序 | 对 {1,1,1} 测试 |
修改 rank 查询方式,greater = sum(m) - sum(rank) 而不是 sum(m) - sum(rank - 1)(要看具体定义) |
| 逆序对结果溢出 int | n 很大时答案超过 2³¹-1 | 输出前转 long long | 使用 long long 存储答案 |
| 离散化后排序顺序错误 | 排序时直接对原数组排序而不是拷贝 | 检查排序对象 | 先 vector<int> sorted = a; 再排序 |
| 内存超大 | 值域很大时直接开数组 | 观察数据范围 | 使用离散化或改用平衡树/有序集合 |
| 删除元素时出现负数 | 删除不存在的值 | 检查逻辑 | 维护一个 map 或 set 确认元素存在后再删 |
7.2 最容易被忽视的坑
树状数组最大的坑是下标从 1 开始。如果你习惯了零基下标,很容易写错。建议在数据读入阶段就直接把下标加 1,或者在树状数组内部使用 pos + 1 作为实际索引。
第二个坑是离散化时重复元素。在逆序对计数中,重复元素不应该被统计为逆序对。用 lower_bound 获取排名时,重复元素会映射到同一个排名,因此查询“大于当前元素”时使用的是 bit.sum(m) - bit.sum(rank),这样等于当前值的元素不会被计入,恰好符合要求。
第三个坑是 lowbit 宏定义。如果写成 #define lowbit(x) (x & -x),在 x 有副作用时可能出错。建议写成内联函数,或者在类内部写成员函数,避免宏展开带来的潜在问题。
7.3 调试建议
树状数组调试最好的方式是小规模对拍。写一个 bruteForce 函数模拟朴素逻辑,再生成随机数据对比输出。如果对拍发现差异,可以在 add 和 sum 中打印每次 pos 的变化,基本上几步就能定位问题。
8. 最佳实践与工程建议
树状数组虽然代码短,但要写出稳定、可维护的实现,还是有一些工程层面的建议值得注意。
8.1 封装为类,避免散落代码
在实际项目中,不要把 add、sum、lowbit 都散落在业务代码里。建议封装成一个类,对外只暴露 add、sum、rangeSum 这几个接口。这样不仅便于复用,也能避免数组越界等问题扩散到业务逻辑中。
8.2 明确数组下标语义
在树状数组中,永远使用 1-based 下标。如果外部数据是 0-based,在传入接口前统一加 1。不要在类内部努力兼容 0-based,逻辑会混乱,也更容易出错。
8.3 根据数据范围选择类型
- 如果 n 和操作次数在 10⁵ 数量级,int 通常够用。
- 如果统计的是逆序对数量,答案可能超过 int,使用
long long。 - 如果维护的是浮点数或大整数,考虑把
vector<int>换成vector<double>或vector<long long>,但要注意浮点数比较时的精度问题。
8.4 理解清楚哪些操作能被前缀差表示
树状数组能快速求区间和,是因为 sum(r) - sum(l-1)。如果是求和、求异或,这种操作没有问题。但如果是求区间最大值、最小值,前缀差是不成立的,需要换线段树或 ST 表。
8.5 树状数组上二分的适用边界
树状数组上二分要求值域可枚举且数组长度可控。如果值域是 1e9,离散化后还可以做,但如果需要动态插入新的值域点,离散化就会失效,这时最好改用平衡树,例如 C++ 的 pbds 或者 Java 的 TreeMap,或者使用离线处理先把所有可能的值收集起来再离散化。
8.6 注意多次查询时的复杂度
树状数组每次操作 O(log n),但如果在一个循环里同时做了大量 sum 调用,总复杂度是 O(m log n)。如果 m 也很大,比如 10⁶,log n 乘下来仍然可能比较吃力。这个时候可以评估一下问题是否真的需要在线查询,如果所有操作都可以离线,有时能用差分或分块优化常数。
8.7 在团队代码中写注释
树状数组的核心难点在于 c[i] 代表哪一段区间,这个语义如果不写注释,别人很难看懂。建议在类注释里写明公式:
这行注释能让接手代码的人少花大量时间。
9. 总结与后续学习方向
树状数组是一个非常经典的“以空间换时间”的数据结构。它用一段额外的数组和二进制位运算,把单点修改和前缀查询的复杂度从 O(n) 降到了 O(log n)。这篇文章从 lowbit 原理讲起,给出了基础模板、树状数组上二分实现动态第 k 小,以及逆序对计数的完整代码和验证方式。
如果你能把树状数组做成肌肉记忆,后面学线段树、字典树、并查集这些数据结构时会轻松很多。建议的练习路径是:
- 先背熟 lowbit、add、sum 三个基础模板,理解每一步在做什么。
- 做 10 道左右的单点修改 + 区间查询入门题,直到不需要想就能写对。
- 然后练习树状数组上二分,常见的题目包括动态中位数、带插入的第 k 小。
- 最后练习逆序对类题目,尝试把二维偏序、带权逆序对等进阶场景逐步吸收。
这篇文章没有覆盖的内容包括:树状数组的区间修改 + 区间查询(需要使用差分技巧)、树状数组与 CDQ 分治的结合、以及二维树状数组。如果基础模板已经熟练,可以继续沿着这些方向深入。特别是 CDQ 分治配合树状数组处理三维偏序问题,是算法竞赛中非常高频的组合思路。
建议收藏这篇文章,以后需要树状数组模板或者排查逆序对问题的时候,可以直接翻出来对照。动手写一遍,比看十遍理解得更深。