AIGC标识 数据结构模板

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 懒标记的设计原则

一个标记能用,需要满足三条:

  1. 可合并:两个同类标记能合成一个(加法:\(a + b\);赋值:后者覆盖)。
  2. 可作用于区间信息:知道区间的和、长度就能算出打标记后的和。
  3. 多种标记时有明确的复合顺序。

区间乘 + 区间加(洛谷 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})\)。

\[\text{lmax} = \max(L.\text{lmax},\ L.\text{sum} + R.\text{lmax}),\quad \text{ans} = \max(L.\text{ans},\ R.\text{ans},\ L.\text{rmax} + R.\text{lmax}) \]

区间矩阵乘:当转移是线性的(如斐波那契:区间加 \(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\),则

\[\sum_{i=1}^{x} a_i = \sum_{j=1}^{x} d_j\,(x - j + 1) = (x+1)\sum_{j \le x} d_j - \sum_{j \le x} j\,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 序中

  1. 子树是连续区间 \([\text{dfn}_u,\ \text{dfn}_u + \text{sz}_u - 1]\)。
  2. 重链是连续区间,且从任一点向上跳到根经过 \(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\))。

步骤:

  1. 找当前连通块的重心 \(c\)(最大子树最小)。
  2. 统计经过 \(c\) 的路径:收集各子树到 \(c\) 的距离,合并计算。
  3. 删掉 \(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 【模板】点分树 | 震波。

用途:动态树的连接、断开、路径信息查询、换根。

结构:把树剖成若干实链(可以任意变化),每条实链用一棵 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\) 数量。

  1. 按 \(a\) 排序(去重,相同三元组合并计数)。
  2. 分治 \([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]\) 和操作集合:

  1. 用树状数组统计值 \(\le \text{mid}\) 的修改对每个查询的贡献 \(\text{cnt}\)。
  2. 若 \(\text{cnt} \ge k\),查询归到左半(答案 \(\le \text{mid}\));否则 \(k \gets k - \text{cnt}\),归到右半。修改按值归左右。
  3. 递归。

\(O((n+q)\log n \log V)\)。与 CDQ 的区别:CDQ 对下标分治,整体二分对答案分治。

6.3 线段树分治 + 可撤销并查集

问题(洛谷 P5787 二分图):每条边在时间区间 \([l_i, r_i]\) 存在,问每个时刻图是否是二分图。

  1. 把每条边挂到时间线段树上覆盖 \([l_i, r_i]\) 的 \(O(\log)\) 个节点。
  2. DFS 线段树,进入节点时加入它的边(可撤销并查集,扩展域或带权判奇环),离开时撤销。
  3. 到叶子时当前状态就是该时刻的答案。若某节点加边时已出现奇环,整个子树都不是二分图,直接标记不递归。
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. 复杂度与实现的实战经验

  1. \(\log\) 的层数决定生死。 \(n = 10^5\) 时 \(O(n\log^2 n)\) 稳过,\(O(n\log^3 n)\) 需要小常数,\(O(n\sqrt n\log n)\) 通常超时。
  2. 递归线段树 vs 树状数组:能用树状数组就不用线段树,快 3 倍以上。
  3. 空间:动态开点线段树 \(O(n\log n)\) 节点,每个节点两个 int 儿子 + 信息,\(4\times10^6\) 节点约 50 MB,注意题目限制。
  4. 随机化:Treap 的 rand() 在 Windows 下只有 15 位,用 mt19937。
  5. 常数优化顺序:先换更优的算法结构(如线段树换树状数组),再考虑 inline、读入优化、register。
  6. 对拍:所有高阶结构必须与暴力对拍。分块和莫队尤其容易在边界上出错。

10. 常见错误清单

  1. 懒标记复合顺序错。 乘加标记必须"先乘后加";两种标记都存在时,新来的加法要乘上已有的乘法系数吗?(不用;新来的乘法要乘到已有加法上。)
  2. 线段树合并没有新建节点就复用了 \(b\) 树。 如果之后还要用 \(b\),必须复制。
  3. Beats 忘记维护严格次大值。 \(\text{mx2}\) 必须严格小于 \(\text{mx1}\),相等时要合并计数。
  4. FHQ Treap 的 merge 忘记 pushdown。 有翻转标记时必须在访问儿子前下传。
  5. 莫队块长写死 \(\sqrt n\)。 带修莫队要 \(n^{2/3}\),普通莫队最优是 \(n / \sqrt q\)。
  6. 回滚莫队没有处理同块询问。 左右端点同块时必须暴力。
  7. CDQ 分治忘记清空树状数组。 用"逆操作"清空而不是 memset。
  8. 可撤销并查集用了路径压缩。 路径压缩改变了大量 \(\text{fa}\),无法撤销。
  9. LCT cut 前没有检查连通性和相邻性。 直接 cut 不相邻的两点会破坏结构。
  10. 点分治没有重新计算子连通块大小。 找重心时用的 \(\text{sz}\) 必须是当前连通块内的。
  11. 主席树版本数组开小。 节点数是 \(n\log n\) 级别,通常开 \(n \times 25\) 到 \(n \times 40\)。
  12. 长链剖分 \(k\) 级祖先跳错方向。 先跳 \(2^{\lfloor\log k\rfloor}\) 级再在链上查,不是反过来。

11. 课堂练习

  1. 推导"区间乘 \(a\)、区间加 \(b\)"两个标记的复合公式,并说明为什么下传要先乘后加。
  2. 写出区间赋值 + 区间加共存时,标记下传的四种情况。
  3. 证明线段树合并总复杂度 \(O(n\log n)\)。
  4. Segment Tree Beats 中若只有 chmin 没有区间加,为什么复杂度是单 \(\log\)?(提示:势能取"区间内不同值个数"。)
  5. 用 2.1 的方法推导二维区间加矩形和需要几个二维树状数组,各存什么。(答:4 个,\(d\)、\(i\cdot d\)、\(j \cdot d\)、\(ij\cdot d\))
  6. 回滚莫队为什么右指针可以只增不减?
  7. 给出一个 CDQ 分治不能替代树套树的场景。(答:在线强制)
  8. 在 LCT 中,split(x, y) 之后为什么 \(y\) 的 Splay 恰好包含路径 \(x \sim y\)?
  9. 写出点分治统计"长度恰好为 \(k\) 的路径数"的容斥版本。
  10. 用并查集"下一个可用位置"技巧解决:\(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 跳过已处理。

posted @ 2026-09-20 08:31  ylzqwq  阅读(31)  评论(0)    收藏  举报