~/posts/树基础(树的概念&直径&重心&最小生成树).md
树基础(树的概念&直径&重心&最小生成树)

树基础(树的概念&直径&重心&最小生成树)

周五 3月 27 2026
9733 字 · 50 分钟

树是图论中最基础也最重要的数据结构之一。它具有优美的性质和广泛的应用,从文件系统到决策树,从 HTML 文档到组织架构,树无处不在。本文将从基础概念出发,逐步深入探讨树的直径、重心以及最小生成树等重要专题。


第一部分:树基础

树的定义

无根树

无根树是指没有固定根节点的树。它有多种等价定义:

  1. nn 个节点和 n1n-1 条边的连通无向图
  2. 无环的连通无向图
  3. 任意两点间有且仅有一条简单路径的图
  4. 任意添加一条边都会形成环的图

这些定义从不同角度描述了树的本质特征:连通、无环、边数等于点数减一

有根树

有根树是在无根树的基础上,指定一个节点作为”根”所形成的树。根节点赋予了树明确的层级关系,形成了自上而下的结构。

交互演示 无根树 ⇄ 有根树

点击"选择根节点"后,再点击树上任意节点作为根

树的术语

通用术语

术语定义
森林每个连通分量都是树的图,即多棵树的集合
生成树包含图中所有顶点且连通的 n1n-1 条边构成的子图
叶节点在无根树中度数不超过 1 的节点;在有根树中没有子节点的节点
度数与节点相连的边数(无根树)或子节点数(有根树)
交互演示 通用术语可视化
点击上方按钮查看不同术语的图示说明

有根树专属术语

  • 父亲 (Parent):节点到根路径上的相邻上级节点
  • 子节点 (Child):节点的相邻下级节点
  • 兄弟 (Sibling):具有相同父亲的节点
  • 祖先 (Ancestor):从节点到根路径上的所有节点
  • 后代 (Descendant):以该节点为根的子树中的所有节点
  • 深度 (Depth):节点到根节点的路径边数
  • 高度 (Height):所有节点深度的最大值,也称为树的深度
  • 子树 (Subtree):删除与父亲相连的边后,该节点所在的子图
交互演示 树的术语可视化
点击上方按钮查看不同术语的图示说明

特殊的树

链 (Chain)

所有节点度数不超过 2 的树。链可以看作是一条线性路径,是最”瘦长”的树结构。

PLAINTEXT
1 --- 2 --- 3 --- 4 --- 5

菊花图 / 星星 (Star)

除了一个中心节点外,其余所有节点都只与该中心节点相连。这是最”扁平”的树结构。

PLAINTEXT
    2
    |
4 - 1 - 3
    |
    5

二叉树

每个节点最多只有两个子节点的有根树,分为左子节点和右子节点。

二叉树的分类:

类型定义
完整二叉树每个节点的子节点数要么是 0,要么是 2
完全二叉树除最后一层外全满,且最后一层节点靠左排列
完美二叉树所有叶节点深度相同,所有非叶节点都有两个子节点
交互演示 特殊树类型
链 (Chain):所有节点度数 ≤ 2,最"瘦长"的树结构

树的存储方式

只记录父节点

使用数组 parent[N] 记录每个节点的父节点。

CPP
int parent[N];
// parent[i] = j 表示节点 i 的父节点是 j

适用于需要自底向上递推的场景,但无法快速找到子节点。

邻接表

使用 std::vector 记录每个节点的所有相邻节点(无根树)或子节点(有根树)。

CPP
vector<int> adj[N];  // 无根树:存储所有相邻节点
vector<int> children[N];  // 有根树:只存储子节点

最常用的存储方式,支持高效的遍历操作。

左孩子右兄弟表示法

记录节点的第一个子节点和它的下一个兄弟节点,可以将任意多叉树转化为二叉树结构。

CPP
struct Node {
    int firstChild;   // 第一个子节点
    int nextSibling;  // 下一个兄弟节点
};

树的遍历

深度优先搜索 (DFS)

普通树 DFS

对于无向图形式的树,需要记录 from 参数避免重复访问:

CPP
void dfs(int u, int from) {
    for (int v : adj[u]) {
        if (v != from) {
            dfs(v, u);
        }
    }
}

二叉树的三种 DFS 遍历

遍历方式访问顺序
先序遍历 (Preorder)根 → 左 → 右
中序遍历 (Inorder)左 → 根 → 右
后序遍历 (Postorder)左 → 右 → 根

代码实现:

CPP
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)

利用队列实现层序遍历,严格按照从上到下、从左到右的层级访问节点。

CPP
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);
        }
    }
}
交互演示 树的遍历
遍历序列: -
未访问 正在访问 已访问

练习题推荐


第二部分:树的直径

树的直径(Tree Diameter)是树上任意两节点之间最长的简单路径。它是树论中最基础也最重要的问题之一,广泛应用于网络设计、路径规划等领域。

求解方法

求树的直径有两种时间复杂度为 O(n)O(n) 的经典方法:

方法一:两次 DFS

思路:从任意节点出发找到最远点 zz,再从 zz 出发找到最远点 zz',则 zzzz' 的路径即为直径。

原理证明

设树的直径端点为 uuvv,从任意节点 yy 出发找到的最远点为 zz。我们需要证明 zz 必须是 uuvv

采用反证法:假设 zz 既不是 uu 也不是 vv

yyzz 的路径离开直径的分叉点为 xx(即 xx 在直径上,从 xx 开始路径离开直径走向 zz)。分两种情况讨论:

情况一:yy 在直径上

设直径上各点的顺序为 uxyvu \to x \to y \to vxxyyuu 方向一侧)。

d(y,z)=d(y,x)+d(x,z)d(y,z) = d(y,x) + d(x,z)

由于 zz 是最远点,有 d(y,z)d(y,v)d(y,z) \ge d(y,v)。而 d(y,v)=d(y,x)+d(x,v)d(y,v) = d(y,x) + d(x,v),因此:

d(x,z)d(x,v)d(x,z) \ge d(x,v)

现在构造路径 uxzu \to x \to z,其长度为:

d(u,x)+d(x,z)d(u,x)+d(x,v)=d(u,v)d(u,x) + d(x,z) \ge d(u,x) + d(x,v) = d(u,v)

如果 d(x,z)>d(x,v)d(x,z) > d(x,v),则新路径长度严格大于直径,矛盾!

xxyyvv 方向一侧,类似可证 zz 必须是 uu

情况二:yy 不在直径上

yy 到直径的唯一连接点为 wwyyww 的距离为 cc。则:

d(y,u)=c+d(w,u),d(y,v)=c+d(w,v)d(y,u) = c + d(w,u), \quad d(y,v) = c + d(w,v)

假设 d(w,u)d(w,v)d(w,u) \le d(w,v),即 ww 更靠近 uu,则 vv 是从 yy 出发在直径方向上的最远点。

如果 zz 不是 vv,设 zzww 的路径在点 xx 处离开直径(xxwwzz 的路径上)。则:

d(y,z)=c+d(w,x)+d(x,z)d(y,z) = c + d(w,x) + d(x,z)

由于 zz 是最远点,d(y,z)d(y,v)=c+d(w,v)d(y,z) \ge d(y,v) = c + d(w,v)

xxwwvv 的方向上,则 d(w,v)=d(w,x)+d(x,v)d(w,v) = d(w,x) + d(x,v),从而:

d(x,z)d(x,v)d(x,z) \ge d(x,v)

构造路径 uwxzu \to w \to x \to z

d(u,w)+d(w,x)+d(x,z)d(u,w)+d(w,x)+d(x,v)=d(u,v)d(u,w) + d(w,x) + d(x,z) \ge d(u,w) + d(w,x) + d(x,v) = d(u,v)

d(x,z)>d(x,v)d(x,z) > d(x,v),路径长度严格大于直径,矛盾!

结论:从任意点出发,最远点一定是直径的某个端点。

交互演示 两次 DFS 求直径
1 从任意点出发 DFS
2 找到最远点 z
3 从 z 出发 DFS
4 找到最远点 z'
直径: -

方法二:树形 DP

思路:对每个节点,计算从它向下延伸的最长链 d1d_1 和次长链 d2d_2(两条链不能共享边),直径即为所有 d1+d2d_1 + d_2 的最大值。

正确性证明

我们分两步证明:max(d1[u]+d2[u])\max(d_1[u] + d_2[u]) 确实等于树的直径。

第一步:max(d1[u]+d2[u])\max(d_1[u] + d_2[u]) 是直径的一个候选

树的直径是一条路径,设这条路径为 p1p2pkp_1 \to p_2 \to \cdots \to p_k

由于树是无向图,我们可以选定任意节点作为根。以直径路径上深度最小的节点作为根(或者将树”拉直”,直径就是一条横向路径)。

对于直径路径上的每个节点 uu,直径从 uu 的两个不同子树方向延伸出去。设这两个方向分别为子节点 aa 和子节点 bb

uuaa 方向延伸的部分,就是 uu 向子树 aa 延伸的一条链。这条链的长度不超过 d1[u]d_1[u](因为 d1[u]d_1[u] 是从 uu 向下延伸的最长链)。

同理,向 bb 方向延伸的链长度不超过 uu 向子树 bb 延伸的最长链。由于 aabb 是不同的子节点,这条链与向 aa 的链不共享边,其长度不超过次长链 d2[u]d_2[u]

因此,直径长度 d1[u]+d2[u]\le d_1[u] + d_2[u],进而直径长度 maxu(d1[u]+d2[u])\le \max_u(d_1[u] + d_2[u])

第二步:max(d1[u]+d2[u])\max(d_1[u] + d_2[u]) 是一条可行路径

uu 是使 d1[u]+d2[u]d_1[u] + d_2[u] 最大的节点。d1[u]d_1[u] 是从 uu 向某个子节点 v1v_1 的子树延伸的最长链,d2[u]d_2[u] 是向另一个子节点 v2v_2 的子树延伸的次长链。

由于 v1v2v_1 \neq v_2,这两条链不共享边。将它们在 uu 处连接,形成一条经过 uu 的路径,长度恰为 d1[u]+d2[u]d_1[u] + d_2[u]

这条路径完全在树内,因此其长度不超过直径。

结论

由第一步,直径 max(d1[u]+d2[u])\le \max(d_1[u] + d_2[u])

由第二步,max(d1[u]+d2[u])\max(d_1[u] + d_2[u]) \le 直径。

因此两者相等,max(d1[u]+d2[u])\max(d_1[u] + d_2[u]) 即为树的直径。

状态转移d1[u]=max(d1[v]+w(u,v))对所有子节点 vd_1[u] = \max(d_1[v] + w(u,v)) \quad \text{对所有子节点 } v d2[u]=次大值d_2[u] = \text{次大值}

交互演示 树形 DP 求直径
diameter = max(d₁ + d₂) d₁=最长链, d₂=次长链
节点 d₁ d₂ d₁+d₂

方法对比

特性两次 DFS树形 DP
时间复杂度O(n)O(n)O(n)O(n)
空间复杂度O(n)O(n)O(n)O(n)
负权边❌ 不支持✅ 支持
记录路径✅ 简单⚠️ 稍复杂
实现难度简单中等

重要性质:中点重合

当树上所有边权均为正数时,一个优美的性质成立:

所有直径的中点必定重合。

证明(反证法): 设两条直径 uvu \to vuvu' \to v' 的中点分别为 mmmm',且 mmm \neq m'

由于两条直径长度相等,我们可以构造一条更长的路径:

  • mm 出发沿直径到 uu
  • 再从 uu 通过公共部分到 uu'
  • 最后从 uu' 沿另一条直径到 vv'

这样构造的路径长度必然大于原直径,矛盾!

交互演示 直径的中点重合(中点在边上)

当边权为正时,所有直径的中点必定重合。

两条直径 L→RU→D 的中点都在边 A-B 的正中间。

代码实现

两次 DFS(C++)

CPP
#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++)

CPP
#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. 树的直径⭐⭐无向树的直径

小结

  1. 两次 DFS 实现简单,方便记录路径,但不支持负权边
  2. 树形 DP 更通用,支持负权边,是竞赛中的首选方法
  3. 中点重合 是正权树的重要性质,可用于优化和证明

第三部分:树的重心

树的重心(Tree Centroid)是树论中的核心概念之一。删去重心后,树会被相对均匀地分割成若干部分,这一性质使其成为点分治(重心分解)算法的基础。

定义

如果在树中删去某个节点 vv 后,得到的每个连通分量的大小均不超过原树节点总数的一半,那么节点 vv 就是整棵树的重心。

重量:删去某节点后,得到的最大连通分量的大小称为该节点的重量。重心即重量不超过 n/2n/2 的节点。

交互演示 树的重心
当前选中: -
最大连通块: -
是否为重心: -

点击节点查看删除后的连通块大小

等价定义与性质

等价定义

重心有多种等价的描述方式:

等价定义说明
连通分量极值删去重心得到的最大连通分量是所有删点方案中最小的
距离和最小树中所有节点到重心的距离之和最小
有根树视角以重心为根时,任意真子树大小都不超过 n/2n/2
交互演示 距离和最小 = 重心
节点 距离和 状态

重心 = 距离和最小的节点

核心性质

性质 1:重心数量

一棵树最多有两个重心;如果有两个重心,它们必定相邻。

交互演示 双重心现象

一棵树最多有两个重心

如果有两个重心,它们必定相邻

橙色节点为重心

性质 2:动态稳定性

添加或删除一个叶子节点,新树的重心最多移动一条边的距离。

这一性质在动态维护重心时非常有用。

性质 3:合并性质

将两棵树通过一条边合并,新树的重心必在原来两棵重心之间的路径上。

重心的求法

方法:一次 DFS

通过一次 DFS 统计每个节点的子树大小,同时计算删去该节点后各连通块的最大值。

算法流程

  1. DFS 遍历,计算每个节点 uu 的子树大小 size[u]
  2. 对于节点 uu,删去它后的连通块有:
    • 各子树:size[v]vvuu 的子节点)
    • 向上的部分:n - size[u]
  3. 取这些值的最大值作为 uu 的重量
  4. 重量 n/2\le n/2 的节点即为重心
交互演示 DFS 求重心
size[u] = 1 + Σ size[v] up_size = n - size[u]
节点 size max_part 重心?

重心条件: max_part ≤ n/2

代码实现

C++ 模板

CPP
#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 计算所有节点的距离和,然后取最小值对应的节点作为重心。这种方法的优势是能同时得到所有节点作为根时的距离和信息。

交互演示 换根DP:两次DFS计算距离和
1 初始状态
2 DFS1: 计算size与dp[根]
3 DFS2: 换根转移
4 完成:找出重心

转移公式

dp[v] = dp[u] + n - 2*size[v]

从父亲 u 换根到子节点 v:

  • v 的子树内 (? 个节点):距离各 -1
  • v 的子树外 (? 个节点):距离各 +1

当前状态

等待开始...
节点 size dp值 说明
当前根 正在处理 已完成 重心

算法原理

换根 DP 的核心思想是利用父子关系,从已知的根节点信息推导所有节点的信息

dp[u]dp[u] 表示以 uu 为根时,所有节点到 uu 的距离之和。如果我们已经计算出了 dp[root]dp[root](某个根节点的距离和),如何高效地计算出其他节点的 dpdp 值?

关键观察:换根时的变化

考虑从父亲节点 uu 换根到子节点 vv 时,各节点距离的变化:

  • vv 的子树内节点(共 sizevsize_v 个):原本距离是到 uu 的,现在变成到 vv 的,距离各减少 1
  • vv 的子树外节点(共 nsizevn - size_v 个):原本距离是到 uu 的,现在需要经过 uu 再到 vv,距离各增加 1

因此得到转移公式:

dp[v]=dp[u]sizev+(nsizev)=dp[u]+n2sizevdp[v] = dp[u] - size_v + (n - size_v) = dp[u] + n - 2 \cdot size_v

两次 DFS 的具体过程

第一次 DFS(dfs1):以任意节点为根(如节点 1),自顶向下传递深度,自底向上累加 size 和 dp。

  • size[u]:以 uu 为根的子树大小
  • dep[u]uu 到根节点的距离(深度)
  • 初始时,dp[u]=dep[u]dp[u] = dep[u](每个节点对根的距离贡献初始为深度值)
  • 回溯时累加子节点信息:size[u]+=size[v]size[u] += size[v]dp[u]+=dp[v]dp[u] += dp[v]

这样 DFS1 结束后,dp[root]dp[root] 就是以 root 为根时的距离和。

第二次 DFS(dfs2):从根开始,利用转移公式依次计算每个子节点作为根时的 dp 值。

CPP
dp[v] = dp[u] + n - 2 * size[v];

DFS2 结束后,所有节点的 dpdp 值都已计算完毕,取最小值即为重心。

时间复杂度分析

操作复杂度
DFS1O(n)O(n)
DFS2O(n)O(n)
找最小值O(n)O(n)
总计O(n)O(n)

相比于暴力方法(每个节点做一次 BFS/DFS,O(n2)O(n^2)),换根 DP 将复杂度优化到了线性!

代码实现

CPP
#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;
}

经典应用

点分治(重心分解)

重心最重要的应用是点分治算法。每次选择子树的重心作为分治中心,可以保证:

  • 每层分治后子树大小不超过原来的一半
  • 递归深度为 O(logn)O(\log n)
  • 总时间复杂度 O(nlogn)O(n \log n)

练习题推荐

题目难度说明
洛谷 P1364 医院设置距离和最小应用
CF 715C Digit Tree⭐⭐⭐点分治经典题
洛谷 P2634 聪聪可可⭐⭐⭐点分治入门

小结

要点内容
定义删去后最大连通块 n/2\le n/2
等价定义距离和最小 / 子树大小都不超过 n/2n/2
数量最多 2 个,若有 2 个则相邻
求法一次 DFS,O(n)O(n)
应用点分治的核心基础

第四部分:最小生成树

最小生成树(Minimum Spanning Tree, MST)是图论中最经典的问题之一。给定一个带权无向连通图,最小生成树是一棵包含所有顶点、边权和最小的生成树。

核心定义

生成树:对于一个有 nn 个顶点的连通图,生成树是包含全部 nn 个顶点且恰好有 n1n-1 条边的连通子图。

最小生成树:所有生成树中,边权总和最小的那一棵。

求解算法

求最小生成树有两种经典算法,都基于贪心策略:

Kruskal 算法

思想:将所有边按边权从小到大排序,依次尝试加入。如果某条边的两个端点不在同一个连通块内(用并查集判断),则加入该边,直到加入了 n1n-1 条边。

算法步骤

  1. 将所有边按边权排序
  2. 初始化并查集,每个节点自成一个集合
  3. 按顺序遍历每条边 (u,v,w)(u, v, w)
    • 如果 find(u) != find(v),则加入该边,执行 union(u, v)
    • 直到选了 n1n-1 条边
交互演示 Kruskal 算法
边列表(按权重排序)
已选边数: 0 / 5
当前总权重: 0

点击"开始演示"查看 Kruskal 算法执行过程

时间复杂度O(mlogm)O(m \log m),主要来自排序。适合稀疏图(边数较少)。

Prim 算法

思想:从任意一个节点开始,每次从未加入树的节点中,选择一个与已加入节点集合距离最近的节点加入,直到所有节点都被加入。

算法步骤

  1. 选择一个起始节点加入集合 SS
  2. 维护每个节点到集合 SS 的最小距离
  3. 每次选择距离最小的未加入节点,加入 SS 并更新其他节点的距离
  4. 重复直到所有节点都加入
交互演示 Prim 算法
已加入集合 S
(空)
候选边
(空)
已加入节点: 0 / 6
当前总权重: 0

点击"开始演示"查看 Prim 算法执行过程

时间复杂度

  • 朴素实现:O(n2)O(n^2),适合稠密图
  • 堆优化:O(mlogn)O(m \log n)

算法对比

特性KruskalPrim
时间复杂度O(mlogm)O(m \log m)O(n2)O(n^2)O(mlogn)O(m \log n)
适用场景稀疏图稠密图
数据结构并查集优先队列
实现难度简单中等
思路从边出发从点出发

代码实现

Kruskal 算法(C++)

CPP
#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++)

CPP
#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 算法堆优化版本

CPP
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)证明:

切分定理:将图的顶点集任意分成两个非空集合 SSVSV-S,则所有连接这两个集合的边中,权值最小的边一定在某个最小生成树中。

Kruskal:每次选择的边都是某个切分的最小边(因为按权重排序)。

Prim:每次选择的节点对应的边,就是当前切分的最小边。

练习题推荐

题目难度说明
洛谷 P3366 最小生成树模板题
洛谷 P1547 Out of Hay⭐⭐最小生成树的最大边
洛谷 P2872 Roads⭐⭐构图 + MST
洛谷 P1991 无线通讯网⭐⭐⭐二分 + MST / 第 k 大边
LeetCode 1584. 连接所有点的最小费用⭐⭐构图 + Prim

小结

  1. Kruskal 从边出发,按权重排序后贪心选择,用并查集判环,适合稀疏图
  2. Prim 从点出发,逐步扩展生成树,类似 Dijkstra,适合稠密图
  3. 两种算法都基于贪心,正确性由切分定理保证
  4. 实际竞赛中,Kruskal 实现简单,是最常用的 MST 算法

总结

本文系统介绍了树论的核心内容:

专题核心要点
树基础无根树/有根树定义、术语、存储、DFS/BFS 遍历
树的直径两次 DFS(简单但不支持负权)、树形 DP(通用)
树的重心删去后最大连通块 n/2\le n/2,点分治的基础
最小生成树Kruskal(稀疏图)、Prim(稠密图)
经典例题消防(直径+尺取)、Digit Tree(点分治+数论)

这些知识点环环相扣:树的遍历是基础算法,直径和重心是重要的树上结构性质,最小生成树是图论中的重要应用,而经典例题则展示了知识的综合运用。掌握这些内容,将为解决更复杂的树论问题打下坚实基础。


第五部分:经典例题详解

本部分精选两道经典竞赛题目,综合运用树论的核心知识,展示算法在实际问题中的应用。

例题一:P2491 [SDOI2011] 消防

题目背景

这道题的背景包装得很花哨,但剔除掉背景后,它是一个非常经典的图论问题。

等价题意

给定一棵包含 nn 个节点、边带有非负权值的无向树,以及一个长度上限 ss。请在树上找出一条简单路径,满足以下条件:

约束条件:这条路径的总长度(即路径上所有边的权值之和)不能超过 ss

优化目标:最小化树中所有节点到这条路径的距离的最大值。

补充说明:树上任意一个节点到某条路径的”距离”,定义为该节点到路径上所有节点最短距离的最小值。

问题本质

这道题在算法竞赛中通常被称为**「树的核」(Tree Core)问题**。

因为距离路径最远的节点一定在树的某个”边缘”上,所以直觉上(也有严谨证明),这条最优路径一定完全包含在树的直径(树上最长的一条简单路径)上。

因此,这类问题通常的解题方向是:

  1. 先求出树的直径
  2. 然后利用尺取法(双指针)二分答案在直径上寻找最优的线段

算法思路

第一步:求树的直径

使用两次 DFS 方法求出直径的两个端点,并记录直径路径上所有节点的距离(从某个端点出发)。

第二步:尺取法在直径上找最优路径

设直径端点为 uuvv,直径长度为 DD。我们需要在直径上找一段长度不超过 ss 的路径,使得”偏心距”(所有节点到路径的最大距离)最小。

用双指针 iijj 在直径路径上移动(尺取法):

  • iijj 都从同一端点出发
  • ii 先向前移动
  • jj 跟随移动,保证 jjii 的距离不超过 ss

对于每对 (i,j)(i, j),偏心距由两部分构成:

  1. 直径端点到路径的距离:dis[i]dis[i]Ddis[j]D - dis[j]
  2. 直径外分支的最大深度

第三步:处理直径外的分支

对于不在直径上的分支,需要从直径上的节点出发 DFS,计算每个分支的最大深度。

关键性质证明

为什么最优路径一定在直径上?

设最优路径为 PP,不在直径上。考虑距离 PP 最远的节点 xx

由于 PP 不在直径上,从 PPxx 的路径必定在某点 yy 处离开直径(或从未进入直径)。

设直径端点为 uuvvuuvv 的路径经过 yy。那么 uuxx 的距离或 vvxx 的距离中,至少有一个大于 xxPP 的距离。

这意味着,如果把 PP 移到直径上某个包含 yy 的位置,偏心距不会变大,反而可能变小。因此最优路径一定在直径上。

代码实现

CPP
#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;
}

算法分析

步骤时间复杂度说明
求直径O(n)O(n)两次 DFS
尺取法O(n)O(n)直径长度不超过 nn
计算分支深度O(n)O(n)每个节点只访问一次
总计O(n)O(n)线性复杂度

例题二:CF 715C Digit Tree

等价题意

求树上有多少个有序节点对 (u,v)(u, v),使得从 uuvv 路径上的数字按顺序拼接成的十进制数能被 MM 整除。

问题分析

看到”树上所有路径统计”以及 n105n \le 10^5 的数据范围,这道题通常是指向**点分治(Centroid Decomposition)**的经典模型。

结合”顺序拼接数字”和”与 10 互质”的性质,需要利用扩展欧几里得求逆元,将路径 urootvu \to root \to v 的余数合并起来进行快速统计。

算法思路

核心思想:点分治 + 路径余数统计

对于每个分治中心 centcent,统计所有经过 centcent 的满足条件的路径对 (u,v)(u, v)

路径余数的表示

设从 uucentcent 的路径上数字拼接成的数为 XuX_u,从 centcentvv 的路径上数字拼接成的数为 YvY_v

ucentvu \to cent \to v 路径上的数为 Xu×10len(Yv)+YvX_u \times 10^{len(Y_v)} + Y_v

我们需要统计满足 Xu×10len+Yv0(modM)X_u \times 10^{len} + Y_v \equiv 0 \pmod M 的对数。

两个方向的处理

  • down 方向:从 centcent 向下走到节点 uu,数字从左往右拼接
  • up 方向:从节点 uu 向上走到 centcent,数字从右往左拼接(需要乘 10h10^{h}

down[u]down[u] 为从 centcentuu 的路径余数,up[u]up[u] 为从 uucentcent 的路径余数,h[u]h[u] 为路径高度(长度)。

合并条件

up[u]×10h[v]+down[v]0(modM)up[u] \times 10^{h[v]} + down[v] \equiv 0 \pmod M

等价于:up[u]down[v]×10h[v](modM)up[u] \equiv -down[v] \times 10^{-h[v]} \pmod M

这里需要用到模逆元,要求 MM1010 互质(题目保证)。

统计方法

使用两个 map:

  • tottot:所有子树中 upup 值的总统计
  • vec[part]vec[part]:各子树内部的 upup 值统计(避免重复计数)

对于每个节点 vv,查询 tottot 中满足条件的数量,减去同一子树内的数量(避免路径起点终点在同一子树)。

代码实现

CPP
#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;
}

算法分析

组件说明
点分治保证递归深度 O(logn)O(\log n),总复杂度 O(nlogn)O(n \log n)
欧拉函数用于计算模逆元(要求 MM1010 互质)
up/down数组分别表示向上和向下方向的路径余数
map统计快速查询满足合并条件的路径数

关键技巧总结

  1. 路径方向的区分:数字拼接有方向性,uvu \to vvuv \to u 是不同的
  2. 模逆元的应用:将”乘以 10len10^{len}“转化为”乘以逆元”
  3. 避免重复计数:统计时减去同一子树内的路径数
  4. 重心优化:点分治保证线性对数复杂度

小结

这两道例题展示了树论知识的综合应用:

题目核心知识点算法技巧
P2491 消防树的直径尺取法、DFS分支处理
CF 715C Digit Tree树的重心(点分治)模逆元、路径余数统计

学习建议:

  • 先掌握基础算法(DFS、求直径、求重心)
  • 理解点分治的核心思想:每次选重心,保证递归深度
  • 多练习综合题目,体会知识点的融合应用

Thanks for reading!

树基础(树的概念&直径&重心&最小生成树)

周五 3月 27 2026
9733 字 · 50 分钟