树链剖分把根到 x 的路径拆成 O(log n) 段连续区间,配合带懒标记的线段树做区间赋值与求和,用段长减和得到安装数、子树区间和得到卸载数。
OJ: roj
题目 ID: 1562
难度:提高
标签:树链剖分线段树树python
创建: 2026-09-30 18:11
更新: 2026-10-07 12:15
形式化题目
给定一棵以
- 安装
:把根到 的整条路径上未安装的节点全部置为已安装; - 卸载
:把 的整棵子树中已安装的节点全部置为未安装。
若
两类操作始终维护同一条性质:已安装集合对祖先封闭——某个节点已安装,则它的全部祖先必然已安装(安装会带上整条祖先链,卸载只删子树、不会波及祖先)。因此「
样例的依赖树如下(边表示「父依赖子方向」的父子关系,例如
graph TD
0["0"]
1["1"]
2["2"]
3["3"]
4["4"]
5["5"]
6["6"]
0 --> 1
0 --> 2
0 --> 3
1 --> 4
1 --> 5
5 --> 6
按样例依次操作:安装
正解
思路
朴素做法:直接模拟。安装
瓶颈:真实数据里有深度上万的链,反复「安装深节点 → 卸载根」时,总改动量达到
关键观察:两类操作恰好都能翻译成一维区间上的「区间赋值 + 区间求和」,只差一个把树映射成一维下标的方案:
- 子树是连续区间:对树做先序编号(DFS 序),
的子树恰好占据 。于是卸载 = 该区间求和(即被卸载个数)后整体赋 。 - 根到
的路径拆成 段连续区间:先做树链剖分——每个节点选子树最大的儿子作重儿子,重儿子串成重链。再按「重儿子优先」的顺序编号,每条重链在编号上连续、子树也仍然连续。安装时从 沿链头跳向根,每跳一次处理 一整段,段数等于路径上的轻边数,不超过 。每段 = 段长 区间和(未安装个数)后整体赋 。 - 线段树带懒标记同时支持区间求和与区间赋值,把每段的代价压到
。
样例树按上述规则编号后的结果(重链为
| 节点 | pos | 链头 | 子树区间 |
|---|---|---|---|
| 0 | 0 | 0 | |
| 1 | 1 | 0 | |
| 5 | 2 | 0 | |
| 6 | 3 | 0 | |
| 4 | 4 | 4 | |
| 2 | 5 | 2 | |
| 3 | 6 | 3 |
表格展示了 pos、链头与子树区间三种下标视角:重链 install 4 为例:先跳到链头
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 18:20
# update_at: 2026-09-30 18:20
import sys
def solve() -> None:
tokens = iter(sys.stdin.buffer.read().split())
n = int(next(tokens))
par = [-1] + [int(next(tokens)) for _ in range(n - 1)] # 1..n-1 号各自依赖的软件包
q = int(next(tokens))
# 孩子表 + 先序(祖先先于后代),逆序即为自底向上的求值顺序
ch: list[list[int]] = [[] for _ in range(n)]
for v in range(1, n):
ch[par[v]].append(v)
order: list[int] = []
stack = [0]
while stack:
v = stack.pop()
order.append(v)
stack.extend(ch[v])
size = [1] * n
heavy = [-1] * n
for v in reversed(order):
for c in ch[v]:
size[v] += size[c]
if heavy[v] < 0 or size[c] > size[heavy[v]]:
heavy[v] = c # 最重儿子:子树最大的儿子,重链沿它延伸
# 重链优先的先序压成 pos:重链区间连续、子树区间 [pos, pos+size-1] 也连续
pos = [0] * n
head = [0] * n
seq: list[int] = []
stack = [0]
while stack:
v = stack.pop()
seq.append(v)
for c in reversed(ch[v]):
if c != heavy[v]:
stack.append(c) # 轻儿子先压栈,重儿子最后压 = 最先弹出
if heavy[v] != -1:
stack.append(heavy[v])
for i, v in enumerate(seq):
pos[v] = i
head[0] = 0
for v in seq[1:]:
head[v] = v if heavy[par[v]] != v else head[par[v]] # 轻儿子自立链头
# 线段树:区间求和 + 区间赋值,标记 -1 表示该节点没有未下推的赋值
tr = [0] * (4 * n)
tg = [-1] * (4 * n)
def cover(i: int, nl: int, nr: int, l: int, r: int, v: int) -> int:
"""把区间 [l,r] 全部赋值为 v,返回它们修改前的和。"""
if r < nl or nr < l:
return 0
if l <= nl and nr <= r:
prev = tr[i]
tr[i] = v * (nr - nl + 1)
tg[i] = v
return prev
mid = (nl + nr) >> 1
t = tg[i]
if t != -1: # 下推标记后才能安全地下探
tr[i << 1] = t * (mid - nl + 1)
tr[i << 1 | 1] = t * (nr - mid)
tg[i << 1] = tg[i << 1 | 1] = t
tg[i] = -1
prev = cover(i << 1, nl, mid, l, r, v) + cover(i << 1 | 1, mid + 1, nr, l, r, v)
tr[i] = tr[i << 1] + tr[i << 1 | 1]
return prev
last = n - 1
out: list[str] = []
for _ in range(q):
op = next(tokens)
x = int(next(tokens))
if op == b"install":
# 沿重链跳到根:路径拆成 O(log n) 段连续区间,逐段置 1,
# 段长减去原有和 = 这段里原来是 0(未安装)的个数
cnt, v = 0, x
while True:
h = head[v]
lo, hi = pos[h], pos[v]
cnt += hi - lo + 1 - cover(1, 0, last, lo, hi, 1)
if h == 0:
break
v = par[h]
out.append(str(cnt))
else:
# 子树在 pos 上是连续区间:置 0 前的和即被卸载的个数
lo = pos[x]
out.append(str(cover(1, 0, last, lo, lo + size[x] - 1, 0)))
sys.stdout.write("\n".join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 08:26
* update_at: 2026-10-05 08:26
*/
// 软件包管理器:树链剖分 + 线段树(区间赋值 + 区间求和)
// install x:把根到 x 的路径拆成 O(log n) 段重链区间,每段用 段长-和 数出未安装个数后整体赋 1
// uninstall x:x 的子树在 DFS 序上恰好是连续区间 [pos[x], pos[x]+size[x]-1],
// 用区间和数出已安装个数后整体赋 0
#include <cstdio>
#include <cstring>
typedef long long ll;
const int MAXN = 100005;
int n, qnum;
int fa[MAXN]; // fa[v]:v 的父节点(依赖的软件包)
int son[MAXN]; // son[v]:v 的重儿子(子树最大的儿子),没有则为 -1
int head[MAXN]; // head[v]:v 所在重链的链头
int pos[MAXN]; // pos[v]:v 在重链优先 DFS 序中的下标
int sz[MAXN]; // sz[v]:v 的子树大小
int chhead[MAXN]; // chhead[v]:v 的第一个孩子(孩子表链表头),-1 表示没有孩子
int chnext[MAXN]; // chnext[i]:孩子表中下一条边的下标,与 chhead 配对,节省建 vector
int ord[MAXN]; // 迭代版 DFS 的先序序列,ord[i] = 第 i 个访问的节点
int seq_cnt;
// 线段树:sum 为区间内已安装个数;lazy = -1 表示没有未下推的赋值标记,0/1 表示整段赋值
ll sum[4 * MAXN];
ll lazy[4 * MAXN]; // 标记用 ll,与 sum 同类型相乘,避免强制转换
// 建立孩子表:把 v 挂到 fa[v] 的孩子表头
void add_child(int v) {
chnext[v] = chhead[fa[v]];
chhead[fa[v]] = v;
}
// 迭代版 DFS:先序遍历求出 sz、son,顺带保证父亲先于儿子被访问
// 用 ord[] 数组手工模拟栈,避免深链时递归爆栈
void dfs_build() {
seq_cnt = 0;
ord[seq_cnt++] = 0;
// 先序逆序处理即为自底向上:先算完所有孩子,再算父亲
for (int i = 0; i < seq_cnt; ++i) {
int v = ord[i];
for (int c = chhead[v]; c != -1; c = chnext[c]) ord[seq_cnt++] = c;
}
// 此时 ord 已经完整,从后往前累加子树大小并挑重儿子
for (int i = seq_cnt - 1; i >= 0; --i) {
int v = ord[i];
sz[v] = 1;
int best = -1;
for (int c = chhead[v]; c != -1; c = chnext[c]) {
sz[v] += sz[c];
if (best == -1 || sz[c] > sz[best]) best = c;
}
son[v] = best;
}
}
// 重链优先的先序编号:重儿子接着当前链编,轻儿子自立新链
// 每条重链的编号连续,每个子树的编号区间 [pos, pos+sz-1] 也连续
int build_pos_cnt;
void dfs_split() {
// 数组模拟栈:栈顶指针 tp,元素为待访问节点
static int stk[MAXN];
int tp = 0;
stk[tp++] = 0;
head[0] = 0;
while (tp > 0) {
int v = stk[--tp];
pos[v] = build_pos_cnt++;
// 先压轻儿子再压重儿子:重儿子最后压入 = 最先弹出,编号紧接父亲
for (int c = chhead[v]; c != -1; c = chnext[c]) {
if (c == son[v]) continue;
head[c] = c; // 轻儿子自立链头
stk[tp++] = c;
}
if (son[v] != -1) {
head[son[v]] = head[v]; // 重儿子继承父亲的链头
stk[tp++] = son[v];
}
}
}
// 左右孩子的和上推
void push_up(int i) {
sum[i] = sum[i * 2] + sum[i * 2 + 1];
}
// 下推懒标记:把整段赋值标记传给两个孩子
void push_down(int i, int nl, int nr) {
if (lazy[i] == -1) return;
int mid = (nl + nr) / 2;
lazy[i * 2] = lazy[i];
sum[i * 2] = lazy[i] * (mid - nl + 1);
lazy[i * 2 + 1] = lazy[i];
sum[i * 2 + 1] = lazy[i] * (nr - mid);
lazy[i] = -1;
}
void build_tree(int i, int nl, int nr) {
lazy[i] = -1;
sum[i] = 0;
if (nl == nr) return;
int mid = (nl + nr) / 2;
build_tree(i * 2, nl, mid);
build_tree(i * 2 + 1, mid + 1, nr);
}
// 把 [l,r] 整段赋值为 v,返回修改前的区间和(即被改变状态的节点数)
ll cover(int i, int nl, int nr, int l, int r, int v) {
if (l <= nl && nr <= r) {
ll before = sum[i];
lazy[i] = v;
sum[i] = (ll)v * (nr - nl + 1); // v 是 0/1,乘段长得到区间和
return before;
}
push_down(i, nl, nr);
int mid = (nl + nr) / 2;
ll before = 0;
if (l <= mid) before += cover(i * 2, nl, mid, l, r, v);
if (r > mid) before += cover(i * 2 + 1, mid + 1, nr, l, r, v);
push_up(i);
return before;
}
int main() {
scanf("%d", &n);
memset(chhead, -1, sizeof(int) * n);
fa[0] = -1;
for (int v = 1; v < n; ++v) {
scanf("%d", &fa[v]);
add_child(v);
}
dfs_build();
build_pos_cnt = 0;
dfs_split();
build_tree(1, 0, n - 1);
scanf("%d", &qnum);
char op[16];
int x;
for (int t = 0; t < qnum; ++t) {
scanf("%s %d", op, &x);
ll changed;
if (op[0] == 'i') {
// 安装:从 x 沿重链跳向根,每跳一段 [pos[head], pos[x]] 赋 1
changed = 0;
while (x != -1) {
int h = head[x];
// cover 返回修改前的和(已安装数),新增安装数 = 段长 - 和
ll seg_len = pos[x] - pos[h] + 1;
changed += seg_len - cover(1, 0, n - 1, pos[h], pos[x], 1);
x = fa[h];
}
} else {
// 卸载:整棵子树是连续区间 [pos[x], pos[x]+sz[x]-1],赋 0
changed = cover(1, 0, n - 1, pos[x], pos[x] + sz[x] - 1, 0);
}
printf("%lld\n", changed);
}
return 0;
}复杂度
预处理(求子树大小、重儿子、重链编号)为
总结
把「逐点改状态」换成「区间批量改」是本题的核心:先序编号让子树变成连续区间,树链剖分让根到
图示解析
这张图串起本题从朴素模拟到最终做法的推导主线:
逐点模拟(安装走上链、卸载遍历子树)
|- 改动量 = 总状态变化,深链数据下爆炸
`- 换成区间批量操作
|- 先序编号:子树 = 连续区间 [pos, pos+size-1]
`- 重链剖分:根到 x 路径 = O(log n) 段连续区间
`- 线段树懒标记:区间赋值 + 求和
`- install:段长-和 后置 1;uninstall:求和后置 0先看第一行暴露的瓶颈,再顺着分支检查两种编号如何把树上操作翻译成一维区间,最后确认线段树如何同时给出答案并完成赋值。