【NOI2024 D2T2】登山 题解(树剖,均摊复杂度)
非常好的题
题意
题意比较复杂。放个链接,自己看吧点我
分析
一道有很多种解法的题。
比较主要的问题在于怎么解决:系数只能从下往上求, \(dp\) 只能从上往下转移 的问题。
\(Sol_1\)(倍增 + 树状数组)
焦神的做法。
将登山的过程看作从一个点 \(x\) 出发,每次滑落到子树中的一个点 \(y\)(\(y\) 可能等于 \(x\)),然后再向上冲刺一个满足高度限制的点。不难发现每次冲刺后的限制完全变成了当前点的限制。因此可以设计出一个 \(dp\):
设 \(dp_{x}\) 表示以 \(x\) 为起点,经过若干步爬到 \(1\) 的方案数。
那么一个状态 \(dp_{x}\) 显然由祖先链上的状态转移过来,系数则是由子树内的情况决定。
为了方便,将 \(l_i,r_i\) 变成冲刺能到的点的深度区间,\(d_i\) 为 \(i\) 的深度, \(lim_i = d_i - h_i - 1\) 表示满足 \(i\) 号点限制所要冲刺到的最大深度。\(v_{i \to j}\) 表示 \(i\) 滑落到 \(j\) 的路径上 \(lim\) 的最小值。
那么有转移:
\(dp_{i} = \sum\limits_{k\in anc(i)} dp_{k}\sum\limits_{j \in subtree(i)}[l_j \leq d_k \leq \min(v_{i \to j}, r_j)]\)
那么 \(O(n^2)\) 是很容易的: \(i\) 子树里每个 \(j\) 都是将一段区间的系数\(+ 1\), \(O(n^2)\) 枚举 \(i, j\) 后差分便可以维护出每个 \(i\) 的系数数组,然后从上到下 \(dp\) 每次 \(O(n)\) 求 \(dp\) 值即可。
来考虑 \(l_j = r_j\) 的部分:
将贡献方式转变成每次求出一个与 \(j\) 有关的 \(dp\) 值时就将 \(j\) 对每个 \(i\) 的贡献计算上。
那么在 \(l_j = r_j\) 时,显然只有 \(j\) 向上冲刺唯一能到的点与 \(j\) 有关。
假设这个点为 \(k\),我们将所有向上冲刺到 \(k\) 的点挂到 \(k\) 上,从上到下求出 \(dp_k\) 时枚举所有这样的 \(j\)。那么此时一个 \(j\) 能贡献给的 \(i\) 是祖先链上的一段后缀,这是因为如果太高 \(v_{i \to j} < r_j\)。那么每次操作就是一条链加,在 \(dfs\) 序上用树状数组维护即可。
当 \(l_j \ne r_j\) 时:
考虑将 \(j \to i\) 的贡献拆成前缀和相减的形式,这一步很重要。
那么 \(j\) 对 \(i\) 的贡献为 \(S_{\min(r_j, v_{i\to j})} - S_{l_j - 1}\),我们 \(dfs\) 的求 \(dp\) 数组,因此可以维护当前位置到根的 \(dp\) 前缀和。
这时候与 \(j\) 对 \(i\) 的贡献 有关的就是 \(S_{l_j - 1}\) 和 \(S_{\min(r_j, v_{i \to j})}\) 了。那么我们从上到下每次求出一个 \(sum_{p}\),就分别考虑 \(p = l_{j} - 1\) 和 \(p = \min(r_j, v_{i \to j})\) 的贡献。
\(p = l_{j} - 1\) 的贡献与上面的类似,将 \(l_j = p\) 的 \(j\) 挂到 \(p\) 上,那么每次就是一个链加。
对于\(p = \min(r_j, v_{i \to j})\):我们考虑随着 \(i\) 的不断升高,\(v_{i \to j}\) 是越来越小的,满足 \(\min(v_{i \to j}, r_j) = r_j\) 的是 \(j\) 祖先链上的一段后缀,那么取到 \(p = r_j\) 的贡献跟上面类似是一个链加。
当取到 \(v_{i \to j}\) 时:发现 \(v_{i \to j}\) 不断减小的过程中发生变化的位置是 \(j\) 祖先链上 \(lim\) 后缀最小值的位置,我们规定 \(lim\) 相同时深度越大的点限制越紧。称 \(i \to j\) 路径上的 严格后缀最小值 取到的点为 \((i, j)\) 的支配点。 那么取到 \(v_{i \to j}\) 时 \(i \to j\) 肯定跨过了一个 \(lim_k = p\) 的点 \(k\) 并且 \(k\) 一定是 \(i \to j\) 路径上 \(lim\) 的严格后缀最小值。那么我们将所有 \(k\) 挂到 \(lim_k\) 上,求出 \(sum_{lim_k}\) 时就枚举这样的 \(k\) 看有多少个\((i, j)\) 满足 \((i, j)\) 的支配点为 \(k\)。 \(i\) 是 \(k\) 上面的点, \(j\) 是 \(k\) 下面的点,不难发现此时 \(i, j\) 独立,假设求出来 \(j\) 的数量为 \(w_k\),那么只需要对 \(k\) 祖先上所有的合法的 \(i\) 加上 \(c \times sum_{lim_k}\) 即可。这样的 \(i\) 也是链上的一段后缀。
求 \(w_k\):将 \(i\) 连向 \(i\) 的祖先链上第一个 \(lim_j < lim_i\) 的点 \(j\),这形成了一棵树。每个 \(i\) 都对这棵树祖先链上的点有贡献,差分即可。
总复杂度就是 \(O(n \log n)\)。
\(Sol_2\)(倍增 + 线段树)
先来考虑 \(l_j = r_j, h_i = 0\) 的情况:
可以这样做:自上向下,每次求出一个点 \(p\) 的 \(dp\) 值,就让所有 \(l_j = p\) 的点上的权值加上 \(dp_{p}\),那么求一个点的 \(dp\) 值就是求子树和。
拓展到 \(l_j = r_j, h_i \ne 0\) 也是容易的:一个点 \(j\) 只有当求到祖先链上的一个后缀时才有用,因此某个分界点将它加上 \(dp_{l_j}\) 即可。
拓展到 \(l_j \ne r_j\) 比较难,\(j\) 会被加 \(n\) 次并且 \(j\) 对不同的 \(i\) 的贡献并不等于 \(j\) 此时的权值,而是 \([l_j, r_j]\) 的一段前缀的 \(dp\) 值。
考虑 \(i\) 往子树里 \(bfs\) 的过程,如果第一次碰到一个点 \(j\) 满足 \(lim_{j} \leq lim_{i}\),那么 \(i\) 滑落到 \(j\) 后的限制就完全等于 \(j\) 了,因此可以由 \(dp_{j}\) 转移,并且不用往 \(j\) 的子树里 \(bfs\) 了。
称这样的点为 \(1\) 类点,那么将点 \(i\) 子树中所有 \(1\) 类点去掉,剩下的点就都满足 \(lim_j > lim_i\),那么 \(v_{i \to j} = lim_{i}\)。这样的 \(j\) 对 \(i\) 的转移区间就比较简单: \([l_j, \min(r_j, lim_i)]\)。称这样的 \(j\) 为二类点。
那么可以得出一种 \(dp\) 顺序,按照 \(lim\) 从小到大求 \(dp\) 值。
分别考虑这两类点对 \(i\) 的贡献:
先假设没有 \(1\) 类点的存在,包含了 \(l_j \ne r_j, h_{i} = 0\) 的情况。
还是将贡献拆成前缀和相减的形式 \(s_{\min(r_j, lim_i)} - s_{l_j - 1}\),我们自上向下求 \(dp\) 值,每次求出前缀的一个位置 \(p\) 后确定 \(lim_i = p\) 的 \(i\) 的 \(dp\) 值。
首先每求出一个前缀和 \(p\) 先让 \(l_j - 1 = p\) 的节点 \(j\) 加上 \(-s_{l_j - 1}\),让 \(r_j = p\) 的节点 \(j\) 加上 \(s_{j}\) 。
现在来求 \(lim_j = p\) 的点 \(j\) 的 \(dp\) 值:只需要求出 \(j\) 的子树和以及有多少个子树内的点 \(k\) 满足 \(k\) 只加了 \(-s_{l_k - 1}\),那么这样的 \(k\) 需要补上 \(+s_p\)。可以算出 \(j\) 的 \(dp\) 值。
接下来考虑存在一类点的情况:发现我们计算二类点还是可以从求子树和的角度考虑,那么只需要支持每次确定 \(lim_j = p\) 的点 \(j\) 后删去 \(j\) 的子树即可。
不能子树打标记,因为 \(j\) 子树中的点可能还没有确定 \(dp\) 值,如果打标记了那这些点就没办法算了。有一种很巧的想法是对每个点记录了被删除几次,相当于删除子树操作就是子树 \(+1\)。那么一个点子树 \(x\) 内的所有点 \(y\) 的删除次数 \(c_y\) 都 \(\geq\) \(c_x\),并且没被删掉的点一定满足 \(c_y = c_x\)。证明很简单。所以只需要支持维护子树内 \(c\) 最小的信息即可。
因此我们将线段树的每个节点的信息用三元组表示(和,加入了多少右端点,\(c\)),那么每次 \(pushup\) 是简单的。
还有一个问题:怎么将一类点的 \(dp\) 值贡献进去:这个只需要维护区间内的 \(dp\) 值之和即可,因为没确定的点的 \(dp\) 值为 \(0\)。
复杂度 \(O(n \log n)\)。
\(Sol_3\)(树剖 + 撤销):
其实离不开一个问题:系数数组是从下往上求的,但是 \(dp\) 是从上往下转移的,没有办法同时维护。
两种做法的本质其实是每次求出一个 \(dp\) 值就将它贡献到它能贡献的地方去。相当于一个 刷表。
下面这种做法则是考虑自下向上维护系数数组,然后通过 撤销 的方式来计算每个点的 \(dp\) 值。类比的话就是 填表 吧。
首先将一个点 \(j\) 的跳跃区间 \([l_j, r_j]\) 拆成前缀和相减的形式:这意味着 \(sum_{r_j}\) 的系数为 \(1\), \(sum_{l_j - 1}\) 的系数为 \(-1\)。
那么 \(j\) 对 \(i\) 的贡献区间在路径限制下发生的变化可以怎么看呢?
发现是不断将 \(r_j,l_{j} - 1\) 与 \(lim_p\) 取 \(\min\),其中 \(p\) 是 \(j \to i\) 路径上的点。
考虑一个自下到上维护前缀和系数的过程:
用二元组 \((i, f_i)\) 表示当前点 \(x\) 由前缀和 \(s_i\) 转移过来的系数为 \(f_i\),那么每次向上合并可以看作:
- 将每个二元组集合的 \((i, f_i)\) 变成 \((\min(i, lim_x), f_{\min(i, lim_x)})\)。
- 将二元组集合合并起来,重叠部分将 \(f_i\) 加起来。
惊奇的发现对于第一部分复杂度竟然是对的?
分析一下发现一个点初始时只会对两个位置贡献系数,那么总势能就是 \(O(n)\)。然后每次将存在的大于 \(lim_x\) 位置 合并回来,势能会每次减少。
那么第二部分就很简单了,只需要启发式合并即可。
现在的问题在于怎么还原,我们的想法是每次撤销,由于与合并恰为反操作因此第一种操作的撤销次数也是 \(O(n)\) 的。但是第二步,我们需要遍历若干个集合才能得到二操作前每个每个集合的答案,但是遍历复杂度不太对。我们好像还需要支持一步 启发式分裂 。其实也可以完全将刚才的启发式合并逆过来。
但是有一种更简单的思路。
考虑刚才比较难操作的是向多个儿子分裂,但是如果我们只有一个儿子就可以 不分裂只撤销 了。
因此考虑树剖,每次确定一条重链的答案。
自下到上枚举到一个点时,加入所有轻儿子子树中的系数,每次暴力取 \(\min\) 来加入限制,求出链顶答案后撤销求出所有儿子的答案。
分析一下复杂度:
维护系数数组只需要支持单点加,找到最大的系数不为 \(0\) 的位置。可以开 \(map\) 或 \(hash\) 表维护。
复杂度就是所有轻子树的大小之和乘上 \(map\) 的 \(\log\),因此总复杂度 \(O(n \log^2n)\)。
CODE:
#include<bits/stdc++.h>
#define pb emplace_back
#define MP make_pair
using namespace std;
typedef long long LL;
typedef pair< int, int > PII;
const LL mod = 998244353;
const int N = 3e5 + 10;
int testid, T;
int n, p[N], l[N], r[N], h[N], lim[N];
int dep[N], big[N], sz[N], ID[N], dfn[N], dfc, bel[N], L[N], R[N];
int tot;
LL dp[N], sum[N];
vector< PII > vec[N];
vector< int > E[N];
map< int, int > f; // f[i] 表示深度为 i 的系数
void dfs(int x, int fa) {
sz[x] = 1; dep[x] = dep[fa] + 1; big[x] = 0;
for(auto v : E[x]) {
if(v == fa) continue;
dfs(v, x); sz[x] += sz[v];
if(sz[v] > sz[big[x]]) big[x] = v;
}
}
void dfs1(int x, int b) {
L[x] = R[x] = 0;
dfn[x] = ++ dfc; bel[x] = b; ID[dfc] = x;
if(x == b) L[x] = dfc;
R[b] = max(R[b], dfn[x]);
if(big[x]) dfs1(big[x], b);
for(auto v : E[x]) {
if(dep[v] == dep[x] - 1 || v == big[x]) continue;
dfs1(v, v);
}
}
void get(int x, int fa, int mn, int b) {
mn = min(mn, lim[x]);
vec[b].pb(MP(min(l[x] - 1, mn), min(r[x], mn)));
for(auto v : E[x]) {
if(v == fa) continue;
get(v, x, mn, b);
}
}
int tx[N], ty[N], len;
vector< PII > del[N];
void calc(int x) { // x 是某个链头
f.clear();
for(int i = R[x]; i >= L[x]; i -- ) { // 从下到上get系数
int y = ID[i]; f[l[y] - 1] --; f[r[y]] ++;
del[y].pb(MP(l[y] - 1, -1)); del[y].pb(MP(r[y], 1));
for(auto v : E[y]) {
if(dep[v] == dep[y] + 1 && v != big[y]) {
for(auto k : vec[v]) f[k.first] --, f[k.second] ++, del[y].pb(MP(k.first, -1)), del[y].pb(MP(k.second, 1));
}
}
// 跟 y 的 lim 取 min
auto it = f.end(); len = 0; LL s = 0;
while(it != f.begin()) {
it --;
if(it->first > lim[y]) {
if(it->second != 0) tx[++ len] = it->first, ty[len] = it->second;
f.erase(it);
it = f.end();
}
else break;
}
for(int i = 1; i <= len; i ++ ) del[y].pb(MP(tx[i], -ty[i])), s += ty[i];
if(s != 0) f[lim[y]] += s, del[y].pb(MP(lim[y], s));
}
LL ans = 0;
for(auto it : f) ans = (ans + sum[it.first] * it.second % mod + mod) % mod;
auto it = f.end();
for(int i = L[x]; i <= R[x]; i ++ ) { // 回退求每个点的答案
int y = ID[i];
dp[y] = ans; sum[tot + 1] = (sum[tot] + ans) % mod; tot ++;
for(int j = del[y].size() - 1; j >= 0; j -- ) { // 回退每一步操作
int p = del[y][j].first, q = -del[y][j].second;
f[p] += q; ans = (ans + sum[p] * q % mod + mod) % mod;
}
}
tot -= (R[x] - L[x] + 1);
}
void dfs2(int x, int fa) { // 依次求解每条链的答案
if(bel[x] == x) calc(x); // 求解这条链的答案
sum[tot + 1] = (sum[tot] + dp[x]) % mod; tot ++;
for(auto v : E[x]) {
if(v == fa || v == big[x]) continue;
dfs2(v, x);
}
if(big[x]) dfs2(big[x], x);
tot --;
}
void solve() {
scanf("%d", &n); dfc = 0; tot = 0;
for(int i = 0; i <= n; i ++ ) dp[i] = 0, del[i].clear(), vec[i].clear();
for(int i = 1; i <= n; i ++ ) E[i].clear();
for(int i = 2; i <= n; i ++ ) {
scanf("%d%d%d%d", &p[i], &l[i], &r[i], &h[i]);
E[p[i]].pb(i); E[i].pb(p[i]);
}
dfs(1, 0);
for(int i = 2; i <= n; i ++ ) {
swap(l[i], r[i]);
l[i] = dep[i] - l[i], r[i] = dep[i] - r[i]; // 深度区间
lim[i] = dep[i] - h[i] - 1; // 限制
}
dfs1(1, 1);
for(int i = 2; i <= n; i ++ ) {
if(big[p[i]] != i) get(i, p[i], n + 1, i);
}
dp[0] = 1; sum[0] = 1;
dfs2(1, 0);
for(int i = 2; i <= n; i ++ ) printf("%lld ", dp[i]);
puts("");
}
int main() {
scanf("%d%d", &testid, &T);
while(T -- ) solve();
return 0;
}
总结
如果遇到需要自下向上处理一些信息,并且利用这些信息自上到下求出 \(dp\) 值或别的信息时,有两种思考方式:
- 考虑刷表去求 \(dp\) 值,即每求出一个值就去对它能贡献到的位置加上贡献。
- 考虑自下向上求出第一种信息,然后每次撤销完成自上向下求第二类信息。一般需要所求信息可以 增量求得 并且合并的复杂度是对的。

浙公网安备 33010602011771号