欢迎来到起遇信息学
起遇信息学正处于上线筹建阶段,以下功能已全部开放免费体验: ✅ 完整题库浏览与代码提交评测(C / C++ / Python / Java 等) ✅ 入门到进阶的系列课程试读、作业与考试 ✅ AI 提示、AI 作业分析等智能助教功能 ✅ 赛事模拟与个人能力报告 ✅ 邮箱注册开放 ⏳ 付费课程订阅与微信/支付宝支付通道 ⏳ 手机号登录,微信扫码登录、微信公众号绑定 使用中如遇任何问题,欢迎通过页面底部 **"联系我们"** 与我们沟通。
Day 14-plus 学生讲义:树状数组——从动态前缀和到逆序对
前置知识:前缀和、差分、二进制基础、排序与二分。
本课是 Day14 后的独立拓展内容,原 Day14 图论/DSU 模拟卷不变。全篇数组使用 1 下标。
今天要解决一类反复出现的问题:
数组会修改,但还要快速查询前缀或区间;
扫描序列时,要快速统计已经出现了多少个更小值或更大值。
1. 学习路线
| 顺序 | 内容 | 学完要能做到 |
|---|---|---|
| 1 | lowbit 与管辖区间 |
看懂 tree[x] 保存哪一段 |
| 2 | 单点增加、前缀查询 | 默写 add/query |
| 3 | 单点修改、区间求和 | 写出基础完整模板 |
| 4 | 差分树状数组 | 区间增加、单点查询 |
| 5 | 两棵树状数组 | 区间增加、区间求和 |
| 6 | 离散化与逆序对 | 统计左边更大或更小的个数 |
| 7 | CF61E 三元组贡献 | 枚举中间位置,左右两边相乘 |
| 8 | 树状数组上二分 | 找动态频率中的第 k 个 |
2. 为什么普通前缀和会卡住
静态数组的区间和可以写成:
但若 a[x] 增加 d,所有 s[x..n] 都要跟着增加 d。一次修改最坏是 O(n)。
| 方法 | 修改一个数 | 查询区间和 |
|---|---|---|
| 直接数组 | O(1) |
O(n) |
| 普通前缀和 | O(n) |
O(1) |
| 树状数组 | O(log n) |
|
树状数组不保存每一个完整前缀,而是保存若干段长度为 1、2、4、8……的小区间和。查询时拼起来,修改时只更新包含该位置的区间。
3. lowbit 是什么
lowbit(x) 表示 x 的二进制最低位 1 对应的数值:
int lowbit(int x)
{
return x & -x;
}
x |
二进制 | lowbit(x) |
|---|---|---|
| 3 | 0011 |
1 |
| 4 | 0100 |
4 |
| 6 | 0110 |
2 |
| 8 | 1000 |
8 |
| 12 | 1100 |
4 |
先自算:
lowbit(10)=?lowbit(16)=?lowbit(20)=?
答案分别是 2、16、4。
注意:lowbit(0)=0。若 add 从下标 0 开始,x+=lowbit(x) 永远不动,会死循环。
4. tree[x] 保存哪一段
定义:
所以 tree[x] 管辖:
n=8 时:
x |
tree[x] 管辖区间 |
|---|---|
| 1 | [1,1] |
| 2 | [1,2] |
| 3 | [3,3] |
| 4 | [1,4] |
| 5 | [5,5] |
| 6 | [5,6] |
| 7 | [7,7] |
| 8 | [1,8] |
例如:
tree[6]保存a[5]+a[6];tree[4]保存a[1]+a[2]+a[3]+a[4];tree[7]只保存a[7]。
这里的 tree 不是一棵用指针连接的树,而是把树形关系压在数组下标的二进制中。
5. 单点增加:add
若 a[x] 增加 d,所有管辖范围包含 x 的节点都要增加 d:
void add(int x, long long d)
{
for (; x <= n; x += lowbit(x))
tree[x] += d;
}
当 n=8,执行 add(3,5):
tree[3] += 5
tree[4] += 5
tree[8] += 5
路径:3 -> 4 -> 8
为什么是向上加 lowbit(x)?因为要跳到下一个包含当前位置的更大管辖区间。
再手算:
add(5,d)的路径:5 -> 6 -> 8;add(6,d)的路径:6 -> 8;add(8,d)的路径:8。
6. 前缀查询:query
query(x) 返回前缀 a[1]+...+a[x]:
long long query(int x)
{
long long res = 0;
for (; x > 0; x -= lowbit(x))
res += tree[x];
return res;
}
手算 query(6):
tree[6] 保存 [5,6]
tree[4] 保存 [1,4]
[1,6] = [5,6] + [1,4]
路径:6 -> 4 -> 0
手算 query(7):
[1,7] = tree[7] + tree[6] + tree[4]
路径:7 -> 6 -> 4 -> 0
每次减去 lowbit(x),就是把已经统计的最后一段删掉。
区间和仍然是两个前缀之差:
long long rangeQuery(int l, int r)
{
return query(r) - query(l - 1);
}
7. 基础完整模板
操作:
1 x d:a[x]增加d;2 l r:查询[l,r]的和。
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2e5 + 10;
int n, q;
ll tree[N];
int lowbit(int x)
{
return x & -x;
}
void add(int x, ll d)
{
for (; x <= n; x += lowbit(x))
tree[x] += d;
}
ll query(int x)
{
ll res = 0;
for (; x > 0; x -= lowbit(x))
res += tree[x];
return res;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (int i = 1; i <= n; i++)
{
ll x;
cin >> x;
add(i, x);
}
while (q--)
{
int op;
cin >> op;
if (op == 1)
{
int x;
ll d;
cin >> x >> d;
add(x, d);
}
else
{
int l, r;
cin >> l >> r;
cout << query(r) - query(l - 1) << '\n';
}
}
return 0;
}
复杂度:
- 初始化:
O(nlog n); - 每次修改:
O(log n); - 每次查询:
O(log n); - 空间:
O(n)。
7.1 题目若说“改成”怎么办
add(x,d) 是“增加 d”。若题目要求把 a[x] 改成 v:
long long d = v - a[x];
a[x] = v;
add(x, d);
不要把“改成 10”写成“再加 10”。
8. 差分树状数组:区间加、单点查
若操作是:
[l,r]全部增加d;- 查询
a[x]。
普通差分的区间修改为:
diff[l] += d;
diff[r + 1] -= d;
原数组单点值是差分前缀和,因此让树状数组维护 diff:
void rangeAdd(int l, int r, long long d)
{
add(l, d);
if (r + 1 <= n) add(r + 1, -d);
}
long long pointQuery(int x)
{
return query(x);
}
例子:
对 [2,5] 加 3
diff[2] += 3
diff[6] -= 3
query(1) 不包含 +3
query(2..5) 包含 +3
query(6) 又被 -3 抵消
本质不是树状数组突然会修改区间,而是差分把一次区间修改变成了两次单点修改。
9. 两棵树状数组:区间加、区间和
差分数组记为 d:
原数组的前缀和为:
$$\begin{aligned} \sum_{i=1}^{x}a[i] &=\sum_{j=1}^{x}(x-j+1)d[j]\\ &=(x+1)\sum_{j=1}^{x}d[j]-\sum_{j=1}^{x}j\cdot d[j]. \end{aligned}$$所以:
- 第一棵树维护
d[j]; - 第二棵树维护
j*d[j]。
核心代码:
long long t1[N], t2[N];
void add(long long tree[], int x, long long d)
{
for (; x <= n; x += lowbit(x))
tree[x] += d;
}
long long query(long long tree[], int x)
{
long long res = 0;
for (; x > 0; x -= lowbit(x))
res += tree[x];
return res;
}
void change(int x, long long d)
{
add(t1, x, d);
add(t2, x, 1ll * x * d);
}
void rangeAdd(int l, int r, long long d)
{
change(l, d);
if (r + 1 <= n) change(r + 1, -d);
}
long long prefix(int x)
{
return 1ll * (x + 1) * query(t1, x) - query(t2, x);
}
long long rangeQuery(int l, int r)
{
return prefix(r) - prefix(l - 1);
}
这部分不要只背代码。必须知道两棵树分别维护什么,否则公式中的 x、x+1、i 很容易写混。
10. 离散化
若数组值达到 10^9,不能开十亿大小的树状数组。但统计大小关系时,只需要保留排名。
原值: [100, -5, 20, 20]
排序去重: [-5, 20, 100]
排名: [3, 1, 2, 2]
标准写法:
vector<int> b;
for (int i = 1; i <= n; i++) b.push_back(a[i]);
sort(b.begin(), b.end());
b.erase(unique(b.begin(), b.end()), b.end());
for (int i = 1; i <= n; i++)
rnk[i] = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1;
为什么要 +1?因为树状数组不能从下标 0 更新。
离散化只保留大小关系。排名 3 与排名 1 的差是 2,并不代表原值相差 2。
11. 什么时候优先想到树状数组
适合:
- 单点修改、区间求和;
- 区间增加、单点查询;
- 区间增加、区间求和;
- 扫描过程中统计已出现元素的大小关系;
- 逆序对、顺序对、三元组贡献;
- 动态频率中的第
k个。
基础树状数组不擅长:
- 区间赋值;
- 任意修改下的区间最值;
- 需要复杂懒标记的区间操作。
这些问题通常要改模型或使用线段树。
12. 易错点清单
- 下标必须从 1 开始,不能
add(0,d)。 query(0)=0,所以query(l-1)在l=1时没有问题。- 求和、逆序对和三元组答案优先用
long long。 - “增加”与“改成”不同,赋值要计算差值。
- 区间差分修改注意
r+1是否越界。 - 离散化更新排名,不更新原值。
- 严格与非严格只差一个等号,要先写清统计条件。
- 多组数据和多遍扫描都要清空树状数组。
- CF61E 两个计数相乘前转成
long long。 - 第
k个查询要求频率非负。
13. 总结
tree[x] 管辖结尾在 x、长度为 lowbit(x) 的区间。
单点修改向上跳,前缀查询向下跳。
区间和等于两个前缀和相减。
区间修改先想差分,区间和进阶用两棵树。
值域太大先离散化,排名必须从 1 开始。
统计大小关系先分清严格与非严格。
二元关系一遍统计,三元组枚举中点算两边贡献。
0 条评论
目前还没有评论...
Be the first to comment!