0. Change log
2021.12.10:重构文章,修改部分表述与代码。目前重构至虚树例题部分。
一些基础定义:
- dfs 序表示对一棵树进行深度优先搜索得到的结点序列,而时间戳 dfn 表示每个结点在 dfs 序中的位置。这两个概念需要着重区分。
- \(\mathrm{son}(i)\) 表示 \(i\) 的儿子结点,\(\mathrm{ancestor}(i)\) 表示 \(i\) 的祖先结点,\(\mathrm{dist}(i,j)\) 表示 \(i\) 与 \(j\) 的树上距离,\(\mathrm{subtree}(i)\) 表示 \(i\) 的子树点集。
1. kruskal 重构树
前置知识:kruskal 算法求最小 / 最大生成树,倍增。
1.1. 算法简介
回想用克鲁斯卡尔算法求最小生成树的过程:如果当前边 \((u,v)\) 两端不连通就连起来。kruskal 重构树只需将这步操作略加修改:新建结点 \(c\),则 \(u,v\) 的代表元的父亲指向 \(c\)。所有连边形成的有根树即 kruskal 重构树。
下文中,我们称 “原结点” 为原图中的结点,“新结点” 为我们新建的结点,kruskal 重构树简称重构树(所以 “重构树” 表示树,而 kruskal 重构树表示算法)。新结点 \(c\) 的权值为 \(w_{u,v}\),这里为虚点设置权值的目的是方便解题。巧妙地设置边权对解题有极大帮助。
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;
add(node, u), add(node, v);
}
1.2. 性质与应用
kruskal 重构树有很好的性质(否则要它有什么用呢):
- 一般情况下它是一棵二叉树,由重构树的构建方法易知。特例见 1.5 优化:多叉树。
- 原图的所有结点是树的叶子,这一点显然。因此,下文中的原结点和 重构树上的叶子结点是同一概念,时刻记住。
- 对于任意一个新结点 \(u\) 与其祖先 \(v\),\(w_u\) 与 \(w_v\) 满足相同的偏序关系。即父结点的权值总结点不小于或不大于子结点的权值,这取决于究竟按边权从小到大还是从大到小排序。
性质 3 非常重要,是 kruskal 重构树的核心。有了这一性质,我们可以做很多事情:例如原结点 \(x\) 在原图上所有经过边权不大于 \(d\) 的边能够到达的点,就是它在按边权从小到大排序得到的重构树上最浅的,权值 \(\leq d\) 的祖先 \(a\) 的子树内的所有叶子结点。而这个 \(a\) 可以通过倍增找到,因此 kruskal 重构树通常与倍增相结合。
换句话说,从一个原结点 \(x\) 倍增找到权值 \(\leq d\) 的最浅的结点 \(a\),那么 \(a\) 子树内所有叶子就是在原图上只看边权 \(\leq d\) 的边,\(x\) 所在连通块的所有点。
综上所述,我们总结出一个常用套路:如果题目限制形如 “只经过权值不大 / 小于某个值的点 / 边”,那么可以使用 kruskal 重构树。当然,部分题目也可以用可持久化并查集,这是题外话了。有时间写一个 kruskal 重构树与可持久化并查集的对比。
1.3. 扩展:点权重构树
上面说到 kruskal 重构树不仅适用于限制边权的题目,也可以处理限制点权的情况,这需要为每条边巧妙地赋值。具体地,如果限制经过的点的点权最大值,那么走一条边 \((u,v)\) 会经过两个点 \(u,v\),需要满足 \(w_u,w_v\) 都不超过最大值,所以 \(w_{u,v}=\max(w_v,w_v)\)。类似地,若限制最小值则 \(w_{u,v}=\min(w_u,w_v)\)。
1.4. 扩展:上下界重构树
如果上下界都给出,即既限制了边权最大值,也限制了边权最小值,我们可以分别建出边权从小到大(根结点权值最大)和从大到小(根结点权值最小)排序的重构树,下文中记为 \(T_{\max}\) 和 \(T_{\min}\),并根据上界和下界从出发点 \(x\) 倍增到相应结点。这样题目被转化为求子树叶子结点交,使用 dfs 序可化为经典二维偏序问题:
形式化地,设 \(a\) 为 \(T_{\max}\) 的 dfs 序(这里 dfs 序定义为对一棵树进行深度优先搜索得到的结点序列,而非每个结点的时间戳 dfn),\(b\) 为 \(T_{\min}\) 的 dfs 序。多次询问给出 \(l_1,r_1,l_2,r_2\),求所有 \(i\in[l_1,r_1]\) 满足存在 \(j\in[l_2,r_2]\) 使得 \(a_i=b_j\) 的 \(i\) 的个数。\(i,j\) 的实际含义就是 \(T_{\max}\) 和 \(T_{\min}\) 的时间戳。
令 \(c_i\) 表示编号为 \(a_i\) 的结点在 \(T_{\min}\) 上的时间戳,问题即求 \(i\in [l_1,r_1]\) 且 \(c_i\in [l_2,r_2]\) 的 \(i\) 的个数,这是经典二维偏序问题,可以在线主席树或离线树状数组解决。具体方法超出了我们的讨论范围。
1.5. 优化:多叉重构树
实际上我们几乎用不到正统重构树是二叉树这一性质。因此,当题目限制点权时,存在更加高妙的做法:
不妨设题目限制点权最小值,那么按编号从大到小遍历每个点 \(i\) 及其所有出边 \((i,u)\),若 \(i<u\) 说明 \(\min(i,u)\) 取到 \(i\),此时若 \(i,u\) 不连通则从 \(i\) 向 \(u\) 的代表元连边。
这种做法与一般的 kruskal 重构树算法几乎等价:普通重构树中对于点权相同的虚点,仅有深度最小的那个有用,以及从大到小枚举结点相当于对所有边排序,因为边权就是 \(\min(i,u)\)。这样免去了新建虚点和对边排序的麻烦过程,大幅减小了常数。
不同点在于重构树是二叉树,而该做法建出来的是一棵多叉树,因此我将其称为多叉重构树。多叉重构树的写法见例题 I。
1.6. 例题
P4899 [IOI2018] werewolf 狼人
上下界重构树的板题。IOI 也会出裸题?建出 \(T_{\min}\) 和 \(T_{\max}\)。在 \(T_{\min}\) 上从 \(S\) 倍增到使 \(w_a\) 最小且 \(w_a\geq L\) 的祖先 \(a\),在 \(T_{\max}\) 上从 \(E\) 倍增到使 \(w_b\) 最大且 \(w_b\leq R\) 的 \(b\)。求 \(a\) 的子树和 \(b\) 的子树叶子结点是否有交即可,时间复杂度线性对数。
const int N = 2e5 + 5;
const int K = 18;
int n, m, q, lg, fa[N]; vint e[N];
void init() {for(int i = 1; i <= n; i++) fa[i] = i;}
int find(int x) {return fa[x] == x ? x : fa[x] = find(fa[x]);}
struct Kruskal {
int cnt, dn, hd[N], nxt[N], to[N], fa[K][N], dfn[N], rev[N], sz[N];
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
void dfs(int id, int f) {
fa[0][id] = f, dfn[id] = ++dn, rev[dn] = id, sz[id] = 1;
for(int i = 1; i <= lg && fa[i - 1][id]; i++) fa[i][id] = fa[i - 1][fa[i - 1][id]];
for(int i = hd[id]; i; i = nxt[i]) if(to[i] != f) dfs(to[i], id), sz[id] += sz[to[i]];
}
} Tmin, Tmax;
int node, R[N], ls[N << 5], rs[N << 5], val[N << 5];
void modify(int pre, int &x, int l, int r, int p) {
val[x = ++node] = val[pre] + 1, ls[x] = ls[pre], rs[x] = rs[pre];
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(ls[pre], ls[x], l, m, p);
else modify(rs[pre], rs[x], m + 1, r, p);
}
bool query(int l, int r, int ql, int qr, int x, int y) {
if(ql <= l && r <= qr) return val[y] - val[x] > 0;
int m = l + r >> 1;
if(ql <= m && query(l, m, ql, qr, ls[x], ls[y])) return 1;
if(m < qr && query(m + 1, r, ql, qr, rs[x], rs[y])) return 1;
return 0;
}
int main() {
cin >> n >> m >> q, lg = log2(n);
for(int i = 1, u, v; i <= m; i++) e[u = read() + 1].pb(v = read() + 1), e[v].pb(u);
for(int i = (init(), n); i; i--) for(int it : e[i]) if(i < it)
if(i != find(it)) Tmin.add(i, fa[it]), fa[fa[it]] = i; // 多叉重构树
for(int i = (init(), 1); i <= n; i++) for(int it : e[i]) if(i > it)
if(i != find(it)) Tmax.add(i, fa[it]), fa[fa[it]] = i;
Tmin.dfs(1, 0), Tmax.dfs(n, 0);
for(int i = 1; i <= n; i++) modify(R[i - 1], R[i], 1, n, Tmax.dfn[Tmin.rev[i]]);
for(int i = 1; i <= q; i++) {
int S = read() + 1, E = read() + 1, l = read() + 1, r = read() + 1;
for(int i = lg; ~i; i--) if(Tmin.fa[i][S] >= l) S = Tmin.fa[i][S];
for(int i = lg; ~i; i--) if(Tmax.fa[i][E] && Tmax.fa[i][E] <= r) E = Tmax.fa[i][E];
pc(48 + query(1, n, Tmax.dfn[E], Tmax.dfn[E] + Tmax.sz[E] - 1,
R[Tmin.dfn[S] - 1], R[Tmin.dfn[S] + Tmin.sz[S] - 1])), pc('\n');
} return flush(), 0;
}
*II. 2021 联考模拟巴蜀中学 超级加倍
题意简述:定义一条树上简单路径 \(x\to y\) 是好的,当且仅当路径上编号最小的点为 \(x\),编号最大的点为 \(y\)。
路径上编号最小的点是 \(x\),编号最大的点是 \(y\) …… 有没有联想到 IOI2019 狼人?考察以 \(x\) 为一端的合法路径,另一端必然不经过任何编号小于 \(x\) 的点。也就是说如果把编号小于 \(x\) 的点去掉,那么剩下的连通块的任何一个点都满足与 \(x\) 之间不经过编号小于 \(x\) 的点,这一点是显然的。编号最大点为 \(y\) 的限制同理。
注意到这类似一个按编号从小到大不断删点并维护连通性的过程。化删点为加点,这就是 Kruskal 重构树!把所有边按照 \(\min(u,v)\) 从大到小排序建出重构树 \(T_{\min}\),边化点的点权 \(val_{e}\) 即为 \(\min(u,v)\),按照 \(\max(u,v)\) 从小到大排序建出重构树 \(T_{max}\),边化点的点权即为 \(\max(u,v)\)。
根据重构树的性质,对于任何一对在 \(T_{\min}\) 上具有祖先后代关系的点对 \((u,v)\),其中 \(u\) 是编号 \(>n\) 的重构树新建结点,\(v\) 是编号 \(\leq n\) 的叶子结点,那么在原树上 \(val_u\) 到 \(v\) 之间没有 \(<val_u\) 的结点,此外,权值为 \(i\) 的结点若存在(也有可能不存在,因为可能没有 \(\min(u,v)=i\) 的边),则在 \(T_{\min}\) 上必然有一个深度最浅点且其它权值为 \(i\) 的结点都在该点的子树内(仍然是重构树的性质),记为 \(c_i\)。\(T_{\max}\) 同理,记为 \(d_i\)。那么问题转化为求有多少对点对 \((u,v)\) 使得在 \(T_{\min}\) 上 \(c_u\) 是 \(v\) 的祖先,且在 \(T_{\max}\) 上 \(d_v\) 是 \(u\) 的祖先。
转化后的问题比较经典:对 \(T_{\min}\) 求一遍 dfs 序,那么使得 \(c_u\) 是 \(v\) 的祖先的 \(v\) 的 dfn 一定是一段区间,不妨设为 \([l_u,r_u]\)。在 \(T_{\max}\) 的每个叶子 \(i\) 处挂上 \([l_i,r_i]\),那么编号为 \(v\) 的叶子结点对答案的贡献就是从根到 \(v\) 有多少个 \(c_i\) 使得 \(d_i\) 在 \(T_{\min}\) 上的 dfn 在 \([l_v,r_v]\) 之间,这个可以通过一遍 dfs + 树状数组带走。综上,时间复杂度 \(\mathcal{O}(n\log n)\)。
注意若权值为 \(i\) 的新建结点在两棵树上同时存在则会对答案产生 \(1\) 的重复贡献,因为 \(T_{\max}\) 上 \(d_i\) 是 \(i\) 的祖先,\(T_{\min}\) 上 \(c_i\) 也是 \(i\) 的祖先。
2. 虚树
虚树这一算法用于求解形如多组询问,但询问的点集大小之和在接受范围内的树上问题。常与动态规划相结合。
P2495 [SDOI2011]消耗战
\(q=1\) 时我们有一个简单的动态规划做法:设 \(f_i\) 表示结点 \(i\) 与其子树内任意关键点不连通的最小代价。当 \(i\) 本身是关键结点时,\(f_i=+\infty\)。否则枚举所有子结点 \(v\) 以及是否断掉 \((i,v)\) 这条边,有 \(f_i=\sum_{\\v\in \mathrm{son}(i)}\min(f_v,w_{i,v})\)。
如果每次询问都对整棵树进行一次 DP,时间复杂度无法接受。但注意到存在很多分支,我们根本没有用到它们的 DP 值。并且对于一条链形式的转移,若这条链上面没有关键点,我们完全可以用一条边代替,其中边权为链上所有边权的最小值。
究竟哪些点是 “有用的” 呢?一个自然的想法是所有关键点与它们两两之间的最近公共祖先。由于 LCA 数量与询问点数量同级(可以每次合并两个 dfn 时间戳相邻的关键点至它们的 LCA 处证明),故所有询问有用点的总数仍是线性的。
求出有用点之后我们就可以缩链了:由于所有有用点完整地描述了关键点 “张成” 的树的形态,所以只需对存在 “祖先 — 后代” 关系且路径上不存在其它有用点的有用点对之间连边即可。
2.2. 虚树的构建
我们首先对原树进行 dfs,求出每个点的时间戳 dfn,记为 \(f_i\)。对于一组询问点集 \(S\),我们先按照 dfn 排序,然后借助一个单调栈,使用增量法建出虚树(像极了笛卡尔树的构建方式)。
具体地,栈内从栈底到栈顶维护了原树(也是虚树)自上而下的一条链。设当前要加入虚树的结点编号为 \(i\),栈顶结点为 \(t\),栈顶第二个结点为 \(t_2\),\(d\) 为 \(i\) 与 \(t\) 的 LCA。
首先,若栈为空,则将 \(i\) 入栈。接下来对于栈非空的情况,我们来看两种构建虚树的方法,其中第一种是错误的。
法一:
- 若 \(d=t\),说明 \(t\) 是 \(i\) 的祖先。\(t,i\) 之间连边,再将 \(i\) 压入栈。
- 若 \(d\neq t\),说明 \(t\) 和 \(i\) 属于 \(d\) 的不同子树。弹出栈顶直到栈为空或者栈顶深度 $\leq $ \(d\) 的深度。然后判断 \(d\) 是否等于栈顶:
- 若相等,则 \(d\) 和 \(i\) 之间连边。
- 否则 \(t\) 和 \(d\) 之间连边, \(d\) 和 \(i\) 之间连边,同时将 \(d\) 压入栈。
- 最后将 \(i\) 压入栈。
法二:
- 若 \(f_d\leq f_{t_2}\),说明 \((t_2,t)\) 之间一定连了一条边。弹出栈顶,重复该过程直到栈中元素 \(<2\) 或不满足判断条件。
- 接下来,若 \(d\neq t\),说明 \(f_d<f_t\),此时将 \(t\) 弹出,连边 \((d,t)\) 并将 \(d\) 压入栈。否则 \(d=t\),不需要做任何事情。
- 最后将 \(i\) 压入栈。
- 在整个算法的最后,在栈中剩余的相邻结点之间连边。
对比两个方法,前者究竟错在哪?注意到法一中,我们在压入一个结点 \(i\) 时直接将栈顶 \(t\) 与其连边,但是在添加接下来的某个结点 \(j\) 时,\(j\) 与其所对应的栈顶 \(t'\) 的 LCA \(d\) 可能处于结点 \(i\) 和栈顶 \(t\) 之间的位置。这说明 \(i,t\) 之间不应连边,因为它们之间有 “有用点” \(d\)。
在引入的例题中,虚树上 \((i,j)\) 之间边的权值应设置为原树上 \((i,j)\) 之间边的权值的最小值,因为要使代价最小。这相当于压缩了原树的一条链。对于不同的题目,边权设置方法也不同。
由于我们要求很多次 LCA,所以当询问量级比树的大小更大时,可以考虑 \(\mathcal{O}(n\log n)\) 预处理 \(\mathcal{O}(1)\) 回答的 RMQ 求最近公共祖先。总复杂度线性对数,因为我们需要预处理,并且对给定点集按照 dfn 排序。
注意点:存虚树的邻接表需要在每组询问之前清空,但不可以每次都 memset 一遍,复杂度爆炸。可以这样实现:清空所有关键点的邻接表,令这些点的 head 值为 \(0\),并且在构建虚树的过程中,若要将某个不属于关键点的 LCA \(d\) 压入栈,则清空 \(d\) 的邻接表,即令 head[d] = 0。
一般形式的虚树具体实现见例题 I。
2.3. 例题
P2495 [SDOI2011]消耗战
引入部分的例题,在此给出代码,作为一般形式的虚树代码参考。
const int N = 3e5 + 5;
const int K = 19;
int n, cnt, hd[N], nxt[N << 1], to[N << 1], val[N << 1];
void add(int u, int v, int w) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, val[cnt] = w;}
int dn, dfn[N], dep[N], fa[K][N], dis[K][N];
void dfs1(int id, int f) {
fa[0][id] = f, dep[id] = dep[f] + 1, dfn[id] = ++dn;
for(int i = 1; i < K; i++) fa[i][id] = fa[i - 1][fa[i - 1][id]], dis[i][id] = min(dis[i - 1][id], dis[i - 1][fa[i - 1][id]]);
for(int i = hd[id]; i; i = nxt[i]) if(to[i] != f) dis[0][to[i]] = val[i], dfs1(to[i], id);
}
int LCA(int u, int v) {
if(dep[u] < dep[v]) swap(u, v);
for(int i = 18; ~i; i--) if(dep[fa[i][u]] >= dep[v]) u = fa[i][u];
if(u == v) return u;
for(int i = 18; ~i; i--) if(fa[i][u] != fa[i][v]) u = fa[i][u], v = fa[i][v];
return fa[0][u];
}
int dist(int u, int anc) {
int k = dep[u] - dep[anc], ans = N;
for(int i = 18; ~i; i--) if(k >> i & 1) cmin(ans, dis[i][u]), u = fa[i][u];
return ans;
}
int m, k, p[N], label[N], top, stc[N]; ll f[N];
vint G[N];
void dfs2(int id) {
if(label[id]) return f[id] = N, void(); f[id] = 0;
for(int it : G[id]) dfs2(it), f[id] += min(f[it], (ll)dist(it, id));
}
ll query() {
for(int i = 1; i <= k; i++) G[p[i]].clear(), label[p[i]] = 1; label[1] = 0;
sort(p + 1, p + k + 1, [&](int x, int y) {return dfn[x] < dfn[y];}), 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]]) G[stc[top - 1]].pb(stc[top]), top--;
if(d != stc[top]) G[d].clear(), label[d] = 0, G[d].pb(stc[top]), stc[top] = d; stc[++top] = p[i];
} for(int i = 1; i < top; i++) G[stc[i]].pb(stc[i + 1]); dfs2(1); // 一般应从 stc[1] 开始 dfs, 但本题 1 一定属于特殊点故从 1 开始
return f[1];
}
int main(){
cin >> n;
for(int i = 1, u, v, w; i < n; i++) u = read(), v = read(), add(u, v, w = read()), add(v, u, w);
dfs1(1, 0), m = read();
while(m--) {
k = read() + 1, p[1] = 1;
for(int i = 2; i <= k; i++) p[i] = read();
cout << query() << "\n";
} return 0;
}
P4103 [HEOI2014]大工程
算是比较裸的虚树了。建出虚树后进行 DP,维护子树内与该结点距离最大值,次大值,最小值和次小值即可回答第二、三问。注意若当前结点为关键点则最小值为 \(0\)。
对于第一问,考虑每条边 \(i\to j\ (i\in \mathrm{ancestor}(j))\) 被计算了多少次,显然为 \(sz_j\times (k-sz_j)\),其中 \(sz_j\) 是 \(j\) 的子树内关键点个数。时间复杂度线性对数。
*3. 点分治
3.1. 静态点分治
点分治就是把分治搬到了树上。我们先看一道例题。
3.1.1. 序列分治
给出序列 \(a_i\),求是否存在 \(i,j\) 使得 \(\sum_{\\x=i}^ja_x=k\)。\(n\leq 10^6\),\(|a_i|\leq 10^9\)。
研究树上问题,通常可以从对应的序列问题入手。
相信大家对于序列上的分治都不陌生。当然,本题也有其它很多做法,不过我们尝试使用分治法解决:对于当前区间 \([l,r]\)(\(l<r\),因为 \(l=r\) 是平凡的)及其中点 \(m\),要么 \(i,j\leq m\) 或 \(i,j>m\),要么 \(i\leq m<j\)。前者可以转化为 \([l,m]\) 与 \([m+1,r]\) 的子问题,故着重思考第二种情况。
首先进行前缀和将区间转化为端点,记前缀和数组为 \(s\),则问题变为:是否存在 \(i\in [l-1,m-1]\) 且 \(j\in [m+1,r]\),使得 \(s_j-s_i=k\)。对此,我们可以先递归两个子区间,然后将 \(s\) 归并排序(对于 \(i=l-1\) 单独考虑,枚举所有右端点)。接下来,按顺序枚举每个 \(s_j\ (m<j\leq r)\),并判断 \(s_j-k\) 是否在 \(s_i\ (l\leq i<m)\) 中出现过。由于 \(s_i\) 与 \(s_j-k\) 都单调递增,故可以维护指针 \(i\) 表示使 \(s_i\geq s_j-k\) 的最小的 \(i\)。对于每一个 \(s_j\),这样的 \(i\) 都是唯一的,因此直接判断是否有 \(s_i+k=s_j\) 即可。
接下来分析复杂度:每个区间的复杂度是线性的。由于最多递归 \(\log n\) 层,且每一层的时间复杂度是该层区间总长即 \(n\),故时间复杂度为 \(\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 它的每一个子树(不能经过打了标记的结点,即不能跨越已经作为分治中心的点,就好比序列分治向两端递归时不能越过边界 \(m\)),求出子树内每个结点到分治中心的距离以及来源于哪个子树(因为来自于相同子树的距离是不能被相加计算的,有重复的边),排序后可以使用双指针计算答案。
然后对于找到 \(r\) 的每个未被打标记的相邻结点 \(s\) 的子树的重心 \(r_s\),然后向 \(r_s\) 递归分治即可。子树 \(s\) 的总大小不需要重新 DP,可以直接看为以 \(r\) 的上一层为根时子树 \(s\) 的大小,正确性证明见一种基于错误的寻找重心方法的点分治的复杂度分析。
时间复杂度为 \(\mathcal{O}(n\log^2 n)\)。其中有一个 \(\log\) 是排序的复杂度。代码见例题 I。
易错点 1:如果找重心时初始化重心 Root = 0,且用分裂后子树大小最大值最小的那个点作为重心,即判断 maxsize[i] < maxsize[Root] 则令 Root = i,那么不要忘记在一开始初始化 maxsize[0] = n。
易错点 2:通常情况下,在统计一个分治重心 \(r\) 的子树信息时,我们求得任意两个点对的贡献之和后特别注意要减掉同在 \(r\) 的某个儿子的子树内的点对贡献。具体去重方法因题目而异,一般是对整棵子树记录信息的同时,再对每个儿子的子树单独统计类似的信息(也可以尝试用新添加的儿子子树信息去合并已经遍历的所有儿子子树信息)。
易错点 3:不要忘记考虑以根为某个端点的点对贡献。
3.2. 动态点分治
给出一棵树,点有点权。\(q\) 次询问给出 \(x,k\) 求与 \(x\) 树上距离 \(\leq k\) 的所有点的点权和。带修点权,强制在线。\(n,q\leq 10^5\)。
首先考虑不修改点权怎么做:如果对于每次询问都暴力点分治,那复杂度显然还没有直接线性做更快。不过我们注意到树的形态是固定的,这意味着我们不需要每次询问都分治求出重心。具体地,考虑每一层分治重心和上一层的重心连边,我们会得到一棵重构树 \(T\)。这就是所谓的点分树。
它有很好的性质,其中最重要的一条是树深不超过 \(\log n\)。这意味着我们可以暴力跳父结点计答案。具体地,对于每个询问 \(x,k\),我们先找到 \(T\) 上的对应结点 \(x\),求出 \(x\) 的子树中距离 \(x\) 不超过 \(k\) 的所有点的点权和,然后不断跳父亲。不妨设当前跳到的祖先为 \(u\) 并设 \(d=\mathrm{dist}(x,u)\)(注意是原树上距离),那么加上 \(u\) 的子树中距离 \(u\) 不超过 \(k-d\) 的所有点的点权和就行了 …… 吗?
如果既满足与 \(x\) 距离 \(\leq k\),又满足与 \(u\) 距离 \(\leq k-d\),就会被重复计算。但我们可以减掉来自这样的点的重复贡献。
综上,我们需要维护以下信息:对于 \(T\) 上的每个结点 \(x\) 及其父亲 \(u\),预处理 \(C_{0,x,d}\) 表示 \(x\) 的子树内与 \(x\) 原树上距离 \(\leq d\) 的结点权值和,\(C_{1,x,d}\) 表示 \(x\) 的子树内与 \(u\) 原树上距离 \(\leq d\) 的结点权值和。注意到第三维不超过 \(x\) 子树最大深度,所以对于每个 \(C_{i,x}\),用动态数组 vector 存储即可。也可以开一个超大内存池并记录每个不定长数组在内存池的开始下标以减小部分常数。由于我们要求 \((n+q)\log n\) 次树上点对距离,所以需要使用 \(\mathcal{O}(n\log n)-\mathcal{O}(1)\) 的 RMQ 求 LCA。时间复杂度为 \(\mathcal{O}((n+q)\log n)\)。
修改是平凡的,用树状数组换掉 vector 即可,不过注意修改时要对被修改的 \(x\) 到根上所有结点的 \(C\) 值进行更新,所以单次修改和查询的复杂度均为 \(\log^2 n\)。带修后时间复杂度 \(\mathcal{O}((n+q)\log^2 n)\)。代码见例题 II。
易错点:假设当前根为 \(r\),寻找某个儿子 \(i\) 的子树的重心 \(r'\) 并为 \(r'\) 设置 vector 大小时应使用 \(i\) 的子树大小 size 而非以 \(i\) 为根的子树最大深度 maxdep,因为以 \(i\) 为根时子树最大深度可能小于以 \(r'\) 为根时的子树最大深度,一个扫把就能卡掉(如下图),导致 \(r'\) 子结点与 \(r'\) 之间的原树距离大于我们设置的 vector 大小从而出现 RE / WA。综上,即使要用 maxdep 作为大小也应再从找到的 \(r'\) 进行一遍 dfs,不如直接用子树大小来得方便。
3.3. 点分树的性质
- 性质 1:树深 \(\mathcal{O}(\log n)\) 级别。
- 性质 2:树上每个结点的度数不大于其在原树上的度数 \(+1\)。
3.4. 例题
P3806 【模板】点分治
点分治经典例题,这里给出一般形式的静态点分治代码。注意本题有多组询问,时间复杂度为 \(\mathcal{O}(n\log n(m+\log n))\),因为排序的 \(\log\) 每层只需要一次而非 \(m\) 次。
const int N = 1e4 + 5;
int n, m, k;
vpii e[N];
int R, mx[N], sz[N], found[N], qu[N];
int cnt, vis[N];
pii dis[N];
void findr(int id, int f, int tot) {
sz[id] = 1, mx[id] = 0;
for(pii it : e[id]) if(!vis[it.fi] && it.fi != f)
findr(it.fi, id, tot), sz[id] += sz[it.fi], cmax(mx[id], sz[it.fi]);
if((mx[id] = max(mx[id], tot - sz[id])) < mx[R]) R = id;
}
void findd(int id, int f, int d, int anc) {
dis[++cnt] = {d, anc};
for(pii it : e[id]) if(!vis[it.fi] && it.fi != f) findd(it.fi, id, d + it.se, anc);
}
void divide(int id) {
dis[cnt = 1] = {0, id}, vis[id] = 1;
for(pii it : e[id]) if(!vis[it.fi]) findd(it.fi, id, it.se, it.fi);
sort(dis + 1, dis + cnt + 1);
for(int i = 1; i <= m; i++) if(!found[i])
for(int l = 1, r = cnt; l <= r; l++) {
while(r > 1 && dis[l].fi + dis[r].fi > qu[i]) r--;
if(dis[l].fi + dis[r].fi == qu[i] && dis[l].se != dis[r].se) found[i] = 1;
}
for(pii it : e[id]) if(!vis[it.fi]) R = 0, findr(it.fi, id, sz[it.fi]), divide(R);
}
int main(){
cin >> n >> m, mx[0] = N; // 不要忘记一开始将 mx[0] 设为极大值
for(int i = 1, u, v, w; i < n; i++) cin >> u >> v >> w, e[u].pb(v, w), e[v].pb(u, w);
for(int i = 1; i <= m; i++) cin >> qu[i];
findr(1, 0, n), divide(R);
for(int i = 1; i <= m; i++) cout << (found[i] ? "AYE" : "NAY") << "\n";
return 0;
}
P6329 【模板】点分树 | 震波
点分治经典例题,这里给出一般形式的点分树代码。
const int N = 1e5 + 5;
const int K = 18;
int n, m, val[N], dep[N];
int dn, dfn[N], lg[N << 1], mi[K][N << 1];
vint e[N], C[2][N];
int get(int x, int y) {return dfn[x] < dfn[y] ? x : y;}
int LCA(int x, int y) {
if((x = dfn[x]) > (y = dfn[y])) swap(x, y);
int d = lg[y - x + 1];
return get(mi[d][x], mi[d][y - (1 << d) + 1]);
}
int dis(int x, int y) {return dep[x] + dep[y] - (dep[LCA(x, y)] << 1);}
void dfs(int id, int f) {
mi[0][dfn[id] = ++dn] = id, dep[id] = dep[f] + 1;
for(int it : e[id]) if(it != f) dfs(it, id), mi[0][++dn] = id;
}
int R, fa[N], mx[N], sz[N], mxd[N], vis[N];
void add(int x, int v, int id, int tp) {x++; while(x <= sz[id]) C[tp][id][x] += v, x += x & -x;}
int query(int x, int id, int tp) {
if(x < 0) return 0; x = min(sz[id], x + 1);
int s = 0; while(x) s += C[tp][id][x], x -= x & -x;
return s;
}
void findr(int id, int f, int tot) {
mxd[id] = sz[id] = 1, mx[id] = 0;
for(int it : e[id]) if(!vis[it] && it != f)
findr(it, id, tot), sz[id] += sz[it], cmax(mx[id], sz[it]), cmax(mxd[id], mxd[it] + 1);
if((mx[id] = max(mx[id], tot - sz[id])) < mx[R]) R = id;
}
void divide(int id, int f) {
vis[id] = 1, fa[id] = f, C[0][id].resize(sz[id] + 1), C[1][id].resize(sz[id] + 1);
for(int it : e[id]) if(!vis[it]) R = 0, findr(it, id, sz[it]), sz[R] = mxd[it] + 2, divide(R, id);
}
void modify(int id, int val) {
for(int i = id; i; i = fa[i]) {
add(dis(id, i), val, i, 0);
if(fa[i]) add(dis(id, fa[i]), val, i, 1);
}
}
int query(int id, int k) {
int ans = 0;
for(int i = id; i; i = fa[i]) {
ans += query(k - dis(id, i), i, 0);
if(fa[i]) ans -= query(k - dis(id, fa[i]), i, 1);
} return ans;
}
int main(){
cin >> n >> m, mx[0] = n;
for(int i = 1; i <= n; i++) cin >> val[i];
for(int i = 1, u, v; i < n; i++) cin >> u >> v, e[u].pb(v), e[v].pb(u);
dfs(1, 0);
for(int i = 2; i <= dn; i++) lg[i] = lg[i >> 1] + 1;
for(int i = 1; i <= lg[dn]; i++)
for(int j = 1; j + (1 << i) - 1 <= dn; j++)
mi[i][j] = get(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
findr(1, 0, n), divide(R, 0);
for(int i = 1; i <= n; i++) modify(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 modify(x, y - val[x]), val[x] = y;
} return 0;
}
P4149 [IOI2011]Race
经典点分治题目。注意到 \(k\leq 10^6\),开一个桶记录答案即可。注意进入每个新的根时都要清空桶,但只能记录修改信息并撤回而非直接 memset。时间复杂度 \(\mathcal{O}(n\log n)\)。
P2634 [国家集训队]聪聪可可
同样是点分治裸题:只需要对于每个重心 \(r\) 记录以 \(r\) 为一端,另一端在子树内的权值之和 \(\bmod 3=0/1/2\) 的路径个数即可。时间复杂度线性对数。也可以树形 DP 做到线性。
P4178 Tree
类似例题 I,排序后直接双指针统计。分别统计跨过分治重心的点对数量(需要用分治重心整棵子树的贡献,减去都在分治重心同一儿子的子树的点对数量)与恰好一个端点是分治重心的点对数量。前者除以 \(2\) 加上后者就是答案。时间复杂度线性对数平方。
P4075 [SDOI2016]模式字符串
路径数量考虑点分治:对于一个分治中心,使用哈希判断每个点是否能作为从该点到分治重心的一段前缀和后缀(如果能单独成一段则直接计入答案贡献,同时特判 \(m=1\) 且分治中心的字符等于模式串的情况),再用桶统计即可。时间复杂度线性对数。
CF914E Palindromes in a Tree
题意简述:给一棵树,结点上有字符。对于每个点,求有多少条经过该点的路径满足该路径上出现奇数次的字符最多有 \(1\) 个。\(n\leq 2\times 10^5\),字符集大小为 \(20\)。
话说这个标题有点误导人,一开始想严格意义上的回文串觉得很不可做。
树上路径统计不难想到点分治,而判断最多有一个字符出现奇数次可以用状压。具体地,对于一个分治中心 \(r\),用一个 \(20\) 位二进制数统计它的所有子结点 \(u\) 到 \(r\)(不包括 \(r\))的路径上每个字符的出现次数的奇偶性,记为 \(m_u\)。
同时,如果我们知道了有多少条路径以端点 \(u\) 结尾并经过分治中心,设为 \(p_u\),那么从 \(u\) 到 \(r\) 上的所有点(除了 \(r\) 需要单独处理)都要加上 \(p_u\)。由于是离线所以使用树上差分即可。
对于每个 \(u\),我们将只到分治中心的路径和跨过分治中心的路径分开来考虑,因为在根结点 \(r\) 处,前者只会被计算一次,而后者会被计算两次。记 \(c=m_u\oplus 2^{s_r}\)。
- 只到分治中心:如果存在 \(k\) 使得 \(c\oplus 2^k=0\) 或 \(c\) 本身就为 \(0\),那么路径 \((u,r)\) 是合法的。将 \(p_u\) 和 \(ans_r\) 增加 \(1\)。
- 跨过分治中心:枚举出现次数为奇数的字符 \(k\),对于所有使得 \(c\oplus 2^k\oplus m_v=0\) 即 \(c\oplus 2^k=m_v\) 的 \(v\),\((u,v)\) 都是一条合法路径,可以通过用两个桶分别记录在整棵 \(r\) 的子树内 和 在当前点 \(u\) 所在的子树中(为了除去不经过分治中心的答案)\(m_v=x\) 的 \(v\) 数量,分别记为 \(a_x\) 和 \(b_x\),那么 \(v\) 的数量即为 \(a_{c\oplus k}-b_{c\oplus k}\)。不要忘记考虑没有出现次数为奇数的情况。
综上,我们有一个 \(\mathcal{O}(n\log n\mathbf|Σ|)\) 的算法:点分治,遍历到分治重心 \(r\) 的子树结点 \(u\) 统计答案时枚举 \(i=0\) 和 \(i=2^k\),记 \(c=m_u\oplus 2^{s_r}\oplus i\)。若 \(c=0\),则将标记记为 \(1\)。同时将 \(p_u\) 加上 \(a_c-b_c\)。枚举结束后,若标记为 \(1\) 说明从 \(u\) 到 \(r\) 的路径合法,将 \(p_u\) 和 \(ans_r\) 都加上 \(1\)。最后使用树上差分统计非分治中心的所有结点对答案的贡献。
记得将桶清零。此外,由于我们单独考虑只到分治中心的路径,所以不需要在一开始将 \(a_0\) 设为 \(1\)。
P3345 [ZJOI2015]幻想乡战略游戏
一道神仙动态点分治。注意到对于一个结点 \(u\),如果它有一个子树内军队数量的两倍超过了整棵树的军队数量,那么向该结点移动更优。调整法可证。
那么我们建出点分树,点分树每个结点的儿子个数 \(\leq 21\)(点分树的性质 2),因此可以暴力找到军队数量更多的子结点并判断其两倍是否大于当前结点子树军队个数,若是则向下移动,否则直接返回。
统计答案有点麻烦:记录当前结点 \(u\) 所有祖先节点 \(a\) 不包含当前结点的子树的 \(\sum_{\\ v} d_v\) 和 \(\sum_{\\v} d_v\times \mathrm{dist}(a,v)\) 并使用 \(\mathcal{O}(1)\) LCA 快速算出所有父结点到当前结点的距离即可对于每个结点 \(\log n\) 统计答案。
不过特别注意,从父结点 \(f\) 移动到子结点 \(u\) 时,记路径 \(f\to u\) 上第一个结点为 \(v\),那么 \(v\) 的子树大小需要增加 \(n-sz_u\),这一更新在整棵点分树上都要体现。这部分对于每个结点仍然是 \(\log n\) 的,不要忘记回溯时清空修改。综上,时间复杂度 \(\mathcal{O}(n\log n+q\log n(\log n+k))\),其中 \(k\) 是结点度数。
AT3611 Tree MST
一个经典结论是对于 MST 问题,我们每次选出一个边集求 MST,那么没有被选中的边也一定不会在最终的 MST 中,正确性显然。因此,只要我们选出的边集的并等于原图,并将所有边集的 MST 的并再求一次 MST,就能保证正确性。
具体地,我们怎么选出这些边集呢?树上问题考虑点分治,记重心为 \(r\),令 \(p_i=w_i+\mathrm{dist}(i,r)\),则 \(w(i,j)\leq p_i+p_j\),因为 \(\mathrm{dist}(i,j)\leq \mathrm{dist}(i,r)+\mathrm{dist}(r,j)\)。由于最终 \(w(i,j)\) 一定会被正确地考虑到(在以 \(i,j\) 在点分树上的 LCA 处正确计算),即存在一个分治重心 \(c\) 使得 \(i,j\) 在 \(c\) 不同儿子的子树内且 \(\mathrm{dist}(i,j)=\mathrm{dist}(i,c)+\mathrm{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 n\log(n\log n))\)。
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))\)。
CF150E Freezing with Style
看到中位数最大值想到二分答案,将所有 \(\geq m\) 的边看做 \(1\),\(<m\) 的边看做 \(-1\),点分治找是否存在边权和 \(\geq 0\) 的路径即可。\(l,r\) 的限制提示我们使用单调队列,但对于一个分治重心 \(u\),合并它的一个儿子 \(v\) 所做的每一次单调队列的复杂度是 \(\min(d_v+r,\max d_w)\),其中 \(w\) 是已经处理过的所有儿子,非常容易被卡到 \(n^2\log n\):一个扫帚。即先遍历一个子树深度非常大的儿子 \(w\),再遍历很多深度只有 \(1\) 的儿子 \(v\)。此时对于每个 \(v\) 做一次单调队列就是 \(\min(r,d_w)\) 的,直接卡上天。
此时需要一个奇技淫巧:启发式合并单调队列,即优先合并子树大小 / 深度更小的那个儿子,我们只需要在每次合并前按照子树大小从小到大排序即可。时间复杂度线性对数平方。
4. 长链剖分
4.1. 算法简介与性质
长链剖分与重链剖分有相通之处,后者是将子树大小最大的儿子作为重儿子,前者则是将子树深度最大的儿子作为重儿子。可见两者只是换了一个剖分形式。
长链剖分有如下性质:
- 性质 1:从根结点到任意叶子结点经过的轻边条数不超过 \(\sqrt n\),这比重链剖分的 \(\log n\) 稍劣一些。
- 性质 2:一个结点的 \(k\) 级祖先所在长链长度一定不小于 \(k\),反证法易证。
- 性质 3:每个节点所在长链末端为其子树内最深节点。根据定义可知。
\(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。如果子树内每个深度仅有一个信息,i.e. 相同深度结点的贡献一个状态,就可以使用长链剖分优化。
其一般形式如下:设 \(f_{i,j}\) 表示以 \(i\) 为根的子树内,深度为 \(j\) 的结点的贡献,转移视具体题目而异。
4.3.2. 例题:CF1009F Dominant Indices
我们看一道长链剖分优化 DP 的经典例题:十二省联考2019 希望 CF1009F Dominant Indices。
注意到我们只关心每个结点子树内深度为 \(j\) 的结点个数而非具体是哪些结点,因此子树内深度相同的点等价。故设 \(f_{i,j}\) 表示子树 \(i\) 内深度为 \(j\) 的结点个数,有转移:
且 \(f_{i,0}=1\)。直接做是 \(n^2\) 的,无法接受,考虑长链剖分优化 DP:具体地,对于重儿子,我们直接继承它的答案,然后将所有轻儿子的答案合并过来即可。因为每一个点 \(u\) 最多被合并一次,即合并 \(u\) 所在重链顶端 \(t\) 的父亲 \(fa\) 与 \(t\) 时,\(u\) 所包含的信息就和 \(f_{fa}\) 在 \(dep_u-dep_{fa_t}\) 处的信息融为了一体,相当于点 \(u\) 直接消失了。因此时间复杂度是优秀的 \(\mathcal{O}(n)\),代码见例题部分 I。
4.3.3. 注意点与技巧
长链剖分实现起来有很多细节,例如应如何继承重儿子的 DP 值,以及如何处理合并时下标偏移的问题。
一个解决方案是使用指针动态申请内存:对于一条重链,共用一个大小为重链长度的数组。同时解决了上述两个问题。不过实现时需要特别注意开足空间。该种写法一般形式见例题 V。
另一个方法是对每个结点开一个动态数组 vector 表示 \(f\),继承重儿子的 DP 值直接 swap:vector 的 swap 是 \(\mathcal{O}(1)\) 的。需要支持在 vector 前面插入数时,如果用 deque 常数太大。不妨将信息倒过来存储在 vector 中,在动态数组末端插入一个数,push_back 即可。这种方法适用范围比较窄,仅对于一些较平凡的转移方式有效。如果需要同时在头尾增加信息,deque 会导致 MLE(如例题 V.),此时只能动态申请内存。
4.4. 例题
CF1009F Dominant Indices
长链剖分应用的例题,这里给出代码。
const int N = 1e6 + 5;
int n, dep[N], ans[N], res[N];
vector <int> e[N], f[N];
void dfs(int id, int fa) {
dep[id] = dep[fa] + 1;
int son = 0;
for(int it : e[id]) if(it != fa) {
dfs(it, id);
if(f[it].size() > f[son].size()) son = it;
} ans[id] = ans[son], swap(f[id], f[son]);
for(int it : e[id]) {
if(it == fa || it == son) continue;
for(int i = 0; i < f[it].size(); i++) {
int pos = i + f[id].size() - f[it].size();
f[id][pos] += f[it][i];
if(f[id][pos] > f[id][ans[id]] ||
(f[id][pos] == f[id][ans[id]] && ans[id] < pos)) ans[id] = pos;
}
} f[id].pb(1);
if(f[id][ans[id]] == 1) ans[id] = f[id].size() - 1;
res[id] = f[id].size() - 1 - ans[id];
}
int main() {
cin >> n;
for(int i = 1, u, v; i < n; i++) e[u = read()].pb(v = read()), e[v].pb(u);
dfs(1, 0);
for(int i = 1; i <= n; i++) printf("%d\n", res[i]);
return 0;
}
P4292 [WC2010]重建计划
点分治和长链剖分都是两个 \(\log\) 的,CF150E Freezing with Style 用了淀粉质,因此本题写个长链剖分。
一眼 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)\)。
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. NOIP2021 模拟赛 pb D2T3 上升
题意简述:给出一棵树,点有点权。求去掉任意一个结点后剩余连通块内部 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)\)。
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)\) 的数量。转移方程比状态设计简单多了:
但是 \(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_{i,j-1}+\sum_{\\j} f_{i,j-1}g_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;
}
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)\) 建出笛卡尔树。
\(\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. 例题
除了纯纯的笛卡尔树,也会放一些比较进阶的单调栈题目,一般和笛卡尔树都有些联系。
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;
}
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 序列)。
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\) 方案数,妙不可言!代码。
Subsequence *2900
我们建出笛卡尔树,然后是裸的树形背包。设 \(f_{i,j}\) 表示在 \(i\) 的子树内选了 \(j\) 个点的最大值,若不选 \(i\) 则有:
选 \(i\) 则有:
时间复杂度 \(n^2\)。笛卡尔树可以直接递归建。代码。
7. 圆方树
见 高级图论 第三部分。