树状数组全解:动态前缀和、第k小与逆序对实战指南

树状数组动态前缀和逆序对
于 2026-08-31 03:54:09 修改
·本内容遵循CC 4.0 BY-SA版权协议

在做排名统计、在线排行榜或者比赛计分系统时,经常会遇到这样一个需求:数据在不断变化,我们却要反复询问“当前前 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。计算方式为:

CPP
int lowbit(int x) {
return x & (-x);
}

为什么 x & (-x) 能取到最低位的 1?因为负数在计算机中以补码形式存储,-x 等于 ~x + 1。x 与 -x 进行按位与运算后,高位全部变为 0,只有最低位的那个 1 会被保留下来。

树状数组在逻辑上维护一个数组 a[1..n],但它真正存储的是另一个数组 c[1..n]。c[i] 的含义是:从 a[i] 开始,向前数 lowbit(i) 个元素,这些元素的和。换句话说:

TEXT
c[i] = a[i - lowbit(i) + 1] + a[i - lowbit(i) + 2] + ... + a[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++ 基础模板

CPP
// 文件路径:bit_basic.cpp
// 编译方式:g++ -O2 -std=c++17 bit_basic.cpp -o bit_basic
# include <bits/stdc++.h>
using namespace std;
 
class Fenwick {
private:
int n;
vector<int> c;
 
int lowbit(int x) {
return x & (-x);
}
 
public:
Fenwick(int n) : n(n), c(n + 1, 0) {}
 
// 单点修改:下标 pos 增加 delta,注意下标从 1 开始
void add(int pos, int delta) {
while (pos <= n) {
c[pos] += delta;
pos += lowbit(pos);
}
}
 
// 前缀查询:返回 [1, pos] 的区间和
int sum(int pos) {
int res = 0;
while (pos > 0) {
res += c[pos];
pos -= lowbit(pos);
}
return res;
}
 
// 区间查询:返回 [l, r] 的区间和
int rangeSum(int l, int r) {
return sum(r) - sum(l - 1);
}
};
 
int main() {
int n = 8;
Fenwick bit(n);
 
// 初始化数组 a[1..n] = {0, 1, 2, 3, 4, 5, 6, 7}
for (int i = 1; i <= n; i++) {
bit.add(i, i - 1);
}
 
cout << bit.sum(7) << endl; // 0+1+2+3+4+5+6 = 21
cout << bit.rangeSum(3, 6) << endl; // 2+3+4+5 = 14
 
bit.add(3, 10); // a[3] 从 2 变为 12
cout << bit.sum(7) << endl; // 21 + 10 = 31
 
return 0;
}

核心逻辑分析:

  • add 函数从待修改下标开始,不断加 lowbit,更新所有覆盖该位置的 c 数组项。
  • sum 函数从查询下标开始,不断减 lowbit,累加所有组成前缀和的 c 数组项。
  • 下标从 1 开始,这是树状数组最常见的写法,因为 0 的 lowbit 是死循环。
  • rangeSum(l, r) 用前缀和相减实现,前提是维护的操作满足可减性。

3.2 Python 基础模板

PYTHON
# 文件路径:bit_basic.py
class Fenwick:
def __init__(self, n: int):
self.n = n
self.c = [0] * (n + 1)
 
def lowbit(self, x: int) -> int:
return x & (-x)
 
def add(self, pos: int, delta: int) -> None:
while pos <= self.n:
self.c[pos] += delta
pos += self.lowbit(pos)
 
def sum(self, pos: int) -> int:
res = 0
while pos > 0:
res += self.c[pos]
pos -= self.lowbit(pos)
return res
 
def range_sum(self, l: int, r: int) -> int:
return self.sum(r) - self.sum(l - 1)
 
 
bit = Fenwick(8)
for i in range(1, 9):
bit.add(i, i - 1)
 
print(bit.sum(7)) # 21
print(bit.range_sum(3, 6)) # 14
 
bit.add(3, 10)
print(bit.sum(7)) # 31

Python 版本逻辑完全一致,适合快速验证算法思路。实际比赛或项目中如果对性能要求高,建议用 PyPy 运行,或者直接使用 C++。

3.3 验证方法

把代码保存对应文件后,运行:

BASH
g++ -O2 -std=c++17 bit_basic.cpp -o bit_basic
./bit_basic

预期输出:

TEXT
21
14
31

如果输出与预期不符,先检查下标是否从 1 开始,以及 addsum 中 while 循环的条件是否写反。

4. 树状数组上二分:动态第 k 小的实现

很多人在学会树状数组基础操作后,会遇到一个很自然的问题:如果数组在不断变化,我想快速找到“当前所有数字中第 k 小的数是多少”,该怎么做?

一种直观的方法是用权值线段树,线段树的每个叶子节点代表一个值,内部节点维护区间内数字的个数,然后在线段树上二分查找。这个思路完全正确,但代码量较大。树状数组其实也支持类似的操作,这就是“树状数组上二分”。

4.1 原理:二进制逼近

假设我们维护了一个权值树状数组,其中 c[i] 表示某个值域区间内数字出现的次数。要找第 k 小的数,我们可以从高到低枚举二进制位,尝试构造答案。

核心思想是:我们想知道“前缀和最早达到 k 的下标”。令 pos = 0cur = 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++ 完整代码

CPP
// 文件路径:bit_kth.cpp
// 功能:动态维护一个多重集合,支持插入、删除、查询第 k 小
# include <bits/stdc++.h>
using namespace std;
 
class FenwickKth {
private:
int n;
int maxLog;
vector<int> c;
 
int lowbit(int x) {
return x & (-x);
}
 
public:
FenwickKth(int n) : n(n), c(n + 1, 0) {
maxLog = 0;
int tmp = n;
while (tmp > 0) {
maxLog++;
tmp >>= 1;
}
}
 
void add(int pos, int delta) {
while (pos <= n) {
c[pos] += delta;
pos += lowbit(pos);
}
}
 
// 查询第 k 小的数,k 从 1 开始计数
// 约束:集合中至少有 k 个元素
int kth(int k) {
int pos = 0;
int cur = 0;
for (int bit = maxLog; bit >= 0; bit--) {
int nxt = pos + (1 << bit);
if (nxt <= n && cur + c[nxt] < k) {
pos = nxt;
cur += c[nxt];
}
}
return pos + 1;
}
};
 
int main() {
int MAXV = 100000;
FenwickKth bit(MAXV);
 
// 插入 1, 3, 3, 5, 7
vector<int> nums = {1, 3, 3, 5, 7};
for (int x : nums) {
bit.add(x, 1);
}
 
cout << bit.kth(1) << endl; // 第 1 小:1
cout << bit.kth(2) << endl; // 第 2 小:3
cout << bit.kth(4) << endl; // 第 4 小:5
 
// 插入一个 2,此时集合为 {1, 2, 3, 3, 5, 7}
bit.add(2, 1);
cout << bit.kth(2) << endl; // 第 2 小:2
 
// 删除一个 3,此时集合为 {1, 2, 3, 5, 7}
bit.add(3, -1);
cout << bit.kth(3) << endl; // 第 3 小:3
 
return 0;
}

这里的关键点:

  • maxLog 取值为数组长度的二进制位数,一般从 2030 足够,取决于值域大小。
  • 循环中 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 朴素逆序对做法

CPP
int countInversionsNaive(vector<int>& a) {
int n = a.size();
int res = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (a[i] > a[j]) {
res++;
}
}
}
return res;
}

复杂度 O(n²),对于 n=10⁵ 完全不可行。

5.2 树状数组求逆序对:离散化 + 权值统计

树状数组求逆序对的核心思路是:从左到右扫描数组,把每个元素“当作值域下标”插入权值树状数组。每插入一个元素 a[i] 之前,查询当前树状数组中比 a[i] 大的元素个数,这个数量就是以 a[i] 为第二个元素构成的逆序对数量。

如果数组元素范围很大,比如 10⁹,不能直接开这么大的数组,所以要先把数组元素离散化。离散化就是把数值映射到 1 到 n 的排名上,但我们需要保留原数值之间的相对大小关系。对逆序对问题而言,映射后的值大小关系和原数组保持一致即可。

CPP
// 文件路径:inversion.cpp
// 功能:使用树状数组求逆序对
# include <bits/stdc++.h>
using namespace std;
 
class Fenwick {
private:
int n;
vector<long long> c;
 
int lowbit(int x) {
return x & (-x);
}
 
public:
Fenwick(int n) : n(n), c(n + 1, 0) {}
 
void add(int pos, int delta) {
while (pos <= n) {
c[pos] += delta;
pos += lowbit(pos);
}
}
 
long long sum(int pos) {
long long res = 0;
while (pos > 0) {
res += c[pos];
pos -= lowbit(pos);
}
return res;
}
};
 
long long countInversions(vector<int>& a) {
// 离散化:把 a 压缩到 1..m
vector<int> sorted = a;
sort(sorted.begin(), sorted.end());
sorted.erase(unique(sorted.begin(), sorted.end()), sorted.end());
 
int m = sorted.size();
Fenwick bit(m);
 
long long ans = 0;
for (int i = 0; i < (int)a.size(); i++) {
// 当前元素离散化后的排名,从 1 开始
int rank = lower_bound(sorted.begin(), sorted.end(), a[i]) - sorted.begin() + 1;
 
// 求已经插入的元素中大于 a[i] 的元素个数
// 总数 = bit.sum(m)
// 小于等于 a[i] 的个数 = bit.sum(rank)
long long greater = bit.sum(m) - bit.sum(rank);
 
ans += greater;
bit.add(rank, 1);
}
 
return ans;
}
 
int main() {
vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6};
cout << countInversions(a) << endl; // 预期输出 8
 
vector<int> b = {5, 4, 3, 2, 1};
cout << countInversions(b) << endl; // 最大逆序对:10
 
return 0;
}

运行验证:

BASH
g++ -O2 -std=c++17 inversion.cpp -o inversion
./inversion

预期输出:

TEXT
8
10

代码关键解释:

  • 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)。

CPP
// 文件路径:prefix_kth.cpp
// 功能:对每个前缀查询第 k 小
# include <bits/stdc++.h>
using namespace std;
 
class FenwickKth {
private:
int n;
int maxLog;
vector<int> c;
 
int lowbit(int x) {
return x & (-x);
}
 
public:
FenwickKth(int n) : n(n), c(n + 1, 0) {
maxLog = 0;
int tmp = n;
while (tmp) {
maxLog++;
tmp >>= 1;
}
}
 
void add(int pos, int delta) {
while (pos <= n) {
c[pos] += delta;
pos += lowbit(pos);
}
}
 
int kth(int k) {
int pos = 0;
int cur = 0;
for (int bit = maxLog; bit >= 0; bit--) {
int nxt = pos + (1 << bit);
if (nxt <= n && cur + c[nxt] < k) {
pos = nxt;
cur += c[nxt];
}
}
return pos + 1;
}
};
 
int main() {
int n = 6;
vector<int> a = {3, 1, 4, 1, 5, 9};
FenwickKth bit(n);
 
for (int i = 0; i < n; i++) {
bit.add(a[i], 1);
int k = min(i + 1, 3); // 每个前缀查询第 min(前缀长度, 3) 小
cout << "prefix " << i + 1 << " kth " << k << " = " << bit.kth(k) << endl;
}
 
return 0;
}

运行结果:

TEXT
prefix 1 kth 1 = 3
prefix 2 kth 2 = 3
prefix 3 kth 2 = 3
prefix 4 kth 3 = 4
prefix 5 kth 3 = 4
prefix 6 kth 3 = 3

这个例子把树状数组的基础操作、离散化理解、上二分技巧串联了起来。自己动手跑一遍,对比每一步的输出,会很有帮助。

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 函数模拟朴素逻辑,再生成随机数据对比输出。如果对拍发现差异,可以在 addsum 中打印每次 pos 的变化,基本上几步就能定位问题。

8. 最佳实践与工程建议

树状数组虽然代码短,但要写出稳定、可维护的实现,还是有一些工程层面的建议值得注意。

8.1 封装为类,避免散落代码

在实际项目中,不要把 addsumlowbit 都散落在业务代码里。建议封装成一个类,对外只暴露 addsumrangeSum 这几个接口。这样不仅便于复用,也能避免数组越界等问题扩散到业务逻辑中。

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] 代表哪一段区间,这个语义如果不写注释,别人很难看懂。建议在类注释里写明公式:

CPP
// c[i] 表示 a[i - lowbit(i) + 1] 到 a[i] 的区间和

这行注释能让接手代码的人少花大量时间。

9. 总结与后续学习方向

树状数组是一个非常经典的“以空间换时间”的数据结构。它用一段额外的数组和二进制位运算,把单点修改和前缀查询的复杂度从 O(n) 降到了 O(log n)。这篇文章从 lowbit 原理讲起,给出了基础模板、树状数组上二分实现动态第 k 小,以及逆序对计数的完整代码和验证方式。

如果你能把树状数组做成肌肉记忆,后面学线段树、字典树、并查集这些数据结构时会轻松很多。建议的练习路径是:

  • 先背熟 lowbit、add、sum 三个基础模板,理解每一步在做什么。
  • 做 10 道左右的单点修改 + 区间查询入门题,直到不需要想就能写对。
  • 然后练习树状数组上二分,常见的题目包括动态中位数、带插入的第 k 小。
  • 最后练习逆序对类题目,尝试把二维偏序、带权逆序对等进阶场景逐步吸收。

这篇文章没有覆盖的内容包括:树状数组的区间修改 + 区间查询(需要使用差分技巧)、树状数组与 CDQ 分治的结合、以及二维树状数组。如果基础模板已经熟练,可以继续沿着这些方向深入。特别是 CDQ 分治配合树状数组处理三维偏序问题,是算法竞赛中非常高频的组合思路。

建议收藏这篇文章,以后需要树状数组模板或者排查逆序对问题的时候,可以直接翻出来对照。动手写一遍,比看十遍理解得更深。

逆序对 树状数组
本文介绍了如何使用树状数组来高效计算逆序对问题。首先,通过离散化处理将原数组中的元素映射到连续的整数范围,以适应树状数组的大小。接着,初始化树状数组并遍历离散化后的数组,利用树状数组前缀和查询功能来统计每个元素之前比它大的元素数量,从而计算逆序对的总数。文章还提供了Python代码实现,并通过示例分析验证了方法的正确性。
yukigogo
树状数组前缀和
本文介绍了树状数组(Binary Indexed Tree 或 Fenwick Tree)和前缀和的概念、联系以及操作方法。树状数组是一种高效计算动态数组前缀和的数据结构,通过特殊的数组存储方式支持快速的单点更新和范围查询。前缀和是指数组中从首元素到当前索引位置所有元素之和。树状数组的操作包括单点更新和区间查询,适用于动态变化的数据环境。
wxwxwxwxwwxx
列举树状数组的所有用处
树状数组是一种高效的数据结构,广泛应用于各种算法问题中。它支持单点修改和区间查询,以及区间修改和单点查询等操作。树状数组可以用于求取前缀和、后缀和,统计区间内数的出现次数和不同数的个数,维护逆序对、众数、第k小值、排名、中位数,以及连续子序列和的最大值。此外,它还能处理离线区间修改和查询,并扩展到二维树状数组操作。
m0_73386272
树状数组概述、原理及应用.pdf
- **动态求第\( k \)小/大问题**在不断变化的序列中动态查找第\( k \)小或第\( k \)大的元素。
凛鼕将至
34
树状数组可以做哪些问题
树状数组是一种高效的数据结构,能够处理多种区间查询问题。它支持单点修改和区间求和、最大值、最小值、逆序对数量、前缀和、众数、第k大(小)数、不同数的个数、和的最大值最小值、差值、平均数、中位数、最长连续递增(递减)序列以及区间内两数的最大公约数等查询。
a易安
给定一个序列a1,a2,…,an,如果存在iaj,那么我们称之为逆序对,求逆序对的数目。
本文介绍了求解序列中逆序对数目的两种算法归并排序和树状数组。归并排序通过分治思想在合并过程中统计逆序对,时间复杂度为O(nlogn);树状数组则利用数据结构特性在O(logn)时间内完成单点修改和前缀和查询,同样实现逆序对统计。
给你一个长度为n的序列a,构造b数组,bi=(ai+k)%m,询问k从0到m-1时,b数组的逆序对个数。1<=n,m<=2*10^5,0<=ai<m
本文探讨了如何高效计算给定数组a构造数组b后,统计k从0到m-1时b数组的逆序对个数问题。通过分析模运算的性质,将逆序对的贡献分为三类,并分别处理对应的k区间。使用树状数组统计元素分布,差分数组求前缀和,实现时间复杂度为O(n log m + m)。
Jcqsunny
树状数组:高效区间查询更新的二进制艺术
树状数组(Fenwick Tree)是一种高效的数据结构,适用于单点修改和区间查询。它通过将数组分成2的幂次小区间来表示元素和,实现快速更新和查询。树状数组操作基于二进制低位表示,时间复杂度为O(logn),适用于求解逆序对、区间第k小值等问题。
潇潇子
树状数组求第k小原理
树状数组是一种高效的数据结构,适用于解决区间统计问题,如求解第k小的数。其核心思想是将序列分解为2的幂次方长度的子序列,并用数组维护这些子序列的和,从而快速计算任意区间和。在求第k小数时,通过排序原序列并维护前缀和,结合二分查找,可以快速定位第k小数的位置。
Heredy
权值树状数组
权值树状数组是一种数据结构,用于处理离散化数值范围内的统计问题。它通过离散化、单点更新和区间查询等操作,简化了对数值本身的操作。本文通过Python实现了一个简单的例子,并分析了其在K-th Number Problem、逆序对数量统计和动态排名系统等场景中的应用。
shy_lihui
树状数组(求前缀和逆序对
本文介绍了如何利用树状数组高效地解决求解数组前缀和与计算逆序对的问题,重点阐述了在查询过程中k与-k的关系。
poppy_bei
385
szu 寒训第二天 树状数组 二维树状数组详解,以及树状数组扩展应用【求逆序对,以及动态k小数】
本文深入讲解树状数组(Binary Indexed Tree)的原理应用,包括动态前缀和问题的解决方法,查询修改操作,以及如何处理区间修改和单点查询等问题。通过实例解析,介绍树状数组逆序对计算、二维树状数组使用及区间第k小数求解中的应用。
黑夜和白天
449
树状数组+求逆序对
本文介绍了树状数组动态求连续区间和及逆序对问题中的应用,详细阐述了树状数组的工作原理和操作过程,并提供了相关算法的解释和实例分析。
努力的小司仪
2485
树状数组逆序对前缀和
树状数组是一种用于动态维护数组前缀和的数据结构,通过lowbit函数实现单点修改和区间查询。它可以高效地处理区间和、逆序对等问题。在求解逆序对时,需要先对数组进行离散化处理,避免重复计算。树状数组的优势在于能在线性时间内完成修改和查询操作。
Corvus_c
216
树状数组全解析原理、模板与逆序对实战
本文系统讲解树状数组(Fenwick Tree)的核心原理,包括lowbit运算、单点修改(update)和前缀和查询(query)机制;详细对比其与前缀和、线段树的适用场景;重点剖析逆序对计数的经典应用,涵盖离散化必要性、从右向左遍历策略及时间复杂度优化;同时介绍进阶技巧如树状数组上二分求第k小、双树状数组支持区间修改查询,并总结常见错误工程实践建议。
weixin_30460489
360
树状数组从原理到实战:单点修改、区间求和与逆序对高效解法
本文系统讲解树状数组(Fenwick Tree)的核心原理工程实践,涵盖单点修改、区间求和、树状数组上二分、逆序对高效求解四大关键场景。重点剖析lowbit机制、离散化必要性、前缀和单调性约束及高频报错排查(如循环方向错误、越界、负值未偏移等)。强调其相较于前缀和与线段树的适用边界适用于动态单点更新+区间可合并统计,不支持原生区间更新。
weixin_33768481
341
树状数组从原理到实战:深入解读lowbit、前缀和与更新查询
本文深入剖析树状数组(Fenwick Tree)的核心机制,重点阐释lowbit运算的位运算本质、tree[i]所维护的区间语义、前缀和查询的‘向下拆分’逻辑单点更新的‘向上传播’路径,并从二进制位数视角严格证明其O(log n)时间复杂度。同时提供标准代码模板及两大进阶应用:逆序对求解与树状数组上二分查找第k小元素。
weixin_34353714
399
树状数组原理高频应用从lowbit到逆序对计数
本文深入剖析树状数组的核心原理,重点解释lowbit机制如何决定更新向上、查询向下的路径,并系统讲解单点更新、前缀查询、区间修改查询、树状数组上二分求第k小、逆序对计数等高频应用场景。内容涵盖C++Python模板、复杂度分析、离散化必要性、常见错误排查及最佳实践,强调结合原理理解而非机械套用代码。
苏黎世贝勒爷
215
树状数组(BIT)原理实现从lowbit到动态前缀和的高效维护
本文系统讲解树状数组(BIT)的核心原理,包括lowbit操作、二进制分解思想、单点更新前缀查询的O(log N)实现机制;涵盖基础模板、下标处理、初始化要点;延伸至差分实现区间更新、逆序对统计、前缀最值维护及非负前缀和上的二分查找等进阶应用,并总结常见编码陷阱调试方法。
weixin_34401479
499
《算法竞赛进阶指南》0x42树状数组
本文介绍了树状数组的基本原理核心操作,包括单点更新和前缀和查询,时间复杂度均为O(log n)。结合多个例题讲解了树状数组逆序对、区间修改、二分定位等场景中的高效应用,适用于算法竞赛中对动态前缀和问题的优化处理。
0x7F7F7F7F
1165
树状数组与结合dp
本文重点介绍树状数组,包括其定义、功能(单点修改区间查询等)、二维树状数组,还对比了树状数组与线段树。阐述了树状数组逆序对、二维偏序问题中的应用。最后浅讲了树状数组与动态规划的结合,通过例题展示如何借助树状数组实现动态规划的状态转移。
努力成为大佬的牢大
1118
动态逆序对,记校赛怨念
本文通过回顾校赛中的动态逆序对题目P1393,探讨逆序对的概念,介绍了常用的逆序对求解方法,如归并排序和树状数组,并详细解析了三维偏序问题,特别是CDQ分治策略。通过学习,旨在理解和掌握动态逆序对的计算方法。
Fatdog Jo
297
树状数组(Fenwick Tree)实战精讲从原理到高阶应用
本文深入讲解树状数组(Fenwick Tree)的核心机制,包括lowbit运算、单点更新与前缀和查询的O(log n)实现;拓展至区间修改(结合差分)、二维树状数组、离散化适配、区间最值维护及二分协同求第K小等高阶技巧;强调其在动态前缀和逆序对统计、值域计数等算法场景中的高效实用性。
680
动态规划与前缀和优化逆序对计数问题看算法思维训练
本文围绕P2513逆序对数列问题,系统讲解动态规划状态定义(dp[i][j]表示1~i排列中恰有j个逆序对的数量)、朴素DP转移方程(dp[i][j] = Σdp[i-1][j-k])及其O(N*K*min(N,K))时间复杂度瓶颈;重点阐述前缀和优化原理——将区间求和转为O(1)操作,使时间复杂度降至O(N*K);并补充滚动数组空间优化至O(K),以及边界处理、模运算细节和测试验证方法。
weixin_30706691
495
树状数组原理与实战:从lowbit到区间更新、第k与逆序对
本文深入讲解树状数组的核心原理,包括lowbit机制、单点修改与前缀和查询、差分树状数组实现区间更新/查询、权值树状数组上二分求第k小、离散化优化的逆序对计数。强调其在频次统计、动态求和排序类问题中的高效性,对比线段树明确适用边界,并给出建树优化、溢出防护、下标规范等工程实践建议。
weixin_34293246
399
树状数组笔记
本文详细介绍了树状数组的原理、单点修改和前缀和查询方法,以及逆序对问题的动态转换权值树状数组应用。同时涵盖了二分查询优化和二维树状数组的复杂度降低技巧。
懂又不懂啊
1880
树状数组总结
本文介绍了树状数组(也称作线段树)在解决动态前缀和、计算逆序对、单点及区间修改等问题上的应用。通过实例代码展示了如何利用树状数组高效地处理数组操作,包括单点修改、区间查询、动态求第k大数等经典问题。
谁是凶手1703
150
动态统计逆序对树状数组 twopointer)
本文介绍了一种高效算法,用于解决动态统计逆序对问题。对于给定序列及参数k,该算法能够找出所有子区间内逆序对数量不超过k的区间数目。通过使用两个树状数组分别维护序列前后缀的逆序对计数,实现O(n)的时间复杂度。
w4149
575
树状数组
本文围绕树状数组展开,介绍其可解决的问题,如维护数组、支持单点更新和前缀查询等操作,还包括衍生操作。核心思想是分块存储前缀和信息,利用二进制表示,Lowbit 函数是灵魂。同时阐述了树状数组的结构特点,如数组下标从 1 开始等。
^O^凡人多烦事
1911