基础树形结构
树形结构指的是数据元素之间存在着“一对多”的树形关系的数据结构,是一类重要的非线性数据结构。 ——百度百科
1. 树的性质与遍历
1.1 树的性质
树是一个 \(n\) 个节点,\(n - 1\) 条边的无向连通图。
每一个节点有一个父亲节点,若 \(A\) 为 \(B\) 的父亲节点,则 \(B\) 为 \(A\) 的子节点。
深度相同的节点被称为兄弟节点。
如果钦定一个点作为整棵树的根,这就被称为一棵有根树,否则被称为一棵无根树。
注:根节点即树中唯一一个没有父亲节点的节点。
1.2 树的遍历
例题:P5908 猫猫和企鹅
题目大意:
王国里有 \(n\) 个居住区,它们之间有 \(n-1\) 条道路相连,并且保证从每个居住区出发都可以到达任何一个居住区,并且每条道路的长度都为 \(1\)。
每个居住区住着一个小企鹅,有一天一只猫猫从 \(1\) 号居民区出发,想要去拜访一些小企鹅。可是猫猫非常的懒,它只愿意去距离它在 \(d\) 以内的小企鹅们。
猫猫非常的懒,因此希望你告诉他,他可以拜访多少只小企鹅。
对于 \(100\%\) 的数据,\(1 \leq n,d \leq 10^5\)。
树的存储与无向图的存储类似,一般我们用链式前向星或者 \(\text{vector}\) 存储,如果知道父亲节点与儿子节点的关系,可以用有向图的存储方式;否则应当存储双向边。
树的遍历最常用的是 dfs,核心代码如下:
void dfs(int u, int fa) { // u 为当前节点,fa 为父亲节点
for (auto v : G[u]) { // v 为 u 的儿子节点
if (v == fa) continue; // 因为存的是双向边,所以应该判断是否是自己的父亲节点
dfs(v, u);
}
}
设 \(dep_u\) 表示节点 \(u\) 的深度,那么这道题就是求 \(dep_i \leq d\) 的节点的数量。
但是树的遍历还可以用 bfs,虽然并不常用,这里给出核心代码:
queue <int> q;
int fa[N];
void bfs(int root) {
q.push(root); fa[root] = 0;
while (!q.empty()) {
int now = q.front(); q.pop();
for (auto v : G[now]) {
if (v == fa[now]) continue;
fa[v] = now;
q.push(v);
}
}
}
有许多树有特殊的形态,常作为题目中特殊性质的部分分出现。
-
链:每一个节点除了叶子节点只有一个子节点。
-
菊花图:除了根节点外的所有节点都向根节点连边。
2. 树的直径与重心
2.1 树的直径
设一条边的边权为 \(w\),定义
也就是节点 \(u\) 到节点 \(v\) 之间的距离为 \(u\) 到 \(v\) 简单路径上的边权之和。
一棵树的直径就是两点之间距离的最大值,也就是
求直径的常用方法有两种——两遍 dfs 法和树形 dp,这里仅介绍两遍 dfs 法。
两遍 dfs 法是比较推荐的(主要是考场上不容易忘),时间复杂度 \(\Theta(n)\)。
两遍 dfs 法的流程:
-
随意选择一个节点 \(x\) 作为起点,进行第一遍 dfs,找出距离 \(x\) 最远的节点 \(y\)。
-
以 \(y\) 为起点,进行第二遍 dfs,找出距离 \(y\) 最远的节点 \(z\)。
-
则 \(y\) 和 \(z\) 就是直径的两个端点。
具体证明可以看一下这篇 Blog。
注意点:两遍 dfs 法无法处理负边权的情况。
题目大意:
给定一棵 \(n\) 个节点的无根树,边带权,定义一段路径 \(F\) 的偏心距 \(\texttt{ECC}(F)\) 为树中距离路径 \(F\) 最远的节点到路径 \(F\) 的距离。
找到一段路径 \(F\),满足 \(F\) 是树的直径上的一段,长度不超过 \(s\),且 \(\texttt{ECC}(F)\) 最小,输出这个最小值。
原题数据范围:\(n\leq 300, 1 \leq w \leq 10^3, 0 \leq s \leq 10^3\)。
稍稍膜改后的数据范围:\(n \leq 5 \times 10^5\)。
首先我们肯定是要把树的直径求出来的,然后我们考虑这个最小的偏心距怎么求?
首先我们方便起见,以直径的一个端点为根节点。
那么对于所有的节点,我们可以求出不经过直径上的其它点所能够到达的最远距离。
我们考虑一段路径的偏心距怎么算。
考虑这样一张图:
很明显,直径为从节点 \(1\) 到节点 \(6\) 的路径。
我们可以求出对于每一个点,不经过直径上其它点的情况下能够到达的最远的距离 \(f\),显然,因为除了根节点,每一个直径上的节点的父亲节点也是直径上的,所以我们只需要考虑子树内的贡献即可。
引理 1
对于直径上的任意一个点 \(u\),到它的距离最远的点一定是两个端点之一。
证明:
设两个端点分别为 \(A\) 和 \(B\),有一个点 \(P\) 使得 \(dis_{u,P} > dis_{u,A}\),那么就可以用 \(\text{Path}_{u,B}\) 和 \(\text{Path}_{u,P}\) 组成一条更长的路径,不符合直径的定义,原题得证。
所以我们就可以用双指针维护一段长度不超过 \(s\) 的直径上的路径。
然后这条路径的偏心距就是除了两个端点之外的点的 \(f\) 值中最大值和路径两个端点分别到直径两个端点的距离取 \(\max\) 即可。
除了两个端点之外的点的 \(f\) 值中最大值可以用单调队列维护。
时间复杂度 \(\mathcal{O(n)}\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
int n, s;
vector < pair <int, int> > G[N];
int A, B, dis[N], far = 0, f[N], father[N];
bool flag[N] = {false};
int q[N], head, tail, ans;
static inline
void dfs(int u, int fa) {
if (dis[u] > dis[far]) far = u;
for (auto v : G[u]) {
if (v.first == fa) continue;
father[ v.first ] = u;
dis[ v.first ] = dis[u] + v.second;
dfs(v.first, u);
}
}
void dfs2(int u, int fa) {
for (auto v : G[u]) {
if (v.first == fa) continue;
dfs2(v.first, u);
if (!flag[ v.first ]) f[u] = max(f[u], f[ v.first ] + v.second);
}
}
void add(int x, int limit) {
while (head <= tail && dis[ q[head] ] >= dis[limit]) head++;
while (head <= tail && f[ q[tail] ] <= f[x]) tail--;
q[ ++tail ] = x;
}
int QueryMax() {
return f[ q[head] ];
}
int main() {
scanf("%d%d", &n, &s);
for (int i = 1; i < n; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
G[u].push_back({v, w});
G[v].push_back({u, w});
}
dis[1] = 0; dfs(1, 0); A = far;
dis[A] = father[A] = 0; far = 0; dfs(A, 0); B = far;
for (int i = B; i; i = father[i]) flag[i] = true;
dfs2(A, 0);
int l = B, r = B; ans = dis[B]; head = 1; tail = 0;
while (r) {
while (dis[r] - dis[ father[l] ] <= s && l) {
add(l, r);
l = father[l];
int now = max(QueryMax(), max(dis[B] - dis[r], dis[l]));
ans = min(ans, now);
}
r = father[r];
}
printf("%d\n", ans);
return 0;
}
双倍经验:P2491 [SDOI2011] 消防
2.2 树的重心
树的重心就是一个节点 \(u\),使得以 \(u\) 为根时,最小化
或者一个节点 \(u\),使得以 \(u\) 为根时,
从重心的这条结论中,我们能够推出重心的 \(4\) 条性质:
- 以重心为根时,所有子树的大小不超过全树大小的一半。
这是一条挺重要的性质,也是重心的第二条定义,点分治就主要时运用这条性质实现 \(\Theta(n \log n)\) 的。
- 树中所有点到某个点的距离和中,到重心的距离和是最小的;如果有两个重心,那么到它们的距离和一样。
这条性质证明挺简单的,我们考虑最优的节点从重心移动,那么子树内的所有节点贡献 \(-1\),子树外的所有节点贡献 \(+1\),因为以重心为根时,所有子树的大小不超过全树大小的一半,所以加上的肯定比减去的多,易证重心为最优节点。
- 把两棵树通过一条边相连得到一棵新的树,那么新的树的重心在连接原来两棵树的重心的路径上。
- 在一棵树上添加或删除一个叶子,那么它的重心最多只移动一条边的距离。
如果添加或删除一个叶子节点,最多使一棵子树内的节点数量比一半多 \(1\),移动一次重心即可解决问题。
那么如何求重心?
这个其实很简单,只需要进行一遍 dfs,求出每一个 \(siz_u\) 表示以 \(u\) 为根的子树的大小,根据定义求解即可。
int siz[N], f[N], root(0); // root 表示重心
void dfs(int u, int fa) {
siz[u] = 1;
for (auto v : G[u]) {
if (v == fa) continue;
dfs(v, u); siz[u] += siz[v];
f[u] = max(f[u], siz[v]);
}
f[u] = max(f[u], n - siz[u]); // 别忘记把子树外的贡献算上
if (!root || f[u] < f[root]) root = u;
}
例题:P1395 会议
题目大意:
给定一棵 \(n\) 个节点的树,求出一个节点使得所有节点到这个节点的距离之和最小,输出节点编号以及最小值,如果有多个节点符合要求,输出编号最小的那一个。
对于 \(100\%\) 的数据,\(n \leq 5 \times 10^4\)。
根据重心的性质 \(2\),求出重心之后再求一个距离之和,然后就没有然后了……
代码应该不用放了。
这道题还有一种换根 dp 的做法,也是 \(\mathcal{O(n)}\) 的,这就不需要推重心的性质。
这里放一下代码,有兴趣的同学可以学习一下:
#include <bits/stdc++.h>
using namespace std;
const int N = 5e4 + 5;
const int inf = 1e9;
int n, f[N], siz[N];
vector <int> G[N];
void dfs1(int u, int fa, int dis) {
f[1] += dis; siz[u] = 1;
for (auto v : G[u]) {
if (v == fa) continue;
dfs1(v, u, dis + 1); siz[u] += siz[v];
}
}
void dfs2(int u, int fa) {
for (auto v : G[u]) {
if (v == fa) continue;
f[v] = f[u] - siz[v] + n - siz[v];
dfs2(v, u);
}
}
int main() {
scanf("%d", &n);
for (int i = 1; i < n; i++) {
int u, v; scanf("%d%d", &u, &v);
G[u].push_back(v); G[v].push_back(u);
}
dfs1(1, 0, 0);
dfs2(1, 0);
int ans(0); f[0] = inf;
for (int i = 1; i <= n; i++) {
if (f[ans] > f[i]) ans = i;
}
printf("%d %d\n", ans, f[ans]);
return 0;
}
3. 最近公共祖先
最近公共祖先,也被称为 LCA(Lowest Common Ancestor)。
对于有根树 \(T\) 的两个结点 \(u, v\),最近公共祖先 \(\mathrm{LCA}(T, u, v)\) 表示一个结点 \(x\),满足 \(x\) 是 \(u\) 和 \(v\) 的祖先且 \(x\) 的深度尽可能大。在这里,一个节点也可以是它自己的祖先。 ——百度百科
题目大意:
给定一棵 \(n\) 个节点,以节点 \(S\) 为根的树,有 \(m\) 个询问,每次询问 \(x,y\) 的 LCA。
对于 \(100\%\) 的数据,\(1 \leq x, y, S \leq n, m \leq 10^5\)。
3.1 Brute Force 算法
人类历史上最优美的算法 (逃
假如数据范围只有 \(n, m \le 5 \times 10^3\) 怎么做?
肯定是暴力往上跳啊!
我们就有一个显而易见的算法,先把节点 \(x, y\) 往上跳到同一高度,然后往上跳,直到跳到同一个节点,即为 LCA。
inline int LCA(int x, int y) {
if (dep[x] < dep[y]) swap(x, y);
while (dep[x] > dep[y]) x = fa[x];
if (x == y) return x;
while (x != y) x = fa[x], y = fa[y];
return x;
}
显然时间复杂度是 \(\Theta(n^2)\) 的,过不了 \(10^5\) 的数据。
3.2 倍增算法
既然一个一个往上跳太慢了,为什么不直接往上跳多步呢?
于是借鉴倍增的思想,对于一个数 \(n\),进行二进制拆分,必然能够拆分成如下形式:
\(\forall i,j \in [1,k] \cap \N_+\) 且 \(i \neq j\),满足 \(a_i \neq a_j\)。
所以我们可以记录 \(f_{u,j}\) 表示从节点 \(u\) 开始往上跳 \(2^j\) 个节点所到达的节点。
这样我们就最多只需要跳 \(\Theta(\log n)\) 次即可。
我们接下来考虑 \(f_{u, j}\) 的状态转移方程,显然往上跳 \(2^j\) 个节点可以转换为先往上跳 \(2^{j - 1}\) 个节点,再往上跳 \(2^{j - 1}\) 个节点。
有转移方程 \(f_{u,j} = f_{f_{u,j - 1}, j - 1}\)。
void dfs(int u, int fa) {
dep[u] = dep[fa] + 1;
f[u][0] = fa; // 往上跳 1 个节点那就是父亲节点啦~
for (int i = 1; (1 << i) <= dep[u]; i++)
f[u][i] = f[ f[u][ i - 1 ] ][ i - 1 ];
for (auto v : G[u]) {
if (v == fa) continue;
dfs(v, u);
}
}
int LCA(int x, int y) {
if (dep[x] < dep[y]) swap(x, y);
for (int i = 20; i >= 0; i--) {
if (dep[ f[x][i] ] >= dep[y]) x = f[x][i];
if (x == y) return x;
}
for (int i = 20; i >= 0; i--)
if (f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];
/*
这里为什么要写 if (f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];
因为往上跳一个很大的距离最后节点编号肯定是相等的,
这样能够保证是公共祖先,但是不能保证是最近公共祖先,
所以应该始终保持两个节点的编号不相等,一直逼近最近公共祖先,
最终两个节点的父亲节点就是最近公共祖先。
*/
return f[x][0];
}
时间复杂度 \(\Theta((n + m) \log n)\)。
3.3 Tarjan 算法
假如时间复杂度要求 \(\Theta(n + m)\) 呢?
这是一个值得深思的问题……
这里就不得不提到我们和蔼可亲的 Robert Tarjan 老爷爷了。
所以在线算法明显是不行了(其实通过四毛子可以做到在线),考虑离线。
假设有一棵优美的树:
你需要求出 \(\mathrm{LCA}(5, 4)\),\(\mathrm{LCA}(6, 2)\) 和 \(\mathrm{LCA}(2, 8)\)。
首先我们用 vector 把对应的查询挂到对应的节点上,例如:
struct query {
int x, id; // x 表示另一个节点,id 表示询问的编号
query() = default;
query(const int _x, const int _id) : x(_x), id(_id) {}
};
vector <query> q[N]; // 记录查询
int ans[N]; // 记录答案
q[5].push_back(query(4, 1));
q[4].push_back(query(5, 1));
// ...
然后我们用 Tarjan 算法(其实就是在 dfs 上搞点事情)。
这里求 LCA 我们需要用到并查集,这里给出常用的函数。
int f[N]; // 记录并查集中每一个节点的父亲节点编号
static inline
int find(int x) {
return (x ^ f[x] ? f[x] = find(f[x]) : x); // 路径压缩
}
先从 \(1\) 节点进入,然后遍历到 \(2\) 节点,但是 \(8\) 节点还没有被遍历过,所以不用管这个询问$。
遍历到 \(5\),但是 \(4\) 没有被遍历过,所以不用管。
遍历到 \(8\) 节点,因为 \(2\) 已经被遍历过了,所以查询 $\text{find(2)} $ 函数,得到结果 \(2\)。
由于 \(8\) 的子树内已经遍历完了,所以在并查集中从 \(8\) 向 \(8\) 的父亲 \(5\) 连边。
然后剩余的操作以次类推即可。
这里以 P3379 【模板】最近公共祖先(LCA) 为例放一下代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
int n, m, s, f[N];
vector <int> G[N];
struct query {
int x, id;
query() = default;
query(const int _x, const int _id) : x(_x), id(_id) {}
};
vector <query> q[N];
int ans[N];
bool vis[N];
static inline
int find(int x) {
return (x ^ f[x] ? f[x] = find(f[x]) : x);
}
void merge(int x, int y) {
int fx = find(x), fy = find(y);
if (fx ^ fy) f[fx] = fy;
}
void Tarjan(int u, int fa) {
vis[u] = true;
for (auto v : q[u]) if (vis[ v.x ]) {
ans[ v.id ] = find(v.x);
}
for (auto v : G[u]) {
if (v == fa) continue;
Tarjan(v, u);
}
merge(u, fa);
}
int main() {
scanf("%d%d%d", &n, &m, &s);
for (int i = 1; i < n; i++) {
int u, v; scanf("%d%d", &u, &v);
G[u].push_back(v); G[v].push_back(u);
}
for (int i = 1; i <= n; i++) f[i] = i;
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
q[x].push_back(query(y, i)); q[y].push_back(query(x, i));
}
Tarjan(s, s);
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
时间复杂度 \(\Theta((n + m)\cdot \alpha(n))\),使用树上线性并查集可以做到 \(\Theta(n + m)\),虽然这并没有什么用(虽然是 \(\Theta(n + m)\) 的,但是跑的和 \(\Theta((n + m) \log n)\) 真心没区别)。
如果你正式理解了 Tarjan 求 LCA,那么 这道题 你就可以用 Kruskal 重构树 \(\Theta(n + m)\) 解决了。
4. 树上差分
例题:P3128 [USACO15DEC]Max Flow P
简要题意:
给出一棵 \(n\) 个节点的树,有 \(m\) 次修改操作,每次给定节点编号 \(s\) 和 \(t\),把 \(s\) 到 \(t\) 的路径上的点权增加 \(1\),问 \(m\) 次操作后最大点权的值。
对于 \(100\%\) 的数据,\(n, m\leq 10^5\)。
看到许多次修改操作,想到的肯定是差分。序列上的差分我就默认都会了,那么我们只需要把差分搬到树上就行了。
我们定义树上的前缀和就是
也就是前缀和指一棵子树内的所有权值之和。
我们考虑给 \(s\) 到 \(t\) 的路径上的所有点权 \(+1\) 所带来的贡献。
设 \(s = 5,t = 7\),那么点 \(5, 3, 6, 7\) 的点权都要 \(+1\)。
考虑差分怎么做,我们首先就要在 \(s\) 和 \(t\) 处 \(+1\),然后我们发现节点 \(6\) 满足条件了。
但是 \(\mathrm{LCA}(5,7)\),也就是节点 \(3\) 处的点权却加了 \(2\),这明显不符合,我们只要它加 \(1\) 就够了,所以我们就在 LCA 处减去 \(1\)。
然后我们考虑 LCA 以上的部分,这里也就是节点 \(1\),我们会发现这样节点 \(1\) 又加了 \(1\),我们不希望节点 \(1\) 的权值和有变化,所以我们要在 LCA 的父亲节点处减去 \(1\)。
这样树上差分就做好了。
int c[N], sum[N], ans(0);
void dfs(int u, int fa) { // 统计答案
sum[u] = 1;
for (auto v : G[u]) {
if (v == fa) continue;
dfs(v, u); sum[u] += sum[v];
}
ans = max(ans, sum[u]);
}
scanf("%d", &q);
while (q--) { // 树上差分
int s, t; scanf("%d%d", &s, &t);
int lca = LCA(s, t);
c[s]++; c[t]++; c[lca]--; c[ father[lca] ]--;
}
完结撒花!^_の