树论
树的重心
定义 max_part(u) 表示 max{n - siz[u], siz[v1], siz[v2]},表示对于当前点向三个方向上的最大子树大小
定义树的重心即为树中 max_part(u) 取得 最小 时的节点
很容易 dfs 得到树的重心
code
#include <bits/stdc++.h> #define re register int using namespace std; const int N = 5e4 + 10, inf = 0x3f3f3f3f; struct Edge { int to, next; }e[N << 1]; int top, h[N]; int n, siz[N]; struct Node { int ans, id; }a[N]; int cnt; int res[N], idx; bool cmp(Node i, Node j) { return i.ans < j.ans; } inline void add(int x, int y) { e[++ top] = (Edge){y, h[x]}; h[x] = top; } void dfs(int u, int fa) { siz[u] = 1; int max_part = 0; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fa) continue; dfs(v, u); siz[u] += siz[v]; max_part = max(max_part, siz[v]); } max_part = max(max_part, n - siz[u]); a[++ cnt] = (Node){max_part, u}; // cout << "id - max: " << u << ' ' << max_part << '\n'; } int main() { ios::sync_with_stdio(false); cin.tie(0); cout.tie(0); cin >> n; for (re i = 1; i < n; i ++) { int x, y; cin >> x >> y; add(x, y), add(y, x); } dfs(1, 0); sort(a + 1, a + cnt + 1, cmp); int mn = a[1].ans; res[++ idx] = a[1].id; for (re i = 2; i <= cnt; i ++) { if (a[i].ans > mn) break; res[++ idx] = a[i].id; } sort(res + 1, res + idx + 1); for (re i = 1; i <= idx; i ++) cout << res[i] << ' '; return 0; }
树的直径
树的直径就是树上最远两点间简单路径的距离,也就是树上最长的简单路径。
可以用 树形 dp 的思想做
考察树上任意节点 u,若它有 i 条子树,则就有 i 条过 u 点(严格是以 u 为端点)的路径,要找到 悬挂 在 u 点的最长路径,贪心地想就是找到 最长路径 和 次长路径 合起来就是过 u 点的可能解
设 d1,d2 分别表示最长路径,次长路径,边界肯定就是 0(直径只包含一个点)
对于 (u, v) 方向上的子树路径长度 d,可以递推求解
- d > d1,则 d2 = d1, d1 = d
- d > d2,则 d2 = d
结果就是对所有点的最长路径取最大值
code
int dfs(int u, int fa) { int d1 = 0, d2 = 0; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to, w = e[i].w; if (v == fa) continue; int d = dfs(v, u) + w; if (d > d1) d2 = d1, d1 = d; else if (d > d2) d2 = d; } res = max(res, d1 + d2); return d1; } // 当然,如果要记录下来,也可以写成 dp 数组的形式 void dfs(int u, int fa) { for (re i = h[u]; i; i = e[i].next) { int v = e[i].to, w = e[i].w; if (v == fa) continue; dfs(v, u); if (f[v][1] + w > f[u][1]) { f[u][0] = f[u][1]; f[u][1] = f[v][1] + w; } else if (f[v][1] + w > f[u][0]) f[u][0] = f[v][1] + w; } len = max(len, f[u][1] + f[u][0]); }
然鹅,dp 方法不好记录直径的路径
所以,有另一种方法,贪心两次 dfs,从任意点出发,dfs 到离它最远的点 p,再从 p dfs 到离它最远的点 q,则 p、q 一定是树上一条直径的两个端点。
但是,这种方法 不能处理含负边权的情况
证明
当然,这个证明也很简单,我也可以口胡一下。要证明 p -> q 是一条直径,因为 q 已经约束为离 p 最远的点,那么如果 p 是某条直径的端点,则必然有 q 是直径的端点,所以只需证明前者即可。
因为是一棵树,那么点之间必然可以互相到达,即有 i -> x -> y -> b
因为 p 距离 i 最远,则有
变形:
当,有 那么 a -> p 的长度肯定是不短于直径 a -> b 的,
所以 a -> p 也是直径,p 也就是直径的端点。(同时证明中的约束条件也反映出这种做法在有负边权时是无效的)
code
int maxs = 0; void dfs(int u, int fa, int size, int & to, int & res, int type) { if (size > maxs) { maxs = size; to = u; if (type) res = size; //type 区分 1 -> p 或 p -> q } for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fa) continue; if (type) pre[v] = u; dfs(v, u, size + e[i].w, to, res, type); } }
直径中点 trick
性质:
在森林中,对两颗树分别连直径的中点,得到的新直径
这是显然的,要尽可能使原子树的直径均分,差值最小。
注意: 这个性质是普适的,但给出的代数式有局限的,只满足当树的边权都为 1 时,因为当边权不为 1 时,代数求得中点并不一定有对应的实际的点
练习:
P3761 [TJOI2017] 城市(这个题就是边权不为 1 要求树的半径,60pts 还没调出来)
很多时候,树的直径的题目没什么思考方向时,可以想一想如果找到了直径中点,有什么很好的东西
LCA
方法 | 预处理 | 查询 |
---|---|---|
倍增法 | ||
树链剖分 | ||
欧拉序转化成 rmq 问题(结合 st 表) |
(注意,虽然树剖查询的理论复杂度跟倍增法一样,但常数小很多,所有实际跑起来会更快,比如在模板题上,倍增法跑完大数据要 880ms±,而树剖是 360ms±)
倍增法
#include <bits/stdc++.h> #define re register int using namespace std; const int N = 5e5 + 10, logN = 50; struct Edge { int to, next; }e[N << 1]; int top, h[N]; int n, q, s, dep[N], f[N][logN], lg[N]; inline void add(int x, int y) { e[++ top] = (Edge){y, h[x]}; h[x] = top; } void dfs(int u, int fa) { dep[u] = dep[fa] + 1; f[u][0] = fa; for (re i = 1; i <= lg[n]; i ++) f[u][i] = f[f[u][i - 1]][i - 1]; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fa) continue; dfs(v, u); } } inline int lca(int x, int y) { if (dep[x] < dep[y]) swap(x, y); for (re i = lg[n]; i >= 0; i --) if (dep[f[x][i]] >= dep[y]) x = f[x][i]; if (x == y) return x; for (re i = lg[n]; i >= 0; i --) if (f[x][i] != f[y][i]) x = f[x][i], y = f[y][i]; return f[x][0]; } int main() { ios::sync_with_stdio(false); cin.tie(0); cout.tie(0); cin >> n >> q >> s; for (re i = 1; i < n; i ++) { int x, y; cin >> x >> y; add(x, y), add(y, x); } lg[0] = -1; for (re i = 1; i <= n; i ++) lg[i] = lg[i / 2] + 1; dfs(s, 0); while (q --) { int x, y; cin >> x >> y; cout << lca(x, y) << '\n'; } return 0; }
树链剖分
#include <bits/stdc++.h> #define re register int using namespace std; const int N = 5e5 + 10; struct Edge { int to, next; }e[N << 1]; int idx, h[N]; int fa[N], son[N], top[N], dep[N], siz[N]; int n, q, s; inline void add(int x, int y) { e[++ idx] = (Edge){y, h[x]}; h[x] = idx; } void dfs1(int u, int fu) { fa[u] = fu; dep[u] = dep[fu] + 1; siz[u] = 1; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fu) continue; dfs1(v, u); siz[u] += siz[v]; if (siz[son[u]] < siz[v]) son[u] = v; } } void dfs2(int u, int t) { top[u] = t; if (!son[u]) return; dfs2(son[u], t); for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fa[u] || v == son[u]) continue; dfs2(v, v); } } inline int lca(int u, int v) { while (top[u] != top[v]) { if (dep[top[u]] < dep[top[v]]) swap(u, v); u = fa[top[u]]; } return (dep[u] > dep[v] ? v : u); } int main() { ios::sync_with_stdio(false); cin.tie(0); cout.tie(0); cin >> n >> q >> s; for (re i = 1; i < n; i ++) { int x, y; cin >> x >> y; add(x, y), add(y, x); } dfs1(s, 0); dfs2(s, s); while (q --) { int x, y; cin >> x >> y; cout << lca(x, y) << '\n'; } return 0; }
两简单路径相交(点)trick
命题:
在一棵树中,有两组端点
若两路径存在交点,则必然有
这里我用反证法证明
若交点集合不包括两 lca,我可以构造如下情况
x,y 到相交点集合中任意点存在不同路径,到根节点也存在不同路径,形成环路,矛盾
练习:
维护路径边权最值
很简单,倍增 lca 的同时再开个 st 表记录最值。
code
void dfs(int u, int fa) { dep[u] = dep[fa] + 1; f[u][0] = fa; for (re i = 1; i <= log2(n); i ++) { f[u][i] = f[f[u][i - 1]][i - 1]; fw[u][i] = max(fw[u][i - 1], fw[f[u][i - 1]][i - 1]); } for (re i = h[u]; i; i = e[i].next) { int v = e[i].to, w = e[i].w; if (v == fa) continue; fw[v][0] = w; dfs(v, u); } } inline int lca(int u, int v) { int res = 0; if (dep[u] < dep[v]) swap(u, v); for (re i = log2(n); i >= 0; i --) if (dep[f[u][i]] >= dep[v]) { res = max(res, fw[u][i]); u = f[u][i]; } if (u == v) return res; for (re i = log2(n); i >= 0; i --) if (f[u][i] != f[v][i]) { res = max(res, max(fw[u][i], fw[v][i])); u = f[u][i]; v = f[v][i]; } return max(res, max(fw[u][0], fw[v][0])); }
树链剖分
顺便写这好了
初学感觉 树链剖分 就是在树形结构上维护区间修改和区间查询(类似序列上的线段树)
一些注意点:
-
特别地,单个叶子节点也算作一条重链
-
整棵树会被完全剖分成若干条重链
-
每条重链的顶点一定是轻儿子
-
任意一条路径不会被切分为超过
条链
口胡证明:
对一条轻边,因为 是 的轻儿子,不妨设重儿子为 ,则根据定义有
那么就有既然对于任意轻边有
,那么每次上跳经过一个轻边,子树大小就会变成原来的至少两倍
那么最多上跳的轻边为条,同理,最多上跳的重边不超过 条
树链剖分,简而言之,就是将树分成一条条链,然后用数据结构去维护这些链,以支持树上两点间的各种询问操作。
树链剖分大约有三种,分别是重链剖分、长链剖分和实链剖分(Link Cut Tree)。其中的重链剖分最为常见,所以一般说树链剖分(简称树剖)就是指重链剖分。
例如如果要分别在树上支持以下两种操作:
- 在两点间的简单路径上每个点的权值 + k
很显然,我们可以通过树上点差分,找到 lca,
- 求两点间的简单路径上的节点权值之和
也很显然,可以先 dfs一遍
但是。。。
如果要同时在线支持两种操作呢?暴力结合这两种做法就是
所以,
树链剖分就是将树分成不同的链,并再次对每条链的点重新编号,
使每条链的节点编号是连续的,这样就可以将每条链到线段树上去维护
例如更新路径 7 - 12(这个图节点编号和权值相等),就分别上跳(类似),直至同一条链
树链剖分查询路径上覆盖的链需要
所以总的时间复杂度就是
code
#include <bits/stdc++.h> #define re register int #define lp p << 1 #define rp p << 1 | 1 using namespace std; const int N = 1e5 + 10; struct Edge { int to, next; }e[N << 1]; int idx, h[N]; struct Tree { int l, r, sum, tag; }t[N << 2]; int n, q, s, mod, a[N]; int fa[N], son[N], top[N], dep[N], siz[N]; int id[N], cnt, mat_w[N]; inline void add(int x, int y) { e[++ idx] = (Edge){y, h[x]}; h[x] = idx; } inline void push_up(int p) { t[p].sum = t[lp].sum + t[rp].sum; } inline void push_down(int p) { if (t[p].tag) { t[lp].sum += (t[lp].r - t[lp].l + 1) * t[p].tag; t[rp].sum += (t[rp].r - t[rp].l + 1) * t[p].tag; t[lp].tag += t[p].tag; t[rp].tag += t[p].tag; t[p].tag = 0; } } void build(int p, int l, int r) { t[p].l = l, t[p].r = r; if (l == r) { t[p].sum = mat_w[l]; return; } int mid = (l + r) >> 1; build(lp, l, mid); build(rp, mid + 1, r); push_up(p); } inline void update(int p, int l, int r, int k) { if (l <= t[p].l && t[p].r <= r) { t[p].sum += (t[p].r - t[p].l + 1) * k; t[p].tag += k; return; } push_down(p); int mid = (t[p].l + t[p].r) >> 1; if (l <= mid) update(lp, l, r, k); if (r > mid) update(rp, l, r, k); push_up(p); } inline int query(int p, int l, int r) { if (l <= t[p].l && t[p].r <= r) return t[p].sum; push_down(p); int res = 0; int mid = (t[p].l + t[p].r) >> 1; if (l <= mid) res += query(lp, l, r) % mod; if (r > mid) res += query(rp, l, r) % mod; return res % mod; } void dfs(int u, int fu) { fa[u] = fu; dep[u] = dep[fu] + 1; siz[u] = 1; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fu) continue; dfs(v, u); siz[u] += siz[v]; if (siz[son[u]] < siz[v]) son[u] = v; } } void mark(int u, int t) { top[u] = t; id[u] = ++ cnt; mat_w[cnt] = a[u]; if (!son[u]) return; mark(son[u], t); for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (v == fa[u] || v == son[u]) continue; mark(v, v); } } inline void update_path(int u, int v, int k) { while (top[u] != top[v]) { if (dep[top[u]] < dep[top[v]]) swap(u, v); update(1, id[top[u]], id[u], k); u = fa[top[u]]; } if (dep[u] < dep[v]) swap(u, v); update(1, id[v], id[u], k); } inline int query_path(int u, int v) { int res = 0; while (top[u] != top[v]) { if (dep[top[u]] < dep[top[v]]) swap(u, v); res += query(1, id[top[u]], id[u]) % mod; u = fa[top[u]]; } if (dep[u] < dep[v]) swap(u, v); res += query(1, id[v], id[u]) % mod; return res; } int main() { ios::sync_with_stdio(false); cin.tie(0); cout.tie(0); cin >> n >> q >> s >> mod; for (re i = 1; i <= n; i ++) cin >> a[i]; for (re i = 1; i < n; i ++) { int x, y; cin >> x >> y; add(x, y), add(y, x); } dfs(s, 0); mark(s, s); build(1, 1, n); while (q --) { int op; cin >> op; if (op == 1) { int x, y, z; cin >> x >> y >> z; update_path(x, y, z); } if (op == 2) { int x, y; cin >> x >> y; cout << query_path(x, y) % mod << '\n'; } if (op == 3) { int x, z; cin >> x >> z; update(1, id[x], id[x] + siz[x] - 1, z); } if (op == 4) { int x; cin >> x; cout << query(1, id[x], id[x] + siz[x] - 1) % mod << '\n'; } } return 0; }
写树剖这种代码量的算法,难调是真的,还是要细心
这里再记录一下我遇到的一些情况:
在某些树剖题中,有的出题人喜欢节点编号从 0 开始(
然后,你直接套板子上去,根节点从 0 开始,你就会发现 siz[0] = 1
!
这样的后果就是可能无法更新重儿子,从实际含义出发理解也可以,之前,根节点为 1 时,初始 son[1] = 0
为空,此时 siz[0] = 0
,而现在 siz[0] = 1
就可能会导致 siz[son[u]] < siz[v]
不能成立,即有的重儿子没有被记录
重儿子少了,第二次搜索时连成的重边,重链就少了,上跳复杂度就从
基环树
n 个点,n 条边的图,也就是树上加一条边有且仅有一个环
基环树(也称环套树)分三类
无向基环树,内向树、外向树(有向图中)
首先基环树最主要的特征,就是环,所有先要找到环
显然地,把无向边看作双向边,我们可以用拓扑排序
外向树呢,我没有想到直接的处理办法,但是可以将它的所有边换向,转化为内向树处理,同样可以记录环,topsort 完后还原即可
code
inline void topsort() { queue<int> q; for (re i = 1; i <= n; i ++) if (in[i] == 1) q.push(i); // in[i] == 0 (有向图) while (!q.empty()) { int x = q.front(); q.pop(); for (re i = h[x]; i; i = e[i].next) { int y = e[i].to; if (-- in[y] == 1) q.push(y); // in[i] == 0 } } for (re i = 1; i <= n; i ++) if (in[i] == 2) ans[++ cnt] = i; // in[i] == 1 }
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步