NOIP 算法笔记全部章节
本章目录

第 5 章 数据结构

本章定位:近五届 NOIP 的 T4(2021 棋局、2022 比赛、2023 天天爱打卡、2024 树上查询、2025 序列询问)正解都是数据结构,但那些不是两周能练出来的,也不是你现在的得分点。这一章要的是另外三件事:

  1. 堆、树状数组这类小工具,在 T1~T3 里随手就要用,必须一次写对;
  2. 单调栈、单调队列、ST 表、线段树能照着模板写出来——它们常常是 T2、T3 中间那几档部分分的钥匙;
  3. 看到 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 分钟写一个稳的暴力,比赌正解划算得多。

这类题的部分分怎么拿

  1. 表里最前面的一两档(n、q 只有几十到几千):完全照题意模拟,两三重循环。每道 T4 都有这一档,必须拿到。
  2. “n 小 q 大”或“q 小 n 大”的档:前者先 O(n²) 把所有区间的答案算出来存进表里,每次询问直接查;后者每个询问 O(n) 现算。
  3. 特殊性质档:把性质代进去,题目常常退化成本章的某个基础工具。“所有询问的左端点都是 1”——只需要前缀信息;“序列单调”——最值就在端点;“没有修改”——前缀和或 ST 表。
  4. 给暴力提速一档:暴力里如果有“每次 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 的数据范围表,能指出哪几档是暴力能拿的