数据结构模板
0. 选型总表
先给结论,后面各节展开。
| 需求 | 首选 | 备选 | 复杂度 |
|---|---|---|---|
| 区间加 / 区间和 | 树状数组 ×2 | 线段树 | \(O(\log n)\) |
| 区间 chmin / chmax + 区间和 | Segment Tree Beats | \(O(\log n)\) 均摊 | |
| 区间开方 / 取模 | 势能线段树 | \(O(\log n)\) 均摊 | |
| 区间第 \(k\) 小(静态) | 主席树 | 整体二分 | \(O(\log n)\) |
| 区间第 \(k\) 小(带修) | 树套树 / 整体二分 | 分块 | \(O(\log^2 n)\) |
| 区间众数 / 区间不同数 | 回滚莫队 / 莫队 | 分块 | \(O(n\sqrt n)\) |
| 三维偏序 | CDQ 分治 | 树套树 | \(O(n\log^2 n)\) |
| 动态图连通性(离线) | 线段树分治 + 可撤销并查集 | LCT | \(O(n\log^2 n)\) |
| 动态树路径信息 | LCT | 树剖(无 link/cut 时) | \(O(\log n)\) 均摊 |
| 树上路径计数 | 点分治 | \(O(n\log n)\) | |
| 树上带修路径计数 | 点分树 | \(O(\log^2 n)\) | |
| 子树统计(离线) | DSU on tree | 线段树合并 | \(O(n\log n)\) |
| 插入直线求最值 | 李超线段树 | 凸包 / 斜率优化 | \(O(\log^2 n)\) |
| 序列翻转 / 插入删除 | FHQ Treap | Splay | \(O(\log n)\) |
| \(k\) 级祖先 \(O(1)\) | 长链剖分 | 倍增 | \(O(1)\) |
1. 线段树进阶
1.1 懒标记的设计原则
一个标记能用,需要满足三条:
- 可合并:两个同类标记能合成一个(加法:\(a + b\);赋值:后者覆盖)。
- 可作用于区间信息:知道区间的和、长度就能算出打标记后的和。
- 多种标记时有明确的复合顺序。
区间乘 + 区间加(洛谷 P3373):把标记统一写成 \(x \mapsto ax + b\)。新标记 \((a', b')\) 作用在旧标记 \((a, b)\) 上得 \((a'a,\ a'b + b')\)。下传时先乘后加。
区间赋值 + 区间加:赋值标记存在时,后来的加法累加到赋值上;加法标记存在时,后来的赋值直接清空加法。
讲课提示:让学生把 \((a, b)\) 复合的式子自己推一遍,理解"标记是函数,复合是函数复合",之后任何标记组合都能自己推。
1.2 标记永久化
不下传标记,查询时累加路径上的所有标记。
适用:可持久化线段树(下传会新建节点)、二维线段树(内层无法下传)、标记只有加法且查询是求和 / 求最值时。
限制:标记之间必须可交换(加法可以,赋值不行)。
void add(int p, int l, int r, int ql, int qr, ll v) {
if (ql <= l && r <= qr) { tag[p] += v; sum[p] += v * (r - l + 1); return; }
sum[p] += v * (min(r, qr) - max(l, ql) + 1); // 部分覆盖也更新 sum
int mid = (l + r) >> 1;
if (ql <= mid) add(p << 1, l, mid, ql, qr, v);
if (qr > mid) add(p << 1 | 1, mid + 1, r, ql, qr, v);
}
ll query(int p, int l, int r, int ql, int qr, ll acc) { // acc: 祖先标记累加
if (ql <= l && r <= qr) return sum[p] + acc * (r - l + 1);
int mid = (l + r) >> 1; ll res = 0;
if (ql <= mid) res += query(p << 1, l, mid, ql, qr, acc + tag[p]);
if (qr > mid) res += query(p << 1 | 1, mid + 1, r, ql, qr, acc + tag[p]);
return res;
}
1.3 线段树上二分
问题:找第一个位置 \(p \ge l\) 使得 \(a_p \ge v\)。
int findFirst(int p, int l, int r, int ql, int v) { // 返回 [ql, r] 内第一个 ≥ v 的位置,无则 -1
if (r < ql || mx[p] < v) return -1;
if (l == r) return l;
pushdown(p);
int mid = (l + r) >> 1;
int res = findFirst(p << 1, l, mid, ql, v);
if (res == -1) res = findFirst(p << 1 | 1, mid + 1, r, ql, v);
return res;
}
复杂度 \(O(\log n)\):左子树返回 \(-1\) 只可能因为"整个左子树 \(\max < v\)"(\(O(1)\) 判掉)或"左子树与 \([ql, r]\) 不交",真正的递归路径只有一条。
权值线段树上找第 \(k\) 小:若左子树计数 \(\ge k\) 走左,否则 \(k\) 减去左子树计数走右。这是主席树、线段树合并等一切"值域线段树"的基本操作。
1.4 线段树合并与分裂
合并:把两棵值域线段树合成一棵。
int merge(int a, int b, int l, int r) {
if (!a || !b) return a | b; // 一边为空,直接接上
if (l == r) { cnt[a] += cnt[b]; return a; }
int mid = (l + r) >> 1;
ls[a] = merge(ls[a], ls[b], l, mid);
rs[a] = merge(rs[a], rs[b], mid + 1, r);
pushup(a);
return a;
}
复杂度:每次递归到的节点是两棵树都有的节点,合并后消失一个。总节点数 \(O(n \log n)\),所以所有合并的总复杂度 \(O(n \log n)\)。势能分析:节点只减不增。
分裂:按排名把前 \(k\) 个留在 \(a\),其余分到新树 \(b\)。
void split(int a, int &b, int k) {
if (!a) return;
b = newnode();
int lc = cnt[ls[a]];
if (k > lc) split(rs[a], rs[b], k - lc);
else swap(rs[a], rs[b]);
if (k < lc) split(ls[a], ls[b], k);
cnt[b] = cnt[a] - k; cnt[a] = k;
}
例题:洛谷 P4556 [Vani有约会] 雨天的尾巴。树上路径 \((u, v)\) 加一种颜色,问每点最多的颜色。树上差分:\(u, v\) 处 \(+1\),\(\text{lca}\) 和 \(\text{fa}(\text{lca})\) 处 \(-1\),每点开一棵值域线段树,自底向上合并,合并后查全局最大。
例题:洛谷 P5494 【模板】线段树分裂。
1.5 势能线段树与 Segment Tree Beats
思想:某些区间操作没有好的懒标记,但每个元素被真正修改的次数有限。线段树只负责"快速找到还需要修改的元素",用区间最值剪枝。
(a)区间开方(洛谷 P4145 花神游历各国):一个数开方 \(O(\log\log V)\) 次后变成 1。节点维护 \(\max\),\(\max \le 1\) 的区间直接跳过。总复杂度 \(O(n \log n \log\log V)\)。
(b)区间取模(CF 438D):\(x \bmod m\) 若 \(m \le x\) 则至少减半。\(\max < m\) 的区间跳过。
(c)区间 chmin + 区间和(Segment Tree Beats,吉司机线段树)
操作:\(a_i \gets \min(a_i, v)\) 对区间;查询区间和。
节点维护:最大值 \(\text{mx1}\)、严格次大值 \(\text{mx2}\)、最大值个数 \(\text{cnt}\)、区间和 \(\text{sum}\)。
struct Node { ll sum; int mx1, mx2, cnt; } t[N << 2];
void pushup(int p) {
Node &L = t[p << 1], &R = t[p << 1 | 1];
t[p].sum = L.sum + R.sum;
if (L.mx1 == R.mx1) { t[p].mx1 = L.mx1; t[p].cnt = L.cnt + R.cnt; t[p].mx2 = max(L.mx2, R.mx2); }
else if (L.mx1 > R.mx1) { t[p].mx1 = L.mx1; t[p].cnt = L.cnt; t[p].mx2 = max(L.mx2, R.mx1); }
else { t[p].mx1 = R.mx1; t[p].cnt = R.cnt; t[p].mx2 = max(L.mx1, R.mx2); }
}
void applyMin(int p, int v) { // 前提:mx2 < v < mx1
if (v >= t[p].mx1) return;
t[p].sum -= (ll)(t[p].mx1 - v) * t[p].cnt;
t[p].mx1 = v;
}
void pushdown(int p) { applyMin(p << 1, t[p].mx1); applyMin(p << 1 | 1, t[p].mx1); }
void chmin(int p, int l, int r, int ql, int qr, int v) {
if (qr < l || r < ql || t[p].mx1 <= v) return; // 剪枝 1:整段都不大于 v
if (ql <= l && r <= qr && t[p].mx2 < v) { applyMin(p, v); return; } // 剪枝 2:只有最大值会变
pushdown(p);
int mid = (l + r) >> 1;
chmin(p << 1, l, mid, ql, qr, v);
chmin(p << 1 | 1, mid + 1, r, ql, qr, v);
pushup(p);
}
叶子的 \(\text{mx2} = -\infty\)。标记是隐式的:子节点的 \(\text{mx1}\) 若大于父节点的 \(\text{mx1}\),就说明父节点有未下传的 chmin。
复杂度:只有 chmin 时 \(O((n + q)\log n)\);加上区间加时 \(O((n+q)\log^2 n)\)。证明用"区间内不同值的个数"作势能,每次递归到剪枝 2 以下必然减少一个不同值。
例题:洛谷 P6242 【模板】线段树 3(chmin + 区间加 + 区间和 + 历史最大值,完整版)。
1.6 区间单调栈:楼房重建
问题(洛谷 P4198):\(n\) 座楼,单点修改高度,每次查从原点能看到几座楼(斜率严格递增的前缀最大值个数)。
节点维护:区间最大斜率 \(\text{mx}\),以及只考虑本区间时可见的楼数 \(\text{cnt}\)。
关键是 pushup:左区间的贡献直接是 \(\text{cnt}_L\),右区间的贡献需要在"左区间最大值为门槛"的条件下重算:
int calc(int p, int l, int r, double lim) { // 区间 p 在左侧最大值为 lim 时的可见数
if (t[p].mx <= lim) return 0;
if (l == r) return 1;
int mid = (l + r) >> 1;
if (t[p << 1].mx <= lim) return calc(p << 1 | 1, mid + 1, r, lim); // 左半全被挡,只看右半
return calc(p << 1, l, mid, lim) + t[p].cnt - t[p << 1].cnt; // 左半有贡献,右半贡献不变
}
void pushup(int p, int l, int r) {
int mid = (l + r) >> 1;
t[p].mx = max(t[p << 1].mx, t[p << 1 | 1].mx);
t[p].cnt = t[p << 1].cnt + calc(p << 1 | 1, mid + 1, r, t[p << 1].mx);
}
calc 每层只递归一边,\(O(\log n)\);单次修改 \(O(\log^2 n)\)。
推广:任何"右区间的信息依赖左区间的某个统计量,且这个依赖可以右区间上二分"的问题都能这样做。
1.7 维护可合并的复杂信息
最大子段和:节点存 \((\text{sum}, \text{lmax}, \text{rmax}, \text{ans})\)。
区间矩阵乘:当转移是线性的(如斐波那契:区间加 \(k\) 项相当于乘 \(\begin{pmatrix}1&1\\1&0\end{pmatrix}^k\)),标记就是矩阵,标记复合是矩阵乘。例题 CF 718C Sasha and Array。
原则:只要信息是一个半群(有结合律的合并运算),线段树就能维护。设计时先写出"两个相邻区间的信息如何合成一个"。
1.8 李超线段树
功能:插入直线 \(y = kx + b\)(或线段),查询某个 \(x\) 处所有直线的最大值。
每个节点保存一条"在本区间中点处最优"的直线。插入新直线时与节点直线比较:中点处更优的留下,另一条在它可能更优的那一侧递归。
struct Line { ll k, b; ll operator()(ll x) const { return k * x + b; } };
Line t[N << 2]; bool has[N << 2];
void insert(int p, int l, int r, Line f) {
if (!has[p]) { t[p] = f; has[p] = true; return; }
int mid = (l + r) >> 1;
if (f(mid) > t[p](mid)) swap(f, t[p]); // 中点更优的留在本节点
if (l == r) return;
if (f(l) > t[p](l)) insert(p << 1, l, mid, f); // 被换下的直线只可能在一侧更优
else if (f(r) > t[p](r)) insert(p << 1 | 1, mid + 1, r, f);
}
ll query(int p, int l, int r, int x) {
ll res = has[p] ? t[p](x) : -INF;
if (l == r) return res;
int mid = (l + r) >> 1;
return max(res, x <= mid ? query(p << 1, l, mid, x) : query(p << 1 | 1, mid + 1, r, x));
}
插入直线 \(O(\log n)\),插入线段(先定位到 \(O(\log n)\) 个节点再各自插入)\(O(\log^2 n)\),查询 \(O(\log n)\)。
用途:斜率优化 DP 中决策点不单调、或者 \(x\) 不单调时的替代方案。模板:洛谷 P4097 [HEOI2013] Segment。
1.9 可持久化线段树
主席树(区间第 \(k\) 小,洛谷 P3834):对前缀 \([1, i]\) 建值域线段树,版本 \(i\) 由版本 \(i-1\) 修改一个位置得到,只新建 \(O(\log n)\) 个节点。查询 \([l, r]\) 用版本 \(r\) 减版本 \(l-1\)。
int update(int pre, int l, int r, int pos) {
int p = ++tot; t[p] = t[pre]; t[p].cnt++;
if (l == r) return p;
int mid = (l + r) >> 1;
if (pos <= mid) t[p].ls = update(t[pre].ls, l, mid, pos);
else t[p].rs = update(t[pre].rs, mid + 1, r, pos);
return p;
}
int kth(int a, int b, int l, int r, int k) { // 版本 b 减版本 a
if (l == r) return l;
int mid = (l + r) >> 1, x = t[t[b].ls].cnt - t[t[a].ls].cnt;
if (k <= x) return kth(t[a].ls, t[b].ls, l, mid, k);
return kth(t[a].rs, t[b].rs, mid + 1, r, k - x);
}
可持久化数组 / 并查集:把数组存成线段树,单点修改产生新版本。可持久化并查集用它存 \(\text{fa}\) 和 \(\text{dep}\),按秩合并不路径压缩,\(O(\log^2 n)\)(洛谷 P3402)。
区间 mex(洛谷 P4137):版本 \(r\) 的线段树在值域上维护"每个值最后出现的位置",查询 \([l, r]\) 的 mex 即找最小的值使其最后出现位置 \(< l\),线段树上二分。
2. 树状数组进阶
2.1 区间加、区间和
设差分数组 \(d\),\(a_i = \sum_{j \le i} d_j\),则
维护两个树状数组:\(B_1\) 存 \(d_j\),\(B_2\) 存 \(j \cdot d_j\)。
void rangeAdd(int l, int r, ll v) {
B1.add(l, v); B1.add(r + 1, -v);
B2.add(l, l * v); B2.add(r + 1, -(r + 1) * v);
}
ll prefix(int x) { return (x + 1) * B1.sum(x) - B2.sum(x); }
ll rangeSum(int l, int r) { return prefix(r) - prefix(l - 1); }
比线段树快 3 到 5 倍,代码短一半。
2.2 树状数组上倍增找第 \(k\) 小
值域树状数组 \(c\),找第 \(k\) 小的值。从高位往低位试探:
int kth(int k) {
int pos = 0;
for (int i = LOG; i >= 0; i--)
if (pos + (1 << i) <= n && c[pos + (1 << i)] < k) {
pos += 1 << i;
k -= c[pos];
}
return pos + 1;
}
\(O(\log n)\),比"二分 + 前缀和"的 \(O(\log^2 n)\) 快一个 \(\log\)。原理:树状数组节点 \(c[pos + 2^i]\) 恰好管辖 \((pos, pos + 2^i]\)。
2.3 二维偏序与离线
"\(i < j\) 且 \(a_i < a_j\) 的对数":按 \(i\) 扫描,树状数组维护已扫描的 \(a\) 值。一维排序、一维树状数组是所有偏序题的原型,CDQ 分治只是再加一维分治。
区间不同数个数(洛谷 P1972 HH 的项链):离线按 \(r\) 排序,对每个值只保留最后出现位置为 1,查询 \([l, r]\) 的和。
2.4 二维树状数组
\(O(\log^2 n)\) 单点修改矩形查询。矩形加矩形和需要 4 个二维树状数组(类比 2.1 的推导,展开 \((x+1)(y+1)\) 得四项)。
3. 平衡树:FHQ Treap
3.1 核心操作
只需两个原语:分裂和合并。所有操作都是它们的组合。
int ch[N][2], val[N], pri[N], sz[N], tot;
int newnode(int v) { ++tot; val[tot] = v; pri[tot] = rand(); sz[tot] = 1; return tot; }
void pushup(int x) { sz[x] = sz[ch[x][0]] + sz[ch[x][1]] + 1; }
void split(int x, int v, int &a, int &b) { // a: 所有 ≤ v 的,b: 所有 > v 的
if (!x) { a = b = 0; return; }
if (val[x] <= v) { a = x; split(ch[x][1], v, ch[a][1], b); }
else { b = x; split(ch[x][0], v, a, ch[b][0]); }
pushup(x);
}
int merge(int a, int b) { // 前提:a 中所有值 ≤ b 中所有值
if (!a || !b) return a | b;
if (pri[a] > pri[b]) { ch[a][1] = merge(ch[a][1], b); pushup(a); return a; }
ch[b][0] = merge(a, ch[b][0]); pushup(b); return b;
}
void insert(int v) { int a, b; split(root, v, a, b); root = merge(merge(a, newnode(v)), b); }
void erase(int v) { // 删一个 v
int a, b, c;
split(root, v, a, c); split(a, v - 1, a, b);
b = merge(ch[b][0], ch[b][1]); // 去掉 b 的根
root = merge(merge(a, b), c);
}
int rankOf(int v) { int a, b; split(root, v - 1, a, b); int r = sz[a] + 1; root = merge(a, b); return r; }
第 \(k\) 小、前驱、后继都是"分裂 → 看 \(\text{sz}\) 或取极值 → 合并回去"。模板:洛谷 P3369。
3.2 按排名分裂与区间翻转
bool rev[N];
void pushdown(int x) {
if (rev[x]) { swap(ch[x][0], ch[x][1]); rev[ch[x][0]] ^= 1; rev[ch[x][1]] ^= 1; rev[x] = 0; }
}
void splitSz(int x, int k, int &a, int &b) { // a: 前 k 个
if (!x) { a = b = 0; return; }
pushdown(x);
if (sz[ch[x][0]] + 1 <= k) { a = x; splitSz(ch[x][1], k - sz[ch[x][0]] - 1, ch[a][1], b); }
else { b = x; splitSz(ch[x][0], k, a, ch[b][0]); }
pushup(x);
}
void reverse(int l, int r) {
int a, b, c;
splitSz(root, r, a, c); splitSz(a, l - 1, a, b);
rev[b] ^= 1;
root = merge(merge(a, b), c);
}
merge 中也要在访问儿子前 pushdown。模板:洛谷 P3391 文艺平衡树。
3.3 可持久化 Treap
每次 split 和 merge 中凡是要修改的节点先复制。随机优先级存在节点上,复制时一起复制。\(O(\log n)\) 空间每操作。模板:洛谷 P5055。
3.4 什么时候不用平衡树
- 只需要有序集合的插入、删除、前驱后继:
std::set/multiset。 - 需要第 \(k\) 小和排名:
__gnu_pbds::tree(tree_order_statistics_node_update)。 - 值域已知且离线:值域线段树 / 树状数组更快更短。
只有"序列上的插入、删除、翻转、分裂、合并"才必须自己写平衡树。
4. 分块与根号算法
4.1 分块的基本平衡
块长 \(B\),\(n/B\) 个块。整块操作 \(O(n/B)\),散块 \(O(B)\)。\(B = \sqrt n\) 时总 \(O(\sqrt n)\)。
不对称时重新平衡:若修改代价 \(O(B)\)、查询代价 \(O(n/B)\),修改次数 \(q_1\)、查询次数 \(q_2\),则取 \(B = \sqrt{n q_2 / q_1}\)。
4.2 值域分块
值域 \([1, V]\) 分成 \(\sqrt V\) 块,维护每块的计数。
- 插入 / 删除:\(O(1)\)(更新元素计数和块计数)。
- 查询前缀和 / 第 \(k\) 小:\(O(\sqrt V)\)(先跳整块再扫散块)。
用在莫队里:莫队每次移动指针 \(O(1)\) 修改、每次查询 \(O(\sqrt n)\),与树状数组的 \(O(\log n) / O(\log n)\) 相比,总复杂度 \(O(n\sqrt n + q\sqrt n)\) 优于 \(O(n\sqrt n\log n)\)。
4.3 莫队
普通莫队:离线,按 \((\text{block}(l),\ r)\) 排序,\(l, r\) 指针移动总次数 \(O(n\sqrt q)\)。
sort(qs.begin(), qs.end(), [&](const Q& a, const Q& b) {
if (bl[a.l] != bl[b.l]) return a.l < b.l;
return (bl[a.l] & 1) ? a.r < b.r : a.r > b.r; // 奇偶排序优化,常数减半
});
int l = 1, r = 0;
for (auto& q : qs) {
while (l > q.l) add(--l);
while (r < q.r) add(++r);
while (l < q.l) del(l++);
while (r > q.r) del(r--);
ans[q.id] = cur;
}
带修莫队:加时间维 \(t\),按 \((\text{block}(l), \text{block}(r), t)\) 排序,块长取 \(n^{2/3}\),总 \(O(n^{5/3})\)。模板:洛谷 P1903。
回滚莫队:适用于"加入容易、删除困难"(如区间众数:加入时更新最大值容易,删除时无法回退最大值)。
- 按左端点块分组,同块内右端点递增。
- 右指针只增;左指针每次从块的右边界出发向左扫到 \(l\),回答后撤销左侧的加入(记录修改栈或直接重算)。
- 左右端点同块的询问暴力。
\(O(n\sqrt n)\)。模板:洛谷 P5906。
树上莫队:用欧拉序(每点进出各一次)把路径 \(u \to v\) 转成区间 \([\text{in}_u, \text{in}_v]\)(\(u\) 为祖先时)或 \([\text{out}_u, \text{in}_v]\)(否则,另加 lca),区间内出现奇数次的点即路径上的点。模板:洛谷 SP10707 COT2。
4.4 根号分治
按某个量的大小分两类,分别用不同的算法,使两边复杂度平衡。
例题:洛谷 P3396 哈希冲突。询问 \(\sum_{i \equiv y \pmod x} a_i\),带单点修改。
- \(x \le \sqrt n\):预处理 \(f[x][y]\),\(O(n\sqrt n)\) 空间;修改时更新所有 \(x \le \sqrt n\) 的 \(f\),\(O(\sqrt n)\)。
- \(x > \sqrt n\):直接暴力跳,至多 \(n / x < \sqrt n\) 项。
度数分治(三元环计数):度数 \(> \sqrt m\) 的点不超过 \(\sqrt m\) 个。给边定向(度小指向度大),枚举每个点 \(u\) 的出边 \((u,v)\) 和 \((u,w)\) 查 \((v,w)\),总 \(O(m\sqrt m)\)。模板:洛谷 P1989。
4.5 分块 + bitset
区间"是否存在某值"、"两个区间元素集合是否相同"等,用 bitset 压位,把 \(O(n)\) 的集合操作压到 \(O(n / 64)\)。配合分块或莫队。例题:洛谷 P4688 [Ynoi2016] 掉进兔子洞。
5. 树上数据结构
5.1 重链剖分的两个关键性质
DFS 序中
- 子树是连续区间 \([\text{dfn}_u,\ \text{dfn}_u + \text{sz}_u - 1]\)。
- 重链是连续区间,且从任一点向上跳到根经过 \(O(\log n)\) 条重链。
所以"子树修改 / 查询"是一次线段树操作,"路径修改 / 查询"是 \(O(\log n)\) 次。
ll queryPath(int u, int v) {
ll res = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
res += seg.query(dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
if (dep[u] > dep[v]) swap(u, v);
res += seg.query(dfn[u], dfn[v]); // 边权时改为 dfn[u]+1
return res;
}
5.2 长链剖分
按子树深度选重儿子。性质:每点到叶子的长链总长 \(O(n)\);任一点向上跳到链顶的次数 \(O(\sqrt n)\),但\(k\) 级祖先可以 \(O(1)\)。
\(k\) 级祖先(洛谷 P5903):预处理每条链顶向上、向下各链长个祖先 / 后代。查询时先跳 \(2^{\lfloor\log k\rfloor}\) 级(倍增表),落到某链上,该链长 \(\ge 2^{\lfloor\log k\rfloor} \ge\) 剩余步数,直接查表。
深度相关 DP 合并:\(f[u][d]\) 表示子树 \(u\) 中深度为 \(d\) 的信息。长儿子的数组直接继承(指针偏移),短儿子暴力合并,总 \(O(n)\)。例题:洛谷 P5904 [POI2014] HOT-Hotels 加强版。
5.3 DSU on tree(树上启发式合并)
问题:对每个点 \(u\) 回答关于子树 \(u\) 的统计(如出现次数最多的颜色),离线。
void dfs(int u, int p, bool keep) {
for (int v : adj[u]) if (v != p && v != heavy[u]) dfs(v, u, false); // 轻儿子:算完就清
if (heavy[u]) dfs(heavy[u], u, true); // 重儿子:保留
for (int v : adj[u]) if (v != p && v != heavy[u])
for (int x = dfn[v]; x < dfn[v] + sz[v]; x++) add(node[x]); // 把轻子树加回来
add(u);
ans[u] = query();
if (!keep) for (int x = dfn[u]; x < dfn[u] + sz[u]; x++) del(node[x]);
}
复杂度:每个点被 add 的次数 = 它到根路径上轻边数 \(= O(\log n)\),总 \(O(n\log n)\)。模板:CF 600E Lomsat gelral。
与线段树合并的选择:DSU on tree 常数小、写法简单,但要求信息可增删;线段树合并支持更复杂的信息(如"第 \(k\) 大"),空间 \(O(n\log n)\)。
5.4 点分治
问题:统计树上满足某条件的路径数(如长度 \(\le k\))。
步骤:
- 找当前连通块的重心 \(c\)(最大子树最小)。
- 统计经过 \(c\) 的路径:收集各子树到 \(c\) 的距离,合并计算。
- 删掉 \(c\),对每个子树递归。
重心保证递归深度 \(O(\log n)\),每层总 \(O(n)\) 或 \(O(n\log n)\)。
去重:统计经过 \(c\) 的路径时,"两端在同一子树"的路径不合法。两种处理:
- 容斥:先算所有点对,再减去每个子树内部的点对(子树内距离要加上到 \(c\) 的边)。
- 顺序合并:逐个子树处理,先与已有集合配对再加入,天然不重。
模板:洛谷 P3806。
5.5 点分树(动态点分治)
把点分治的递归结构建成一棵树(重心的父亲是上一层重心),深度 \(O(\log n)\)。这棵树上的祖先关系覆盖了原树所有路径:任意两点 \(u, v\) 的路径必经过它们在点分树上的 lca。
每个点 \(c\) 维护:以 \(c\) 为分治中心的连通块内所有点的信息(按到 \(c\) 的距离分桶,用树状数组 / 动态开点线段树),以及到点分树父亲的距离信息(用于容斥去重)。
查询 \(u\) 周围距离 \(\le k\) 的点权和:从 \(u\) 沿点分树向上爬每个祖先 \(c\),加上 \(c\) 桶中距离 \(\le k - \text{dist}(u, c)\) 的部分,减去 \(c\) 的儿子方向(即上一个祖先)重复统计的部分。\(O(\log^2 n)\)。
模板:洛谷 P6329 【模板】点分树 | 震波。
5.6 Link-Cut Tree
用途:动态树的连接、断开、路径信息查询、换根。
结构:把树剖成若干实链(可以任意变化),每条实链用一棵 Splay 维护(按深度为中序),Splay 之间通过"认父不认子"的虚边连接。
int ch[N][2], fa[N], val[N], sum[N]; bool rev[N];
bool nroot(int x) { return ch[fa[x]][0] == x || ch[fa[x]][1] == x; } // x 是否为所在 Splay 的根
void pushup(int x) { sum[x] = sum[ch[x][0]] ^ sum[ch[x][1]] ^ val[x]; }
void flip(int x) { swap(ch[x][0], ch[x][1]); rev[x] ^= 1; }
void pushdown(int x) {
if (rev[x]) { if (ch[x][0]) flip(ch[x][0]); if (ch[x][1]) flip(ch[x][1]); rev[x] = 0; }
}
void rotate(int x) {
int y = fa[x], z = fa[y], k = ch[y][1] == x, w = ch[x][!k];
if (nroot(y)) ch[z][ch[z][1] == y] = x;
ch[x][!k] = y; ch[y][k] = w;
if (w) fa[w] = y;
fa[y] = x; fa[x] = z;
pushup(y); pushup(x);
}
void splay(int x) {
static int st[N]; int top = 0, y = x;
st[top++] = y;
while (nroot(y)) st[top++] = y = fa[y];
while (top) pushdown(st[--top]); // 从上到下放标记
while (nroot(x)) {
int y = fa[x], z = fa[y];
if (nroot(y)) rotate((ch[y][0] == x) ^ (ch[z][0] == y) ? x : y);
rotate(x);
}
}
void access(int x) { // 打通根到 x 的实链,x 成为链底
for (int y = 0; x; y = x, x = fa[x]) { splay(x); ch[x][1] = y; pushup(x); }
}
void makeroot(int x) { access(x); splay(x); flip(x); } // 换根:翻转整条链
int findroot(int x) {
access(x); splay(x);
while (ch[x][0]) pushdown(x), x = ch[x][0];
splay(x); return x;
}
void split(int x, int y) { makeroot(x); access(y); splay(y); } // 之后 sum[y] 即路径 x~y 的信息
void link(int x, int y) { makeroot(x); if (findroot(y) != x) fa[x] = y; }
void cut(int x, int y) {
makeroot(x);
if (findroot(y) == x && fa[y] == x && !ch[y][0]) { fa[y] = ch[x][1] = 0; pushup(x); }
}
维护子树信息:额外维护"虚子树信息和" \(\text{vir}[x]\),在 access 改变右儿子和 link 时更新。子树信息 = Splay 子树和 + 虚子树和。
典型应用:
- 动态连通性 + 路径异或 / 和:洛谷 P3690。
- 动态最小生成树 / 最小差值生成树:按边权排序依次加边,成环时删掉环上最大边(把边拆成点维护最值)。洛谷 P4234。
- 维护森林的连通块大小、动态 LCA。
5.7 其他树上技巧速览
- 欧拉序 + ST 表 LCA:\(O(n\log n)\) 预处理 \(O(1)\) 查询,比倍增快。
- 树上差分:路径加转成 4 个点的单点加,最后一次 DFS 求子树和。
- 括号序:子树 = 连续区间,配合可持久化做"子树历史版本"查询。
- 虚树:只保留关键点及其两两 lca,把 \(O(n)\) 的树 DP 压到 \(O(k\log n)\)。
6. 离线与分治技巧
6.1 CDQ 分治:三维偏序
问题(洛谷 P3810 陌上花开):统计 \(a_j \le a_i,\ b_j \le b_i,\ c_j \le c_i\) 的 \(j\) 数量。
- 按 \(a\) 排序(去重,相同三元组合并计数)。
- 分治 \([l, r]\):递归两半后,左半对右半的贡献满足 \(a\) 维(左半 \(a\) 更小),按 \(b\) 归并,树状数组维护 \(c\)。
void cdq(int l, int r) {
if (l == r) return;
int mid = (l + r) >> 1;
cdq(l, mid); cdq(mid + 1, r);
int i = l, j = mid + 1, k = l;
while (j <= r) {
while (i <= mid && a[i].b <= a[j].b) { bit.add(a[i].c, a[i].cnt); tmp[k++] = a[i++]; }
a[j].ans += bit.query(a[j].c); tmp[k++] = a[j++];
}
for (int t = l; t < i; t++) bit.add(a[t].c, -a[t].cnt); // 清空树状数组
while (i <= mid) tmp[k++] = a[i++];
for (int t = l; t <= r; t++) a[t] = tmp[t]; // 此时 [l, r] 按 b 有序
}
\(O(n\log^2 n)\)。
动态逆序对(洛谷 P3157):把"删除时间"作为第一维,位置和值作为二、三维。
CDQ 优化 DP:转移 \(f_i = \max_{j < i,\ \text{某偏序}} f_j + w\) 时,先递归左半算出 \(f\),用左半更新右半,再递归右半。
6.2 整体二分
问题(洛谷 P2617 Dynamic Rankings):带修区间第 \(k\) 小。
把所有操作(插入、删除、查询)放在一起,对答案值域二分。对当前值域 \([L, R]\) 和操作集合:
- 用树状数组统计值 \(\le \text{mid}\) 的修改对每个查询的贡献 \(\text{cnt}\)。
- 若 \(\text{cnt} \ge k\),查询归到左半(答案 \(\le \text{mid}\));否则 \(k \gets k - \text{cnt}\),归到右半。修改按值归左右。
- 递归。
\(O((n+q)\log n \log V)\)。与 CDQ 的区别:CDQ 对下标分治,整体二分对答案分治。
6.3 线段树分治 + 可撤销并查集
问题(洛谷 P5787 二分图):每条边在时间区间 \([l_i, r_i]\) 存在,问每个时刻图是否是二分图。
- 把每条边挂到时间线段树上覆盖 \([l_i, r_i]\) 的 \(O(\log)\) 个节点。
- DFS 线段树,进入节点时加入它的边(可撤销并查集,扩展域或带权判奇环),离开时撤销。
- 到叶子时当前状态就是该时刻的答案。若某节点加边时已出现奇环,整个子树都不是二分图,直接标记不递归。
void solve(int p, int l, int r) {
int saved = stk.size(); bool ok = true;
for (auto [u, v] : edges[p])
if (!merge(u, v)) { ok = false; break; } // 返回 false 表示出现奇环
if (ok) {
if (l == r) ans[l] = true;
else { int mid = (l + r) >> 1; solve(p << 1, l, mid); solve(p << 1 | 1, mid + 1, r); }
} else for (int i = l; i <= r; i++) ans[i] = false;
rollback(saved);
}
\(O(m\log m\log n)\)。"离线动态图"问题的通用解:删边变成"存在区间",撤销代替删除。
6.4 扫描线
矩形面积并(洛谷 P5490):按 \(x\) 扫描,线段树维护 \(y\) 轴上被覆盖的长度。节点存覆盖次数 \(\text{cnt}\) 和覆盖长度 \(\text{len}\),\(\text{cnt} > 0\) 时 \(\text{len}\) = 区间长,否则等于儿子之和。不需要下传标记(标记永久化的特例)。
二维数点:按一维排序扫描,另一维用树状数组。所有"矩形内点数"查询都可以拆成 4 个前缀查询离线做。
7. 并查集进阶
7.1 带权并查集
维护每个点到根的"相对关系",路径压缩时合并关系。
食物链(洛谷 P2024):\(d[x] \in \{0,1,2\}\) 表示 \(x\) 与根的关系(同类 / 吃 / 被吃),合并时 \(d\) 相加模 3。
银河英雄传说(洛谷 P1196):\(d[x]\) = \(x\) 到根的距离,合并时加上被合并集合的大小。
int find(int x) {
if (fa[x] == x) return x;
int r = find(fa[x]);
d[x] = (d[x] + d[fa[x]]) % 3; // 先递归让 fa[x] 的 d 变成相对根的
return fa[x] = r;
}
7.2 扩展域
把每个元素拆成多个"域"(如 \(x\) 和 \(x'\) 表示"\(x\) 属于 A 组"和"\(x\) 属于 B 组"),用"域之间连边"表达关系。判二分图:\((u, v)\) 有边则合并 \((u, v')\) 和 \((u', v)\),若 \(u, u'\) 同集则矛盾。
7.3 可撤销并查集
只按秩合并,不路径压缩(否则无法撤销),每次合并记录被改的 \(\text{fa}\) 和秩,撤销时弹栈恢复。单次 \(O(\log n)\)。
bool merge(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false;
if (rk[x] > rk[y]) swap(x, y);
stk.push_back({x, rk[x] == rk[y]});
fa[x] = y; if (rk[x] == rk[y]) rk[y]++;
return true;
}
void rollback(int saved) {
while ((int)stk.size() > saved) {
auto [x, inc] = stk.back(); stk.pop_back();
if (inc) rk[fa[x]]--;
fa[x] = x;
}
}
7.4 并查集维护"下一个可用位置"
删除位置 \(i\) 后令 \(\text{fa}[i] = i + 1\),find(i) 即 \(\ge i\) 的第一个未删除位置。把"跳过已处理元素"的暴力扫描变成均摊 \(O(\alpha)\)。 常见于"区间赋值只保留首次"(倒序处理)、"每个数只被操作一次"的势能题。
8. 单调结构与杂项
8.1 单调栈与笛卡尔树
单调栈:\(O(n)\) 求每个位置左右第一个更大 / 更小的元素。
笛卡尔树:满足堆性质(父 \(\ge\) 子)和 BST 性质(中序为原下标)。\(O(n)\) 用单调栈建:
for (int i = 1; i <= n; i++) {
int last = 0;
while (top && a[stk[top]] < a[i]) last = stk[top--];
if (top) rs[stk[top]] = i;
ls[i] = last;
stk[++top] = i;
}
用途:区间最值分治("以 \(a_i\) 为最大值的区间"就是 \(i\) 在笛卡尔树上的子树对应区间)。
8.2 单调队列
滑动窗口最值 \(O(n)\)。DP 优化:\(f_i = \min_{i - k \le j < i} f_j + w_i\) 型转移。
8.3 ST 表与可重复贡献
\(\max, \min, \gcd, \text{or}, \text{and}\) 满足 \(x \circ x = x\),ST 表可以 \(O(1)\) 查询(两块可重叠)。
技巧:固定左端点 \(l\),\(\gcd(a_l..a_r)\) 随 \(r\) 增大只变化 \(O(\log V)\) 次。ST 表 + 二分找每段变化点,总 \(O(n\log n\log V)\)。
8.4 哈希 + 数据结构
线段树维护区间哈希:节点存哈希值和长度,合并 \(h = h_L \cdot B^{\text{len}_R} + h_R\)。支持单点修改后 \(O(\log n)\) 判断两个区间是否相等、是否回文(同时维护反向哈希)。
珂朵莉树(ODT):用 std::set 维护值相同的极长区间,区间赋值时 split 两端再删除中间。仅在数据随机或有区间赋值保证时复杂度正确,否则会被卡。
8.5 bitset 加速
\(n \le 10^5\) 的 \(O(n^2)\) 布尔运算(传递闭包、可达性、子集判定)用 bitset 压到 \(O(n^2 / 64)\),常常是省选题的预期解。
9. 复杂度与实现的实战经验
- \(\log\) 的层数决定生死。 \(n = 10^5\) 时 \(O(n\log^2 n)\) 稳过,\(O(n\log^3 n)\) 需要小常数,\(O(n\sqrt n\log n)\) 通常超时。
- 递归线段树 vs 树状数组:能用树状数组就不用线段树,快 3 倍以上。
- 空间:动态开点线段树 \(O(n\log n)\) 节点,每个节点两个
int儿子 + 信息,\(4\times10^6\) 节点约 50 MB,注意题目限制。 - 随机化:Treap 的
rand()在 Windows 下只有 15 位,用mt19937。 - 常数优化顺序:先换更优的算法结构(如线段树换树状数组),再考虑
inline、读入优化、register。 - 对拍:所有高阶结构必须与暴力对拍。分块和莫队尤其容易在边界上出错。
10. 常见错误清单
- 懒标记复合顺序错。 乘加标记必须"先乘后加";两种标记都存在时,新来的加法要乘上已有的乘法系数吗?(不用;新来的乘法要乘到已有加法上。)
- 线段树合并没有新建节点就复用了 \(b\) 树。 如果之后还要用 \(b\),必须复制。
- Beats 忘记维护严格次大值。 \(\text{mx2}\) 必须严格小于 \(\text{mx1}\),相等时要合并计数。
- FHQ Treap 的
merge忘记pushdown。 有翻转标记时必须在访问儿子前下传。 - 莫队块长写死 \(\sqrt n\)。 带修莫队要 \(n^{2/3}\),普通莫队最优是 \(n / \sqrt q\)。
- 回滚莫队没有处理同块询问。 左右端点同块时必须暴力。
- CDQ 分治忘记清空树状数组。 用"逆操作"清空而不是
memset。 - 可撤销并查集用了路径压缩。 路径压缩改变了大量 \(\text{fa}\),无法撤销。
- LCT
cut前没有检查连通性和相邻性。 直接cut不相邻的两点会破坏结构。 - 点分治没有重新计算子连通块大小。 找重心时用的 \(\text{sz}\) 必须是当前连通块内的。
- 主席树版本数组开小。 节点数是 \(n\log n\) 级别,通常开 \(n \times 25\) 到 \(n \times 40\)。
- 长链剖分 \(k\) 级祖先跳错方向。 先跳 \(2^{\lfloor\log k\rfloor}\) 级再在链上查,不是反过来。
11. 课堂练习
- 推导"区间乘 \(a\)、区间加 \(b\)"两个标记的复合公式,并说明为什么下传要先乘后加。
- 写出区间赋值 + 区间加共存时,标记下传的四种情况。
- 证明线段树合并总复杂度 \(O(n\log n)\)。
- Segment Tree Beats 中若只有 chmin 没有区间加,为什么复杂度是单 \(\log\)?(提示:势能取"区间内不同值个数"。)
- 用 2.1 的方法推导二维区间加矩形和需要几个二维树状数组,各存什么。(答:4 个,\(d\)、\(i\cdot d\)、\(j \cdot d\)、\(ij\cdot d\))
- 回滚莫队为什么右指针可以只增不减?
- 给出一个 CDQ 分治不能替代树套树的场景。(答:在线强制)
- 在 LCT 中,
split(x, y)之后为什么 \(y\) 的 Splay 恰好包含路径 \(x \sim y\)? - 写出点分治统计"长度恰好为 \(k\) 的路径数"的容斥版本。
- 用并查集"下一个可用位置"技巧解决:\(n\) 个位置,\(m\) 次操作把 \([l, r]\) 中所有未染色的位置染成颜色 \(c\),求最终每个位置的颜色。(提示:倒序或正序都可以,正序时跳过已染色。)
12. 推荐题单
| 难度 | 题目 | 考点 |
|---|---|---|
| 基础 | 洛谷 P3373 【模板】线段树 2 | 乘加标记复合 |
| 基础 | 洛谷 P3834 【模板】可持久化线段树 2 | 主席树 |
| 基础 | 洛谷 P3369 / P3391 【模板】平衡树 | FHQ Treap |
| 基础 | 洛谷 P1972 HH 的项链 | 离线 + 树状数组 |
| 基础 | 洛谷 P3810 【模板】三维偏序 | CDQ |
| 基础 | 洛谷 P3806 【模板】点分治 | 点分治 |
| 基础 | 洛谷 P3384 【模板】重链剖分 | 树剖 |
| 提高 | 洛谷 P4145 花神游历各国 | 势能线段树 |
| 提高 | 洛谷 P4198 楼房重建 | 区间单调栈 |
| 提高 | 洛谷 P4556 雨天的尾巴 | 线段树合并 |
| 提高 | 洛谷 P4097 [HEOI2013] Segment | 李超线段树 |
| 提高 | 洛谷 P1903 [国家集训队] 数颜色 | 带修莫队 |
| 提高 | 洛谷 P5906 【模板】回滚莫队 | 回滚莫队 |
| 提高 | 洛谷 P3396 哈希冲突 | 根号分治 |
| 提高 | 洛谷 P2617 Dynamic Rankings | 整体二分 |
| 提高 | 洛谷 P5787 二分图 | 线段树分治 |
| 提高 | 洛谷 P3690 【模板】LCT | LCT |
| 提高 | 洛谷 P5903 【模板】树上 k 级祖先 | 长链剖分 |
| 提高 | CF 600E Lomsat gelral | DSU on tree |
| 挑战 | 洛谷 P6242 【模板】线段树 3 | Beats 完整版 |
| 挑战 | 洛谷 P6329 【模板】点分树 | 震波 |
| 挑战 | 洛谷 P4234 最小差值生成树 | LCT 维护 MST |
| 挑战 | 洛谷 P4688 [Ynoi2016] 掉进兔子洞 | 莫队 + bitset |
| 挑战 | 洛谷 P3402 可持久化并查集 | 可持久化数组 |
附录:一页纸总结
线段树:标记是函数、复合有顺序;不能打标记就用最值剪枝(势能);右区间依赖左区间就在 pushup 里二分(楼房重建);值域线段树支持合并 / 分裂 / 二分 / 可持久化。
树状数组:区间加区间和用 \((x+1)\sum d - \sum jd\);倍增找第 \(k\) 小;一维排序一维 BIT 是偏序原型。
平衡树:FHQ 只有 split / merge;按值分裂做集合,按大小分裂做序列。
根号:块长按操作代价平衡;加入易删除难用回滚莫队;按大小阈值分治。
树上:子树连续、重链 \(\log\) 段;深度相关合并用长链;路径统计用点分治,带修用点分树;动态连边用 LCT。
离线:下标分治是 CDQ,答案分治是整体二分,时间分治是线段树分治;删除变撤销。
并查集:带权维护相对关系;按秩不压缩可撤销;fa[i] = i+1 跳过已处理。

浙公网安备 33010602011771号