树是图论中最基础也最重要的数据结构之一。它具有优美的性质和广泛的应用,从文件系统到决策树,从 HTML 文档到组织架构,树无处不在。本文将从基础概念出发,逐步深入探讨树的直径、重心以及最小生成树等重要专题。
第一部分:树基础
树的定义
无根树
无根树是指没有固定根节点的树。它有多种等价定义:
- 有 个节点和 条边的连通无向图
- 无环的连通无向图
- 任意两点间有且仅有一条简单路径的图
- 任意添加一条边都会形成环的图
这些定义从不同角度描述了树的本质特征:连通、无环、边数等于点数减一。
有根树
有根树是在无根树的基础上,指定一个节点作为”根”所形成的树。根节点赋予了树明确的层级关系,形成了自上而下的结构。
点击"选择根节点"后,再点击树上任意节点作为根
有根树中,根节点通常绘制在最上方,子节点在下方,形成”倒置”的树形结构。
树的术语
通用术语
| 术语 | 定义 |
|---|---|
| 森林 | 每个连通分量都是树的图,即多棵树的集合 |
| 生成树 | 包含图中所有顶点且连通的 条边构成的子图 |
| 叶节点 | 在无根树中度数不超过 1 的节点;在有根树中没有子节点的节点 |
| 度数 | 与节点相连的边数(无根树)或子节点数(有根树) |
有根树专属术语
- 父亲 (Parent):节点到根路径上的相邻上级节点
- 子节点 (Child):节点的相邻下级节点
- 兄弟 (Sibling):具有相同父亲的节点
- 祖先 (Ancestor):从节点到根路径上的所有节点
- 后代 (Descendant):以该节点为根的子树中的所有节点
- 深度 (Depth):节点到根节点的路径边数
- 高度 (Height):所有节点深度的最大值,也称为树的深度
- 子树 (Subtree):删除与父亲相连的边后,该节点所在的子图
特殊的树
链 (Chain)
所有节点度数不超过 2 的树。链可以看作是一条线性路径,是最”瘦长”的树结构。
1 --- 2 --- 3 --- 4 --- 5菊花图 / 星星 (Star)
除了一个中心节点外,其余所有节点都只与该中心节点相连。这是最”扁平”的树结构。
2
|
4 - 1 - 3
|
5二叉树
每个节点最多只有两个子节点的有根树,分为左子节点和右子节点。
二叉树的分类:
| 类型 | 定义 |
|---|---|
| 完整二叉树 | 每个节点的子节点数要么是 0,要么是 2 |
| 完全二叉树 | 除最后一层外全满,且最后一层节点靠左排列 |
| 完美二叉树 | 所有叶节点深度相同,所有非叶节点都有两个子节点 |
在 OI 竞赛中,“满二叉树”通常指完美二叉树,而非完整二叉树。注意术语的差异。
树的存储方式
只记录父节点
使用数组 parent[N] 记录每个节点的父节点。
int parent[N];
// parent[i] = j 表示节点 i 的父节点是 j适用于需要自底向上递推的场景,但无法快速找到子节点。
邻接表
使用 std::vector 记录每个节点的所有相邻节点(无根树)或子节点(有根树)。
vector<int> adj[N]; // 无根树:存储所有相邻节点
vector<int> children[N]; // 有根树:只存储子节点最常用的存储方式,支持高效的遍历操作。
左孩子右兄弟表示法
记录节点的第一个子节点和它的下一个兄弟节点,可以将任意多叉树转化为二叉树结构。
struct Node {
int firstChild; // 第一个子节点
int nextSibling; // 下一个兄弟节点
};树的遍历
深度优先搜索 (DFS)
普通树 DFS
对于无向图形式的树,需要记录 from 参数避免重复访问:
void dfs(int u, int from) {
for (int v : adj[u]) {
if (v != from) {
dfs(v, u);
}
}
}二叉树的三种 DFS 遍历
| 遍历方式 | 访问顺序 |
|---|---|
| 先序遍历 (Preorder) | 根 → 左 → 右 |
| 中序遍历 (Inorder) | 左 → 根 → 右 |
| 后序遍历 (Postorder) | 左 → 右 → 根 |
代码实现:
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
};
// 先序遍历:根 → 左 → 右
void preorder(TreeNode* root) {
if (!root) return;
cout << root->val << " "; // 访问根
preorder(root->left); // 递归左子树
preorder(root->right); // 递归右子树
}
// 中序遍历:左 → 根 → 右
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left); // 递归左子树
cout << root->val << " "; // 访问根
inorder(root->right); // 递归右子树
}
// 后序遍历:左 → 右 → 根
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left); // 递归左子树
postorder(root->right); // 递归右子树
cout << root->val << " "; // 访问根
}观察三种遍历的代码结构可以发现:唯一的区别在于访问根节点的时机不同。
- 先序:访问在最前
- 中序:访问在中间
- 后序:访问在最后
广度优先搜索 (BFS)
利用队列实现层序遍历,严格按照从上到下、从左到右的层级访问节点。
void bfs(int root) {
queue<int> q;
q.push(root);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : children[u]) {
q.push(v);
}
}
}已知中序遍历和先序/后序中的任意一个,可以唯一确定一棵二叉树的结构。
练习题推荐
- LeetCode 104. 二叉树的最大深度↗
- LeetCode 236. 二叉树的最近公共祖先↗
- LeetCode 102. 二叉树的层序遍历↗
- LeetCode 144/94/145. 二叉树的前序/中序/后序遍历↗
第二部分:树的直径
树的直径(Tree Diameter)是树上任意两节点之间最长的简单路径。它是树论中最基础也最重要的问题之一,广泛应用于网络设计、路径规划等领域。
求解方法
求树的直径有两种时间复杂度为 的经典方法:
方法一:两次 DFS
思路:从任意节点出发找到最远点 ,再从 出发找到最远点 ,则 到 的路径即为直径。
原理证明:
设树的直径端点为 和 ,从任意节点 出发找到的最远点为 。我们需要证明 必须是 或 。
采用反证法:假设 既不是 也不是 。
设 到 的路径离开直径的分叉点为 (即 在直径上,从 开始路径离开直径走向 )。分两种情况讨论:
情况一: 在直径上
设直径上各点的顺序为 ( 在 的 方向一侧)。
由于 是最远点,有 。而 ,因此:
现在构造路径 ,其长度为:
如果 ,则新路径长度严格大于直径,矛盾!
若 在 的 方向一侧,类似可证 必须是 。
情况二: 不在直径上
设 到直径的唯一连接点为 , 到 的距离为 。则:
假设 ,即 更靠近 ,则 是从 出发在直径方向上的最远点。
如果 不是 ,设 到 的路径在点 处离开直径( 在 到 的路径上)。则:
由于 是最远点,。
若 在 到 的方向上,则 ,从而:
构造路径 :
若 ,路径长度严格大于直径,矛盾!
结论:从任意点出发,最远点一定是直径的某个端点。
两次 DFS 方法方便记录直径路径——只需在第二次 DFS 时记录前驱节点即可。
两次 DFS 方法不适用于负权边!当边权存在负数时,证明的正确性会失效。
方法二:树形 DP
思路:对每个节点,计算从它向下延伸的最长链 和次长链 (两条链不能共享边),直径即为所有 的最大值。
正确性证明:
我们分两步证明: 确实等于树的直径。
第一步: 是直径的一个候选
树的直径是一条路径,设这条路径为 。
由于树是无向图,我们可以选定任意节点作为根。以直径路径上深度最小的节点作为根(或者将树”拉直”,直径就是一条横向路径)。
对于直径路径上的每个节点 ,直径从 的两个不同子树方向延伸出去。设这两个方向分别为子节点 和子节点 。
从 向 方向延伸的部分,就是 向子树 延伸的一条链。这条链的长度不超过 (因为 是从 向下延伸的最长链)。
同理,向 方向延伸的链长度不超过 向子树 延伸的最长链。由于 和 是不同的子节点,这条链与向 的链不共享边,其长度不超过次长链 。
因此,直径长度 ,进而直径长度 。
第二步: 是一条可行路径
设 是使 最大的节点。 是从 向某个子节点 的子树延伸的最长链, 是向另一个子节点 的子树延伸的次长链。
由于 ,这两条链不共享边。将它们在 处连接,形成一条经过 的路径,长度恰为 。
这条路径完全在树内,因此其长度不超过直径。
结论
由第一步,直径 。
由第二步, 直径。
因此两者相等, 即为树的直径。
状态转移:
diameter = max(d₁ + d₂) d₁=最长链, d₂=次长链树形 DP 方法适用于负权边,因为它是自底向上计算,考虑了所有可能的路径组合。
方法对比
| 特性 | 两次 DFS | 树形 DP |
|---|---|---|
| 时间复杂度 | ||
| 空间复杂度 | ||
| 负权边 | ❌ 不支持 | ✅ 支持 |
| 记录路径 | ✅ 简单 | ⚠️ 稍复杂 |
| 实现难度 | 简单 | 中等 |
重要性质:中点重合
当树上所有边权均为正数时,一个优美的性质成立:
所有直径的中点必定重合。
证明(反证法): 设两条直径 和 的中点分别为 和 ,且 。
由于两条直径长度相等,我们可以构造一条更长的路径:
- 从 出发沿直径到
- 再从 通过公共部分到
- 最后从 沿另一条直径到
这样构造的路径长度必然大于原直径,矛盾!
当边权为正时,所有直径的中点必定重合。
两条直径 L→R 和 U→D 的中点都在边 A-B 的正中间。
代码实现
两次 DFS(C++)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int dist[N], parent[N];
void dfs(int u, int fa, int d) {
dist[u] = d;
parent[u] = fa;
for (int v : adj[u]) {
if (v != fa) dfs(v, u, d + 1);
}
}
int main() {
int n;
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
// 第一次 DFS
dfs(1, -1, 0);
int z = max_element(dist + 1, dist + n + 1) - dist;
// 第二次 DFS
dfs(z, -1, 0);
int z2 = max_element(dist + 1, dist + n + 1) - dist;
cout << "直径长度: " << dist[z2] << endl;
// 输出直径路径
vector<int> path;
for (int u = z2; u != -1; u = parent[u]) {
path.push_back(u);
}
// path 即为直径路径
return 0;
}树形 DP(C++)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<pair<int,int>> adj[N]; // (邻点, 边权)
int d1[N], d2[N]; // 最长链、次长链
int diameter = 0;
void dp(int u, int fa) {
d1[u] = d2[u] = 0;
for (auto [v, w] : adj[u]) {
if (v == fa) continue;
dp(v, u);
int d = d1[v] + w;
if (d > d1[u]) {
d2[u] = d1[u];
d1[u] = d;
} else if (d > d2[u]) {
d2[u] = d;
}
}
diameter = max(diameter, d1[u] + d2[u]);
}
int main() {
int n;
cin >> n;
for (int i = 1; i < n; i++) {
int u, v, w = 1;
cin >> u >> v;
adj[u].push_back({v, w});
adj[v].push_back({u, w});
}
dp(1, -1);
cout << "直径长度: " << diameter << endl;
return 0;
}练习题推荐
| 题目 | 难度 | 说明 |
|---|---|---|
| 洛谷 B4016 树的直径↗ | ⭐ | 入门题,求直径长度 |
| 洛谷 P2491 消防↗ | ⭐⭐⭐ | SDOI2011,直径上的最优选点 |
| 洛谷 P3629 巡逻↗ | ⭐⭐⭐ | APIO2010,加边后的直径变化 |
| LeetCode 1245. 树的直径↗ | ⭐⭐ | 无向树的直径 |
小结
- 两次 DFS 实现简单,方便记录路径,但不支持负权边
- 树形 DP 更通用,支持负权边,是竞赛中的首选方法
- 中点重合 是正权树的重要性质,可用于优化和证明
第三部分:树的重心
树的重心(Tree Centroid)是树论中的核心概念之一。删去重心后,树会被相对均匀地分割成若干部分,这一性质使其成为点分治(重心分解)算法的基础。
定义
如果在树中删去某个节点 后,得到的每个连通分量的大小均不超过原树节点总数的一半,那么节点 就是整棵树的重心。
重量:删去某节点后,得到的最大连通分量的大小称为该节点的重量。重心即重量不超过 的节点。
点击节点查看删除后的连通块大小
等价定义与性质
等价定义
重心有多种等价的描述方式:
| 等价定义 | 说明 |
|---|---|
| 连通分量极值 | 删去重心得到的最大连通分量是所有删点方案中最小的 |
| 距离和最小 | 树中所有节点到重心的距离之和最小 |
| 有根树视角 | 以重心为根时,任意真子树大小都不超过 |
重心 = 距离和最小的节点
核心性质
性质 1:重心数量
一棵树最多有两个重心;如果有两个重心,它们必定相邻。
一棵树最多有两个重心。
如果有两个重心,它们必定相邻。
橙色节点为重心
性质 2:动态稳定性
添加或删除一个叶子节点,新树的重心最多移动一条边的距离。
这一性质在动态维护重心时非常有用。
性质 3:合并性质
将两棵树通过一条边合并,新树的重心必在原来两棵重心之间的路径上。
重心的求法
方法:一次 DFS
通过一次 DFS 统计每个节点的子树大小,同时计算删去该节点后各连通块的最大值。
算法流程:
- DFS 遍历,计算每个节点 的子树大小
size[u] - 对于节点 ,删去它后的连通块有:
- 各子树:
size[v]( 是 的子节点) - 向上的部分:
n - size[u]
- 各子树:
- 取这些值的最大值作为 的重量
- 重量 的节点即为重心
size[u] = 1 + Σ size[v] up_size = n - size[u]重心条件: max_part ≤ n/2
DFS 方法的时间复杂度为 ,是最常用且高效的重心求法。
代码实现
C++ 模板
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int size_[N]; // 子树大小
int max_part[N]; // 删去该节点后的最大连通块
int n;
vector<int> centroids;
void dfs(int u, int fa) {
size_[u] = 1;
max_part[u] = 0;
for (int v : adj[u]) {
if (v == fa) continue;
dfs(v, u);
size_[u] += size_[v];
max_part[u] = max(max_part[u], size_[v]);
}
// 向上的连通块
max_part[u] = max(max_part[u], n - size_[u]);
// 判断是否为重心
if (max_part[u] <= n / 2) {
centroids.push_back(u);
}
}
int main() {
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
dfs(1, -1);
cout << "重心: ";
for (int c : centroids) {
cout << c << " ";
}
cout << endl;
return 0;
}换根 DP 方法
利用「距离和最小」的性质,通过两次 DFS 计算所有节点的距离和,然后取最小值对应的节点作为重心。这种方法的优势是能同时得到所有节点作为根时的距离和信息。
转移公式
从父亲 u 换根到子节点 v:
- v 的子树内 (? 个节点):距离各 -1
- v 的子树外 (? 个节点):距离各 +1
当前状态
算法原理
换根 DP 的核心思想是利用父子关系,从已知的根节点信息推导所有节点的信息。
设 表示以 为根时,所有节点到 的距离之和。如果我们已经计算出了 (某个根节点的距离和),如何高效地计算出其他节点的 值?
关键观察:换根时的变化
考虑从父亲节点 换根到子节点 时,各节点距离的变化:
- 的子树内节点(共 个):原本距离是到 的,现在变成到 的,距离各减少 1
- 的子树外节点(共 个):原本距离是到 的,现在需要经过 再到 ,距离各增加 1
因此得到转移公式:
两次 DFS 的具体过程
第一次 DFS(dfs1):以任意节点为根(如节点 1),自顶向下传递深度,自底向上累加 size 和 dp。
size[u]:以 为根的子树大小dep[u]: 到根节点的距离(深度)- 初始时,(每个节点对根的距离贡献初始为深度值)
- 回溯时累加子节点信息:,
这样 DFS1 结束后, 就是以 root 为根时的距离和。
第二次 DFS(dfs2):从根开始,利用转移公式依次计算每个子节点作为根时的 dp 值。
dp[v] = dp[u] + n - 2 * size[v];DFS2 结束后,所有节点的 值都已计算完毕,取最小值即为重心。
时间复杂度分析
| 操作 | 复杂度 |
|---|---|
| DFS1 | |
| DFS2 | |
| 找最小值 | |
| 总计 |
相比于暴力方法(每个节点做一次 BFS/DFS,),换根 DP 将复杂度优化到了线性!
换根 DP 是一类通用技巧,不仅适用于求距离和最小的问题,还可以用于求「以每个节点为根时的最大子树大小」等问题。核心都是找到换根时的变化规律。
代码实现
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int n, size_[N], dep[N];
long long dp[N]; // dp[u] = 以u为根的距离和
void dfs1(int u, int fa) {
size_[u] = 1;
dp[u] = dep[u]; // 初始值:当前节点对根的距离贡献 = 深度
for (int v : adj[u]) {
if (v == fa) continue;
dep[v] = dep[u] + 1; // 先传递深度
dfs1(v, u); // 递归子节点
size_[u] += size_[v]; // 累加子树大小
dp[u] += dp[v]; // 累加子节点对根的距离贡献
}
}
void dfs2(int u, int fa) {
for (int v : adj[u]) {
if (v == fa) continue;
// 换根转移公式:dp[v] = dp[u] + n - 2*size[v]
dp[v] = dp[u] - size_[v] + (n - size_[v]);
dfs2(v, u);
}
}
int main() {
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
dep[1] = 0; // 根节点深度为0
dfs1(1, -1); // 第一次DFS:计算size和dp[根]
dfs2(1, -1); // 第二次DFS:换根转移,计算所有dp值
// 距离和最小的节点即为重心
int centroid = min_element(dp + 1, dp + n + 1) - dp;
cout << "重心: " << centroid << endl;
cout << "距离和: " << dp[centroid] << endl;
return 0;
}经典应用
点分治(重心分解)
重心最重要的应用是点分治算法。每次选择子树的重心作为分治中心,可以保证:
- 每层分治后子树大小不超过原来的一半
- 递归深度为
- 总时间复杂度
练习题推荐
| 题目 | 难度 | 说明 |
|---|---|---|
| 洛谷 P1364 医院设置↗ | ⭐ | 距离和最小应用 |
| CF 715C Digit Tree↗ | ⭐⭐⭐ | 点分治经典题 |
| 洛谷 P2634 聪聪可可↗ | ⭐⭐⭐ | 点分治入门 |
小结
| 要点 | 内容 |
|---|---|
| 定义 | 删去后最大连通块 |
| 等价定义 | 距离和最小 / 子树大小都不超过 |
| 数量 | 最多 2 个,若有 2 个则相邻 |
| 求法 | 一次 DFS, |
| 应用 | 点分治的核心基础 |
第四部分:最小生成树
最小生成树(Minimum Spanning Tree, MST)是图论中最经典的问题之一。给定一个带权无向连通图,最小生成树是一棵包含所有顶点、边权和最小的生成树。
核心定义
生成树:对于一个有 个顶点的连通图,生成树是包含全部 个顶点且恰好有 条边的连通子图。
最小生成树:所有生成树中,边权总和最小的那一棵。
只有连通图才存在生成树。对于非连通图,只存在最小生成森林。
最小生成树可能不唯一,但当所有边权互不相同时,最小生成树唯一。
求解算法
求最小生成树有两种经典算法,都基于贪心策略:
Kruskal 算法
思想:将所有边按边权从小到大排序,依次尝试加入。如果某条边的两个端点不在同一个连通块内(用并查集判断),则加入该边,直到加入了 条边。
算法步骤:
- 将所有边按边权排序
- 初始化并查集,每个节点自成一个集合
- 按顺序遍历每条边 :
- 如果
find(u) != find(v),则加入该边,执行union(u, v) - 直到选了 条边
- 如果
点击"开始演示"查看 Kruskal 算法执行过程
Kruskal 算法的正确性基于贪心选择性质:每次选择当前最小的”安全边”(不会形成环的边),最终得到最优解。
时间复杂度:,主要来自排序。适合稀疏图(边数较少)。
Prim 算法
思想:从任意一个节点开始,每次从未加入树的节点中,选择一个与已加入节点集合距离最近的节点加入,直到所有节点都被加入。
算法步骤:
- 选择一个起始节点加入集合
- 维护每个节点到集合 的最小距离
- 每次选择距离最小的未加入节点,加入 并更新其他节点的距离
- 重复直到所有节点都加入
点击"开始演示"查看 Prim 算法执行过程
Prim 算法的思想类似于 Dijkstra 最短路径算法,区别在于 Dijkstra 维护的是到起点的最短距离,而 Prim 维护的是到已选集合的最短距离。
时间复杂度:
- 朴素实现:,适合稠密图
- 堆优化:
算法对比
| 特性 | Kruskal | Prim |
|---|---|---|
| 时间复杂度 | 或 | |
| 适用场景 | 稀疏图 | 稠密图 |
| 数据结构 | 并查集 | 优先队列 |
| 实现难度 | 简单 | 中等 |
| 思路 | 从边出发 | 从点出发 |
代码实现
Kruskal 算法(C++)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int parent[N], rank_[N];
// 并查集查找(带路径压缩)
int find(int x) {
return parent[x] == x ? x : parent[x] = find(parent[x]);
}
// 并查集合并(按秩合并)
bool unite(int x, int y) {
x = find(x), y = find(y);
if (x == y) return false;
if (rank_[x] < rank_[y]) swap(x, y);
parent[y] = x;
if (rank_[x] == rank_[y]) rank_[x]++;
return true;
}
struct Edge {
int u, v, w;
bool operator<(const Edge& e) const { return w < e.w; }
};
int main() {
int n, m;
cin >> n >> m;
vector<Edge> edges(m);
for (int i = 0; i < m; i++) {
cin >> edges[i].u >> edges[i].v >> edges[i].w;
}
// 初始化并查集
for (int i = 1; i <= n; i++) parent[i] = i;
// Kruskal
sort(edges.begin(), edges.end());
int totalWeight = 0, edgeCount = 0;
for (auto& e : edges) {
if (unite(e.u, e.v)) {
totalWeight += e.w;
edgeCount++;
cout << "选中边: " << e.u << " - " << e.v
<< " (权重 " << e.w << ")" << endl;
if (edgeCount == n - 1) break;
}
}
if (edgeCount == n - 1) {
cout << "最小生成树总权重: " << totalWeight << endl;
} else {
cout << "图不连通,无法生成最小生成树" << endl;
}
return 0;
}Prim 算法(C++)
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
const int INF = 1e9;
int n, m;
vector<pair<int, int>> adj[N]; // (邻点, 边权)
int minDist[N]; // 到已选集合的最小距离
int parent[N]; // 记录生成树的父节点
bool inMST[N];
int prim(int start) {
fill(minDist, minDist + n + 1, INF);
fill(inMST, inMST + n + 1, false);
fill(parent, parent + n + 1, -1);
minDist[start] = 0;
int totalWeight = 0;
for (int i = 0; i < n; i++) {
// 找最小距离的未选节点
int u = -1, minD = INF;
for (int v = 1; v <= n; v++) {
if (!inMST[v] && minDist[v] < minD) {
minD = minDist[v];
u = v;
}
}
if (u == -1) break; // 图不连通
inMST[u] = true;
totalWeight += minDist[u];
if (parent[u] != -1) {
cout << "选中边: " << parent[u] << " - " << u
<< " (权重 " << minDist[u] << ")" << endl;
}
// 更新邻接节点的最小距离
for (auto [v, w] : adj[u]) {
if (!inMST[v] && w < minDist[v]) {
minDist[v] = w;
parent[v] = u;
}
}
}
return totalWeight;
}
int main() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w});
adj[v].push_back({u, w});
}
int totalWeight = prim(1);
cout << "最小生成树总权重: " << totalWeight << endl;
return 0;
}Prim 算法堆优化版本
int prim_heap(int start) {
fill(minDist, minDist + n + 1, INF);
fill(inMST, inMST + n + 1, false);
priority_queue<pair<int, int>, vector<pair<int, int>>,
greater<pair<int, int>>> pq;
minDist[start] = 0;
pq.push({0, start});
int totalWeight = 0, cnt = 0;
while (!pq.empty() && cnt < n) {
auto [d, u] = pq.top(); pq.pop();
if (inMST[u]) continue;
inMST[u] = true;
totalWeight += d;
cnt++;
for (auto [v, w] : adj[u]) {
if (!inMST[v] && w < minDist[v]) {
minDist[v] = w;
parent[v] = u;
pq.push({w, v});
}
}
}
return totalWeight;
}正确性证明
两种算法的正确性都可以通过切分定理(Cut Property)证明:
切分定理:将图的顶点集任意分成两个非空集合 和 ,则所有连接这两个集合的边中,权值最小的边一定在某个最小生成树中。
Kruskal:每次选择的边都是某个切分的最小边(因为按权重排序)。
Prim:每次选择的节点对应的边,就是当前切分的最小边。
练习题推荐
| 题目 | 难度 | 说明 |
|---|---|---|
| 洛谷 P3366 最小生成树↗ | ⭐ | 模板题 |
| 洛谷 P1547 Out of Hay↗ | ⭐⭐ | 最小生成树的最大边 |
| 洛谷 P2872 Roads↗ | ⭐⭐ | 构图 + MST |
| 洛谷 P1991 无线通讯网↗ | ⭐⭐⭐ | 二分 + MST / 第 k 大边 |
| LeetCode 1584. 连接所有点的最小费用↗ | ⭐⭐ | 构图 + Prim |
小结
- Kruskal 从边出发,按权重排序后贪心选择,用并查集判环,适合稀疏图
- Prim 从点出发,逐步扩展生成树,类似 Dijkstra,适合稠密图
- 两种算法都基于贪心,正确性由切分定理保证
- 实际竞赛中,Kruskal 实现简单,是最常用的 MST 算法
总结
本文系统介绍了树论的核心内容:
| 专题 | 核心要点 |
|---|---|
| 树基础 | 无根树/有根树定义、术语、存储、DFS/BFS 遍历 |
| 树的直径 | 两次 DFS(简单但不支持负权)、树形 DP(通用) |
| 树的重心 | 删去后最大连通块 ,点分治的基础 |
| 最小生成树 | Kruskal(稀疏图)、Prim(稠密图) |
| 经典例题 | 消防(直径+尺取)、Digit Tree(点分治+数论) |
这些知识点环环相扣:树的遍历是基础算法,直径和重心是重要的树上结构性质,最小生成树是图论中的重要应用,而经典例题则展示了知识的综合运用。掌握这些内容,将为解决更复杂的树论问题打下坚实基础。
第五部分:经典例题详解
本部分精选两道经典竞赛题目,综合运用树论的核心知识,展示算法在实际问题中的应用。
例题一:P2491 [SDOI2011] 消防↗
题目背景
这道题的背景包装得很花哨,但剔除掉背景后,它是一个非常经典的图论问题。
等价题意
给定一棵包含 个节点、边带有非负权值的无向树,以及一个长度上限 。请在树上找出一条简单路径,满足以下条件:
约束条件:这条路径的总长度(即路径上所有边的权值之和)不能超过 。
优化目标:最小化树中所有节点到这条路径的距离的最大值。
补充说明:树上任意一个节点到某条路径的”距离”,定义为该节点到路径上所有节点最短距离的最小值。
问题本质
这道题在算法竞赛中通常被称为**「树的核」(Tree Core)问题**。
因为距离路径最远的节点一定在树的某个”边缘”上,所以直觉上(也有严谨证明),这条最优路径一定完全包含在树的直径(树上最长的一条简单路径)上。
因此,这类问题通常的解题方向是:
- 先求出树的直径
- 然后利用尺取法(双指针)或二分答案在直径上寻找最优的线段
算法思路
第一步:求树的直径
使用两次 DFS 方法求出直径的两个端点,并记录直径路径上所有节点的距离(从某个端点出发)。
第二步:尺取法在直径上找最优路径
设直径端点为 和 ,直径长度为 。我们需要在直径上找一段长度不超过 的路径,使得”偏心距”(所有节点到路径的最大距离)最小。
用双指针 和 在直径路径上移动(尺取法):
- 和 都从同一端点出发
- 先向前移动
- 跟随移动,保证 到 的距离不超过
对于每对 ,偏心距由两部分构成:
- 直径端点到路径的距离: 和
- 直径外分支的最大深度
第三步:处理直径外的分支
对于不在直径上的分支,需要从直径上的节点出发 DFS,计算每个分支的最大深度。
关键性质证明
为什么最优路径一定在直径上?
设最优路径为 ,不在直径上。考虑距离 最远的节点 。
由于 不在直径上,从 到 的路径必定在某点 处离开直径(或从未进入直径)。
设直径端点为 和 , 到 的路径经过 。那么 到 的距离或 到 的距离中,至少有一个大于 到 的距离。
这意味着,如果把 移到直径上某个包含 的位置,偏心距不会变大,反而可能变小。因此最优路径一定在直径上。
代码实现
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N=1e6+5;
ll n, s, tot, hd[N], cnt, dis[N], ans=9999999999, top, k, fa[N], line[N];
struct edge { ll t, nxt, va; } es[N<<2];
// 快读快写模板
template <typename T> void rd(T &x) {
ll fl=1; x=0; char c=getchar();
for(; !isdigit(c); c=getchar()) if(c=='-') fl=-fl;
for(; isdigit(c); c=getchar()) x=(x<<3)+(x<<1)+c-'0';
x*=fl;
}
void add(ll u, ll v, ll w) {
es[++tot] = (edge){v, hd[u], w}, hd[u] = tot;
}
// DFS求最远点,同时记录父亲
void dfs(ll x, ll ff) {
fa[x] = ff;
if(dis[x] > dis[k]) k = x; // 更新最远点
for(ll i = hd[x]; i; i = es[i].nxt) {
ll to = es[i].t;
if(to == ff || line[to]) continue; // 跳过父亲和已标记的直径节点
dis[to] = dis[x] + es[i].va;
dfs(to, x);
}
}
int main() {
rd(n); rd(s);
for(ll i=1, u, v, w; i<n; i++) {
rd(u); rd(v); rd(w);
add(u, v, w); add(v, u, w);
}
// 第一步:求直径
dis[1] = 1; dfs(1, 0); // 第一次DFS:从任意点找最远点k
dis[k] = 0; dfs(k, 0); // 第二次DFS:从k找最远点top
top = k; // top是直径的另一端点
// 第二步:尺取法在直径上找最优路径
for(ll i=top, j=top; i; i=fa[i]) {
// i从top向另一端移动,j跟随移动保证路径长度<=s
while(dis[j] - dis[i] > s) j = fa[j];
// 更新答案:偏心距 = max(左端距离, 右端距离)
ans = min(ans, max(dis[i], dis[top] - dis[j]));
}
// 第三步:标记直径上的节点
for(ll i=top; i; i=fa[i]) line[i] = 1;
// 第四步:计算直径外分支的最大深度
for(ll i=top; i; i=fa[i]) {
k = i; dis[k] = 0;
dfs(i, fa[i]); // 从直径节点出发DFS其分支
}
// 最终答案:取所有节点到路径的最大距离
for(ll i=1; i<=n; i++) ans = max(ans, dis[i]);
printf("%lld\n", ans);
return 0;
}算法分析
| 步骤 | 时间复杂度 | 说明 |
|---|---|---|
| 求直径 | 两次 DFS | |
| 尺取法 | 直径长度不超过 | |
| 计算分支深度 | 每个节点只访问一次 | |
| 总计 | 线性复杂度 |
这道题巧妙地结合了树的直径和双指针技巧,是树论综合应用的经典范例。
例题二:CF 715C Digit Tree↗
等价题意
求树上有多少个有序节点对 ,使得从 到 路径上的数字按顺序拼接成的十进制数能被 整除。
问题分析
看到”树上所有路径统计”以及 的数据范围,这道题通常是指向**点分治(Centroid Decomposition)**的经典模型。
结合”顺序拼接数字”和”与 10 互质”的性质,需要利用扩展欧几里得求逆元,将路径 的余数合并起来进行快速统计。
算法思路
核心思想:点分治 + 路径余数统计
对于每个分治中心 ,统计所有经过 的满足条件的路径对 。
路径余数的表示
设从 到 的路径上数字拼接成的数为 ,从 到 的路径上数字拼接成的数为 。
则 路径上的数为 。
我们需要统计满足 的对数。
两个方向的处理
- down 方向:从 向下走到节点 ,数字从左往右拼接
- up 方向:从节点 向上走到 ,数字从右往左拼接(需要乘 )
设 为从 到 的路径余数, 为从 到 的路径余数, 为路径高度(长度)。
合并条件
等价于:
这里需要用到模逆元,要求 与 互质(题目保证)。
统计方法
使用两个 map:
- :所有子树中 值的总统计
- :各子树内部的 值统计(避免重复计数)
对于每个节点 ,查询 中满足条件的数量,减去同一子树内的数量(避免路径起点终点在同一子树)。
代码实现
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int,int> ii;
typedef vector<int> vi;
const int N = 1e5 + 1;
int MOD, n;
vector<ii> adj[N];
int subsize[N];
bool visited[N];
int treesize;
vi clrlist;
ll up[N], down[N];
int h[N];
int PHI;
int dppart[N];
// 快速幂
ll modpow(ll a, ll b) {
ll r = 1;
while(b) {
if(b&1) r = (r*a) % MOD;
a = (a*a) % MOD;
b >>= 1;
}
return r;
}
ll mult(ll a, ll b) { return (a*b) % MOD; }
ll add(ll a, ll b) { return (a+b+MOD) % MOD; }
ll inv(ll a) { return modpow(a, PHI-1); } // 模逆元
// 欧拉函数(用于求逆元)
int phi(int n) {
ll num = 1, num2 = n;
for(ll i = 2; i*i <= n; i++) {
if(n % i == 0) {
num2 /= i;
num *= (i-1);
}
while(n % i == 0) n /= i;
}
if(n > 1) { num2 /= n; num *= (n-1); }
return num * num2;
}
// 计算子树大小
void dfs(int u, int par) {
if(par == -1) clrlist.clear();
subsize[u] = 1;
clrlist.pb(u);
for(int i = 0; i < adj[u].size(); i++) {
int v = adj[u][i].fi;
if(visited[v] || v == par) continue;
dfs(v, u);
subsize[u] += subsize[v];
}
if(par == -1) treesize = subsize[u];
}
// 找重心
int centroid(int u, int par) {
for(int i = 0; i < adj[u].size(); i++) {
int v = adj[u][i].fi;
if(visited[v] || v == par) continue;
if(subsize[v]*2 > treesize) return centroid(v, u);
}
return u;
}
// 计算各节点的up、down、h值
int parts = 0;
void fill(int u, int p, int cent) {
if(p == cent) {
dppart[u] = parts;
parts++;
} else if(p != -1) {
dppart[u] = dppart[p];
}
for(int i = 0; i < adj[u].size(); i++) {
int v = adj[u][i].fi, w = adj[u][i].se;
if(v == p || visited[v]) continue;
down[v] = add(mult(down[u], 10), w);
up[v] = add(up[u], mult(modpow(10, h[u]), w));
h[v] = h[u] + 1;
fill(v, u, cent);
}
}
// 统计经过cent的满足条件的路径数
ll solve(int cent) {
for(int u : clrlist) { up[u] = 0; down[u] = 0; h[u] = 0; }
parts = 0;
fill(cent, -1, cent);
parts--;
dppart[cent] = -1;
map<ll,ll> tot; // 所有up值的统计
vector<map<ll,ll>> vec(parts+1); // 各子树up值统计
tot[0]++;
for(int u : clrlist) {
if(u == cent) continue;
tot[up[u]]++;
vec[dppart[u]][up[u]]++;
}
ll ans = 0;
for(int u : clrlist) {
int ht = h[u], pt = dppart[u];
if(u == cent) {
ans += (tot[0] - 1); // cent作为起点
} else {
ll val = mult((-down[u]) % MOD + MOD, inv(modpow(10, ht)));
ans += (tot[val] - vec[pt][val]); // 减去同一子树内的
}
}
return ans;
}
// 点分治主过程
ll compsolve(int u) {
dfs(u, -1);
int cent = centroid(u, -1);
ll ans = solve(cent);
visited[cent] = true;
for(int i = 0; i < adj[cent].size(); i++) {
int v = adj[cent][i].fi;
if(!visited[v]) ans += compsolve(v);
}
return ans;
}
int main() {
ios_base::sync_with_stdio(0); cin.tie(0);
cin >> n >> MOD;
if(MOD == 1) {
cout << ll(n) * ll(n-1) << '\n';
return 0;
}
PHI = phi(MOD); // 欧拉函数值
for(int i = 0; i < n-1; i++) {
int u, v, w; cin >> u >> v >> w;
adj[u].pb(ii(v, w)); adj[v].pb(ii(u, w));
}
cout << compsolve(0) << '\n';
return 0;
}算法分析
| 组件 | 说明 |
|---|---|
| 点分治 | 保证递归深度 ,总复杂度 |
| 欧拉函数 | 用于计算模逆元(要求 与 互质) |
| up/down数组 | 分别表示向上和向下方向的路径余数 |
| map统计 | 快速查询满足合并条件的路径数 |
题目保证 与 互质,这样才能使用欧拉函数求逆元。如果 不与 互质,需要用其他方法处理。
关键技巧总结
- 路径方向的区分:数字拼接有方向性, 和 是不同的
- 模逆元的应用:将”乘以 “转化为”乘以逆元”
- 避免重复计数:统计时减去同一子树内的路径数
- 重心优化:点分治保证线性对数复杂度
小结
这两道例题展示了树论知识的综合应用:
| 题目 | 核心知识点 | 算法技巧 |
|---|---|---|
| P2491 消防 | 树的直径 | 尺取法、DFS分支处理 |
| CF 715C Digit Tree | 树的重心(点分治) | 模逆元、路径余数统计 |
学习建议:
- 先掌握基础算法(DFS、求直径、求重心)
- 理解点分治的核心思想:每次选重心,保证递归深度
- 多练习综合题目,体会知识点的融合应用
