树状数组(Binary Indexed Tree, BIT)是算法竞赛中极为高效的数据结构,但很多初学者面对实战题时仍会感到无从下手。本文精选6道洛谷经典题目,从逆序对计数到树上差分查询,逐步拆解核心思路与代码实现,助你真正吃透 BIT 的应用场景与技巧。
为什么树状数组值得深入掌握?
树状数组能在 O(log n) 时间内完成单点更新与前缀和查询,是处理区间统计问题的利器。与线段树相比,它代码简洁、常数小,尤其适合处理动态排名、逆序对、二维统计等场景。在 C++、Java、TypeScript 等语言中,树状数组的实现仅需几十行代码,却能解决许多看似复杂的问题。
练习题1:逆序对(洛谷 P1908)
逆序对计数是 BIT 的入门经典。给定一个长度为 n 的序列,求满足 i < j 且 a[i] > a[j] 的对数。暴力 O(n²) 会超时,而树状数组可将复杂度降至 O(n log n)。
核心思路:从左到右遍历,用 BIT 维护已出现元素的频次。对于当前元素 a[i],查询已出现元素中大于 a[i] 的个数,即为以 i 为右侧的逆序对数量。
⚠️ 关键步骤:
- 离散化:将值域压缩到 1~n(n ≤ 5×10⁵),避免值过大无法直接索引。
- 遍历时先查询(query(n) - query(a[i])),再更新(add(a[i], 1))。
- 结果可能超过 int 范围,使用 long long 存储。
#include
#include
#include
using namespace std;
#define lowbit(x) (x & -x)
typedef long long LL;
const int N = 5e5 + 10; // 适配n的最大范围
int n;
int a[N], t[N]; // a存储原序列,t存储用于离散化的序列
int m; // 离散化后的最大排名
unordered_map mp; // 映射:元素值 -> 离散化后的排名
LL tree[N]; // 树状数组,维护元素出现次数
// 单点修改:将排名x的元素计数+1
void modify(int x) {
for (int i = x; i <= m; i += lowbit(i)) {
tree[i] += 1;
}
}
// 区间查询:查询[1, x]的元素个数(即排名≤x的元素个数)
LL query(int x) {
LL sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
sum += tree[i];
}
return sum;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
t[i] = a[i]; // 复制序列用于离散化
}
// 步骤1:离散化(去重+排序+映射)
sort(t + 1, t + 1 + n); // 排序
for (int i = 1; i <= n; i++) {
if (!mp.count(t[i])) { // 去重,避免重复映射
mp[t[i]] = ++m;
}
}
// 步骤2:遍历序列,统计逆序对
LL ans = 0;
for (int i = 1; i <= n; i++) {
int rank = mp[a[i]]; // 当前元素的离散化排名
// 前i-1个元素中比a[i]大的个数 = 总元素个数 - 排名≤rank的个数
ans += query(m) - query(rank);
modify(rank); // 插入当前元素的排名
}
cout << ans << endl;
return 0;
}
练习题2:火柴排队(洛谷 P1966)
本题将贪心与逆序对结合,是 NOIP2013 提高组真题。两列火柴高度互不相同,求最小交换次数使距离最小。
核心思路:根据排序不等式,当两列火柴按相同大小顺序对应时距离最小。因此:
- 将两列火柴排序,得到映射关系。
- 根据映射构造目标数组,其逆序对数即为最少交换次数。
#include
#include
using namespace std;
#define lowbit(x) (x & -x)
typedef long long LL;
const int N = 1e5 + 10;
const int mod = 1e8 - 3; // 取模常数
int n;
// 结构体:存储火柴高度和原下标
struct Node {
int x; // 高度
int id; // 原下标
} a[N], b[N];
int c[N]; // 映射数组:c[a的原下标] = b的原下标
LL tree[N]; // 树状数组,维护逆序对统计
// 排序规则:按高度从小到大排序
bool cmp(Node& x, Node& y) {
return x.x < y.x;
}
// 单点修改:将位置x的计数+1
void modify(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
tree[i] += k;
}
}
// 区间查询:查询[1, x]的元素个数
LL query(int x) {
LL sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
sum += tree[i];
}
return sum;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
// 读入第一列火柴的高度和原下标
for (int i = 1; i <= n; i++) {
cin >> a[i].x;
a[i].id = i;
}
// 读入第二列火柴的高度和原下标
for (int i = 1; i <= n; i++) {
cin >> b[i].x;
b[i].id = i;
}
// 步骤1:按高度排序,建立映射关系
sort(a + 1, a + 1 + n, cmp);
sort(b + 1, b + 1 + n, cmp);
for (int i = 1; i <= n; i++) {
// a排序后的第i个元素的原下标,对应b排序后的第i个元素的原下标
c[a[i].id] = b[i].id;
}
// 步骤2:统计数组c的逆序对个数
LL ans = 0;
for (int i = 1; i <= n; i++) {
// 逆序对个数 = 已插入元素个数 - 比c[i]小的元素个数
ans += query(n) - query(c[i]);
ans %= mod; // 防止溢出,及时取模
modify(c[i], 1); // 插入当前元素
}
cout << ans % mod << endl;
return 0;
}
✨ 这种“映射+逆序对”的技巧,在 Go 和 JavaScript 的算法题中也经常出现,值得熟练掌握。
练习题3:楼兰图腾(洛谷 P10589)
给定 n 个点 (i, y_i),y 是 1~n 的排列,求 V 形和 ∧ 形三元组的个数。
核心思路:以每个点 j 作为中间点,统计左侧比 y_j 大/小的个数,以及右侧比 y_j 大/小的个数,然后相乘累加。
✅ 实现步骤:
- 从左到右遍历,用 BIT 记录已出现元素,统计左侧情况。
- 清空 BIT,从右到左遍历,统计右侧情况。
- 将两次结果相乘并累加。
#include
#include
using namespace std;
#define lowbit(x) (x & -x)
typedef long long LL;
const int N = 2e5 + 10;
int n;
int a[N]; // 存储y数组(a[j] = y_j)
LL up[N], down[N]; // up[j]:左侧比a[j]大的个数;down[j]:左侧比a[j]小的个数
LL tree[N]; // 权值树状数组
// 单点修改:将值x的点标记为已出现(计数+1)
void modify(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
tree[i] += k;
}
}
// 区间查询:查询[1, x]的点个数(即值≤x的点个数)
LL query(int x) {
LL sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
sum += tree[i];
}
return sum;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// 第一次遍历:左到右,统计左侧比a[i]大、小的个数
memset(tree, 0, sizeof tree);
for (int i = 1; i <= n; i++) {
int val = a[i];
down[i] = query(val - 1); // 左侧比val小的个数:[1, val-1]的和
up[i] = query(n) - query(val); // 左侧比val大的个数:总个数 - [1, val]的和
modify(val, 1); // 标记当前点已出现
}
// 第二次遍历:右到左,统计右侧比a[i]大、小的个数,计算贡献
memset(tree, 0, sizeof tree);
LL v_tot = 0, lambda_tot = 0;
for (int i = n; i >= 1; i--) {
int val = a[i];
LL right_down = query(val - 1); // 右侧比val小的个数
LL right_up = query(n) - query(val); // 右侧比val大的个数
v_tot += up[i] * right_up; // V图腾贡献:左大 × 右大
lambda_tot += down[i] * right_down; // ∧图腾贡献:左小 × 右小
modify(val, 1); // 标记当前点已出现
}
cout << v_tot << " " << lambda_tot << endl;
return 0;
}
练习题4:Promotion Counting(洛谷 P3605)
这是一道树上差分与 BIT 结合的经典题。给定一棵树,每个节点有权值,求每个节点的后代中权值大于该节点的个数。
核心思路:DFS 遍历树,在进入节点时记录当前 BIT 状态,递归完子树后,前后差值即为子树中符合条件的节点数。
⚠️ 关键点:
- 权值离散化,确保 BIT 索引连续。
- DFS 过程中,先查询再更新,利用 BIT 的可减性得到子树统计。
#include
#include
#include
#include
using namespace std;
#define lowbit(x) (x & -x)
const int N = 1e5 + 10;
int n;
int p[N], t[N]; // p存储能力值,t存储用于离散化的能力值
unordered_map mp; // 映射:能力值 -> 离散化排名(值越大,排名越高)
vector edges[N]; // 邻接表:edges[fa]存储fa的下属
int ret[N]; // 存储每个节点的答案
int tree[N]; // 树状数组,维护排名的出现次数
// 单点修改:将排名x的节点计数+1
void modify(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
tree[i] += k;
}
}
// 区间查询:查询[1, x]的节点个数
int query(int x) {
int sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
sum += tree[i];
}
return sum;
}
// DFS遍历树
void dfs(int u) {
int rank = p[u]; // 当前节点的能力值排名
// 进入节点u:查询当前树状数组中排名>rank的节点个数(即已插入的非子树节点)
ret[u] -= query(n) - query(rank);
// 递归遍历子节点
for (auto v : edges[u]) {
dfs(v);
}
// 退出节点u:查询当前树状数组中排名>rank的节点个数(子树节点已全部插入)
ret[u] += query(n) - query(rank);
// 将当前节点的排名插入树状数组
modify(rank, 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
// 读入能力值
for (int i = 1; i <= n; i++) {
cin >> p[i];
t[i] = p[i];
}
// 读入树的结构(下属→上司)
for (int i = 2; i <= n; i++) {
int fa;
cin >> fa;
edges[fa].push_back(i);
}
// 离散化能力值(值越大,排名越高)
sort(t + 1, t + 1 + n);
for (int i = 1; i <= n; i++) {
mp[t[i]] = i;
}
for (int i = 1; i <= n; i++) {
p[i] = mp[p[i]];
}
// DFS遍历统计答案
dfs(1);
// 输出结果
for (int i = 1; i <= n; i++) {
cout << ret[i] << endl;
}
return 0;
}
[AFFILIATE_SLOT_1]
练习题5:计数问题(洛谷 P4054)
本题将 BIT 扩展至二维,且需要维护多个权值。给定 n×m 矩阵,支持单点修改和子矩阵查询(特定权值出现次数)。
核心思路:为每个权值 c 维护一个二维 BIT。由于权值范围小(≤100),可以开 100 个 BIT,每个 BIT 记录该权值在矩阵中的分布。
✅ 操作流程:
- 初始化:对每个格子,在对应权值的 BIT 中 add(x, y, 1)。
- 修改:先删除原权值(add(x, y, -1)),再添加新权值。
- 查询:对权值 c 的 BIT 做二维前缀和。
#include
using namespace std;
#define lowbit(x) (x & -x)
const int N = 310; // 方格最大尺寸
const int M = 110; // 权值最大范围(1~100)
int n, m;
int a[N][N]; // 存储每个格子的当前权值
// 二维树状数组结构体
struct BIT2D {
int tree[N][N];
// 单点修改:(x,y)位置的计数±1
void modify(int x, int y, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
tree[i][j] += k;
}
}
}
// 区间查询:查询(1,1)到(x,y)的和
int query(int x, int y) {
int sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
sum += tree[i][j];
}
}
return sum;
}
} bit[M]; // bit[c]:维护权值为c的格子分布
// 子矩阵查询:(x1,y1)到(x2,y2)的和(容斥原理)
int matrix_query(int c, int x1, int x2, int y1, int y2) {
return bit[c].query(x2, y2) - bit[c].query(x1 - 1, y2) -
bit[c].query(x2, y1 - 1) + bit[c].query(x1 - 1, y1 - 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
// 初始化:为每个格子的初始权值标记
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
int c = a[i][j];
bit[c].modify(i, j, 1);
}
}
int Q;
cin >> Q;
while (Q--) {
int op;
cin >> op;
if (op == 1) {
// 操作1:修改格子(x,y)的权值为c
int x, y, c;
cin >> x >> y >> c;
int old_c = a[x][y];
// 从原权值的树状数组中删除
bit[old_c].modify(x, y, -1);
// 加入新权值的树状数组
bit[c].modify(x, y, 1);
// 更新当前格子的权值
a[x][y] = c;
} else {
// 操作2:查询子矩阵[x1,x2]×[y1,y2]中权值为c的个数
int x1, x2, y1, y2, c;
cin >> x1 >> x2 >> y1 >> y2 >> c;
cout << matrix_query(c, x1, x2, y1, y2) << endl;
}
}
return 0;
}
这种“多 BIT 并行”的思路,在 TypeScript 或 Java 中同样可以优雅实现,适合处理多分类统计问题。
练习题6:LOG(洛谷 P3586)
本题是 POI2015 的进阶题,难度较高。维护一个序列,支持单点修改和查询:能否进行 s 次操作,每次从 c 个不同位置各取 1。
核心思路:转化为数学条件:设大于等于 s 的元素有 x 个,其余元素总和为 sum,则条件为 sum ≥ (c - x) * s。
✅ 实现步骤:
- 离散化所有操作值。
- 用两个 BIT 分别维护元素总和和元素个数。
- 查询时,通过 BIT 快速获取 x 和 sum,判断条件。
#include
#include
#include
using namespace std;
#define lowbit(x) (x & -x)
typedef long long LL;
const int N = 1e6 + 10;
int n, m;
// 存储所有操作
struct Op {
char op;
int x, y; // U: x=k, y=a; Z: x=c, y=s
} op[N];
int t[N]; // 存储所有需要离散化的值(a和s)
int a[N]; // 存储序列当前值(初始为0)
unordered_map mp; // 映射:原始值 -> 离散化排名
// 树状数组结构体
struct BIT {
LL tree[N];
void modify(int x, LL k) {
for (int i = x; i <= m; i += lowbit(i)) {
tree[i] += k;
}
}
LL query(int x) {
LL sum = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
sum += tree[i];
}
return sum;
}
} A, B; // A维护总和,B维护个数
int main() {
// 用scanf加速输入(数据量较大)
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf(" %c", &op[i].op);
scanf("%d%d", &op[i].x, &op[i].y);
t[i] = op[i].y; // 收集所有a和s的值,用于离散化
}
// 步骤1:离散化所有需要用到的值
sort(t + 1, t + 1 + m);
int max_rank = 0;
for (int i = 1; i <= m; i++) {
if (!mp.count(t[i])) {
mp[t[i]] = ++max_rank;
}
}
// 步骤2:处理所有操作
for (int i = 1; i <= m; i++) {
if (op[i].op == 'U') {
// 操作U:修改第k个数为a
int k = op[i].x;
int new_val = op[i].y;
int old_val = a[k];
// 1. 删除原数值的贡献
if (old_val != 0) {
int r = mp[old_val];
A.modify(r, -old_val);
B.modify(r, -1);
}
// 2. 添加新数值的贡献
if (new_val != 0) {
int r = mp[new_val];
A.modify(r, new_val);
B.modify(r, 1);
}
// 3. 更新序列当前值
a[k] = new_val;
} else {
// 操作Z:查询能否进行s次操作
int c = op[i].x;
int s = op[i].y;
// 计算所需统计量
LL total = A.query(max_rank); // 总元素总和
if (total < (LL)c * s) { // 总石子数不足,直接不可行
puts("NIE");
continue;
}
// 离散化s,找到其排名
int rank_s = mp.count(s) ? mp[s] : 0;
// cnt:≥s的元素个数 = 总个数 - = required) {
puts("TAK");
} else {
puts("NIE");
}
}
}
return 0;
}
[AFFILIATE_SLOT_2]
题型与核心技巧总结
| 题号 | 题型 | 核心技巧 | 树状数组作用 |
|---|---|---|---|
| 1 | 逆序对计数 | 离散化 + 权值统计 | 统计前缀中特定范围的元素个数 |
| 2 | 火柴排队 | 贪心映射 + 逆序对 | 统计数组有序化所需的交换次数 |
| 3 | 楼兰图腾 | 双向遍历 + 权值统计 | 统计左右两侧特定大小关系的元素个数 |
| 4 | Promotion Counting | DFS + 树上差分 | 统计子树内特定条件的节点个数 |
| 5 | 计数问题 | 多二维树状数组 | 维护不同权值的位置分布,统计子矩阵个数 |
| 6 | LOG | 问题转化 + 双树状数组 | 统计总和、个数,验证可行性条件 |
通用技巧:
- 遇到值域过大时,优先考虑离散化。
- 遇到树上统计,思考 DFS + BIT 的差分思想。
- 遇到多维查询,尝试用多个 BIT 并行维护。
总结
树状数组虽然简单,但应用场景极为广泛。从基础的逆序对,到复杂的树上查询和多维统计,掌握 BIT 能让你在算法竞赛和工程实践中事半功倍。建议读者在 C++、Java 或 Go 中亲手实现这6道题,体会 BIT 的灵活性与高效性。记住:理解本质,比背模板更重要。
浙公网安备 33010602011771号