模板——树上算法

模板——树上算法

LCA,树的一些性质,点分治,树上莫队,DFS 序与欧拉序,笛卡尔树,树上启发式合并,Kruskal 重构树,树链剖分。

LCA

  • \(O(n\log n)\) 预处理,\(O(\log n)\) 求答案。也可以求出欧拉环游序转化为 RMQ 问题,单次求答案变为 \(O(1)\)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void dfs(ll root, ll dad) {
f[0][root] = dad;
dep[root] = dep[dad] + 1;
for (auto son : e[root]) {
if (son == dad) {
continue;
}
dfs(son, root);
}
}

ll LCA(ll x, ll y) {
if (dep[x] < dep[y])
swap(x, y);
for (ll i = L; i >= 0; i--) {
if (dep[f[i][x]] < dep[y])
continue;
x = f[i][x];
}
if (x == y)
return x;
for (ll i = L; i >= 0; i--) {
if (f[i][x] == f[i][y])
continue;
x = f[i][x], y = f[i][y];
}
return f[0][x];
}
  • 离线 \(O(n)-O(1)\) Tarjan 求 LCA。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<ll, ll>
const ll N = 5e5 + 39;

ll n, m, s, dad[N], vis[N], ans[N];
vector<ll> e[N];
vector<PII> q[N];

ll find(ll x) {
while (x != dad[x]) {
x = dad[x] = dad[dad[x]];
}
return x;
}

void dfs(ll root) {
dad[root] = root;
vis[root] = 1;
for (auto son : e[root]) {
if (!vis[son]) {
dfs(son);
dad[son] = root;
}
}
for (auto [son, id] : q[root]) {
if (vis[son]) {
ans[id] = find(son);
}
}
}

void solve() {
cin >> n >> m >> s;
for (ll i = 1, u, v; i <= n - 1; i++) {
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
for (ll i = 1, a, b; i <= m; i++) {
cin >> a >> b;
q[a].push_back({b, i});
q[b].push_back({a, i});
}
dfs(s);
for (ll i = 1; i <= m; i++) {
cout << ans[i] << endl;
}
}

树的一些性质

直径

树上任意两节点之间最长的简单路径即为树的「直径」。

  • 一棵树可以有多条直径,他们的长度相等。
  • 可以用树形 DP 的方法在线性时间求出树的直径。

上面的性质在存在负权时依然成立。

  • 可以用两次 DFS 的方法在线性时间求出树的直径。
  • 树上所有直径都必定交于同一点(或同一条边),这个点(或边)被称为树的中心。
  • 合并两棵树,新树直径端点一定属于原来两棵子树直径端点的集合。
  • 对于树上任意一点 \(x\),离它最远的点一定是直径的某个端点。

中心

在树中,如果节点 \(x\) 作为根节点时,从 \(x\) 出发的最长链最短,那么称 \(x\) 为这棵树的中心。

  • 树的中心不一定唯一,但最多有 2 个,且这两个中心是相邻的。树的中心一定位于树的直径上。
  • 树上所有点到其最远点的路径一定交会于树的中心。
  • 当通过在两棵树间连一条边以合并为一棵树时,连接两棵树的中心可以使新树的直径最小。

重心

三个等价定义:

  1. 在树中删去结点 \(v\) 后,得到的图中每个连通分量的大小均不超过原树结点数的一半。
  2. 在所有删去某个结点后得到的最大连通分量大小中,删去结点 \(v\) 时所得到的值最小。
  3. 树中所有结点到某个结点的距离和中,到结点 \(v\) 的距离和最小。

性质:

  • 树的重心如果不唯一,则恰有两个。这两个重心相邻。而且,删去它们的连边后,树将变为两个大小相同的连通分量。
  • 在一棵树上添加或删除一个叶子,那么它的重心最多只移动一条边的距离。
  • 把两棵树通过一条边相连得到一棵新的树,那么新树的重心在连接原来两棵树的重心的路径上。
  • 一棵有根树的重心一定在根结点所在的重链上。
  • 一棵树的重心一定是:根结点的 重子结点对应子树的 重心的 祖先。

点分治

只要路径信息能由“端点到分治中心”的摘要合并得到,并且能在每层快速查询/插入这些摘要,那么点分治就能处理整棵树所有简单路径的信息。算法框架如下:

1
2
3
4
5
solve(当前连通块):
1. 找到重心 c
2. 统计所有经过 c 的路径
3. 删除 c
4. 对 c 的每个子树递归 solve

注意在重新选择根节点之后一定要重新计算子树的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<ll, ll>
const ll N = 1e4 + 10, M = 1e7 + 10;

ll n, m, siz[N], vis[N], ans[N], que[N];
vector<PII> e[N];
bitset<M> b;

void getSiz(ll root, ll dad) {
siz[root] = 1;
for (auto [son, w] : e[root]) {
if (son == dad || vis[son]) {
continue;
}
getSiz(son, root);
siz[root] += siz[son];
}
}

ll getCent(ll root, ll dad, ll tal) {
for (auto [son, w] : e[root]) {
if (son == dad || vis[son]) {
continue;
}
if (siz[son] > tal / 2) {
return getCent(son, root, tal);
}
}
return root;
}

void getInfo(ll root, ll dad, ll dis, vector<ll> &ret) {
if (dis > M - 10) {
return;
}
ret.push_back(dis);
for (auto [son, w] : e[root]) {
if (son == dad || vis[son]) {
continue;
}
getInfo(son, root, dis + w, ret);
}
}

void calcAns(ll root) {
getSiz(root, root);
ll cent = getCent(root, root, siz[root]);
vector<ll> pre;
pre.push_back(0);
b[0] = 1;
for (auto [son, w] : e[cent]) {
if (vis[son]) {
continue;
}
vector<ll> cur;
getInfo(son, cent, w, cur);
for (auto dis : cur) {
for (ll i = 1; i <= m; i++) {
if (que[i] >= dis && b[que[i] - dis]) {
ans[i] = 1;
}
}
}
for (auto dis : cur) {
if (!b[dis]) {
pre.push_back(dis);
b[dis] = 1;
}
}
}
for (auto dis : pre) {
b[dis] = 0;
}
pre.clear();
vis[cent] = 1;
for (auto [son, w] : e[cent]) {
if (vis[son]) {
continue;
}
calcAns(son);
}
}

void solve() {
cin >> n >> m;
for (ll i = 1, u, v, w; i <= n - 1; i++) {
cin >> u >> v >> w;
e[u].push_back({v, w});
e[v].push_back({u, w});
}
for (ll i = 1; i <= m; i++) {
cin >> que[i];
}
calcAns(1);
for (ll i = 1; i <= m; i++) {
if (ans[i]) {
cout << "AYE" << endl;
} else {
cout << "NAY" << endl;
}
}
}

树上莫队

普通树上莫队

利用下面的欧拉序性质,转化为普通莫队。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<ll, ll>
const ll N = 2e5 + 10, L = 18;

// 判断树上一条链有无给定颜色

ll n, m, t[N];
ll st[N], ed[N], dfn[N], timer = 0;
ll col[N], cnt[N], ans[N], vis[N], Unit;
ll f[L + 5][N], dep[N];
vector<ll> e[N];

struct Query {
ll l, r, id, col, extra;
bool operator<(const Query &x) const {
if (l / Unit != x.l / Unit)
return l < x.l;
if ((l / Unit) & 1)
return r < x.r;
return r > x.r;
}
} q[N];

void dfs(ll root, ll dad) {
dfn[++timer] = root;
st[root] = timer;
f[0][root] = dad;
dep[root] = dep[dad] + 1;
for (auto son : e[root]) {
if (son == dad) {
continue;
}
dfs(son, root);
}
dfn[++timer] = root;
ed[root] = timer;
}

ll LCA(ll x, ll y) {
if (dep[x] < dep[y])
swap(x, y);
for (ll i = L; i >= 0; i--) {
if (dep[f[i][x]] < dep[y])
continue;
x = f[i][x];
}
if (x == y)
return x;
for (ll i = L; i >= 0; i--) {
if (f[i][x] == f[i][y])
continue;
x = f[i][x], y = f[i][y];
}
return f[0][x];
}

void update(ll pos) {
if (vis[dfn[pos]] == 0) {
cnt[col[pos]]++;
} else {
cnt[col[pos]]--;
}
vis[dfn[pos]] ^= 1;
}

void solve() {
cin >> n >> m;
Unit = sqrt(n);
for (ll i = 1; i <= n; i++) {
cin >> t[i];
}
for (ll i = 1, u, v; i <= n - 1; i++) {
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
dfs(1, 1);
for (ll i = 1; i <= L; i++) {
for (ll j = 1; j <= n; j++) {
f[i][j] = f[i - 1][f[i - 1][j]];
}
}
for (ll i = 1; i <= n * 2; i++) {
col[i] = t[dfn[i]];
}
for (ll i = 1, l, r, lca; i <= m; i++) {
cin >> l >> r >> q[i].col;
if (st[l] > st[r]) {
swap(l, r);
}
lca = LCA(l, r);
if (lca == l) {
q[i].l = st[l];
q[i].r = st[r];
} else {
q[i].l = ed[l];
q[i].r = st[r];
q[i].extra = st[lca];
}
q[i].id = i;
}
sort(q + 1, q + 1 + m);
for (ll i = 1, l = 1, r = 0; i <= m; i++) {
while (l > q[i].l)
update(--l);
while (r < q[i].r)
update(++r);
while (l < q[i].l)
update(l++);
while (r > q[i].r)
update(r--);
if (q[i].extra) {
update(q[i].extra);
}
ans[q[i].id] = (cnt[q[i].col] >= 1);
if (q[i].extra) {
update(q[i].extra);
}
}
for (ll i = 1; i <= m; i++) {
cout << ans[i];
}
cout << endl;
}

DFS 序与欧拉序

DFS 序

对树做 DFS,每个节点第一次被访问时,记录下它的编号,得到的序列就是 DFS 序。通常用 dfn[u] 表示节点 u 是第几个被访问的。

  • 一棵子树的所有节点,在 DFS 序中恰好构成一段连续的区间。设 sz[u] 为以 u 为根的子树大小,那么子树 u 对应区间: [dfn[u], dfn[u] + sz[u] - 1]。
  • u 是 v 的祖先,当且仅当:dfn[u] <= dfn[v] <= dfn[u] + sz[u] - 1。

欧拉括号序

对树做 DFS,每次进入一个节点时记录一次,离开这个节点时再记录一次,得到的序列就是欧拉括号序。每个节点会出现两次,整个序列长度是 2n。

设 st[u] 节点 u 第一次出现的位置,ed[u] 节点 u 第二次出现的位置。

  • 子树仍然是一段区间。子树 u 在欧拉序中对应:[st[u], ed[u]]。这个区间里每个节点出现了两次。
  • 路径可以表示成区间。对于树上路径 (u, v),假设 st[u] <= st[v],令 p = LCA(u, v):
    • 如果 p == u,路径对应区间 [st[u], st[v]]。
    • 如果 p != u,路径对应区间 [ed[u], st[v]],再加上 p。
    • 区间内出现两次的节点,不在路径上;出现一次的节点,在路径上。

欧拉环游序

对树做 DFS,每次经过一个节点时都记录一次,得到的序列就是欧拉环游序。整个序列长度是 2n-1。

对树做 DFS,每次到达一个节点就记录它的编号,同时记录它的深度。这样得到两个序列:euler[] 节点编号序列,长度 2n-1;depth[] 对应节点的深度,长度也是 2n-1。

同时记录每个节点第一次出现在 euler[] 中的位置 first[u]。

对于任意两个节点 u 和 v,假设 first[u] <= first[v],那么:

u 和 v 的 LCA,就是 euler[first[u] .. first[v]] 这段区间中,深度最小的那个节点。也就是说,LCA 问题变成了 区间最小值查询(RMQ)。

笛卡尔树

笛卡尔树是一种特殊的二叉树,每一个节点由一个键值二元组构成,并且同时满足以下两个性质:

  • 键——二叉搜索树(BST)性质:对于树中的每一个节点,其左子树中的所有节点的值都小于该节点的值;其右子树中的所有节点的值都大于该节点的值。这保证了树在中序遍历时会按照输入序列的顺序访问元素。
  • 值——堆性质:如果我们将笛卡尔树看作是一个最大堆或最小堆,则对于每个节点来说,它的值要么不小于或者不大于其子节点的值。具体采用哪种堆取决于应用场景。

经常用于最值维护、最近公共祖先问题。例如柱状图找面积最大的矩形。Treap 维护的也是笛卡尔树。

1
2
3
4
5
6
7
8
9
10
11
12
13
int cartesian_build(int n) {
// 建树,满足小根堆性质
for (int i = 1; i <= n; i++) {
int k = i - 1;
while (tree[k].val > tree[i].val)
k = tree[k].par;
tree[i].ch[0] = tree[k].ch[1];
tree[k].ch[1] = i;
tree[i].par = k;
tree[tree[i].ch[0]].par = i;
}
return tree[0].ch[1];
}

树上启发式合并

例如计算 \(\sum_{d(x)=d(y),1\le x<y\le n}d(y)-d(lca(x,y))\)。

\(O(n\log n)\) 版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
vector<ll> e[N];
ll a[N], d[N], cnt[N], big[N], siz[N], ans;
ll dfn[N], L[N], R[N], index;
ll n, m;

ll dfs(ll root, ll dad) {
ll curb = 0;
d[root] = d[dad] + 1;
siz[root]++;
dfn[++index] = root;
L[root] = index;
for (auto son: e[root]) {
if (son == dad)
continue;
siz[root] += dfs(son, root);
if (siz[son] > curb) {
curb = siz[son];
big[root] = son;
}
}
R[root] = index;
return siz[root];
}

void dsu(ll root, ll dad, ll keep) {
for (auto son: e[root]) {
if (son == dad || son == big[root])
continue;
dsu(son, root, 0);
}
if (big[root])
dsu(big[root], root, 1);
for (auto son: e[root]) {
if (son == dad || son == big[root])
continue;
for (ll i = L[son]; i <= R[son]; i++) {
ans += cnt[d[dfn[i]]] * (d[dfn[i]] - d[root]);
}
for (ll i = L[son]; i <= R[son]; i++) {
cnt[d[dfn[i]]]++;
}
}
cnt[d[root]]++;
if (keep == 0) {
for (ll i = L[root]; i <= R[root]; i++) {
cnt[d[dfn[i]]]--;
}
}
}

Kruskal 重构树

原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值。

也就是说,到点 x 的简单路径上最大边权的最小值 \(\leq val\) 的所有点 y 均在 Kruskal 重构树上的某一棵子树内,且恰好为该子树的所有叶子节点。

注意重构树新建了节点,初始化范围为 \(n+m\)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sort(h + 1, h + 1 + m, cmp);//按照边权排序
for (ll i = 1; i <= m; i++) {
ll u = s.find(h[i].u), v = s.find(h[i].v);//并查集
if (u != v) {
cnt++;
s.merge(u, cnt);
s.merge(v, cnt);
high[cnt] = h[i].len;//点权为边权
e[u].push_back(cnt);
e[cnt].push_back(u);
e[v].push_back(cnt);
e[cnt].push_back(v);
}
}
dfs(cnt, cnt);

树链剖分

把一棵树上的节点重新编号,使得“树上的一条路径”和“一棵子树”都能变成数组上的连续区间。然后就可以用线段树、树状数组等维护区间加、区间求和、区间最大值。

从某个节点开始,一直沿着重儿子往下走,形成一条链,叫重链。每条重链有一个链顶 top。

我们做第二次 DFS 时,优先遍历重儿子,得到的节点编号顺序叫 DFS 序,记为 dfn[u]。

这样会得到两个重要性质:

  1. 同一条重链上的节点,dfn 是连续的。
  2. 任意一棵子树内的节点,dfn 也是连续的。
    • 子树 u 对应区间:[dfn[u], dfn[u] + sz[u] - 1]

所以树上问题就变成了数组区间问题。

只有链可以直接使用倍增,只有子树可以直接使用 dfs 序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N = 1e5 + 10;

ll n, m, r, p;
vector<ll> e[N];
ll a[N]; // 原树点权
ll b[N]; // 按 dfn 排列后的点权
ll fa[N], dep[N], sz[N], son[N];
ll top[N], dfn[N], rnk[N], timer = 0;

// 第一次 DFS:求父节点、深度、子树大小、重儿子
void dfs1(ll u, ll f) {
fa[u] = f;
dep[u] = dep[f] + 1;
sz[u] = 1;
son[u] = 0;

for (ll v : e[u]) {
if (v == f)
continue;
dfs1(v, u);
sz[u] += sz[v];
if (sz[v] > sz[son[u]]) {
son[u] = v;
}
}
}

// 第二次 DFS:分配 dfn,求 top
void dfs2(ll u, ll t) {
top[u] = t;
dfn[u] = ++timer;
rnk[timer] = u;
// 优先走重儿子
if (son[u]) {
dfs2(son[u], t);
}
// 再走轻儿子
for (ll v : e[u]) {
if (v == fa[u] || v == son[u])
continue;
dfs2(v, v);
}
}

// 线段树:区间加,区间求和,省略(同线段树模板)
struct SGT {} Tree;

// 路径加
void path_add(ll u, ll v, ll k) {
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]])
swap(u, v);
Tree.add(1, 1, n, dfn[top[u]], dfn[u], k);
u = fa[top[u]];
}
if (dep[u] > dep[v])
swap(u, v);
Tree.add(1, 1, n, dfn[u], dfn[v], k);
}

// 路径求和
ll path_sum(ll u, ll v) {
ll res = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]])
swap(u, v);
res += Tree.query(1, 1, n, dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
if (dep[u] > dep[v])
swap(u, v);
res += Tree.query(1, 1, n, dfn[u], dfn[v]);
return res;
}

// 子树加
void subtree_add(ll u, ll k) {
Tree.add(1, 1, n, dfn[u], dfn[u] + sz[u] - 1, k);
}

// 子树求和
ll subtree_sum(ll u) { return Tree.query(1, 1, n, dfn[u], dfn[u] + sz[u] - 1); }

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);

cin >> n >> m >> r >> p;

for (ll i = 1; i <= n; i++) {
cin >> a[i];
}
for (ll i = 1; i < n; i++) {
ll u, v;
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
dfs1(r, 0);
dfs2(r, r);

// 把原树点权放到按 dfn 排列的数组里
for (ll i = 1; i <= n; i++) {
b[dfn[i]] = a[i];
}
Tree.build(1, 1, n, b);

while (m--) {
ll op, u, v;
ll k;
cin >> op;

if (op == 1) {
// 路径加
cin >> u >> v >> k;
path_add(u, v, k);
} else if (op == 2) {
// 路径求和
cin >> u >> v;
cout << path_sum(u, v) % p << '\n';
} else if (op == 3) {
// 子树加
cin >> u >> k;
subtree_add(u, k);
} else if (op == 4) {
// 子树求和
cin >> u;
cout << subtree_sum(u) % p << '\n';
}
}

return 0;
}

方向合并:某些题目信息是不可交换的。在树链剖分跳链时,由于 dfn 顺序和真实路径方向可能相反,必须把某些区间的信息“翻转”后再按正确顺序合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Info query_path(int u, int v) {
Info left, right; // left: u -> LCA 方向
// right: LCA -> v 方向
while (top[u] != top[v]) {
if (dep[top[u]] >= dep[top[v]]) {
// u 侧向上跳
Info cur = seg.query(dfn[top[u]], dfn[u]);
// cur 的 dfn 方向是 top[u] -> u
// 真实路径方向是 u -> top[u],所以翻转
left = merge(left, rev(cur));
u = fa[top[u]];
} else {
// v 侧向上跳
Info cur = seg.query(dfn[top[v]], dfn[v]);
// cur 的 dfn 方向是 top[v] -> v
// 真实路径方向是 v -> top[v],所以翻转
right = merge(rev(cur), right);
v = fa[top[v]];
}
}
// 现在 u 和 v 在同一条链上
if (dep[u] < dep[v]) {
// u 是 LCA,路径 u -> v
Info cur = seg.query(dfn[u], dfn[v]);
// dfn 方向就是 u -> v,正好是 LCA -> v 方向
right = merge(cur, right);
} else {
// v 是 LCA,路径 v -> u
Info cur = seg.query(dfn[v], dfn[u]);
// dfn 方向是 v -> u,翻转成 u -> v
left = merge(left, rev(cur));
}
return merge(left, right);
}