第 5.8 章:树状数组(BIT)
📝 前置条件: 了解前缀和(第 3.2 章)和位运算。本章与线段树(第 5.7 章)互补——BIT 代码更短,常数更小,但支持的操作更少。
树状数组(又名二进制索引树 / BIT)是竞赛编程中最常用的数据结构之一:不到 15 行代码,却能在 O(log N) 时间内支持单点更新和前缀查询。
1. 先从一个很朴素的问题出发:为什么普通前缀和遇到修改会变慢?
2. 再认识
lowbit:它告诉我们每个小盒子应该管理几个数。3. 然后看懂
tree[i] 到底存的是哪一段和。4. 接着分别拆开讲查询和更新:先手算,再看代码。
5. 最后学习差分 BIT、逆序对、权值 BIT 等进阶用法。
想象一排同学每人手里有一些糖。老师经常问“前 7 个同学一共有多少糖?”,也经常让“第 3 个同学多拿 5 颗糖”。如果每次都从第 1 个数到第 7 个,会很慢;如果每次有人变了都重算所有前缀和,也很慢。树状数组就是把同学分成许多大小不同的小组,每个小组长帮忙记录一段糖果总数,这样查询和修改都不用找太多人。
5.8.1 先从问题出发:为什么需要树状数组?
在学习树状数组前,我们先回忆两个最简单的方法。
方法一:直接数组
如果数组是:
A = [3, 1, 4, 1, 5, 9, 2, 6]
想求 A[1..7] 的和,就从头加到尾:
3 + 1 + 4 + 1 + 5 + 9 + 2 = 25
- 优点:修改很快,比如
A[3] += 10,直接改一个数。 - 缺点:查询慢,问一次前缀和可能要加很多个数。
方法二:前缀和数组
前缀和把“从开头到当前位置的总和”提前存起来:
prefix[i] = A[1] + A[2] + ... + A[i]
这样查询 A[1..7] 很快,直接看 prefix[7]。
但如果 A[3] 变了,会发生什么?
prefix[3], prefix[4], prefix[5], ..., prefix[n]
后面很多前缀和都要跟着改。
所以前缀和的矛盾是:
| 操作 | 直接数组 | 前缀和数组 |
|---|---|---|
| 修改一个位置 | 快,O(1) | 慢,可能要改很多个 |
| 查询前缀和 | 慢,可能要加很多个 | 快,O(1) |
树状数组想解决的就是这个矛盾:
能不能让修改不要太慢,查询也不要太慢?
答案是可以。BIT 让这两个操作都变成 O(log N)。
直接数组:修改快,查询慢
前缀和:查询快,修改慢
树状数组:修改和查询都比较快
5.8.2 核心积木:什么是 lowbit?
树状数组最重要的积木只有一个:lowbit。
int lowbit(int x) {
return x & (-x);
}
先不要急着背公式。我们先弄懂它在回答什么问题:
从右往左看,
x的第一个1代表多大的数?
例如 x = 6:
6 的二进制 = 0110
从右往左看,第一个 1 在第 1 位
这一位代表 2
所以 lowbit(6) = 2
再看几个例子:
lowbit(1) = lowbit(0001) = 1
lowbit(2) = lowbit(0010) = 2
lowbit(3) = lowbit(0011) = 1
lowbit(4) = lowbit(0100) = 4
lowbit(6) = lowbit(0110) = 2
lowbit(8) = lowbit(1000) = 8
lowbit 的位运算原理
对任意正整数 x,lowbit(x) = x & (-x) 返回 x 的二进制表示中最低位 1 所代表的值。
以 x = 6 为例:
x = 6 → 二进制:0110
-x = -6 → 补码:1010(按位取反 + 1)
x & (-x) = 0010 = 2 ← 最低位 1 对应 2^1 = 2
如果你现在还不熟悉补码,也没关系。先把 lowbit(x) 当作一个小工具:
它能帮我们找到
x最右边那个1对应的数值。
示例:
| x | 二进制 | lowbit(x) | 小学生版理解 |
|---|---|---|---|
| 1 | 0001 | 1 | 最右边的 1 代表 1 |
| 2 | 0010 | 2 | 最右边的 1 代表 2 |
| 3 | 0011 | 1 | 最右边的 1 代表 1 |
| 4 | 0100 | 4 | 最右边的 1 代表 4 |
| 6 | 0110 | 2 | 最右边的 1 代表 2 |
| 8 | 1000 | 8 | 最右边的 1 代表 8 |
为什么 lowbit 对 BIT 很重要?
在 BIT 里,lowbit(i) 不只是一个位运算结果,它还表示:
tree[i]这个小盒子要管理几个原数组元素。
例如:
lowbit(6) = 2 → tree[6] 管 2 个数
lowbit(8) = 8 → tree[8] 管 8 个数
lowbit(3) = 1 → tree[3] 管 1 个数
所以可以先记住一句话:
lowbit(i)决定tree[i]这个盒子的大小。
5.8.3 tree[i] 到底存什么?
BIT 的精妙之处:tree[i] 不存储单个元素,而是存储一段区间的和。
更准确地说:
tree[i] = A[i - lowbit(i) + 1] + ... + A[i]
也就是说,tree[i] 管的是:
以 i 结尾,长度为 lowbit(i) 的一段
如果把 tree[i] 想成一个小盒子,那么:
- 盒子的右端点:永远是
i。 - 盒子的长度:由
lowbit(i)决定。 - 盒子里装的东西:这一段
A的总和。
用 n = 8 看完整盒子分工
假设原数组下标从 1 到 8,那么每个盒子负责的范围是:
下标 i: 1 2 3 4 5 6 7 8
tree[i] 管理的范围:
tree[1] = A[1] (长度 lowbit(1)=1)
tree[2] = A[1]+A[2] (长度 lowbit(2)=2)
tree[3] = A[3] (长度 lowbit(3)=1)
tree[4] = A[1]+...+A[4] (长度 lowbit(4)=4)
tree[5] = A[5] (长度 lowbit(5)=1)
tree[6] = A[5]+A[6] (长度 lowbit(6)=2)
tree[7] = A[7] (长度 lowbit(7)=1)
tree[8] = A[1]+...+A[8] (长度 lowbit(8)=8)
BIT 结构(n=8):每个 tree[i] 覆盖恰好 lowbit(i) 个以下标 i 结尾的元素。
为什么这样分组有用?
因为这些盒子大小不一样:有的管 1 个数,有的管 2 个数,有的管 4 个数,有的管 8 个数。
就像班级里有:
1 人小组、2 人小组、4 人小组、8 人小组
老师问“前 7 个人一共有多少糖”时,不需要一个一个问,可以直接问几个组长:
前 7 个 = [1..4] + [5..6] + [7]
这正好对应:
tree[4] + tree[6] + tree[7]
5.8.4 前缀查询:怎样快速算 A[1..i]?
BIT 的查询函数通常叫 query(i),意思是求:
A[1] + A[2] + ... + A[i]
核心动作是:
i -= lowbit(i);
这句话的意思是:
我已经拿到了以
i结尾的一段和,下一步就跳到这段的前面继续拿。
手算 query(7)
我们要算:
A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7]
从 i = 7 开始:
| 当前 i | lowbit(i) | 加哪个盒子 | 这个盒子负责 | 下一步 |
|---|---|---|---|---|
| 7 | 1 | tree[7] | A[7] | 7 - 1 = 6 |
| 6 | 2 | tree[6] | A[5] + A[6] | 6 - 2 = 4 |
| 4 | 4 | tree[4] | A[1] + A[2] + A[3] + A[4] | 4 - 4 = 0 |
所以:
query(7) = tree[7] + tree[6] + tree[4]
= A[7] + (A[5]+A[6]) + (A[1]+A[2]+A[3]+A[4])
= A[1] + A[2] + ... + A[7]
这三段刚好不重不漏:
[1..4] + [5..6] + [7]
查询 prefix(7) 的跳转路径(i -= lowbit(i) 向下跳):
对应代码:
long long query(int i) {
long long sum = 0;
while (i > 0) {
sum += tree[i];
i -= lowbit(i);
}
return sum;
}
💡 记忆口诀: 查询是“往前跳”,每跳一次就拿走一整盒已经算好的和。
5.8.5 单点更新:为什么要一路向上改?
如果 A[3] 增加了 val,哪些 tree[i] 会受影响?
答案是:所有负责范围里包含 A[3] 的盒子都要跟着加 val。
从前面的盒子分工可以看到:
tree[3] = A[3]
tree[4] = A[1]+A[2]+A[3]+A[4]
tree[8] = A[1]+...+A[8]
所以更新 A[3] 时,要改:
tree[3], tree[4], tree[8]
核心动作是:
i += lowbit(i);
这句话的意思是:
当前盒子改完后,跳到下一个更大的、也包含当前位置的盒子。
手算 update(3, val)
| 当前 i | lowbit(i) | 修改哪个盒子 | 下一步 |
|---|---|---|---|
| 3 | 1 | tree[3] += val | 3 + 1 = 4 |
| 4 | 4 | tree[4] += val | 4 + 4 = 8 |
| 8 | 8 | tree[8] += val | 8 + 8 = 16,超过 n,停止 |
更新位置 3 的跳转路径(i += lowbit(i) 向上跳):
对应代码:
void update(int i, long long val) {
while (i <= n) {
tree[i] += val;
i += lowbit(i);
}
}
💡 记忆口诀: 更新是“往后跳”,每跳一次就告诉一个受影响的组长:“你管的总和变了!”
查询和更新的方向对比
| 操作 | 跳转方向 | 跳转代码 | 直觉 |
|---|---|---|---|
查询 query(i) | 往前跳 | i -= lowbit(i) | 拿走当前盒子,继续拿前面的盒子 |
更新 update(i,val) | 往后跳 | i += lowbit(i) | 通知所有包含这个位置的大盒子 |
每次跳转都会让二进制发生明显变化,所以最多跳大约 log N 次。
5.8.6 把查询和更新写成完整代码
前面我们已经分别手算了 query 和 update。现在把它们合在一起,就是最经典的树状数组模板。
先抓住三个重点:
- 数组从 1 开始编号:因为
lowbit(0)=0,用 0 会卡住。 - 查询往前跳:
i -= lowbit(i)。 - 更新往后跳:
i += lowbit(i)。
📄 查看代码:5.8.6 把查询和更新写成完整代码
// ══════════════════════════════════════════════════════════════
// 树状数组(BIT)—— 经典实现
// 支持:单点更新 O(log N),前缀和查询 O(log N)
// 数组必须使用 1-indexed(关键!)
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 300005;
int n;
long long tree[MAXN]; // BIT 数组,1-indexed
// ── lowbit:返回最低位 1 的值 ──
inline int lowbit(int x) {
return x & (-x);
}
// ── 更新:给位置 i 加 val ──
// 向上遍历:i += lowbit(i)
// 覆盖位置 i 的每个祖先节点都被更新
void update(int i, long long val) {
for (; i <= n; i += lowbit(i))
tree[i] += val;
// 时间:O(log N) — 最多 log2(N) 次迭代
}
// ── 查询:返回前缀和 A[1..i] ──
// 向下遍历:i -= lowbit(i)
// 将 [1..i] 分解为 O(log N) 个不重叠的区间
long long query(int i) {
long long sum = 0;
for (; i > 0; i -= lowbit(i))
sum += tree[i];
return sum;
// 时间:O(log N) — 最多 log2(N) 次迭代
}
// ── 构建:从已有数组 A[1..n] 初始化 BIT ──
// 方法一:N 次单独更新 — O(N log N)
void build_slow(long long A[]) {
fill(tree + 1, tree + n + 1, 0LL);
for (int i = 1; i <= n; i++)
update(i, A[i]);
}
// 方法二:O(N) 构建(利用「直接父节点」关系)
void build_fast(long long A[]) {
for (int i = 1; i <= n; i++) {
tree[i] += A[i];
int parent = i + lowbit(i); // BIT 中的直接父节点
if (parent <= n)
tree[parent] += tree[i];
}
}
// 方法三:O(N) 构建(利用前缀和)
// 原理:tree[i] = sum(A[i-lowbit(i)+1 .. i])
// = prefix[i] - prefix[i - lowbit(i)]
void build_prefix(long long A[], long long prefix[]) {
// 先求前缀和
for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + A[i];
// 利用前缀和直接计算每个节点
for (int i = 1; i <= n; i++)
tree[i] = prefix[i] - prefix[i - lowbit(i)];
}
// ── 完整示例 ──
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int q;
cin >> n >> q;
long long A[MAXN] = {};
for (int i = 1; i <= n; i++) cin >> A[i];
build_fast(A); // O(N) 初始化
while (q--) {
int type;
cin >> type;
if (type == 1) {
// 单点更新:A[i] += val
int i; long long val;
cin >> i >> val;
update(i, val);
} else {
// 前缀查询:A[1..r] 的和
int r;
cin >> r;
cout << query(r) << "\n";
}
}
return 0;
}
5.8.7 区间查询:用两个前缀和相减
会了前缀查询后,区间查询就很自然了。
如果要算 A[l..r],可以先算 A[1..r],再减掉前面不需要的 A[1..l-1]:
A[l..r] = A[1..r] - A[1..l-1]
这和前缀和数组的思想完全一样,只是这里的 query 由 BIT 在 O(log N) 内完成:
📄 会了前缀查询后,区间查询就很自然了。
如果要算 A[l..r],可以先算 A[1..r],再减掉前面不需要的 A[1..l-1]:
A[l..r] = A[1..r] - A[1..l-1]
这和前缀和数组的思想完全一样,只是这里的 query 由 BIT 在 O(log N) 内完成:
A[l..r],可以先算 A[1..r],再减掉前面不需要的 A[1..l-1]:A[l..r] = A[1..r] - A[1..l-1]
query 由 BIT 在 O(log N) 内完成:// 区间求和:A[l..r] 的和
// 时间:O(log N) — 两次前缀查询
long long range_query(int l, int r) {
return query(r) - query(l - 1);
// query(r) = A[1] + A[2] + ... + A[r]
// query(l-1) = A[1] + A[2] + ... + A[l-1]
// 差值 = A[l] + A[l+1] + ... + A[r]
}
// 示例:
// A = [3, 1, 4, 1, 5, 9, 2, 6] (1-indexed)
// range_query(3, 6) = query(6) - query(2)
// = (3+1+4+1+5+9) - (3+1)
// = 23 - 4 = 19
// 验证:A[3]+A[4]+A[5]+A[6] = 4+1+5+9 = 19 ✓
5.8.8 什么时候用 BIT?和前缀和、线段树对比
学到这里,你可能会问:既然有前缀和,也有线段树,为什么还要学 BIT?
简单说:
- 前缀和:适合不修改的数组。
- BIT:适合“单点修改 + 区间求和”。
- 线段树:适合更复杂的区间操作。
| 操作 | 前缀和数组 | 树状数组(BIT) | 线段树 |
|---|---|---|---|
| 构建 | O(N) | O(N) 或 O(N log N) | O(N) |
| 前缀查询 | O(1) | O(log N) | O(log N) |
| 区间查询 | O(1) | O(log N) | O(log N) |
| 单点更新 | O(N) 重建 | O(log N) ✓ | O(log N) ✓ |
| 区间更新 | O(N) | O(log N)(差分 BIT) | O(log N)(懒惰标记) |
| 区间最小/最大 | O(1)(稀疏表) | ❌ 不支持 | ✓ 支持 |
| 代码复杂度 | 极简 | 简单(10 行) | 复杂(50+ 行) |
| 常数因子 | 最小 | 非常小 | 较大 |
| 空间 | O(N) | O(N) | O(4N) |
什么时候选 BIT?
- ✅ 只需前缀/区间和 + 单点更新
- ✅ 需要极简代码(竞赛中减少 bug)
- ✅ 逆序对计数、归并排序计数问题
- ❌ 需要区间最小/最大 → 用线段树
- ❌ 需要复杂区间操作(区间乘法等)→ 用线段树
5.8.9 交互式可视化:BIT 更新过程
5.8.10 区间更新 + 单点查询(差分 BIT)
前面讲的是标准 BIT:
单点更新 + 前缀查询
现在往前走一步:如果题目经常说“把一整段都加上 val”,怎么办?
直接逐个位置修改太慢。这里可以借用差分数组,让 BIT 维护差分数组。这样就能支持:
区间更新 + 单点查询
原理
设差分数组 D[i] = A[i] - A[i-1](D[1] = A[1])。
你可以把差分数组理解成“变化通知单”:
D[l] += val:从l开始,每个位置都多val。D[r+1] -= val:从r+1开始,把刚才的增加取消掉。
所以给 A[l..r] 全部加 val,只需要改两个位置:
D[l] += val
D[r+1] -= val
而 A[i] 等于差分数组前缀和:
A[i] = D[1] + D[2] + ... + D[i]
📄 C++ 完整代码
// ══════════════════════════════════════════════════════════════
// 差分 BIT:区间更新 + 单点查询
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 300005;
int n;
long long diff_bit[MAXN]; // 差分数组 D[] 上的 BIT
inline int lowbit(int x) { return x & (-x); }
// 更新差分 BIT 的位置 i:D[i] += val
void diff_update(int i, long long val) {
for (; i <= n; i += lowbit(i))
diff_bit[i] += val;
}
// 查询 A[i] = D[1..i] 的和 = 差分 BIT 的前缀查询
long long diff_query(int i) {
long long s = 0;
for (; i > 0; i -= lowbit(i))
s += diff_bit[i];
return s;
}
// 区间更新:给 A[l..r] 全部加 val
// 等价于:D[l] += val,D[r+1] -= val
void range_update(int l, int r, long long val) {
diff_update(l, val); // D[l] += val
diff_update(r + 1, -val); // D[r+1] -= val
}
// 单点查询:返回 A[i] 的当前值
// A[i] = D[1] + D[2] + ... + D[i] = prefix_sum(D, i)
long long point_query(int i) {
return diff_query(i);
}
进阶:区间更新 + 区间查询(双 BIT)
同时支持区间更新和区间查询,使用两个 BIT:
📄 同时支持区间更新和区间查询,使用两个 BIT:
// ══════════════════════════════════════════════════════════════
// 双 BIT:区间更新 + 区间查询
// 公式:sum(1..r) = B1[r] * r - B2[r]
// 其中 B1 是 D[] 上的 BIT,B2 是 (i-1)*D[i] 上的 BIT
// ══════════════════════════════════════════════════════════════
long long B1[MAXN], B2[MAXN];
inline int lowbit(int x) { return x & (-x); }
void add(long long* b, int i, long long v) {
for (; i <= n; i += lowbit(i)) b[i] += v;
}
long long sum(long long* b, int i) {
long long s = 0;
for (; i > 0; i -= lowbit(i)) s += b[i];
return s;
}
// 区间更新:给 A[l..r] 加 val
void range_add(int l, int r, long long val) {
add(B1, l, val);
add(B1, r + 1, -val);
add(B2, l, val * (l - 1)); // 补偿前缀公式
add(B2, r + 1, -val * r);
}
// 前缀和 A[1..r]
long long prefix_sum(int r) {
return sum(B1, r) * r - sum(B2, r);
}
// 区间和 A[l..r]
long long range_sum(int l, int r) {
return prefix_sum(r) - prefix_sum(l - 1);
}
5.8.11 USACO 风格题:用 BIT 统计逆序对
题目描述
统计逆序对(O(N log N))
给定长度为 N 的整数数组 A(元素不同,范围 1..N),统计逆序对的数量。
逆序对:一对下标 (i, j),满足 i < j 但 A[i] > A[j]。
样例输入:
5
3 1 4 2 5
样例输出:
3
解释: 逆序对是 (3,1)、(3,2)、(4,2),共 3 对。
先用小例子理解逆序对
数组:
[3, 1, 4, 2, 5]
从左到右看:
3在1前面,而且3 > 1,所以(3,1)是逆序对。3在2前面,而且3 > 2,所以(3,2)是逆序对。4在2前面,而且4 > 2,所以(4,2)是逆序对。
一共 3 对。
为什么 BIT 能帮忙?
处理到当前数 a 时,左边的数都已经出现过。我们只需要知道:
左边已经出现的数里面,有多少个 > a?
如果用 BIT 维护“每个值出现过几次”,就可以快速得到:
已经出现的总数 - 已经出现且 <= a 的数量
这就是代码里的:
(i - 1) - query(a)
解法:BIT 逆序对计数
📄 查看代码:解法:BIT 逆序对计数
// ══════════════════════════════════════════════════════════════
// 用树状数组统计逆序对 — O(N log N)
//
// 核心思路:
// 从左到右处理 A[i]。
// 对每个 A[i],以 A[i] 为右端点的逆序对数
// = 已处理过的值中大于 A[i] 的数量
// = (目前处理的元素数) - (已处理的 <= A[i] 的元素数)
// = i-1 - prefix_query(A[i])
// 对所有 i 求和即为总逆序对数。
//
// BIT 的作用:追踪已见过的值的频率。
// 见到值 v 后:update(v, +1)
// 查询 <= x 的值的数量:query(x)
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 300005;
int n;
int bit[MAXN]; // 频率计数 BIT
inline int lowbit(int x) { return x & (-x); }
// 在位置 v 加 1(见到了值 v)
void update(int v) {
for (; v <= n; v += lowbit(v))
bit[v]++;
}
// 统计已见过的 [1..v] 中的值的数量
int query(int v) {
int cnt = 0;
for (; v > 0; v -= lowbit(v))
cnt += bit[v];
return cnt;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cin >> n;
ll inversions = 0;
for (int i = 1; i <= n; i++) {
int a;
cin >> a;
// 统计以 a 为右端点的逆序对:
// 已见过的值中大于 a 的数量
// = (目前已见 i-1 个元素) - (已见的 <= a 的数量)
int less_or_equal = query(a); // [1..a] 中已见的数量
int greater = (i - 1) - less_or_equal; // [a+1..n] 中已见的数量
inversions += greater;
// 标记我们已见过值 a
update(a);
}
cout << inversions << "\n";
return 0;
}
/*
对 A = [3, 1, 4, 2, 5] 的追踪:
i=1, a=3:已见=[],query(3)=0,greater=0-0=0。逆序对=0。update(3)。
i=2, a=1:已见=[3],query(1)=0,greater=1-0=1。逆序对=1。update(1)。
(3 > 1:1 个逆序对:(3,1) ✓)
i=3, a=4:已见=[3,1],query(4)=2,greater=2-2=0。逆序对=1。update(4)。
(没有已见的元素 > 4)
i=4, a=2:已见=[3,1,4],query(2)=1,greater=3-1=2。逆序对=3。update(2)。
(3>2 且 4>2:2 个逆序对:(3,2),(4,2) ✓)
i=5, a=5:已见=[3,1,4,2],query(5)=4,greater=4-4=0。逆序对=3。update(5)。
最终:3 ✓
*/
复杂度分析:
- 时间:O(N log N) —— N 次迭代,每次 O(log N) 的更新 + 查询
- 空间:O(N)(BIT)
扩展: 若数组元素不在 1..N 范围内,先做坐标压缩再使用 BIT:
📄 C++ 完整代码
// 任意值的坐标压缩
vector<int> A(n);
for (int i = 0; i < n; i++) cin >> A[i];
// 步骤一:排序去重
vector<int> sorted_A = A;
sort(sorted_A.begin(), sorted_A.end());
sorted_A.erase(unique(sorted_A.begin(), sorted_A.end()), sorted_A.end());
// 步骤二:将每个值替换为它的排名(1-indexed)
for (int i = 0; i < n; i++) {
A[i] = lower_bound(sorted_A.begin(), sorted_A.end(), A[i]) - sorted_A.begin() + 1;
// A[i] 现在在 [1..M],M = sorted_A.size()
}
// 现在用 n = sorted_A.size() 使用 BIT
5.8.12 权值树状数组:全局第 k 小
权值 BIT 是 BIT 的另一种常见用法。
前面的 BIT 通常把下标当作数组位置:
位置 1、位置 2、位置 3、...
权值 BIT 把下标当作“数值大小”:
值 1、值 2、值 3、...
也就是说,bit[v] 表示值 v 出现了多少次。这样我们就能高效查询「序列中第 k 小的元素」。
例如当前数列是:
[1, 2, 2, 4, 7]
那么频率是:
值 1 出现 1 次
值 2 出现 2 次
值 4 出现 1 次
值 7 出现 1 次
第 3 小是 2,因为排好序后是:
1, 2, 2, 4, 7
↑
第 3 小
朴素做法:二分 + 前缀查询,O(log² N)
📄 查看代码:朴素做法:二分 + 前缀查询,O(log² N)
// 在值域 [1..MAXV] 上的 BIT 中,找第 k 小的值
int kth_binary_search(int k) {
int lo = 1, hi = MAXV;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (query(mid) >= k)
hi = mid;
else
lo = mid + 1;
}
return lo;
}
倍增优化:O(log N)
二分法已经够好理解,但每次二分都要调用一次 query,所以是 O(log² N)。
借助 BIT 的树形结构,倍增法可以在 O(log N) 内完成第 k 小查询:
📄 二分法已经够好理解,但每次二分都要调用一次 `query`,所以是 `O(log² N)`。
借助 BIT 的树形结构,倍增法可以在 O(log N) 内完成第 k 小查询:
O(log N) 内完成第 k 小查询:// 全局第 k 小(倍增法)— O(log N)
// 前提:BIT 维护值域频率,bit[v] = v 的出现次数
int kth(int k) {
int sum = 0, x = 0;
// 从最高位开始,逐位确定答案
for (int i = (int)log2(MAXV); i >= 0; --i) {
int nx = x + (1 << i);
if (nx <= MAXV && sum + bit[nx] < k) {
x = nx; // 这一段全选,继续向右扩展
sum += bit[nx];
}
// 否则答案在 [x+1, x + 2^(i-1)] 范围内,不扩展
}
return x + 1; // x 是最后一个 sum < k 的位置,答案是 x+1
}
// 完整示例:动态维护序列,支持插入和第 k 小查询
// 插入值 v:update(v, 1)
// 删除值 v:update(v, -1)
// 查询第 k 小:kth(k)
💡 原理解析: BIT 的树形态使得
bit[x]正好是以 x 为根的子树之和(x 的二进制最低位之前的区间)。倍增时,每次尝试将 x 的某一位设为 1:若该位为 1 时的前缀和仍 < k,说明答案在右侧,就扩展;否则缩小在左侧查找。共 O(log V) 步。
💡 章节联系: BIT 和线段树是 USACO 中最常配合使用的两个数据结构。BIT 用 1/5 的代码量处理 80% 的场景。掌握 BIT 后,回到第 5.7 章学习线段树懒惰传播——那是 BIT 无法触达的领域。
5.8.13 常见错误
❌ 错误一:lowbit 实现有误
// ❌ 错误 — 常见笔误
int lowbit(int x) { return x & (x - 1); } // 这会清除最低位,而非返回它!
// x=6 (0110):x&(x-1) = 0110&0101 = 0100 = 4(错误,应为 2)
// ✅ 正确
int lowbit(int x) { return x & (-x); }
// x=6:-6 = ...11111010(补码)
// 0110 & 11111010 = 0010 = 2 ✓
记忆口诀: x & (-x) 读作「x 与负 x 相与」。-x 是按位取反加 1,保留最低位的 1,清除其下所有位,反转其上所有位,相与只保留最低位。
❌ 错误二:0-indexed 数组(0-indexed 陷阱)
BIT 必须使用 1-indexed 数组。0-indexed 会导致死循环!
// ❌ 错误 — 0-indexed 导致死循环!
// 如果 i = 0:query 循环:i -= lowbit(0) = 0 - 0 = 0 → 死循环!
// ✅ 正确 — 转换为 1-indexed
for (int i = 0; i < n; i++) {
update(i + 1, arr[i]); // 将 0-indexed 的 i 转换为 1-indexed 的 i+1
}
// 注意:对 0-indexed 范围 [l, r] 的查询用 query(r+1) - query(l)
❌ 错误三:大和的整数溢出
// ❌ 错误 — tree[] 对大和应该用 long long
int tree[MAXN]; // 和超过 2^31 时溢出
// ✅ 正确
long long tree[MAXN];
// 还有:统计逆序对时,逆序对数最多 N*(N-1)/2 ≈ 4.5×10^10(N=3×10^5)
// 结果计数器始终用 long long!
long long inversions = 0; // ✅ 不是 int!
❌ 错误四:多组测试数据间忘记清空 BIT
📄 查看代码:❌ 错误四:多组测试数据间忘记清空 BIT
// ❌ 错误 — 多组测试数据时
int T; cin >> T;
while (T--) {
// 忘记清空 tree[]!
// 上一组测试数据的旧数据污染结果
solve();
}
// ✅ 正确 — 每组测试数据前重置
int T; cin >> T;
while (T--) {
fill(tree + 1, tree + n + 1, 0LL); // 清空 BIT
solve();
}
5.8.14 本章总结
📋 公式速查
| 操作 | 代码 | 描述 |
|---|---|---|
| lowbit | x & (-x) | x 的最低位 1 的值 |
| 单点更新 | for(;i<=n;i+=lowbit(i)) t[i]+=v | 向上传播 |
| 前缀查询 | for(;i>0;i-=lowbit(i)) s+=t[i] | 向下分解 |
| 区间查询 | query(r) - query(l-1) | 差值公式 |
| 区间更新(差分 BIT) | upd(l,+v); upd(r+1,-v) | 差分数组 |
| 逆序对计数 | (i-1) - query(a[i]) | 处理每个元素时计数 |
| 权值 BIT 第 k 小 | 在频率 BIT 上找最小的 x,使 query(x) >= k | 把值域当下标 |
| 数组必须 | 1-indexed | 0-indexed → 死循环 |
🧩 从浅到深的完整脉络
- 先看矛盾:普通数组查询慢,前缀和修改慢。
- 再学
lowbit:它决定每个tree[i]管几个数。 - 理解盒子分工:
tree[i]存的是以i结尾的一段和。 - 掌握两个方向:查询往前跳,更新往后跳。
- 套用区间查询:
sum(l,r)=query(r)-query(l-1)。 - 进阶到差分 BIT:把区间修改变成两个差分点修改。
- 应用到计数问题:逆序对、频率统计、第 k 小都可以用 BIT。
❓ 常见问题
Q1:BIT 和线段树都支持前缀和 + 单点更新,该选哪个?
A:尽可能用 BIT。BIT 只有 10 行代码,常数更小(实测快 2-3 倍),出错概率更低。只有需要区间最小/最大(RMQ)、区间赋色或更复杂区间操作时才选线段树。竞赛中,BIT 是「默认武器」,线段树是「重型火炮」。
Q2:BIT 能支持区间最小查询(RMQ)吗?
A:标准 BIT 不能支持 RMQ,因为最小值运算没有「逆运算」(无法像减法那样「撤销」一次最小合并)。区间最小/最大需要用线段树或稀疏表。有一种「静态 BIT RMQ」技术,但只在无更新情况下有效,实际用处有限。
Q3:BIT 能做二维(2D BIT)吗?
A:可以!二维 BIT 解决二维前缀和 + 单点更新问题,复杂度 O(log N × log M)。代码结构使用两层嵌套循环:
// 二维 BIT 更新 void update2D(int x, int y, long long v) { for (int i = x; i <= N; i += lowbit(i)) for (int j = y; j <= M; j += lowbit(j)) bit[i][j] += v; }USACO 中不常见,但偶尔会在二维坐标计数题中用到。
5.8.15 练习题
🟢 简单一:区间求和(单点更新) 给定长度为 N 的数组,支持两种操作:
1 i x:A[i] 加 x2 l r:查询 A[l] + A[l+1] + ... + A[r]
提示: BIT 的直接应用。用 update(i, x) 和 query(r) - query(l-1)。
🟢 简单二:小于 K 的元素个数 给定 N 次操作,每次要么插入一个整数(范围 1..10^6),要么查询「当前已插入的整数中有多少个 ≤ K?」
提示: BIT 维护值域上的频率数组。update(v, 1) 插入值 v,query(K) 是答案。
🟡 中等一:区间加法,单点查询 给定长度为 N 的数组(初始全零),支持两种操作:
1 l r x:给 A[l..r] 的每个元素加 x2 i:查询 A[i] 的当前值
提示: 使用差分 BIT(第 5.8.10 节)。
🟡 中等二:逆序对计数(含坐标压缩) 给定长度为 N 的数组,元素范围 1..10^9(可能有重复),统计逆序对数量。
提示: 先坐标压缩,再用 BIT 计数(第 5.8.11 节的变体)。注意相等元素:(i,j) 满足 i<j 且 A[i]>A[j](严格大于)才算逆序对。
🔴 困难:区间加法,区间求和(双 BIT) 给定长度为 N 的数组,支持两种操作:
1 l r x:给 A[l..r] 的每个元素加 x2 l r:查询 A[l] + ... + A[r]
提示: 用双 BIT。公式:prefix_sum(r) = B1[r] * r - B2[r],其中 B1 维护差分数组,B2 维护加权差分数组。
✅ 全部 BIT 练习题完整题解
🟢 简单一:区间求和
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
long long tree[MAXN];
int lowbit(int x) { return x & (-x); }
void update(int i, long long val) { for (; i <= n; i += lowbit(i)) tree[i] += val; }
long long query(int i) { long long s=0; for (; i > 0; i -= lowbit(i)) s += tree[i]; return s; }
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
cin >> n >> q;
while (q--) {
int t; cin >> t;
if (t == 1) { int i; long long x; cin >> i >> x; update(i, x); }
else { int l, r; cin >> l >> r; cout << query(r) - query(l-1) << "\n"; }
}
}
🟡 中等一:区间加法,单点查询(差分 BIT)
核心思路:在 BIT 中维护差分数组。range_add(l,r,x) = update(l,x) + update(r+1,-x)。单点查询 = query(i)。
void range_add(int l, int r, long long x) { update(l, x); update(r+1, -x); }
long long point_query(int i) { return query(i); }
🟡 中等二:逆序对计数
// 先坐标压缩,然后对每个元素 x:
// 逆序对 += (已插入的元素数) - query(压缩后的 x)
// 然后插入 x:update(压缩后的 x, 1)
🔴 困难:区间加法,区间求和(双 BIT)
// prefix_sum(r) = (r+1)*sum(D[1..r]) - sum(i*D[i], i=1..r)
// = (r+1)*B1.query(r) - B2.query(r)
// 其中 B1 存 D[i],B2 存 i*D[i]
struct DoubleBIT {
long long B1[MAXN], B2[MAXN];
int n;
DoubleBIT(int n) : n(n) { memset(B1,0,sizeof(B1)); memset(B2,0,sizeof(B2)); }
void add(int i, long long v) {
for (int x=i; x<=n; x+=x&-x) { B1[x]+=v; B2[x]+=v*i; }
}
void range_add(int l, int r, long long v) { add(l,v); add(r+1,-v); }
long long prefix(int i) {
long long s=0; for(int x=i;x>0;x-=x&-x) s+=(i+1)*B1[x]-B2[x]; return s;
}
long long range_query(int l, int r) { return prefix(r)-prefix(l-1); }
};