简单树论

CHANGE LOG

  • 2021.12.10:重构文章,修改部分表述与代码。
  • 2021.2.6:重构文章。新增 LCA 的四种求法。
  • 2022.7.11:重构文章。当前是重构到一半的版本。

基础定义

  • dfs 序表示对一棵树进行深度优先搜索得到的 节点序列
  • dfn 表示每个节点在 dfs 序中的 位置,称为 时间戳。注意区分 dfs 序和时间戳。
  • \(fa(i)\) 表示 \(i\) 的父亲。
  • \(son(i)\) 表示 \(i\) 的儿子节点。
  • \(anc(i)\) 表示 \(i\) 的所有祖先节点。
  • \(sub(i)\) 表示 \(i\) 的子树内所有节点。
  • \(dist(i, j)\) 表示 \(i\)\(j\) 的树上距离。
  • \(lca(i, j)\) 表示 \(i\)\(j\) 的最近公共祖先。

0. LCA 的六种求法

LCA 有六样求法,你知道么?

0.1 倍增

预处理和单次查询的复杂度分别为 \(\mathcal{O}(n \log n)\)\(\mathcal{O}(\log n)\),空间 \(\mathcal{O}(n\log n)\)

倍增预处理 \(i\)\(2 ^ k\) 级祖先,记为 \(f_{k, i}\),求出每个节点的深度 \(dep_i\)

查询 \(u, v\) LCA 时,先将 \(u, v\) 跳到同一深度,再 同时 向上跳到 \(u, v\) 深度最小的祖先 \(anc_u, anc_v\) 满足 \(anc_u\neq anc_v\),则 \(fa(anc_u) = fa(anc_v) = lca(u, v)\)。注意特判 \(u, v\) 跳到同一深度后 \(u = v\) 的情况,此时原 \(u, v\) 为祖先后代关系。

代码,其中lg 等于 \(\log_2 n\) 下取整。因进入每个节点时直接预处理其所有 \(2 ^ k\) 级祖先,为使访问连续,将倍增表较小一维放在后面更快。

  • 易错点:若采用上述代码写法,根节点深度应设为 \(1\) 而非 \(0\),否则若 \(v\) 为根节点,因 \(dep_0\) 没有初始化,故当 \(2 ^ k \geq dep_u\)\(dep(f_{k, u}) = dep(f_0) = 0\),且 \(dep_v = 0\),导致 \(u \gets f_{k, u} = 0\)。笔者初学时因为这个错误调了很长时间。

0.2 欧拉序

\(\mathcal{O}(n\log n) - \mathcal{O}(1)\),空间 \(\mathcal{O}(n\log n)\),有两倍常数。

欧拉序有以下两条性质:

  • 任意两点简单路径上所有节点均在它们的欧拉序之间出现。
  • 任意两点欧拉序之间不会出现它们 LCA 子树外的点。

使用 ST 表,设 \(f_{k, i}\) 表示欧拉序 \(i\sim i + 2 ^ k - 1\) 位置上 深度 最小的节点,查询可做到 \(\mathcal{O}(1)\)

代码,因最后统一处理整张 ST 表,为使访问连续,将倍增表较小一维放在前面更快。

易错点:

  • get 函数比较欧拉序时间戳 dfn
  • 忘记令 u = dfn[u], v = dfn[v]
  • 在递归儿子后没有令 mi[0][++dn] = id,变成 dfs 序。
  • 没有开两倍空间。
  • lg 没有预处理到 \(2n\)
  • ST 表预处理到 lg[n] 而不是 lg[dn]

以上是笔者写欧拉序 LCA 时犯过的错误。

0.3 dfs 序

\(\mathcal{O}(n\log n) - \mathcal{O}(1)\),空间 \(\mathcal{O}(n\log n)\)

详见 冷门科技 —— dfs 序求 LCA

0.4 树链剖分

\(\mathcal{O}(n) - \mathcal{O}(\log n)\),空间 \(\mathcal{O}(n)\)

树剖求 LCA 的空间复杂度更优,可配合需要树剖的题目使用。

查询 \(u, v\) 的 LCA 时,只要 \(u, v\) 不在同一重链,设 \(u\) 重链顶端的深度不小于 \(v\) 重链顶端的深度,则令 \(u\) 变为其重链顶的父亲。最终 \(u, v\) 在同一重链,深度较小的节点即原 \(u, v\) LCA。代码

0.5 Tarjan

dfs 整棵树,搜索过程中当前点 \(u\) 和某个访问过的点 \(v\) 的 LCA \(d\)\(v\) 最深的未回溯至父亲的祖先 \(a\),可根据 dfs 的性质推得:

  • \(d\)\(a\) 的祖先,则存在从 \(a\) 回溯至 \(d\) 的过程,因为 \(a, u\) 处于 \(d\) 的不同子树。
  • \(d\)\(a\) 子树内,则不存在从 \(d\) 回溯至 \(fa(d)\) 的过程,因为 \(u, v\) 同时在 \(d\) 子树内。

考虑并查集维护每个点最深的未回溯至父亲的祖先 \(a\),初始 \(f(a) = a\)

\(u\) 回溯至 \(fa(u)\) 时,\(u\) 整棵子树内所有节点均回溯至父亲,且 \(u\) 所有祖先均未回溯至父亲,因此有 \(f(fa(u)) = fa(u)\)\(f(v) = u(v\in subtree(u))\),令 \(f(u) = fa(u)\) 即可。

查询 \(v\) 时查找 \(v\) 所在集合代表元。

需要离线回答询问,时间复杂度 \(\mathcal{O}(n\log n + q)\)

0.6 The Method of Four Russians

见 Part 6.3.

1. Kruskal 重构树

前置知识:kruskal 求最小生成树,倍增。

1.1 算法简介

以下所有讨论基于 最小 生成树。

请读者回忆 kruskal 求最小生成树的过程:将所有边按照边权排序,若当前边 \((u,v)\) 两端不连通,则往生成树边集 \(E\) 中加入 \((u, v)\) 并连接 \(u, v\)。使用并查集维护连通性。

如果能用某种结构描述每条边被连接的先后顺序,因为越往后加入的边权越大,就可以快速刻画边权有限制时整张图的连通情况。

Kruskal 重构树诞生了。它在 kruskal 基础上进行了一些改进:连接 \(u, v\) 时,找到 \(u, v\) 的代表元 \(U, V\)。新建节点 \(c\),将并查集中 \(U, V\) 的父亲设为 \(c\),并在 \(T\) 上连边 \(c\to U\)\(c\to V\)。注意 \(U, V\) 可能不是原树节点。

通常设 \(c\) 的权值 \(w_c\)\(w_{u, v}\)。为虚点设置权值方便解题,巧妙设置点权对解题有极大帮助。

若原图 \(G\) 连通,则得到一棵大小为 \(2n - 1\) 且以 \(2n - 1\) 为根的 有根树 \(T\)。它就是 kruskal 重构树,其性质在下一节中介绍。

void merge(int u, int v, int w) {
  if((u = find(u)) == (v = find(v))) return;
  node++, f[u] = f[v] = f[node] = node, val[node] = w;
  addedge(node, u), addedge(node, v);
}

1.2 性质与应用

下文称 原节点 为原图节点,共 \(n\) 个,表示原图某个点;新节点 为新建节点,共有 \(n - 1\) 个,表示原图某条边。

Kruskal 重构树 \(T\) 有很多优秀性质:

  1. \(T\) 是一棵二叉树,由构建方法可知。对于部分题目,特殊重构树建法可有效减小常数,详见 Part 1.3 点权多叉重构树。
  2. \(G\) 的所有节点是 \(T\)叶子。因此原节点和重构树叶子节点本质相同。
  3. 对于任意新节点 \(u\) 及其祖先 \(v\)\(w_u \leq w_v\)

性质 3 非常重要,它是 kruskal 重构树的核心:原节点 \(x\) 在原图上经过权值 \(\leq d\) 的边可达的所有点就是它在 \(T\) 上最浅的权值 \(\leq d\) 的祖先 \(a\) 的子树内所有叶子节点。一般倍增求解 \(a\)

换言之,从原节点 \(x\) 倍增找到权值 \(\leq d\) 的最浅祖先 \(a\),那么 \(a\) 子树内所有叶子就是原图仅保留边权 \(\leq d\) 的边时 \(x\) 所在连通块的所有点。

综上,我们可以总结出一个常用套路:当题目限制形如 “只经过权值不大于某个值的点或边” 时,从 kruskal 重构树角度入手。部分题目也可以使用可持久化并查集,因为它同样能够刻画存在边权限制时图的连通情况:实现 kruskal 最小生成树的过程中将并查集可持久化。相较于 kruskal 重构树,可持久化并查集在时空复杂度上更劣,笔者不建议使用。

1.3 点权多叉重构树

Kruskal 重构树不仅适用于限制边权的题目,也可以处理限制点权的情况。

方法一:

为原图每条边巧妙赋值,将点权转化为边权。若限制经过的点权最大值,因为走一条边 \((u, v)\) 需满足 \(w_u, w_v\) 都不超过限制,所以 \(w_{u, v} = \max(w_v, w_v)\)。类似地,若限制最小值则 \(w_{u, v} = \min(w_u, w_v)\)

方法二:

实际上我们几乎用不到 \(T\) 是二叉树这一性质,因此存在更高妙的做法。

不妨设题目限制点权最大值。将节点按权值从小到大排序,按序遍历每个点 \(i\) 及其所有出边 \((i, u)\)。若 \(u\) 已经遍历过,则 \(w_i \geq w_u\)\(\max(w_i, w_u)\) 取到 \(w_i\),此时若 \(i, u\) 不连通则从 \(i\)\(u\) 的代表元连边。

上述做法与一般 kruskal 重构树几乎等价:普通重构树中点权相同的虚点 仅有深度最小的有用;按权值从小到大枚举节点相当于对所有边排序,因为边权即 \(\max(w_i, w_u)\)。这样做不用新建虚点,有效减小了常数。

笔者推荐遇到点权重构树时尽量使用方法二,建出多叉重构树,其写法见例题 I。但读者应时刻记住 可以保证重构树是二叉树

1.4 例题

*I. P4899 [IOI2018] werewolf 狼人

上下界均给出,考虑分别建出点权从小到大和从大到小排序的重构树,记为 \(T_{\min}\)\(T_{\max}\)

\(T_{\max}\) 上从 \(S\) 倍增到使 \(w_a\) 最小且 \(w_a\geq L\) 的祖先 \(a\),在 \(T_{\min}\) 上从 \(E\) 倍增到使 \(w_b\) 最大且 \(w_b\leq R\)\(b\),检查 \(a\) 的子树和 \(b\) 的子树叶子节点是否有交。

静态二维数点,离线 BIT 即可,时间复杂度 \(\mathcal{O}((n + q)\log n)\)

#include <bits/stdc++.h>
using namespace std;
constexpr int K = 18;
constexpr int N = 2e5 + 5;
int n, m, q, lg;
struct linklist {
  int cnt, hd[N], nxt[N << 2], to[N << 2];
  linklist() {cnt = 0, memset(hd, 0, sizeof(hd));}
  void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
} E, ins, del;
struct Tree {
  int f[N];
  int find(int x) {return f[x] == x ? x : f[x] = find(f[x]);}
  void init() {for(int i = 1; i <= n; i++) f[i] = i;}
  linklist G;
  int dn, dfn[N], sz[N], anc[K][N];
  void merge(int u, int v) {
    if((v = find(v)) == u) return;
    f[v] = anc[0][v] = u, G.add(u, v);
  }
  void dfs(int id) {
    dfn[id] = ++dn, sz[id] = 1;
    for(int i = G.hd[id]; i; i = G.nxt[i]) dfs(G.to[i]), sz[id] += sz[G.to[i]];
  }
  void build(int R) {
    dfs(R);
    for(int i = 1; i <= lg; i++)
      for(int j = 1; j <= n; j++)
        anc[i][j] = anc[i - 1][anc[i - 1][j]];
  }
  int binlift(int u, int lim, bool t) {
    for(int i = lg; ~i; i--)
      if(anc[i][u] && (t ? anc[i][u] >= lim : anc[i][u] <= lim))
        u = anc[i][u];
    return u;
  }
} L, R;
struct BIT {
  int c[N];
  void add(int x) {while(x <= n) c[x]++, x += x & -x;}
  int query(int x) {int s = 0; while(x) s += c[x], x -= x & -x; return s;}
  int query(int l, int r) {return query(r) - query(l - 1);}
} tr;
int y[N], l[N], r[N], ans[N];
int main() {
  ios::sync_with_stdio(0);
  cin >> n >> m >> q, lg = log2(n);
  for(int i = 1; i <= m; i++) {
    int x, y;
    cin >> x >> y;
    E.add(++x, ++y), E.add(y, x);
  }
  L.init(), R.init();
  for(int i = 1; i <= n; i++)
    for(int j = E.hd[i]; j; j = E.nxt[j])
      if(i > E.to[j])
        L.merge(i, E.to[j]);
  for(int i = n; i; i--)
    for(int j = E.hd[i]; j; j = E.nxt[j])
      if(i < E.to[j])
        R.merge(i, E.to[j]);
  L.build(n), R.build(1);
  for(int i = 1; i <= n; i++) y[L.dfn[i]] = R.dfn[i];
  for(int i = 1; i <= q; i++) {
    int s, e, l, r;
    cin >> s >> e >> l >> r;
    s++, e++, l++, r++;
    s = R.binlift(s, l, 1);
    e = L.binlift(e, r, 0);
    del.add(L.dfn[e] - 1, i);
    ins.add(L.dfn[e] + L.sz[e] - 1, i);
    ::l[i] = R.dfn[s], ::r[i] = R.dfn[s] + R.sz[s] - 1;
  }
  for(int i = 1; i <= n; i++) {
    tr.add(y[i]);
    for(int j = ins.hd[i]; j; j = ins.nxt[j]) {
      int id = ins.to[j];
      ans[id] += tr.query(l[id], r[id]);
    }
    for(int j = del.hd[i]; j; j = del.nxt[j]) {
      int id = del.to[j];
      ans[id] -= tr.query(l[id], r[id]);
    }
  }
  for(int i = 1; i <= q; i++) cout << (ans[i] > 0) << "\n";
  return 0;
}

*II. [模拟赛] 超级加倍

定义树上简单路径 \(x\to y\) 是好的当且仅当路径上编号最小的点为 \(x\),编号最大的点为 \(y\)。给定大小为 \(n\) 的树 \(T\),求 \(T\) 的好路径数量。

\(1\leq n \leq 2\times 10 ^ 6\)

限制点权的题目自然考虑 kruskal 重构树,易知 \(x, y\) 合法当且仅当 \(x\)\(T_{\max}\) 上是 \(y\) 的祖先,\(y\)\(T_{\min}\) 上是 \(x\) 的祖先。

\(x\)\(T_{\max}\) 上的时间戳为 \(dfn_x\),子树大小为 \(sz_x\),问题相当于求 \(T_{\min}\) 上每个点 \(x\) 的子树内使得 \(dfn_x\in [dfn_y, dfn_y + sz_y)\)\(y\) 的数量。一遍 dfs + 树状数组带走,时间复杂度 \(\mathcal{O}(n\log n)\)

III. CF1628E Groceries in Meteor Town

题目相当于求 \(x\) 以及所有开着的点的虚树上路径最大值。树上路径最值 \(\to\) kruskal 重构树,而若干个点形成的虚树的路径最值就是这些点在 kruskal 重构树上的 LCA 的权值。

因此,建出重构树,线段树维护区间 LCA 即可。时间复杂度线性对数。代码

2. 虚树

树上问题进行单次求解的复杂度与树大小相关,但 \(n, q\leq 10 ^ 5\) 怎么办?由于输入限制,询问点集大小之和 \(\sum V_i\) 必然不大,虚树 可以解决这样的问题。

2.1 引入

P2495 [SDOI2011] 消耗战

考虑 \(q = 1\)。设 \(f_i\) 表示节点 \(i\) 与其子树内任意 询问点 不连通的最小代价。当 \(i\) 本身是询问点时,\(f_i = +\infty\);否则枚举所有子节点 \(v\) 以及是否断掉 \((i, v)\) 这条边,\(f_i = \sum\limits_{v \in son(i)} \min(f_v, w_{i, v})\)

若每次询问都对整棵树进行 DP,时间复杂度无法接受。但注意到存在很多无用分支,我们根本不关心其 DP 值;同时,对于一条链形式的转移,若链上没有询问点,可用一条边代替,新边权为链上边权最小值。

设询问点集为 \(V_Q\)(Q 为 Query 的简称),则令 \(f_i = +\infty(i \in V_Q)\)。转移时,对于非询问点 \(u\) 考虑「子树内存在询问点」的儿子 \(v_1, v_2, \cdots, v_k\),则 \(f_u = \sum\limits_{i = 1} ^ k \min(f_{v_i}, w_{u, v_i})\)

仔细观察后,我们发现大部分情况 \(k = 1\),即 \(u\) 仅有一个儿子子树内存在询问点,而使得 \(k > 1\)\(u\) 很少。进一步地,可以证明 \(\sum k - 1 = \mathcal{O}(|V_Q|)\)。当 \(k > 1\) 时,合并 \(v_1, v_2, \cdots, v_k\) 子树内所有询问点,共合并 \(k - 1\) 次。因最终 \(|V_Q|\) 个零散的询问点被合并成一大组,至多合并 \(|V_Q| - 1\) 次,故 \(\sum k - 1 = \mathcal{O}(|V_Q|)\)

将所有 \(k > 1\) 的点 \(u\) 扣出来,设为 \(V_L\)(L 为 LCA 的简称),因为 \(u\) 有至少两个儿子子树存在询问点,故 \(u\) 必然为某两个询问点的 LCA。充要吗?确实。易证 \(V_Q\cup V_L\) 恰为所有询问点 \(V_Q\) 两两之间的最近公共祖先集(包括一个点和它本身),设为 \(V_C\)(C 为 Critical 的简称),则 \(V_C\) 关于 LCA 运算封闭,且 \(|V_C| = \mathcal{O}(|V_Q|)\)

对于 \(k = 1\) 的点呢?它们形成若干条 \(V_C\) 之间的链,而链上转移只关心边权最小值。

至此,算法已经有了大致轮廓,尝试细化。

所有关键点 \(V_C\) 完整地描述了询问点 \(V_Q\)「张成」的树的形态,考虑对存在祖先后代关系且路径上不存在其它关键点(称为 相邻)的关键点对之间连边。

\(V_C\) 的良好性质保证 任意存在祖先后代关系的相邻关键点对之间不重叠:若 \(a, u\) 相邻,\(a, v\) 相邻,\((a, u)\)\((a, v)\) 重叠且 \(a\)\(u, v(u\neq v)\) 的祖先,考察 \(d = lca(u, v)\),因 \(d\neq a\)(否则无重叠部分)且 \(d\neq u\)(否则 \(v\) 要么等于 \(u\),要么在 \(u\) 子树内,均与假设矛盾),故 \(d\) 在路径 \(a\to u\) 上,与 \(a, u\) 相邻矛盾。即不会出现以下情况。

连边后得到点集 \(V_Q\)虚树 \(T\),它的点数和边数均为 \(\mathcal{O}(|V_Q|)\),在 \(T\) 上进行树形 DP 的时间复杂度可以接受。转移形如 \(f_u = \sum\limits_{v\in son_T(u)} \min(f_v, w_T(u, v))\),其中 \(w_T(u, v)\) 表示原树 \(u, v\) 之间的边权最小值。

注意对于本题应将 \(1\) 加入 \(V_C\)(但不是 \(V_Q\)),因为要求 \(f_1\)

2.2 构建虚树

2.2.1 通俗易懂的简单方法

首先明确我们希望实现这样一个过程:对所有 \(x\in V_C\),与其最深的属于 \(V_C\) 的祖先连边。

  • 如何求解 \(V_C\):若 \(u\in V_C\)\(u\notin V_Q\),则存在 dfs 序相邻的 \(x, y\in V_Q\) 使得 \(lca(x, y) = u\),由 dfs 序的性质易证。这里 dfs 序相邻指不存在 \(z\in V_Q\) 使得 \(z\) 的 dfs 序在 \(x, y\) 之间。因此,将 \(V_Q\) 按照 dfs 序排序,\(V_Q\) 及所有相邻两点的 LCA 去重即得 \(V_C\)

朴素地对整棵树进行 dfs,时间复杂度无法接受,考虑模拟 dfs。为此,将 \(V_C\) 按 dfs 序排序,依次处理每个点 \(x\in V_C\),并维护当前 dfs 栈 \(S\)。注意若 \(x\) 为第一个点则直接入栈,其为 \(T\) 的根。对于当前 \(x\)\(d = lca(S_{top}, x)\),考虑 dfs 的过程,我们不断弹出栈顶直到 \(S_{top} = d\),然后 \(d\to x\) 连边,并将 \(x\) 压入栈。

进一步地,我们发现 \(S_{top}\) 就是 \(x\)\(V_C\) 中关于 dfs 序的前驱,因此直接对 \(V_C\) 所有 dfs 序相邻两点 \(x, y\)\(lca(x, y) \to y\) 连边。

综上,我们得到 \(\mathcal{O}(|V_Q|\log |V_Q|)\) 建出询问点集 \(V_Q\) 虚树的算法。若 \(\mathcal{O}(1)\) 查询 LCA,则复杂度瓶颈仅在于排序。对于部分题目,边权与原树路径边权相关,有时需使用倍增。

2.2.2 效率更高的复杂方法

简单方法容易理解,但瓶颈 \(\mathcal{O}(n\log n)\) 的排序进行了两次,常数较大。接下来给出 OI 界最常使用的虚树构建方法,其只需要对 \(|V_Q|\) 进行一次关于 dfs 序的排序,并查询 \(\mathcal{O}(|V_Q|)\) 次 LCA,因此笔者建议使用 \(\mathcal{O}(1)\) 查询 LCA 的方法,如 dfs 序 LCA。

类似简单方法,我们将 \(V_Q\) 按照时间戳排序,借助栈模拟 dfs 的过程,使用 增量法 建出虚树。栈内从栈底到栈顶维护了原树也是虚树自上而下的一条链。设当前加入虚树的节点编号为 \(i\),栈顶节点为 \(t\),栈顶第二个节点为 \(t_2\)\(d = lca(d, t)\)

首先若栈空则 \(i\) 入栈,此时第一次加入点。对于栈非空的情况,请读者对比以下两种方法,其中第一种是 错误 的,也是笔者一开始口胡的方法。

法一

  • \(d=t\),说明 \(t\)\(i\) 的祖先。\(t \to i\) 之间连边。
  • 否则 \(d\neq t\),说明 \(t\)\(i\) 处于 \(d\)不同子树。弹出栈顶直到栈空或栈顶深度 $\leq $ \(d\) 的深度,然后判断 \(d\) 是否等于栈顶:
    • 若相等,则 \(d \to i\) 之间连边。
    • 否则 \(t \to d\) 之间连边, \(d \to i\) 之间连边,同时 \(d\) 入栈。
  • 最后 \(i\) 入栈。

法二

  • \(dep_d\leq dep_{t_2}\),说明 \((t_2, t)\) 之间一定连边,执行。弹出栈顶,重复该过程直到栈大小 \(<2\) 或不满足判断条件。

  • 接下来,若 \(d\neq t\),说明 \(dep_d < dep_t\)(根据上述判断条件,不存在深度小于 \(d\) 的节点,或 \(t_2\) 是栈内第一个深度小于 \(d\) 的节点,或 \(d\) 一开始就等于 \(t\),故 \(t\) 的深度不小于 \(d\),再根据 \(d\neq t\) 得证),故 \(t\)\(i\) 处于 \(d\)不同子树。此时将 \(t\) 弹出,连边 \(d\to t\)\(d\) 入栈。否则 \(d=t\),不需要做任何事情。

  • 最后 \(i\) 入栈。

  • 整个算法的最后,不要忘记在栈内剩余相邻节点之间连边,从深度小的往深度大的连。

  • 算法单步流程如图:\(x\) 是一开始的 \(t\)\(x\) 和若干 \(y\) 是黄点,表示它们作为 \(t\) 时对应 \(t_2\) 的深度没有 \(d\) 小,于是被弹出。粉边即新建的边:在被弹出的点 \(t\) 及其对应的 \(t_2\) 之间连边 \(t_2\to t\)

    当现在的 \(t\) 作为 \(t\) 时,对应的 \(t_2\) 深度小于 \(d\),因此我们找到了分叉点。由于一开始将所有关键点按照时间戳排序,根据时间戳的性质,不可能再有点从 \(t\) 的子树内加入了,所以将 \(t\) 弹出,\(d\) 压入栈,\(d\to t\) 之间连边。最后 \(i\) 入栈。

    需要被加入的绿点 \(d, i\) 均变成了入栈的红点,而原来在栈中的 \(d\to t\) 分支全部连好了边。栈内原先存储的 \(stk_1 \to t_2 \to t\to x\) 这条链变成了 \(stk_1\to d\to i\),下一个被加入的点 \(i'\) 将面对和 \(i\)​ 类似的局面,这体现出增量法。

    HKqsHg.png

对比两个方法,前者究竟错在哪里?法一在压入节点 \(i\) 时直接将栈顶 \(t\) 与其连边,但接下来添加某个节点 \(j\) 时,\(j\) 与其对应的栈顶的 LCA \(d\) 可能处于节点 \(i\) 和栈顶 \(t\) 之间。这说明 \(i,t\) 不应连边,因为它们之间存在关键点 \(d\)

引入例题中,为使代价最小,虚树边 \((i, j)\) 的权值应设置为原树 \(i, j\) 简单路径上所有边的权值的最小值,相当于压缩了原树的一条链。对于不同的题目,边权设置方法也不同。

注意点:存虚树的邻接表需在每组询问前清空(忘清空是常见错误),但不可每次都 memset,复杂度无法接受。可以这样实现:首先清空所有询问点的邻接表,令这些点的 head 值为 \(0\)。构建虚树的过程中,若某个不属于询问点的关键点 \(d\) 入栈,则在连边 \(d\to t\) 之前清空 \(d\) 的邻接表,即令 head[d] = 0

具体实现方法见例题 I.

2.3 结论与技巧

  • 点集 \(S\) 的虚树边权和等于所有时间戳 循环相邻 的节点距离之和除以 \(2\)。换言之,将 \(S\) 按时间戳从小到大排序得到序列 \(a_0, a_2, \cdots, a_{|S| - 1}\),则虚树边权和为 \(\sum\limits_{i = 0} ^ {|S|} dist(a_i, a_{(i + 1)\ \bmod\ |S|})\) 除以 \(2\)。考虑每条边对和式的贡献,我们发现一条虚树上的边只会被正反各经过一次,否则就和时间戳的定义矛盾了。
  • 当只对虚树进行一遍自底向上的 DP 时,不需要显式建出虚树后 dfs,而是在构建虚树的过程中直接转移,因为构建虚树时每条边被加入的顺序就是回溯顺序。这样可以有效减小常数,具体实现方法见例题 I.

2.4 例题

I. P2495 [SDOI2011] 消耗战

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using pii = pair<int, int>;
constexpr int K = 18;
constexpr int N = 2.5e5 + 5;
constexpr int inf = 1e9;
ll f[N];
vector<pii> e[N];
int n, m, lg[N], k, p[N], mark[N];
int dn, dfn[N], dep[N], mi[K][N], fa[K][N], d[K][N];
int get(int x, int y) {return dep[x] < dep[y] ? x : y;}
void dfs(int id, int ff) {
  dfn[id] = ++dn;
  dep[id] = dep[ff] + 1;
  fa[0][id] = ff;
  mi[0][dfn[id]] = ff;
  for(pii _ : e[id]) {
    int it = _.first;
    if(it == ff) continue;
    dfs(it, id), d[0][it] = _.second;
  }
}
int lca(int u, int v) {
  if(u == v) return u;
  if((u = dfn[u]) > (v = dfn[v])) swap(u, v);
  int d = lg[v - u++];
  return get(mi[d][u], mi[d][v - (1 << d) + 1]);
}
int dist(int x, int a) {
  int ans = 1e9;
  for(int i = lg[dep[x] - dep[a]]; ~i; i--)
    if(dfn[fa[i][x]] >= dfn[a]) {
      ans = min(ans, d[i][x]);
      x = fa[i][x];
    }
  return ans;
}
int main() {
  ios::sync_with_stdio(0);
  cin >> n;
  for(int i = 2; i <= n; i++) lg[i] = lg[i >> 1] + 1;
  for(int i = 1; i < n; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    e[u].push_back({v, w});
    e[v].push_back({u, w});
  }
  dfs(1, 0);
  for(int i = 1; i <= lg[n]; i++)
    for(int j = 1; j <= n; j++) {
      fa[i][j] = fa[i - 1][fa[i - 1][j]];
      d[i][j] = min(d[i - 1][j], d[i - 1][fa[i - 1][j]]);
      if(j + (1 << i) - 1 <= n) mi[i][j] = get(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
    }
  cin >> m;
  for(int _ = 1; _ <= m; _++) {
    cin >> k;
    for(int i = 1; i <= k; i++) cin >> p[i], f[p[i]] = 0, mark[p[i]] = 1;
    p[++k] = 1;
    sort(p + 1, p + k + 1, [&](int x, int y) {return dfn[x] < dfn[y];});
    auto trans = [&](int u, int v) {
      if(mark[v]) f[v] = inf;
      f[u] += min((ll) dist(v, u), f[v]);
    };
    static int stc[N], top;
    stc[top = 1] = p[1];
    for(int i = 2; i <= k; i++) {
      int d = lca(stc[top], p[i]);
      while(top > 1 && dep[d] <= dep[stc[top - 1]]) trans(stc[top - 1], stc[top]), top--;
      if(d != stc[top]) f[d] = 0, trans(d, stc[top]), stc[top] = d;
      stc[++top] = p[i];
    }
    for(int i = top - 1; i; i--) trans(stc[i], stc[i + 1]);
    for(int i = 1; i <= k; i++) mark[p[i]] = 0;
    cout << f[1] << "\n", f[1] = 0;
  }
  return 0;
}

II. P4103 [HEOI2014]大工程

建出虚树后进行 DP,维护子树内关键点数量 \(sz\),距离最大值 \(mx\) 个距离最小值 \(mn\),注意若当前节点为关键点则最小值为 \(0\)。每次转移 \((i, j)\) 则用 \(mn_i + mn_j + d\) 更新第二问答案,用 \(mx_i + mx_j + d\) 更新第三问答案。

对于第一问,考虑每条边 \(i\to j\)被计算了多少次,其中 \(i\)\(j\) 在虚树上的父亲。容易得到 \(sz_j\times (k - sz_j)\)

时间复杂度 \(\mathcal{O}((n + \sum k)\log n)\)代码

III. [模拟赛] 山花

给定以 \(1\) 为根的树及参数 \(k\),点有点权 \(a_i\)\(q\) 次询问给定 \(x, c\),求 \(\prod\limits_{y\in subtree(x) \land dis(x, y) < k} \gcd(c, a_y)\)\(10 ^ 9 + 7\)

\(1\leq a_i, c\leq 10 ^ 7\)\(1\leq n \leq 10 ^ 5\)

考虑对 \(c\) 的每个质因子单独算贡献。把与该质因子相关的节点和询问节点全部拉下来建虚树,\(k\) 固定的话考虑每个节点对询问的贡献,直接树上差分,时间复杂度 \(\mathcal{O}(V + n\omega(V)\log n)\)\(k\) 不固定也可以线段树合并。

IV. CF986E Prince's Problem

类似 III.,考虑值域内所有质数 \(p\),将点权 \(a_i\) 或询问权值 \(v_j\) 含质因数 \(p\)\(i\)\(x_j, y_j\) 拎出来建虚树,然后在上面树上差分即可。

时间复杂度 \(\mathcal{O}(V + (n + q)(\frac{\sqrt V}{\log V} + \omega(V) \log V))\)代码

V. CF639F Bear and Chemistry

对原图进行边双缩点。

\(\sum n_i, \sum m_i \leq 3\times 10 ^ 5\) 自然考虑对每组询问相关的所有点建出虚树。若图不连通,则可能为虚树森林。

因虚树保留了关键点在原图上的相对形态,故直接在虚树上添加新增的边,对新图检查所有 \(V_i\) 是否边双连通即可。

时间复杂度 \(\mathcal{O}((n + \sum n_i + \sum m_i) \log n)\),复杂度瓶颈在预处理 LCA + 排序。代码

  • 注意对每棵树分别求虚树。
  • 千万注意清空。

CHAPTER 2 DONE

3. 点分治

点分治就是把分治搬到了树上,其核心思想仍然是分治:将问题经过处理后,转化为同类型的,规模更小的问题求解。

3.1 静态点分治

3.1.1 序列分治

给出序列 \(a_i\),求是否存在 \(i, j\) 使得 \(\sum\limits_{x = i} ^ j a_x = k\)\(n\leq 10 ^ 6\)\(|a_i|\leq 10 ^ 9\)

研究树上问题,通常可以从对应的序列问题入手

相信读者对于序列分治不陌生。当然本题也有其它做法,不过我们尝试使用分治法解决:对于当前区间 \([l, r](l < r)\) 及其中点 \(m\),要么 \(i, j\leq m\)\(i, j > m\),要么 \(i\leq m < j\)。前者可以转化为 规模更小 的关于 \([l, m]\)\([m + 1, r]\) 的子问题,故只需处理第二种情况。

首先对 \(a\) 求前缀和,将区间和转化为端点差分。记前缀和数组为 \(s\),则问题转化为:是否存在 \(i\in [l - 1, m - 1]\)\(j\in [m + 1, r]\) 满足 \(s_j - s_i = k\)

\(s_{l - 1}\sim s_{m - 1}\)\(s_{m + 1} \sim s_r\) 分别从小到大排序,然后按从小到大的顺序枚举每个 \(s_j(m < j\leq r)\),并判断 \(s_j - k\) 是否在 \(s_i(l\leq i < m)\) 中出现过。由于 \(s_j\) 有序,\(s_j - k\) 单调不降,故可以维护指针 \(i\) 表示满足 \(s_i\geq s_j - k\) 的最小的 \(i\)。直接判断是否有 \(s_i + k = s_j\)

复杂度分析:每个区间的复杂度为 \(\mathcal{O}(L\log L)\),其中 \(L\) 是区间长度。因为递归 \(\log n\) 层,且每层的时间复杂度是 \(n \log n\),故总时间复杂度 \(\mathcal{O}(n\log ^ 2 n)\)。若归并排序合并 \(s\),则可做到 \(\mathcal{O}(n\log n)\)

值得指出,若题目保证 \(a_i\geq 0\),可使用双指针做到线性。

3.1.2 点分治

给一棵树,边有边权,求是否存在 \(i, j(i\neq j)\) 使得 \(i, j\) 之间简单路径长度为 \(k\)\(n\leq 10 ^ 5\)

我们把问题从序列搬到了树上。同样的思路,考虑分而治之:选择一个节点,考虑所有 跨过该节点以该节点为一端 的路径,同时把问题分解到若干子树内。

该如何寻找分治点使时间复杂度最小呢?将目光投向 树的重心。树的重心,即删去后剩余连通块大小最小的节点,它有一个很好的性质:若原连通块大小为 \(2n\),则删去树的重心与所有相邻的边后,剩余连通块大小最大不超过 \(n\)。类似序列分治将区间长度砍半,一次点分治也让整棵树的规模减半,这保证分治树至多 \(\log n\) 层。

同时,我们知道两节点之间的简单路径要么经过根,要么不经过根且完全在根的某个子树内,后者是子问题。类似序列分治要么 \(i, j \leq m\)\(i, j > m\),要么 \(i\leq m < j\)

具体地,我们寻找重心 \(r\) 作为分治中心,打上删除标记,然后 dfs 它的每一棵子树。注意,不能经过打了删除标记的节点,即不能跨越已经成为过分治中心的点。求出子树内每个节点到分治中心的距离,以及来源于哪个儿子子树(来源于相同子树 的信息不能相加计算,因为根本没有跨过分治中心,否则导致计算 非简单 路径),排序后使用双指针求解。

接下来找到 \(r\) 的每个 未被打删除标记 的邻居 \(s\) 的子树的重心 \(r_s\),向 \(r_s\) 递归分治。子树 \(s\) 的大小不需要重新计算,可直接使用以 \(r\) 的上一层分治重心为根时 \(s\) 的子树大小,正确性证明见 一种基于错误的寻找重心方法的点分治的复杂度分析

时间复杂度 \(\mathcal{O}(n\log ^ 2 n)\)。其中有一个 \(\log\) 是排序。代码见例题 I.

  • 若找重心时初始化 Root = 0,注意令 maxsize[0] = inf
  • 统计分治重心 \(r\) 的子树信息时,我们求得任意两个点对的贡献之和后要 减掉同在 \(r\) 的某个儿子子树内的点对贡献。具体去重方法与题目内容相关,一般是统计整棵子树信息的同时,再对每个儿子子树单独统计类似的信息,也可以尝试用新添加的子树信息去合并已经遍历的所有儿子子树信息。
  • 考虑 以根为端点 的点对贡献。
  • 打删除标记的点不能经过。

3.2 动态点分治

给一棵树,点有点权。\(q\) 次询问给定 \(x, k\),求与 \(x\) 树上距离 \(\leq k\) 的所有点的点权和。带修点权,强制在线。\(n, q\leq 10 ^ 5\)

首先考虑不修改点权怎么做。对每次询问都点分治复杂度太劣。注意到分治树的形态固定,形如将每一层分治重心和上一层的连边得到分治树 \(T\)。这就是 点分树

它是一棵 有根树,并且有很好的性质,最重要的一条是 树深不超过 \(\mathcal{O}(\log n)\)。这意味着我们可以 暴力跳父节点统计答案

对于询问 \(x, k\),找到 \(T\) 上对应节点 \(x\),求出 \(x\)点分树 子树中 原树 距离 \(x\) 不超过 \(k\) 的所有点(设为 \(V(x, k)\))的点权和。然后考虑 \(x\) 的父亲 \(u\),设 \(d = dis(x, u)\)(注意 \(dis\)原树距离),则加上 \(u\)点分树 子树中 原树 距离 \(u\) 不超过 \(k - d\) 的所有点 \(V(u, k - d)\) 的点权和。不断令 \(u\gets fa_u\) 直到 \(u\) 为根,对 \(x\) 和所有 \(u\) 产生的贡献求和即可。

等等!如果一个 \(x\) 子树内的点满足与 \(x\) 距离 \(\leq k\) 且与 \(u\) 距离 \(\leq k - d\),就会被重复计算,必须考虑到这样的点的重复贡献。

因此,在计算 \(x\) 的贡献时,需要减掉能被其父亲计算到的贡献,即加上 \(V(x, k)\) 的点权和之后减掉 \(V(x, k)\cup V(u, k - d)\) 的点权和。当 \(x\) 为根时,其父亲不存在,故此时不需要减去。

综上,对 \(T\) 的每个节点 \(x\) 及其父亲 \(u\),预处理 \(C_{0, x, d}\) 表示 \(x\) 的点分树子树内与 \(\color{red} x\) 原树距离 \(\leq d\) 的节点权值和,\(C_{1, x, d}\) 表示 \(x\) 的点分树子树内与 \(\color{red} u\) 原树距离 \(\leq d\) 的节点权值和。因为 \(d\) 这一维不超过 \(x\) 的子树最大深度,所以用 vector 存储每个 \(C_{i, x}\),也可以开内存池并记录每个不定长数组在内存池的开始下标,减小常数。由于我们要求 \((n + q)\log n\) 次树上距离,所以使用 \(\mathcal{O}(n\log n) - \mathcal{O}(1)\) 的 LCA。时间复杂度为 \(\mathcal{O}((n + q)\log n)\)

如果带修,用树状数组维护 \(C\) 即可。修改时对被修节点 \(x\) 到点分树 \(T\) 的根上所有节点的 \(C\) 进行更新,查询同理,复杂度均为 \(\log ^ 2 n\)。带修后时间复杂度 \(\mathcal{O}((n + q)\log ^ 2 n)\)。代码见例题 II.

易错点:设当前根为 \(r\),寻找其某个儿子 \(i\) 的子树的重心 \(r'\) 并为 \(r'\) 设置 vector 大小时,应使用 \(i\)子树大小 size 而非以 \(i\) 为根的 子树最大深度 maxdep,因为后者可能小于以 \(r'\) 为根时子树的最大深度,但不会小于一半,如下图。这使得 \(r'\) 子节点与 \(r'\) 之间的原树距离大于设置的 vector 大小,导致 RE。即使要用 maxdep 作为大小,也应从找到的 \(r'\) 开始进行 dfs,或使用 \(2\)maxdep

oTuoQg.png

  • 重要注意点:在直接使用以 \(r\) 的上一层分治中心为根时 \(s\) 的子树大小时,点分治递归深度 有两倍常数,即 \(2\log_2 n\)。因此在借助点分树深度 \(\leq \log_2 n\) 满足题目要求时,如果 \(2\log_2 n\) 不能满足限制,那么千万不要用这种写法。

3.3 性质与总结

  • 性质:点分树深 \(\mathcal{O}(\log n)\) 级别。
  • 性质:树上每个节点的度数不大于其在原树上的度数 \(+1\)
  • 对于每一道点分治问题,都需要注意如何避免合并 来自相同儿子的子树 的信息。
  • 对于每一道点分树问题,都需要注意如何去掉每个节点与其父节点之间 重叠的贡献
  • 树上 点对 / 简单路径计数 可以考虑点分治。
  • 技巧:对于统计可减信息的点分治,通常用子树内任意两个点对之间的贡献,减去每个儿子子树内任意两个点对之间的贡献。它们的统计方式相同,可以共用同一个函数减小码量。如例题 V.

3.4. 例题

I. P3806 【模板】点分治

注意本题需离线处理询问去掉乘在 \(m\) 上的排序的 \(\log\),时间复杂度 \(\mathcal{O}(n\log n(m+\log n))\)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using pii = pair<int, int>;
#define fi first
#define se second
constexpr int N = 1e4 + 5;
int n, q, k[N], ans[N];
int R, vis[N], mx[N], sz[N];
vector<pii> e[N];
void findroot(int id, int ff, int tot) {
  sz[id] = 1, mx[id] = 0;
  for(pii _ : e[id]) {
    int it = _.fi;
    if(it == ff || vis[it]) continue;
    findroot(it, id, tot);
    sz[id] += sz[it];
    mx[id] = max(mx[id], sz[it]);
  }
  mx[id] = max(mx[id], tot - sz[id]);
  if(mx[id] < mx[R]) R = id;
}
vector<pii> info;
void getinfo(int id, int ff, int anc, int d) {
  info.push_back({d, anc});
  for(pii _ : e[id]) {
    int it = _.fi;
    if(it == ff || vis[it]) continue;
    getinfo(it, id, anc, d + _.se);
  }
}
void divide(int id) {
  vis[id] = 1;
  for(pii _ : e[id]) {
    int it = _.fi;
    if(vis[it]) continue;
    getinfo(it, id, it, _.se);
  }
  info.push_back({0, id});
  sort(info.begin(), info.end());
  for(int i = 1; i <= q; i++) {
    int l = 0, r = (int) info.size() - 1;
    while(l < r && !ans[i]) {
      if(info[l].fi + info[r].fi > k[i]) r--;
      else if(info[l].fi + info[r].fi < k[i]) l++;
      else {
        ans[i] = info[l].se != info[r].se;
        if(info[l].fi == info[l + 1].fi) l++;
        else r--;
      }
    }
  }
  info.clear();
  for(pii _ : e[id]) {
    int it = _.fi;
    if(vis[it]) continue;
    R = 0, findroot(it, id, sz[it]);
    divide(R);
  }
}
int main() {
  cin >> n >> q;
  for(int i = 1; i < n; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    e[u].push_back({v, w});
    e[v].push_back({u, w});
  }
  for(int i = 1; i <= q; i++) cin >> k[i];
  mx[0] = N; // 不要忘记将 mx[0] 设为最大值
  findroot(1, 0, n), divide(R);
  for(int i = 1; i <= q; i++) puts(ans[i] ? "AYE" : "NAY");
  return 0;
}

*II. P6329 【模板】点分树 | 震波

点分治经典例题。给出一般形式的点分树代码。

#include <bits/stdc++.h>
using namespace std;
constexpr int K = 17;
constexpr int N = 1e5 + 5;
int n, m, fa[N], lg[N], val[N];
vector<int> e[N], C0[N], C1[N];
void add(vector<int> &c, int x, int v) {
  x++;
  while(x <= c.size()) c[x - 1] += v, x += x & -x;
}
int query(vector<int> &c, int x) {
  x = min(x + 1, (int) c.size()); // 注意 x 和 c.size() 取 min
  int s = 0;
  while(x) s += c[x - 1], x -= x & -x;
  return s;
}
int dn, dep[N], dfn[N], mi[K][N];
int get(int x, int y) {return dep[x] < dep[y] ? x : y;}
int lca(int x, int y) {
  if(x == y) return x;
  if((x = dfn[x]) > (y = dfn[y])) swap(x, y);
  int d = lg[y - x++];
  return get(mi[d][x], mi[d][y - (1 << d) + 1]);
}
int dis(int x, int y) {return dep[x] + dep[y] - 2 * dep[lca(x, y)];}
void dfs(int id, int ff) {
  dep[id] = dep[ff] + 1;
  mi[0][dfn[id] = ++dn] = ff;
  for(int it : e[id]) if(it != ff) dfs(it, id);
}
int sz[N], mx[N], vis[N], R, mxd;
void findroot(int id, int ff, int tot) {
  sz[id] = 1, mx[id] = 0;
  for(int it : e[id]) {
    if(it == ff || vis[it]) continue;
    findroot(it, id, tot);
    sz[id] += sz[it];
    mx[id] = max(mx[id], sz[it]);
  }
  mx[id] = max(mx[id], tot - sz[id]);
  if(mx[id] < mx[R]) R = id;
}
void getdep(int id, int ff, int anc) {
  mxd = max(mxd, dis(id, anc)), sz[id] = 1;
  for(int it : e[id]) if(!vis[it] && it != ff) getdep(it, id, anc), sz[id] += sz[it];
}
void divide(int id) {
  vis[id] = 1;
  mxd = 0, getdep(id, 0, id);
  int tmp = mxd + 1; // 注意这里要开 mxd + 1
  C0[id].resize(tmp);
  for(int it : e[id]) {
    if(vis[it]) continue;
    R = 0, findroot(it, id, sz[it]);
    C1[R].resize(tmp);
    fa[R] = id, divide(R);
  }
}
void add(int x, int v) {
  int u = x;
  while(u) {
    add(C0[u], dis(u, x), v);
    if(fa[u]) add(C1[u], dis(fa[u], x), v);
    u = fa[u];
  }
}
int query(int x, int k) {
  int u = x, ans = 0;
  while(u) {
    if(dis(u, x) <= k) ans += query(C0[u], k - dis(u, x));
    if(fa[u] && dis(fa[u], x) <= k) ans -= query(C1[u], k - dis(fa[u], x));
    u = fa[u];
  }
  return ans;
}
int main() {
  cin >> n >> m, mx[0] = N;
  for(int i = 1; i <= n; i++) cin >> val[i];
  for(int i = 1; i < n; i++) {
    int u, v;
    cin >> u >> v;
    e[u].push_back(v);
    e[v].push_back(u);
  }
  dfs(1, 0);
  for(int i = 2; i <= n; i++) lg[i] = lg[i >> 1] + 1;
  for(int i = 1; i <= lg[n]; i++)
    for(int j = 1; j + (1 << i) - 1 <= n; j++)
      mi[i][j] = get(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
  findroot(1, 0, n), divide(R);
  for(int i = 1; i <= n; i++) add(i, val[i]);
  for(int i = 1, las = 0; i <= m; i++) {
    int op, x, y;
    cin >> op >> x >> y, x ^= las, y ^= las;
    if(op == 0) cout << (las = query(x, y)) << "\n";
    else add(x, y - val[x]), val[x] = y;
  }
  return 0;
}

III. P4149 [IOI2011] Race

经典点分治题目。

因为 \(k\leq 10 ^ 6\),所以开桶记录答案即可。进入每个新的根时都要清空桶,记录修改信息并撤回而不能直接 memset。时间复杂度线性对数。代码

IV. P2634 [国家集训队] 聪聪可可

点分治,对于每个重心 \(r\),记录以 \(r\) 为一端,另一端在子树内的权值和 \(\bmod 3 = 0 / 1 / 2\) 的路径条数。时间复杂度线性对数。

可以线性树形 DP,代码

V. P4178 Tree

排序后双指针统计,时间复杂度 \(\mathcal{O}(n\log ^ 2 n)\)代码

VI. P4075 [SDOI2016] 模式字符串

点分治,对于一个分治中心,使用哈希判断每个点是否能作为从该点到分治重心的一段前缀和后缀,再用桶统计即可。注意边界情况。

时间复杂度线性对数。代码

VII. CF914E Palindromes in a Tree

树上路径统计想到点分治。判断最多有一个字符出现奇数次可以状压。

对于分治重心 \(r\),用 \(20\) 位二进制数统计其所有子节点 \(u\)\(r\) 的路径上每个字符出现次数的奇偶性,记为 \(m_u\)。点分治套个树上差分,开桶算一下即可。注意对每个分治子树分别差分,而不要在原树上差分。具体实现方式见代码。

时间复杂度 \(\mathcal{O}(n\log n|\Sigma|)\)代码

*VIII. P3345 [ZJOI2015] 幻想乡战略游戏

挺复杂的一道点分治,细节要想清楚。

对于节点 \(u\),如果它有一个子树内军队数量的两倍超过了整棵树的军队数量,那么向该节点移动更优。相当于求树上带权重心。

建出点分树,点分树每个节点的儿子个数不超过度数 \(+1\),因此可以暴力找到军队数量更多的子节点并判断其两倍是否大于当前节点子树军队个数,若是则向下移动,否则直接返回。

\(u\)\(f(u) = \sum\limits_v d_v \times dis(u, v)\):类似动态点分治模板题,维护 \(s_{0, u}\) 表示 \(u\) 的点分树子树 \(T_u\)\(\sum\limits_{v\in T_u} d_v \times dis(u, v)\)\(s_{1, u}\) 表示 \(\sum\limits_{v\in T_u} d_v\times dis(fa_u, v)\)\(c_u\) 表示 \(\sum\limits_{v\in T_u} d_v\),则

\[f(u) = \sum\limits_{a \in anc(u)} (s_{0, a} + c_a \times dis(u, a)) - (s_{1, a} + c_a \times dis(u, fa_a)) \]

使用 \(\mathcal{O}(1)\) LCA,计算一次 \(f\) 的时间复杂度为 \(\mathcal{O}(\log n)\)

注意,从父节点 \(x\) 移动到子节点 \(u\) 时,记路径 \(x\to u\) 上第一个节点为 \(suc_u\),那么 \(suc_u\) 的子树大小 \(c_{suc_u}\) 需要增加 \(c_u - c_{suc_u}\)。注意修改需要作用于点分树 \(suc_u\to x\) 路径上除了 \(x\) 以外的所有节点。回溯时清空修改。

综上,时间复杂度 \(\mathcal{O}(n\log n + q\log n(\log n + k))\),其中 \(k\) 是节点度数。

*IX. AT3611 Tree MST

对于 MST 问题,我们每次选出一个边集求 MST,那么没有被选中的边也一定不会在最终的 MST 中,正确性显然。因此,只要我们选出的边集的并等于原图,并将所有边集的 MST 的并再求一次 MST,就能保证正确性。

我们怎么选出这些边集呢?考虑点分治,记重心为 \(r\),令 \(p_i = w_i + dist(i, r)\),则 \(w(i, j) \leq p_i + p_j\),因为 \(dist(i, j)\leq dist(i, r) + dist(r, j)\)。由于最终 \(w(i, j)\) 一定会在点分树上 \(i, j\) 的 LCA 处被正确地考虑到,即存在分治重心 \(c\) 使得 \(i, j\)\(c\) 不同儿子的子树内且 \(dist(i, j) = dist(i, c) + dist(c, j)\),所以算法正确。证明:第一个落在路径 \(i\to j\) 上的分治重心一定包含 \(i, j\) 在不同子树内,该节点即 \(i, j\) 在点分树上的 LCA。

综上,我们每次选取 \(p\) 值最小的节点 \(i\) 与分治子树其它节点 \(j\) 连边,边权为 \(p_i + p_j\)。边的总数为 \(\mathcal{O}(n\log n)\),因此时间复杂度为 \(\mathcal{O}(n\log ^ 2 n)\)代码

LAZTTAG

*X. P6199 [EER1]河童重工

本题是上一题的加强版,因为我们需要考虑两棵树上 \(i,j\) 之间的贡献。

通常涉及到两个树的问题,都需要在其中一棵树上进行点分治:对 \(T_2\) 进行点分治并取出每个分治子树内的所有节点 \(S\)。由于问题还涉及 \(T_1\),我们不能每次 \(\mathcal{O}(n)\) 遍历整棵树但总点集大小级别为 \(\mathcal{O}(n\log n)\),所以对 \(T_1\) 建立 \(S\) 的虚树。

接下来考虑 \(d_i\ (i\in S)\) 的影响,这里的 \(d_i\) 定义为节点 \(i\) 到分治重心 \(r\) 的距离:类似上面一题对每个点赋予权值,但是这样比较麻烦(因为既有点权又有边权),不如对每个点 \(i\) 建立虚点 \(i’\) 并在虚树上连边 \((i,i’,d_i)\)点权转化为边权,并通过换根 DP 求出距离每个点 \(i\) 最近的虚点 \(p_i\) 及距离 \(dis_i\),那么对于虚树的每一条边 \((u,v,w)\),只需要将 \((r_{p_u},r_{p_v},dis_u+dis_v+w)\) 加入候选边集即可,其中 \(r_i\) 表示虚点 \(i\) 对应的原节点。时间复杂度 \(\mathcal{O}(n\log n\log(n\log n))\)

*XI. CF150E Freezing with Style

二分答案,将 \(\geq m\) 的边视为 \(1\)\(< m\) 的边视为 \(-1\),点分治检查是否存在边权和 \(\geq 0\) 的路径。

\(l, r\) 的限制提示我们使用单调队列。对于分治重心 \(R\),计算儿子 \(v\) 的复杂度为 \(d_v + \max d_w\),其中 \(w\) 是已经处理的所有儿子,容易被卡到 \(n ^ 2 \log n\)

考虑启发式合并,按子树最大深度计算所有儿子可使 \(\max d_w\) 对复杂度不产生贡献。只需将所有儿子按照子树最大深度从小到大排序。

时间复杂度 \(\mathcal{O}(n\log ^ 2n)\),本题使用点分治相当卡常,可建出点分树避免大量重复计算。代码

4. 长链剖分

4.1. 算法简介与性质

长链剖分与重链剖分有相通之处,后者是将 子树大小 最大的儿子作为重儿子,前者则是将 子树深度 最大的儿子作为重儿子。可见两者只是换了一个剖分形式。

长链剖分有如下性质:

  • 性质 1:从根节点到任意叶子节点经过的轻边条数不超过 \(\sqrt n\),这比重链剖分 \(\log n\) 稍劣一些。
  • 性质 2:一个节点的 \(k\) 级祖先所在长链长度一定不小于 \(k\)
  • 性质 3:每个节点所在长链末端为其子树内最深节点。根据定义可知。

4.2. 应用:树上 \(k\) 级祖先

\(n\log n\) 倍增预处理求出每个节点 \(u\)\(2^k\) 级祖先,以及对于每条长链,从长链顶端向上 / 向下 \(i\) 步分别能走到哪个节点,其中 \(i\) 不大于长链深度。此外,预处理每个数在二进制下的最高位,记为 \(h_i\)

查询 \((u,k)\) 首先跳到 \(u\)\(2^{h_k}\) 级祖先 \(v\)。由于我们预处理了从 \(v\) 所在长链顶端 \(t\) 向上 / 下走不超过链长步分别到哪个节点,故不难直接查询。综上,时间复杂度为 \(\mathcal{O}(n\log n)-\mathcal{O}(1)\)。代码见例题部分 III.

4.3. 应用:优化深度相关的 DP

4.3.1. 一般形式

长链剖分的价值主要体现在能优化树上 与深度有关 的 DP。如果子树内 每个深度仅有一个信息,就可以使用长链剖分优化。一般形式如:设 \(f_{i,j}\) 表示以 \(i\) 为根的子树内,深度为 \(j\) 的节点的贡献。

4.3.2. 例题:CF1009F Dominant Indices

我们看一道长链剖分优化 DP 的经典例题:十二省联考2019 希望 CF1009F Dominant Indices

注意到我们只关心每个节点子树内深度为 \(j\) 的节点 个数 而非具体是哪些节点,因此子树内深度相同的点等价。设 \(f_{i,j}\) 表示子树 \(i\) 深度为 \(j\) 的节点个数,有转移

\[f_{i, j} = \sum_{k\in \mathrm{son}(i)} f_{k, j - 1} \]

\(f_{i,0}=1\)。直接做的时间复杂度是 \(n ^ 2\),无法接受,考虑长链剖分优化 DP:对于重儿子,我们直接继承它的答案(如何继承见下一部分),然后将所有轻儿子的答案合并过来。因为每个点 \(u\) 最多被合并一次,即合并 \(u\) 所在重链顶端 \(t\) 的父亲 \(fa\)\(t\) 时,\(u\) 所包含的信息就和 \(f_{fa}\)\(dep_u - dep_{fa}\) 处的信息融为了一体,相当于点 \(u\) 直接消失了。因此时间复杂度是优秀的 \(\mathcal{O}(n)\),代码见例题部分 I.

4.3.3. 注意点与技巧

长链剖分实现起来有很多细节,例如如何 继承重儿子 的 DP 值,以及如何处理合并时 下标偏移 的问题。

一个解决方案是使用指针动态申请内存:对于一条重链,共用一个大小为其长度的数组。这同时解决了上述两个问题。实现时需要特别注意 开足空间,并弄清 转移方向

另一个方法是对每个节点开一个动态数组 vector 表示 \(f\),继承重儿子的 DP 值时直接 swap:vector 的 swap\(\mathcal{O}(1)\) 的。需要支持在 vector 前面插入数时,如果使用 deque 则常数太大。不妨将信息倒过来存储在 vector 中,转化为在动态数组末端插入一个数。这种方法适用范围较窄,仅对于一些较平凡的转移方式有效,且常数较大。若需要在头尾增加信息,则只能使用 deque。

两种写法的代码均在例题 I. 中给出。

  • (笔者的)易错点:对于当前节点 \(u\) 及其儿子 \(v\),若 maxdep 即子树最深深度初始化为 \(0\)(即不初始化),且判断 maxdep[v] > maxdep[son[u]] 则更新 \(son_u\)\(v\),则必须在一开始令 \(mxd_0\) 取到一个小于 \(0\) 的数。原因显然。

4.4. 经典结论

这个结论实在是太经典了,以至于它经常出现:选一个节点能覆盖它到根的所有节点。选 \(k\) 个节点,覆盖的最多节点数就是前 \(k\) 条长链长度之和,选择的节点即 \(k\) 条长链末端。

4.5. 例题

I. CF1009F Dominant Indices

长链剖分应用的例题,vector 代码 以及 指针代码

*II. P4292 [WC2010]重建计划

点分治和长链剖分的时间复杂度均为 \(\log ^ 2\),重题 CF150E Freezing with Style 用了淀粉质(见 Part 3. 例题 XI.),因此本题写个长链剖分。

一眼 0/1 分数规划,首先二分答案,将所有边边权减掉 \(m\) 后求是否存在一条长度在 \([L,U]\) 之间的权值非负的路径。我们设 \(f_{i,j}\) 表示以 \(i\) 为一端,在 \(i\) 的子树内且长度为 \(j\) 的路径权值最大值。注意到所有长度为 \(j\) 的路径是等价的,因为我们要求的是最大值,因此一个长度仅会提供一个信息。

这启发我们使用长链剖分,合并子树时先遍历轻儿子的长链求答案,这需要求重链上一段区间 DP 值的最大值,然后若轻儿子的对应位置比重儿子更大则进行修改。此外我们还要对 DP 值区间加,因此使用线段树维护。

使用打标记的做法可以有效减小常数。具体地,维护当前重链进行区间加的总和 \(\Delta\),再维护 \(f_i\) 表示实际 DP 值减去 \(\Delta_i\),那么区间修改时只需要对 \(\Delta\) 进行修改即可,这样省去了线段树的区间加法。不过注意用 \(f_{v,i}\) 更新 \(f_{u,i}\) 时需要考虑两个儿子所在重链的 \(\Delta\) 的影响,我们维护的 DP 值加上 \(\Delta\) 才是真正的 DP 值。

此外我们需类似树链剖分为每个节点赋一个 dfs 序,方便定位到线段树上修改和查询的位置。最后对于每个节点 \(i\),我们还需考虑以 \(i\) 为一端的路径,在线段树上进行查询。时间复杂度 \(\mathcal{O}(n\log^2n)\)

III. P5903 【模板】树上 k 级祖先

长链剖分求树上 \(k\) 级祖先的模板题,时间复杂度 \(n\log n+q\)

const int N = 5e5 + 5;
const int K = 19;
int cnt, hd[N], nxt[N], to[N]; ll ans;
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
int n, q, root, f[N][K], h[N], dep[N], mxd[N], son[N], top[N];
vector <int> up[N], down[N];
void init(int id) {
	dep[id] = dep[f[id][0]] + 1, mxd[id] = 1;
	for(int i = 1; i < K; i++) f[id][i] = f[f[id][i - 1]][i - 1];
	for(int i = hd[id]; i; i = nxt[i]) {
		init(to[i]), mxd[id] = max(mxd[id], mxd[to[i]] + 1);
		if(mxd[son[id]] < mxd[to[i]]) son[id] = to[i];
	}
}
void dfs(int id, int t) {
	if(id == t) up[id].resize(mxd[id]), down[id].resize(mxd[id]);
	down[t][dep[id] - dep[t]] = id, top[id] = t;
	if(son[id]) dfs(son[id], t);
	for(int i = hd[id]; i; i = nxt[i]) if(to[i] != son[id]) dfs(to[i], to[i]);
}
int main() {
	cin >> n >> q >> s;
	for(int i = 1; i <= n; i++) !(f[i][0] = read()) ? (root = i, void()) : add(f[i][0], i);
	init(root), dfs(root, root);
	for(int i = 1; i <= n; i++) if(top[i] == i)
		for(int j = 0, cur = i; j < mxd[i] && cur; j++, cur = f[cur][0]) up[i][j] = cur;
	for(int i = 1; i <= n; i++) for(int j = 0; j < K; j++) if(i >> j & 1) h[i] = j;
	for(int i = 1, las = 0; i <= q; i++) {
		int x = (get(s) ^ las) % n + 1, k = (get(s) ^ las) % dep[x];
		x = !k ? x : f[x][h[k]], k -= (k == 0 ? 0 : (1 << h[k]));
		int t = top[x], res;
		if(dep[x] - k >= dep[t]) res = down[t][dep[x] - k - dep[t]];
		else res = up[t][dep[t] - (dep[x] - k)];
		ans ^= 1ll * i * (las = res);
	} cout << ans << endl;
	return 0;
}

IV. 某模拟赛 上升

题意简述:给出一棵树,点有点权。求去掉任意一个节点后剩余连通块内部 LIS 长度最小值。\(n\leq 5\times 10^5\),TL 1s,ML 1GB。

首先本题严格强于 CF490F Treeland tour:我们将原树复制一份后取一组对称点并向一个虚点连边,对该树求原问题就是全局 LIS 的解。

乍看起来没有什么头绪,似乎要换根。但是 长链剖分和线段树合并都不支持换根:它们的复杂度是均摊证明的。

所以本题不可做吗?并不是,一个关键的 observation 是被去掉的点一定在全局 LIS 的两个端点 \(u,v\) 之间的链上。所以我们可以对原树求一遍全局 LIS & LDS(LDS 用于在某个节点处与 LIS 合并求答案)及其端点 \(u,v\),再分别以 \(u,v\) 为根求出去掉链上每个点后剩余连通块的 LIS。

为什么要做两遍:以 \(v\) 为根可以求出以 \(u\) 为根时去掉每个点后无法求出的,其父亲所在连通块的 LIS & LDS。

\(f/g_{u,i}\) 表示在 \(u\) 的子树内长度为 \(i\) 的 LIS / LDS 结尾最大值 / 最小值(必须是一条深度递减的链而不能由两个子树内分别的 LIS 和 LDS 拼起来),这是 DP 求序列 LIS 和 LDS 的经典做法。注意到每个长度(深度)仅会贡献一个信息,因此长链剖分优化即可。时间复杂度 \(\mathcal{O}(n\log n)\)

*V. P5904 [POI2014]HOT-Hotels 加强版

长链剖分优化 DP 好题。考虑满足题意的 \((i,j,k)\) 有怎样的形态:三个点距离其中两个点的 LCA 的距离都为 \(d\)

一个显然的想法是设 \(f_{i,j}\) 表示以 \(i\) 为根的子树内距离 \(i\)\(j\) 的节点个数,但是我们发现这样无法求出答案。考虑在三个点的 LCA 处计算贡献,这个想法启发我们再设计这样一个 DP 状态:设 \(g_{i,j}\) 表示再来一条长度为 \(j\) 的链就可以凑成一个三元组的方案,i.e. 在 \(i\) 的子树内满足 \(i\)\((x,y)\) 的 LCA \(d\) 距离加上 \(j\) 等于 \(\mathrm{dis}(x,d)\) 等于 \(\mathrm{dis}(y,d)\) 的二元无序对 \((x,y)\) 的数量。转移方程比状态设计简单多了:

\[f_{i,j}=\sum_{u\in \mathrm{son}_i}f_{u,j-1} \]

但是 \(g\) 的转移方程似乎不太好直接用儿子的 \(g\) 来表示。遇到这种不容易直接用儿子表示父亲的树形 DP 可以仅考虑合并 \(i\) 及其儿子子树 \(v\)。分两种情况讨论:

  • \(\mathrm{LCA}(x,y)=d\) 已经在 \(u\) 的子树内,此时即 \(g_{i,j}\gets g_{i,j}+g_{u,j+1}\)
  • \(d=i\),此时即合并两条链,\(g_{i,j}\gets g_{i,j}+f_{i,j}\times f_{u,j-1}\)注意这里不要先算 \(f_{i,j}\) 再合并,因为此处 \(f_{i,j}\) 的含义是 \(i\) 已经合并过的子树的答案,因此合并时先算 \(g_i\),再算 \(f_i\),否则会出现两条链都属于 \(u\) 的子树的多余情况。

比较明显的长链剖分优化与深度有关的 DP。统计答案也要讨论一下:

  • \(i\) 刚好是三元组其中一个节点(且是另外两个节点的祖先),类似一个倒着的 Y 字形:答案加上 \(g_{i,0}\) 因为不需要再加链就能形成一个三元组。为了防止重复计算,该部分贡献需要在所有 DP 之前计算(思考一下为什么,可以根据下方统计贡献的方法理解)。
  • 否则根据 \(g_{i,j}\) 的定义,我们要从当前子树中抓一条长度为 \(j-1\) 的链,加上 \((i,u)\) 这条边刚好长度为 \(j\),和 \(g_{i,j}\) 统计的 \((x,y)\) 形成三元组;或者从已经算完的子树抓一条长度为 \(j-1\) 的链,加上 \((i,u)\) 这条边形成长度为 \(j\) 的链,和 \(g_{u,j}\) 统计的 \((x,y)\) 形成三元组。因此答案加上 \(\sum_{\\j}g_{i,j}f_{u,j-1}+\sum_{\\j} f_{i,j-1}g_{u,j}\)\(j\) 的枚举上限是子树重链长度,保证了复杂度。统计该情况的答案在合并之前

边界值 \(f_{i,0}=1\)\(g_{i,0}=0\)。时空复杂度是优秀的 \(\mathcal{O}(n)\)。注意 \(g\) DP 的方向使得我们要开两倍空间并且只能用指针(用 STL 需要支持从头加,从末尾访问,开 \(n\) 个 deque 直接 MLE)。

const int N = 1e5 + 5;
ll buff[N << 3], *f[N], *g[N], *p = buff;
ll n, ans, len[N], son[N];
vector <int> e[N];
void init(int id, int fa) {
	len[id] = 1;
	for(int it : e[id]) if(it != fa) {
		init(it, id), len[id] = max(len[id], len[it] + 1);
		if(len[son[id]] < len[it]) son[id] = it;
	}
}
void dfs(int id, int fa) {
	if(son[id]) f[son[id]] = f[id] + 1, g[son[id]] = g[id] - 1, dfs(son[id], id);
	f[id][0] = 1, ans += g[id][0];
	for(int it : e[id]) if(it != fa && it != son[id]) {
		f[it] = p, p += len[it] + 2 << 1, g[it] = p, p += len[it] + 2, dfs(it, id);
		for(int i = 0; i < len[it]; i++) ans += f[it][i] * g[id][i + 1];
		for(int i = 1; i < len[it]; i++) ans += g[it][i] * f[id][i - 1];
		for(int i = 0; i < len[it]; i++) g[id][i + 1] += f[id][i + 1] * f[it][i];
		for(int i = 1; i < len[it]; i++) g[id][i - 1] += g[it][i];
		for(int i = 0; i < len[it]; i++) f[id][i + 1] += f[it][i];
	}
}
int main() {
	cin >> n;
	for(int i = 1, u, v; i < n; i++) e[u = read()].pb(v = read()), e[v].pb(u);
	init(1, 0), f[1] = p, p += len[1] + 2 << 1, g[1] = p, p += len[1] + 2, dfs(1, 0);
	cout << ans << endl;
	return 0;
}

**VI. CF526G Spiders Evil Plan

*VII. P3441 [POI2006]MET-Subway

CF526G 的弱化版。找到直径一端作为根,长链剖分取 \(2l-1\) 个叶子即可。时间复杂度是桶排的线性。

VIII. LOJ #3561. Redemption

将不会造反的人的数量巧妙转化为对于每个 \(t_i > T\) 的位置 \(i\)\(l_i\sim i\) 之间存在床垫的 \(i\) 的个数,其中 \(l_i\) 表示最大的使 \(t_j + (i - j) \leq T\)\(j\leq i\)\(j\)

关键性质:所有区间 仅包含或相离,因此,建出树后长链剖分,贪心地选前 \(d\) 长的长链即为所求,这是经典贪心问题,和 VII 类似。

5. 树上启发式合并

前置知识:重链剖分,长链剖分,启发式合并。

树上启发式合并,简称 dsu on tree。是比较实用的算法,但是因为太懒一直没有学。

5.1. 算法简介

首先,它是一个静态离线算法。考察重链剖分的过程及其性质:从根到树上任意节点所经过的轻边条数在 \(\log n\) 级别。我们思考能不能用这个性质来做文章。

这和启发式合并有异曲同工之妙:每个元素最多会被合并 \(\log n\) 次。因此我们将其搬到树上:对于每个重儿子,我们直接继承答案,然后将轻儿子的结果合并到当前节点 \(u\)。复杂度证明是容易的,可以参考普通启发式合并,或直接使用重链剖分的性质进行证明。

那什么样的信息可以被这样统计呢?信息大小是子树大小的 \(\rm polylog\)

总结一下,当我们遇到一些统计子树内含有某些性质的节点数量或其他信息如 LIS 等的题目时,可以考虑使用 dsu on tree。此外,dsu on tree 能做的题目,树上线段树合并也可以做,两者会有常数或 \(\rm polylog\) 级别的复杂度差异,在例题中会有所体现。你也可以在 线段树合并 的 blog 中找到一些 dsu on tree 可做题。这一算法的使用套路需要在大量的例题中不断体会并总结。

5.2. 算法流程

鸽子咕咕子鸽。

6. 笛卡尔树:单调栈进阶

最近笛卡尔树越来越火,某一场 CF 甚至有两道笛卡尔树,于是尝试学习。

笛卡尔树的英文名称为 Cartesian Tree,因此它在机房也被称为卡特兰树。

6.1. 定义与性质

笛卡尔树是一种二叉树,每一个节点由一个键值二元组 \((k,w)\) 构成,其中 \(k\) 满足二叉搜索树的性质,而 \(w\) 满足堆的性质。 —— OI Wiki

我们通常所说的 “对一个序列建笛卡尔树”,意思就是将一个位置的下标和权值分别作为 \(k\)\(w\),也就是说,对于一个节点 \(i\) 的左儿子 \(l_i\) 和右儿子 \(r_i\),一定满足 \(l_i<i<r_i\)(下标 \(k\) 满足二叉搜索树的性质)且 \(v_{l_i}\)\(v_{r_i}\) 同时不大于或不小于 \(v_i\)(权值 \(w\) 满足堆的性质)。若序列的权值互不相同,则笛卡尔树形态唯一

这里给出 OI Wiki 的一张图,可以看到一个子树对应的下标一定是连续的

  • 笛卡尔树与 BST 之间的联系:对于插入 BST 的每个值 \(v\),我们记录其被插入的时间 \(t\)。如果将 \(t\) 作为权值,\(v\) 作为下标,那么这棵 BST 就变成了笛卡尔树。

  • 二叉搜索树和堆 …… 你有没有想到 Treap!笛卡尔树本质上就是一种不平衡的 Treap。那我们还要笛卡尔树干啥。

  • 一个节点(不妨设其下标为 \(i\))在笛卡尔树上的祖先节点由 \(v_1\sim v_i\) 形成的单调栈以及 \(v_i\sim v_n\) 形成的单调栈内所有元素组成。因此,笛卡尔树又可以看作两个单调栈

6.2. 建树方法

不妨假设我们要构建满足大根堆性质的笛卡尔树。

考虑使用增量法,从左到右依次加入序列中的每个值,维护一个递减的单调栈表示当前笛卡尔树最靠右的链(我们只需要知道这些信息,因为任何节点的所有左儿子与当前节点无关)。这和虚树的构建比较类似。

加入一个下标为 \(i\) 的值 \(v\) 时,首先找到单调栈中最小的且大于 \(v\) 的值 \(w\),设其对应节点为 \(u\),那么将 \(u\) 的右儿子改成 \(i\),将 \(i\) 的左儿子改成 \(u\) 原来的右儿子(即单调栈中 \(u\) 上方的元素),再将 \(i\) 压入栈即可。核心代码很短,只有两行,是一个非常容易上手的数据结构:

const int N = 1e7 + 5;
int stc[N], top, a[N], ls[N], rs[N];
void build() {
    for(int i = 1; i <= n; i++) {
        while(top && a[stc[top]] < a[i]) ls[i] = stc[top--];
        rs[stc[top]] = i, stc[++top] = i; // 注意这里可能会让 rs[0] = i, 但下标从 1 开始问题不大
    }
}

此外,对于更一般的笛卡尔树构建,我们首先需要将所有信息 \((k,w)\) 按照 \(k\) 排序。而序列下标这一特殊的键值保证了 \(k\) 的单调性,因此可以 \(\mathcal{O}(n)\) 建出笛卡尔树。

6.3. 应用:\(\mathcal{O}(n) - \mathcal{O}(1)\) RMQ

又称四毛子算法:Method of Four Russians.

首先得会欧拉序求 LCA(学虚树必备,因为虚树求 LCA 的次数很多,\(\mathcal{O}(1)\) 查询显著减小常数)。大概的思想是首先对序列建出笛卡尔树,则问题转化为求两个点的树上 LCA。不难看出求得笛卡尔树的欧拉序后,这是一个 \(\pm1\) RMQ 问题,可以使用分块的思想在 \(\mathcal{O}(n)-\mathcal{O}(1)\) 的时间内解决:

具体地,取块长 \(B=\left\lceil\dfrac{\log_2n}2\right\rceil\),将整个序列分成 \(\left\lceil\dfrac{n}B\right\rceil\)个块,对于整块之间的 RMQ 可以 ST 表 \(\mathcal{O}(\frac n B\log\frac n B)=\mathcal{O}(n)\) 预处理。而对于块内任意子区间的 RMQ,注意到本质不同的序列只有 \(2^B=\sqrt n\) 个(差分数组只有 \(+1\)\(-1\)),因此可以对于这 \(\sqrt n\) 个序列分别 \(\mathcal{O}(B)\) 预处理:为什么不是 \(\mathcal{O}(B^2)\) 预处理?实际上如果你要求块 \([l_i,r_i]\) 内区间 \([l,r]\) 最大值,那么左边没有值的位置(\(p\in[l_i,l)\))可视作 \(+1\),右边没有值的位置(\(p\in (r,r_i]\))可视作 \(-1\)

6.4. 例题

除了纯纯的笛卡尔树,也会放一些比较进阶的单调栈题目,一般和笛卡尔树都有些联系。

*I. P6604 [HNOI2016]序列 加强版

P6503 比较类似。我们设 \(f_i\) 表示全局以 \(i\) 结尾的子区间的最小值之和,令 \(p_i\) 为下标在 \(i\) 之前第一个比 \(a_i\) 小的位置,显然有 \(f_i=f_{p_i}+(i-p_i)a_i\):因为 \(a_i\)\(p_i\) 以及 \(p_i\) 以前的最小值没有影响(即 \([1,p_i]\)\([1,i]\)\([2,p_i]\)\([2,i]\cdots\) \([p_i,p_i]\)\([p_i,i]\) 的最小值相同),所以可以直接由 \(f_{p_i}\) 转移得来。而根据 \(p_i\) 的定义,后面 \(i-p_i\) 个子序列(即 \([p_i+1,i],[p_i+2,i]\cdots,[i,i]\))的最小值为 \(a_i\)

注意到 \(p_i\) 实际相当于 \(i\) 在笛卡尔树上第一个向左走的父亲,求 \(p_i\) 的过程十分类似构建笛卡尔树:笛卡尔树上每个节点的祖先由左右两个单调栈构成

考虑对一个区间求答案:求出区间最小值的位置 \(p\),那么左端在 \(p\) 左边,右端在 \(p\) 右边的子区间最小值为 \(a_p\),故答案加上 \(a_p\times (p-l+1)\times (r-p+1)\)。此外,我们还需求出 \([l,p)\)\((p,r]\) 的答案:考虑 \((p,r]\) 每个位置对答案的贡献都是 \(f_r-f_p\),因为 \([i,p]\)\([i,r]\ (1\leq i\leq p)\) 的最小值相同。前缀和优化可以做到 \(\mathcal{O}(1)\) 回答每个询问。对于 \([l,p)\) 同理,我们只需预处理出 \(g_i\) 表示全局以 \(i\) 开头的子区间的最小值之和并类似处理即可。复杂度瓶颈在于区间 RMQ,时间复杂度 \(\mathcal{O}(n\log n+q)\)

const int N = 1e5 + 5;
const int K = 17;

namespace gen {
	ull s, a, b, c, las = 0;
	ull rand() {return s ^= (a + b * las) % c;}
}

int n, q, type, stc[N], *top = stc, a[N], lg[N], pre[N], suf[N];
ll mi[K][N], fp[N], gp[N], fs[N], gs[N]; ull res;
int cmp(int x, int y) {return a[x] < a[y] ? x : y;}
int RMQ(int l, int r) {int d = lg[r - l + 1]; return cmp(mi[d][l], mi[d][r - (1 << d) + 1]);}

int main(){
	cin >> n >> q >> type;
	for(int i = 1; i <= n; i++) a[i] = read(), mi[0][i] = i;
	for(int i = 2; i <= n; i++) lg[i] = lg[i >> 1] + 1;
	for(int i = 1; i <= lg[n]; i++)
		for(int j = 1; j + (1 << i) - 1 <= n; j++)
			mi[i][j] = cmp(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
	for(int i = 1; i <= n; i++) { 
		while(*top && a[*top] >= a[i]) suf[*top--] = i; // 可以类比求笛卡尔树的过程: ls[i] = *top, 因此 suf[*top] = i;
		pre[i] = *top, *++top = i; // 同理, rs[*top] = i, 所以 pre[i] = *top: 笛卡尔树上每个节点的祖先是由两个单调栈构成的!
	}
	for(int i = 1; i <= n; i++)
		fp[i] = fp[pre[i]] + 1ll * a[i] * (i - pre[i]), gp[i] = gp[i - 1] + fp[i];
	for(int i = n; i; i--)
		fs[i] = fs[suf[i]] + 1ll * a[i] * (suf[i] - i), gs[i] = gs[i + 1] + fs[i];
	if(type) gen :: s = read(), gen :: a = read(), gen :: b = read(), gen :: c = read();
	for(int i = 1, l, r; i <= q; i++) {
		if(type == 0) l = read(), r = read();
		else {
			l = gen :: rand() % n + 1;
			r = gen :: rand() % n + 1;
			if(l > r) swap(l, r);
		} ll p = RMQ(l, r), ans;
		ans = a[p] * (r - p + 1) * (p - l + 1);
		ans += gp[r] - gp[p] - fp[p] * (r - p);
		ans += gs[l] - gs[p] - fs[p] * (p - l);
		res ^= gen :: las = ans;
	} cout << res << endl;
    return flush(), 0;
}

*II. CF1117G Recursive Queries

hot tea。实际上题目转化一下,把区间的贡献算到单点上,就是求对 \([l,r]\) 的元素建出笛卡尔树,求每个节点的 \(dep / size\) 之和:

做法 1:但是这样做反而麻烦了,考虑每个位置究竟作为哪个区间的最大值出现:设 \(p_i\) 表示 \(i\) 左边第一个比 \(a_i\) 大的元素的位置,\(q_i\) 表示 \(i\) 右边第一个,显然 \(i\) 作为 \([p_i+1,q_i-1]\) 的最大值出现,因此答案为 \(\sum_{\\i=l}^r\max(r,q_i-1)-\min(p_i+1,l)+1\)

将答案拆成 \(\max\)\(\min\) 两部分来算,考虑将 \(r\)\(n\) 拖到 \(1\) 的过程中,每个点究竟对应 \(q_i-1\) 还是 \(r\) 只会改变一次。因此将询问离线下来,对于对应 \(q_i-1\) 的部分,直接树状数组维护查询区间 \(q_i-1\) 的和,而对于对应 \(r\) 的部分,再开一个树状数组维护有多少个对应 \(r\) 的数,区间求和并乘以 \(r\) 即可。对于 \(\min\) 同理。时间复杂度 \(\mathcal{O}((n+q)\log n)\)代码


做法 2:对于一个区间 \([l,r]\),它的笛卡尔树可以通过前缀 \([1,r]\) 的笛卡尔树得到。求出 \([l,r]\) 最大值位置 \(p\) 后,\(p\) 显然是 \([l,r]\) 笛卡尔树的根。由于一个子树对应的下标连续,因此 \([p+1,r]\) 对答案的贡献可以通过求它们在 \([1,r]\) 的笛卡尔树上的 \(dep\) 之和减去 \(dep_p\times (r-p)\) 得到,即 \(p\) 的右子树一定是 \([p+1,r]\) 的笛卡尔树。为什么 \([l,p-1]\) 不行呢?因为受到了 \([1,l-1]\) 的影响,即一个节点的父亲很有可能不在 \([l,p-1]\) 而在 \([1,l-1]\) 当中。

怎么办呢,问题不大,倒过来再做一遍就行了。求区间最大值可以在单调栈维护的笛卡尔树的右链上二分。

整理一下,在加入节点时我们需要支持区间修改:不断弹出小于当前值的栈顶直到大于当前值,设其下标为 \(p\),那么 \(p\) 的右子树对应的下标 \([p+1,r]\) 的深度加上 \(1\)。查询时需要区间查询。BIT 即可,时间复杂度 \(\mathcal{O}((n+q)\log n)\)代码

启发:对于和最值有关的题目,将询问按照区间最大值分割成两个互不相关的查询可以有效简化问题。同时,左右两端第一个大于 / 小于当前值的位置有很好的性质,要好好利用(HNOI2016 序列)。

*III. AT4436 [AGC028B] Removing Blocks

nb tea. 考虑每个点对答案的贡献:如果按照删除时间为值建出笛卡尔树,那么一个点的贡献应为其在笛卡尔树上的深度。

众所周知,这类统计 sum 的计数题可以转化成方案数 \(\times\) 答案的期望,前者是 \(n!\),而后者根据期望的线性性,可以被拆为 \(\sum a_i\times E(d_i)\),再进一步拆为 \(\sum_i a_i\times \sum_{\\u\neq i}E([u\ \mathrm{is\ the\ ancestor\ of}\ i])\)。考虑 \(u\) 成为 \(i\) 的祖先的概率(不妨设 \(u<i\),反之同理),这意味着如果只看下标 \(u\sim i\),那么 \(u\) 是第一个被删去的,因此其余所有数被删去的顺序对答案没有影响,而在所有 \((i-u+1)!\) 种删去 \(u\sim i\) 的排列中,只有 \((i-u)!\) 种是合法的(即 \(u\) 排在第一个),因此期望加上 \(\dfrac 1 {i-u+1}\)

考虑枚举每个成为 \(i\) 的祖先的下标 \(u\),那么 \(E=\sum_{\\i}a_i\times \sum_{u}\dfrac{1}{|i-u+1|}\)。预处理 \(\dfrac 1 i\) 关于 \(i\) 的前缀和即可做到 \(\mathcal{O}(n)\)

总和 \(=\) 期望 \(\times\) 方案数,妙不可言!代码

*IV. CF1580D. Subsequence *2900

我们建出笛卡尔树,然后是裸的树形背包。设 \(f_{i,j}\) 表示在 \(i\) 的子树内选了 \(j\) 个点的最大值,若不选 \(i\) 则有:

\[f_{i,j}=\max_{k=0}^jf_{l,k}+f_{r,j-k}-j\times k\times v_i \]

\(i\) 则有:

\[f_{i,j}=\max_{k=0}^{j-1}f_{l,k}+f_{r,j-k-1}-(j\times k+2j-1-m)\times v_i \]

时间复杂度 \(n^2\)。笛卡尔树可以直接递归建。代码

7. 其它

8. 更多技巧

posted @ 2021-06-24 16:30  qAlex_Weiq  阅读(6757)  评论(8编辑  收藏  举报