第 5 章 数据结构
本章定位:近五届 NOIP 的 T4(2021 棋局、2022 比赛、2023 天天爱打卡、2024 树上查询、2025 序列询问)正解都是数据结构,但那些不是两周能练出来的,也不是你现在的得分点。这一章要的是另外三件事:
- 堆、树状数组这类小工具,在 T1~T3 里随手就要用,必须一次写对;
- 单调栈、单调队列、ST 表、线段树能照着模板写出来——它们常常是 T2、T3 中间那几档部分分的钥匙;
- 看到 T4 能认出“这是数据结构题”,然后安心去写暴力(5.9 节)。
5.1 该用哪个 【必会】
先看数据范围,再选工具。n ≤ 5000 时 O(n²) 能过,直接两重循环,什么数据结构都不需要。 数据结构的作用是把“每次 O(n) 的操作”降到 O(log n) 或 O(1),只有 n、q 都到 10⁵ 这个量级才用得上。
| 要做的事 | 用什么 | 每次操作 | 在哪 |
|---|---|---|---|
| 数组不变,多次问区间和 | 前缀和 | O(1) | 1.4 |
| 数组不变,多次问区间最大 / 最小值 | ST 表 | 预处理 O(n log n),询问 O(1) | 5.6 |
| 改一个数 + 问区间和 | 树状数组 | O(log n) | 5.7 |
| 改一段数 + 问区间和 / 区间最值 | 线段树 | O(log n) | 5.8 |
| 每个数左边 / 右边第一个比它大(小)的 | 单调栈 | 总共 O(n) | 5.3 |
| 固定长度的窗口滑过去,每个窗口的最值 | 单调队列 | 总共 O(n) | 5.4 |
| 不断放进去,不断取出当前最大 / 最小的 | 堆 priority_queue |
O(log n) | 5.5 |
| 前面有几个数比我大(逆序对) | 树状数组 + 离散化 | O(log n) | 5.7 |
| 要保持有序,还要删任意元素、找前驱后继 | set / multiset |
O(log n) | 1.10 |
| 某个值出现过没有、出现了几次 | 数组计数 / map |
O(1) / O(log n) | 1.10 |
| 合并两个集合,问两个元素在不在一起 | 并查集 | 近似 O(1) | 4.6 |
选的时候从上往下找第一个够用的,不要一上来就写线段树:代码越长,写错的地方越多。
5.2 栈、队列、双端队列 【必会】
一句话:栈后进先出,队列先进先出,双端队列两头都能进出。
stack<int> st; // st.push(x) st.top() st.pop()
queue<int> q; // q.push(x) q.front() q.pop() BFS 用它
deque<int> dq; // dq.push_back(x) dq.push_front(x) dq.pop_back() dq.pop_front() dq.front() dq.back()
// 三个都有 empty() 和 size()
手写数组版更快,清空也只要一条赋值,单调栈和单调队列的模板用的就是它:
int stk[N], top = 0; // 入栈 stk[++top] = x 栈顶 stk[top] 出栈 top-- 判空 top == 0 清空 top = 0
int que[N], head = 1, tail = 0; // 入队 que[++tail] = x 队头 que[head] 出队 head++ 判空 head > tail 清空 head = 1, tail = 0
易错点
- 空的时候调用
top()、front()、pop()不会报错,而是直接 RE 或者读到垃圾值。取之前先判empty()。 pop()没有返回值。要先x = q.front();再q.pop();。stack、queue、priority_queue都没有clear()。多组数据时,把它们定义在每组数据的循环里面,或者用while (!q.empty()) q.pop();弹空。- 手写队列的数组要开到“入队的总次数”那么大。BFS 里每个点只入队一次才是 n;会重复入队的话就不止 n。
5.3 单调栈 【常考】
一句话:一次扫描,求出每个元素左边 / 右边第一个比它大(或小)的元素。 识别信号:“右边第一个比它高的”“向左能看到多远”“以 a[i] 为最大值的区间有多少个”“最大的矩形”。 复杂度:O(n)。每个元素只进栈一次、出栈一次。
// 单调栈:对每个 i,求它右边第一个比 a[i] 大的元素的下标(没有则为 0)。洛谷 P5788。
// 栈里存下标。新元素进来时,栈顶那些比它小的元素等到了答案——就是这个新元素——于是出栈。
#include <bits/stdc++.h>
using namespace std;
const int N = 3000005;
int a[N], ans[N], stk[N], top; // stk 是手写栈,存下标;top 是栈里的元素个数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) {
while (top > 0 && a[stk[top]] < a[i]) { // 严格小于才出栈:相等不算“更大”
ans[stk[top]] = i;
top--;
}
stk[++top] = i;
}
// 循环结束后还留在栈里的元素,右边没有比它大的,ans 保持初值 0
for (int i = 1; i <= n; i++) cout << ans[i] << ' ';
cout << '\n';
return 0;
}
用 1 4 2 3 5 走一遍(栈里写的是下标):
| i | a[i] | 出栈并得到答案 | 之后的栈 |
|---|---|---|---|
| 1 | 1 | — | 1 |
| 2 | 4 | 1 出栈,ans[1] = 2 | 2 |
| 3 | 2 | — (4 不比 2 小) | 2 3 |
| 4 | 3 | 3 出栈,ans[3] = 4 | 2 4 |
| 5 | 5 | 4 出栈,ans[4] = 5;2 出栈,ans[2] = 5 | 5 |
结束时 5 还在栈里,ans[5] 保持 0。输出 2 5 4 5 0。
四种变形只是把出栈条件换一下。从左往右扫,新元素是 a[i],栈顶下标是 t:
| 要找的 | 出栈条件 | 答案在哪 |
|---|---|---|
| 右边第一个 > 它的 | a[t] < a[i] |
出栈时,t 的答案是 i |
| 右边第一个 ≥ 它的 | a[t] <= a[i] |
同上 |
| 左边第一个 ≥ 它的 | a[t] < a[i] |
出栈做完后,剩下的栈顶就是 i 的答案 |
| 左边第一个 > 它的 | a[t] <= a[i] |
同上 |
找“更小的”就把所有不等号反过来。
相等的元素:一边严格,一边不严格。 算“每个 a[i] 是多少个区间的最大值”时(贡献法,见 7.6 节和练习 E12),区间里有两个相同的最大值怎么办?例如 2 2,区间 [1, 2] 的最大值只能算一次。办法是规定“并列时算最左边那个”:
for (int i = 1; i <= n; i++) R[i] = n + 1; // 右边没有更大的:记成 n+1
for (int i = 1; i <= n; i++) {
while (top > 0 && a[stk[top]] < a[i]) { // 右边第一个 > 它的(严格)
R[stk[top]] = i;
top--;
}
L[i] = top > 0 ? stk[top] : 0; // 左边第一个 >= 它的(不严格);没有记成 0
stk[++top] = i;
}
// a[i] 负责的区间:左端点在 (L[i], i],右端点在 [i, R[i]),一共 (i - L[i]) * (R[i] - i) 个
两边都写成严格,2 2 的区间 [1, 2] 会被两个 2 各算一次;两边都不严格,它一次也算不到。
易错点
- 栈里存下标,不存值。存了值就不知道它在哪了。
top > 0 && a[stk[top]] < a[i]:判空必须写在前面。写反了,栈空时会去读stk[0]。- 扫完以后还留在栈里的元素没有答案,要有初值(上面的 0 和 n+1)。
- 输出 300 万个数:
cout配'\n',不要用endl(0.4 节:100 万行endl就要 1 秒多)。
练一练:洛谷 P5788 【模板】单调栈;P2866 Bad Hair Day;本系统 E12 watch。
5.4 单调队列 【常考】
一句话:窗口每次向右挪一格,O(1) 得到窗口里的最大 / 最小值。 识别信号:“每连续 k 个数的最大值”;DP 转移是“从前面 k 个状态里取最小的”(单调队列优化 DP,了解即可)。 复杂度:O(n)。
// 单调队列:长度为 k 的窗口从左滑到右,输出每个窗口的最小值(第一行)和最大值(第二行)。洛谷 P1886。
// 队列里存下标:下标递增,对应的值单调,队头永远是当前窗口的最值。
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int n, k, a[N], q[N]; // q 是手写队列,存下标,有效部分是 q[head..tail]
// want_min 为 true 时求每个窗口的最小值,为 false 时求最大值
void slide(bool want_min) {
int head = 1, tail = 0; // head > tail 表示队列为空
for (int i = 1; i <= n; i++) {
// 求最小值时:队尾的值不比 a[i] 小,它又比 a[i] 先滑出窗口,永远轮不到它,删掉
while (head <= tail && (want_min ? a[q[tail]] >= a[i] : a[q[tail]] <= a[i])) tail--;
q[++tail] = i;
while (q[head] <= i - k) head++; // 队头滑出了窗口 [i-k+1, i];刚放进去的 i 一定在窗口里,不会删空
if (i >= k) cout << a[q[head]] << ' '; // 凑满一个窗口才开始输出
}
cout << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) cin >> a[i];
slide(true);
slide(false);
return 0;
}
为什么可以删队尾:求最小值时,如果队尾的数不比新来的 a[i] 小,那它既比 a[i] 老(先离开窗口),又不比 a[i] 小,从此以后任何窗口的最小值都轮不到它。所以队列里留下的数,从队头到队尾是递增的,队头最小。
易错点
- 循环体里四步的顺序:删队尾 → 新元素入队 → 删过期的队头 → 输出。把“输出”写到“入队”前面,窗口里就少了当前这个数。
- 过期条件是
q[head] <= i - k。窗口是 [i-k+1, i],下标 ≤ i-k 的才算出去了,不要写成< i - k。 - 队列里存下标。存值的话没法判断过期。
- 前 k-1 个位置窗口还没凑满,不输出(
if (i >= k))。
练一练:洛谷 P1886 滑动窗口;P1440 求 m 区间内的最小值。
5.5 堆 priority_queue 【必会】
一句话:随时放入元素,随时取出当前最大(或最小)的那个。
识别信号:“每次选最小的两个”“每次处理当前最紧急的”;Dijkstra 最短路;反悔贪心;动态维护前 k 大。
复杂度:push、pop 是 O(log n),top 是 O(1)。
// 堆(优先队列):每次取出最小的两堆合并,代价是两堆之和,求合并成一堆的最小总代价。洛谷 P1090 合并果子。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
priority_queue<long long, vector<long long>, greater<long long> > heap; // 小根堆:top() 是最小值
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
heap.push(x);
}
long long total = 0;
while (heap.size() > 1) {
long long first = heap.top(); // top() 只看不取
heap.pop(); // pop() 才删除,而且没有返回值
long long second = heap.top();
heap.pop();
total += first + second;
heap.push(first + second);
}
cout << total << '\n';
return 0;
}
priority_queue 默认是大根堆,top() 是最大值。要小根堆有三种写法:
priority_queue<int, vector<int>, greater<int> > small; // 写法一:标准写法,照抄
priority_queue<int> negated; // 写法二:存相反数。只适合数字
negated.push(-x);
int smallest = -negated.top();
struct Node { // 写法三:结构体里重载小于号
long long dist;
int id;
bool operator<(const Node &other) const { return dist > other.dist; } // 方向是反的:dist 小的优先
};
priority_queue<Node> heap;
存 pair 时,先比 first,再比 second:priority_queue<pair<long long, int>, vector<pair<long long, int> >, greater<pair<long long, int> > > 是按 first 从小到大出堆,Dijkstra 常这么写。
易错点
- 忘了默认是大根堆,想取最小的却取了最大的。合并果子这样写,样例可能碰巧能过。
- 写法三的方向:想让
dist小的先出来,要写return dist > other.dist;。写成<就是大根堆。 top()之前判空。- 堆不能改、不能删中间的元素。常用办法是“懒删除”:不管它,等它被
top()取出来时再检查是否已经作废,作废就丢掉。Dijkstra 里的if (d > dist[u]) continue;就是这个意思。 - 合并后的和可能超过 int,用
long long。
练一练:洛谷 P1090 合并果子;P3378 【模板】堆;本系统 E04 deliver(Dijkstra 里的堆)。
5.6 ST 表 【常考】
一句话:数组不修改,预处理之后 O(1) 回答任意区间的最大(最小)值。 识别信号:“不修改”+“很多次问区间最值”。只要有修改,就不能用 ST 表。 复杂度:预处理 O(n log n),每次询问 O(1)。
// ST 表:数组不修改,多次询问区间最大值。预处理 O(n log n),每次询问 O(1)。洛谷 P3865。
// f[j][i] 表示从 i 开始、长度为 2^j 的一段(即 [i, i + 2^j - 1])的最大值。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005, LOG = 17; // 2^17 = 131072 >= N;n 到 1e6 时 LOG 取 20
int f[LOG + 1][N], lg[N]; // lg[x] = log2(x) 向下取整
int read() { // 询问有 2e6 次,必须快读
int x = 0, sign = 1, c = getchar();
while (c != '-' && (c < '0' || c > '9')) c = getchar();
if (c == '-') sign = -1, c = getchar();
while (c >= '0' && c <= '9') x = x * 10 + (c - '0'), c = getchar();
return x * sign;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n = read(), m = read();
for (int i = 1; i <= n; i++) f[0][i] = read(); // 长度为 1 的段就是元素自己
for (int i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (int j = 1; j <= LOG; j++)
for (int i = 1; i + (1 << j) - 1 <= n; i++) // 这一段的右端点不能超过 n
f[j][i] = max(f[j - 1][i], f[j - 1][i + (1 << (j - 1))]); // 左半段、右半段各长 2^(j-1)
while (m--) {
int l = read(), r = read();
int j = lg[r - l + 1];
// 两段长 2^j 的区间,一段贴着 l,一段贴着 r,合起来正好盖住 [l, r];中间重叠不影响最大值
cout << max(f[j][l], f[j][r - (1 << j) + 1]) << '\n';
}
return 0;
}
思路只有两句话:长为 2^j 的一段,等于两个长为 2^(j-1) 的半段拼起来;任意区间 [l, r] 可以被两个长为 2^j 的段盖住(一个贴着左端,一个贴着右端),其中 2^j 是不超过区间长度的最大的 2 的幂。两段有重叠没关系,因为求的是最值——所以 ST 表不能用来求和。
易错点
- 预处理第二层循环的条件
i + (1 << j) - 1 <= n。漏了就会用到数组外面的值。 - 询问时右边那一段的起点是
r - (1 << j) + 1,不是r - (1 << j)。 LOG要够大:n = 10⁵ 取 17,n = 10⁶ 取 20。同时算一下空间,f[21][1000005]是 84 MB。lg用递推预处理(lg[i] = lg[i / 2] + 1),不要调用浮点函数log2(),有精度问题而且慢。- 求最小值时把两处
max都换成min,别只换一处。
练一练:洛谷 P3865 【模板】ST 表;P2880 Balanced Lineup(同时要最大值和最小值)。
5.7 树状数组 【必会】
一句话:支持“某个位置加一个数”和“求前缀和”,各 O(log n),核心代码不到十行。 识别信号:单点修改 + 区间求和;“前面有多少个数比我大 / 小”(把值当下标,数出现次数);逆序对。 复杂度:每次操作 O(log n)。
// 树状数组:单点加、区间求和,每次 O(log n)。洛谷 P3374。
// c[i] 存的是以 i 结尾、长度为 lowbit(i) 的那一段的和。
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
int n, m;
long long c[N];
int lowbit(int x) { return x & -x; } // x 的二进制里最低位的 1 所代表的数,例如 lowbit(6) = 2
void add(int pos, long long value) { // 第 pos 个数加上 value
for (int i = pos; i <= n; i += lowbit(i)) c[i] += value;
}
long long prefix(int pos) { // 前 pos 个数的和
long long sum = 0;
for (int i = pos; i > 0; i -= lowbit(i)) sum += c[i];
return sum;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
add(i, x); // 初值也当成一次“单点加”
}
while (m--) {
int op, x, y;
cin >> op >> x >> y;
if (op == 1) add(x, y); // 第 x 个数加上 y
else cout << prefix(y) - prefix(x - 1) << '\n'; // 区间 [x, y] 的和 = 两个前缀和相减
}
return 0;
}
lowbit(x) 是 x 的二进制最低位的 1 代表的数:lowbit(6) = 2(6 = 110₂),lowbit(8) = 8。c[i] 管的是以 i 结尾、长为 lowbit(i) 的那一段:c[6] = a[5] + a[6],c[8] = a[1] + … + a[8]。修改时往上跳(i += lowbit(i)),把所有管到这个位置的 c 都改掉;查询时往下跳(i -= lowbit(i)),把前缀拆成几段加起来。原理记不住没关系,这两个循环要背下来。
易错点
- 下标必须从 1 开始。
lowbit(0) = 0,add(0, v)里i += 0永远不动,直接死循环。把值当下标时,如果值可能是 0,先整体加 1 或者离散化。 - 区间 [x, y] 的和是
prefix(y) - prefix(x - 1)。写成prefix(x)就少算了 a[x]。 add的循环上界是树状数组的大小。值当下标时,上界是值域的大小(离散化后是不同值的个数或 n),不是别的 n。- 多组数据要把 c[1..n] 清零。
- 和用
long long。
用它求逆序对:从左到右扫,把每个数的“排名”当下标,在树状数组里记“这个排名出现了几次”。扫到 a[i] 时,前面 i-1 个数里不超过它的有 prefix(rk) 个,剩下的都比它大,都和它构成逆序对。
// 逆序对:有多少对 i < j 满足 a[i] > a[j]。离散化 + 树状数组,O(n log n)。洛谷 P1908。
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
int n, a[N], sorted_a[N];
int c[N]; // 树状数组:下标是“值的排名”,存这个排名已经出现了几次
void add(int pos) {
for (int i = pos; i <= n; i += i & -i) c[i]++;
}
int prefix(int pos) {
int sum = 0;
for (int i = pos; i > 0; i -= i & -i) sum += c[i];
return sum;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
sorted_a[i] = a[i];
}
// 离散化:值最大到 1e9,不能直接当下标;只关心大小关系,所以把值换成它的排名 1..cnt
sort(sorted_a + 1, sorted_a + n + 1);
int cnt = unique(sorted_a + 1, sorted_a + n + 1) - (sorted_a + 1);
long long ans = 0; // 最多约 n^2/2 = 1.25e11,超过 int
for (int i = 1; i <= n; i++) {
int rk = lower_bound(sorted_a + 1, sorted_a + cnt + 1, a[i]) - sorted_a; // a[i] 的排名
ans += (i - 1) - prefix(rk); // 前面 i-1 个数里,不超过 a[i] 的有 prefix(rk) 个,剩下的都比它大
add(rk);
}
cout << ans << '\n';
return 0;
}
易错点
- 值到 10⁹,不能直接当下标,必须先离散化(排序、去重、
lower_bound查排名,见 1.7 节)。 - 相等的两个数不算逆序对。
prefix(rk)把相等的也数进“不超过”里了,所以是对的;写成prefix(rk - 1)就把相等的也当成逆序对了。 - 答案最大约 n²/2,n = 5×10⁵ 时是 1.25×10¹¹,必须
long long。 - 先查询、后
add。顺序反了会把自己也数进去。
练一练:洛谷 P3374 【模板】树状数组 1;P3368 【模板】树状数组 2(区间加、单点查:对差分数组用树状数组);P1908 逆序对;本系统 E11 lineup。
5.8 线段树 【常考】
一句话:把区间不断对半分,建成一棵树,每个结点存一段区间的信息;区间修改和区间查询都是 O(log n)。 识别信号:“给一段数都加上 k”+“问一段的和 / 最大值”。树状数组做不了的(区间修改同时问区间最值),就轮到它。 复杂度:建树 O(n),每次操作 O(log n)。
// 线段树:区间加、区间求和,带懒标记,每次操作 O(log n)。洛谷 P3372。
// 结点 p 管区间 [l, r];左儿子 2p 管 [l, mid],右儿子 2p+1 管 [mid+1, r]。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, m;
long long a[N], sum[N * 4], tag[N * 4]; // 开 4 倍;tag[p]:p 这一段的每个数都该加 tag[p],但还没通知儿子
void pushup(int p) { sum[p] = sum[p * 2] + sum[p * 2 + 1]; }
void add_tag(int p, int l, int r, long long v) { // 结点 p(管 [l, r])整段加 v:改自己的和,记下标记
sum[p] += v * (r - l + 1);
tag[p] += v;
}
void pushdown(int p, int l, int r) { // 把欠儿子的标记发下去
if (tag[p] == 0) return;
int mid = (l + r) / 2;
add_tag(p * 2, l, mid, tag[p]);
add_tag(p * 2 + 1, mid + 1, r, tag[p]);
tag[p] = 0;
}
void build(int p, int l, int r) {
tag[p] = 0; // 多组数据时靠这一行清标记
if (l == r) {
sum[p] = a[l];
return;
}
int mid = (l + r) / 2;
build(p * 2, l, mid);
build(p * 2 + 1, mid + 1, r);
pushup(p);
}
void update(int p, int l, int r, int ql, int qr, long long v) { // [ql, qr] 里每个数加 v
if (ql <= l && r <= qr) { // 当前结点被完全包含:打上标记就返回,不再往下
add_tag(p, l, r, v);
return;
}
pushdown(p, l, r); // 要往下走,先把旧标记发下去
int mid = (l + r) / 2;
if (ql <= mid) update(p * 2, l, mid, ql, qr, v);
if (qr > mid) update(p * 2 + 1, mid + 1, r, ql, qr, v);
pushup(p); // 儿子变了,重新算自己
}
long long query(int p, int l, int r, int ql, int qr) { // [ql, qr] 的和
if (ql <= l && r <= qr) return sum[p];
pushdown(p, l, r);
int mid = (l + r) / 2;
long long result = 0;
if (ql <= mid) result += query(p * 2, l, mid, ql, qr);
if (qr > mid) result += query(p * 2 + 1, mid + 1, r, ql, qr);
return result;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (m--) {
int op, x, y;
cin >> op >> x >> y;
if (op == 1) {
long long k;
cin >> k;
update(1, 1, n, x, y, k);
} else {
cout << query(1, 1, n, x, y) << '\n';
}
}
return 0;
}
懒标记是什么:要给 [ql, qr] 整段加 v,走到一个被完全包含的结点时,不再往下走,只改它自己的 sum,再记一笔 tag——“这一段的每个数都欠着一个 +v,还没告诉儿子”。以后有人需要走进它的儿子时(pushdown),才把这笔账发下去。这样每次操作只碰 O(log n) 个结点。
改成区间最大值只动四处,其余照旧:
long long mx[N * 4]; // 1. sum 数组换成 mx
void pushup(int p) { mx[p] = max(mx[p * 2], mx[p * 2 + 1]); } // 2. 合并两个儿子:取较大的
void add_tag(int p, long long v) { mx[p] += v; tag[p] += v; } // 3. 整段加 v,最大值也加 v,和区间长度无关
// 4. query 里:result 的初值改成 LLONG_MIN,两处 result += ... 改成 result = max(result, ...)
如果只有单点修改,就不需要 tag 和 pushdown:update 一路走到叶子(l == r),改掉,回来的路上 pushup。
易错点
- 数组开 4 倍。n 不是 2 的幂时,结点编号会超过 2n(n = 6 就会用到 13 号结点),开 2 倍会越界。
update和query往下走之前都要pushdown。只在update里写了,查询就会读到没更新的儿子。update结尾的pushup别漏,漏了父亲的值就是旧的。add_tag里乘的是这个结点自己的区间长度r - l + 1。pushdown时两个儿子的长度分别是mid - l + 1和r - mid,传参时别把父亲的 l、r 原样传下去。- 递归条件是
ql <= mid和qr > mid。写成qr >= mid会走进不存在的区间,无限递归到 RE。 sum和tag都用long long。- 多组数据:
build里顺手tag[p] = 0,模板里已经写了。 - 调不出来时,先写一个十行的数组暴力(每次操作老老实实循环),随机小数据对拍,比盯着代码看快得多。
练一练:洛谷 P3372 【模板】线段树 1;P1531 I Hate It(单点修改、区间最大值);P3373 【模板】线段树 2(加法和乘法两种标记,了解)。
5.9 近年 T4 里的数据结构 【了解】
| 年份 | T4 | 正解用到的 |
|---|---|---|
| 2021 | 棋局 | 并查集 + 线段树合并 |
| 2022 | 比赛 | 单调栈 + 离线扫描线 + 线段树维护“历史和” |
| 2023~2025 | 天天爱打卡、树上查询、序列询问 | 这三道是模拟卷里的题,做法写在 realp/模拟卷做法要点.md 里,考完对应的一套再看 |
这张表是根据题目和网上公开的题解整理的做法概述,不是官方题解。
这几个词是什么意思:
- 线段树优化 DP:DP 的转移是“从前面所有的 j 里取 f[j] + w(j, i) 的最大值”,朴素做法 O(n²)。如果 i 每往后挪一格,w(j, i) 的变化正好是“给一段连续的 j 都加上同一个数”,就可以把这些值放在线段树上,用区间加、区间最大值来做转移,降到 O(n log n)。
- 离线 + 扫描线:询问不要求立刻回答时,先全部读进来,按右端点排序。让右端点 r 从 1 扫到 n,每扫一格就把“位置 r 带来的影响”更新进数据结构,再回答所有右端点恰好是 r 的询问。这样把“左端点、右端点”两个维度,变成一个靠扫描、一个靠数据结构。
- 历史和:线段树除了维护当前的值,还维护“每个位置从开始到现在,所有时刻的值加起来是多少”。配合扫描线,可以回答“所有子区间的某个量之和”。它的标记怎么合并相当绕,属于省选的内容。
为什么这两周不学:这些都建立在线段树已经闭着眼睛能写对的基础上,每一种还要专门做十来道题才敢在考场上用。对现在的你,在 T4 上花 40 分钟写一个稳的暴力,比赌正解划算得多。
这类题的部分分怎么拿
- 表里最前面的一两档(n、q 只有几十到几千):完全照题意模拟,两三重循环。每道 T4 都有这一档,必须拿到。
- “n 小 q 大”或“q 小 n 大”的档:前者先 O(n²) 把所有区间的答案算出来存进表里,每次询问直接查;后者每个询问 O(n) 现算。
- 特殊性质档:把性质代进去,题目常常退化成本章的某个基础工具。“所有询问的左端点都是 1”——只需要前缀信息;“序列单调”——最值就在端点;“没有修改”——前缀和或 ST 表。
- 给暴力提速一档:暴力里如果有“每次 O(n) 求区间最值 / 区间和”,换成 ST 表 / 前缀和,复杂度少一个 n,可能就多过一档。
怎么读数据范围表、时间怎么分,见第 9 章。
本章必会清单
- 能说出 5.1 表里每一行该用什么
- 知道
stack、queue、priority_queue没有clear(),取元素之前先判空 - 默写小根堆的声明:
priority_queue<int, vector<int>, greater<int> > - 默写树状数组的
add和prefix,并说出为什么下标不能是 0 - 用树状数组 + 离散化求逆序对,一次写对(洛谷 P1908)
- 照着模板写出单调栈,说清楚相等元素为什么要“一边严格、一边不严格”
- 照着模板写出线段树(区间加、区间和),交到洛谷 P3372 通过
- 看到 T4 的数据范围表,能指出哪几档是暴力能拿的