Article / 文章
LeetCode 第261题:以图判树
给定从 0 到 n-1 标号的 n 个结点,和一个无向边列表(每条边以结点对来表示),请编写一个函数用来判断这些边是否能够形成一个合法有效的树结构。
📖 文章摘要
本文详细解析LeetCode第261题“以图判树”,这是一道图论问题。文章提供了从DFS到并查集的多种解法,包含C#、Python、C++三种语言实现,配有详细的算法步骤图解和性能分析。适合想要深入理解图论算法和树的性质的算法学习者。
核心知识点: 图论、DFS、BFS、并查集、树的性质
难度等级: 中等
推荐人群: 图论学习者、算法面试准备者
题目描述
给定从 0 到 n-1 标号的 n 个结点,和一个无向边列表(每条边以结点对来表示),请编写一个函数用来判断这些边是否能够形成一个合法有效的树结构。
示例
示例 1:
输入: n = 5, edges = [[0,1], [0,2], [0,3], [1,4]]
输出: true
示例 2:
输入: n = 5, edges = [[0,1], [1,2], [2,3], [1,3], [1,4]]
输出: false
提示
1 <= n <= 20000 <= edges.length <= 5000edges[i].length == 20 <= ai, bi < nai != bi- 不存在自环或重复的边
解题思路
判断一个图是否是一棵有效的树需要满足两个条件:
- 图是连通的:即从任一节点出发可以到达所有其他节点
- 图中没有环:树的定义就是无环连通图
有一个重要的数学性质可以帮助我们:对于有 n 个节点的树,恰好有 n-1 条边。因此,如果边的数量不等于节点数减一,那么一定不是树。
方法一:深度优先搜索(DFS)
我们可以使用DFS来检查图是否连通且无环:
- 首先判断边的数量是否等于节点数减一,不等于则直接返回false
- 构建邻接表表示图
- 从节点0开始DFS遍历图
- 在DFS过程中,使用visited数组标记已访问的节点,并记录父节点以避免误判“返回父节点”为环
- 如果在DFS过程中发现已访问过的非父节点,则说明存在环,返回false
- 最后检查是否所有节点都被访问到,若未全部访问则说明图不连通,返回false
复杂度分析:
- 时间复杂度:O(n + e),其中n是节点数,e是边数
- 空间复杂度:O(n + e),主要用于存储图和visited数组
方法二:广度优先搜索(BFS)
BFS的思路与DFS类似,也是检查图是否连通且无环:
- 首先判断边的数量是否等于节点数减一
- 构建邻接表表示图
- 使用队列从节点0开始BFS遍历图
- 使用parent数组记录每个节点的父节点,以避免误判“返回父节点”为环
- 如果在BFS过程中发现已访问过的非父节点,则说明存在环
- 最后检查是否所有节点都被访问到
复杂度分析:
- 时间复杂度:O(n + e)
- 空间复杂度:O(n + e)
方法三:并查集(Union Find)
并查集是解决此类问题的另一个强大工具:
- 首先判断边的数量是否等于节点数减一
- 初始化并查集,每个节点自成一组
- 遍历所有边,对每条边的两个节点进行合并操作
- 如果在合并前两个节点已经在同一组中,说明添加这条边会形成环,返回false
- 最后检查是否所有节点都在同一个集合中,若不是则图不连通,返回false
复杂度分析:
- 时间复杂度:O(n + e * α(n)),其中α(n)是阿克曼函数的反函数,近似于常数
- 空间复杂度:O(n)
图解思路
算法步骤分析表
| 步骤 | 操作 | 状态 | 说明 |
|---|---|---|---|
| 初始状态 | n=5, edges=[[0,1],[0,2],[0,3],[1,4]] | 待验证图 | 需要判断是否为树 |
| 第一步 | 检查边数:4 == 5-1 | 边数检查通过 | 满足树的必要条件 |
| 第二步 | 构建邻接表 | 图结构建立 | 0->[1,2,3], 1->[0,4], 2->[0], 3->[0], 4->[1] |
| 第三步 | DFS遍历检查环 | 无环检测 | 从节点0开始,记录父节点避免回退 |
| 第四步 | 检查连通性 | 全部访问 | 所有节点都被访问到,图连通 |
| 结果 | 返回true | 验证成功 | 该图是一棵有效的树 |
并查集操作过程表
| 边 | 节点1的根 | 节点2的根 | 操作结果 | 集合状态 |
|---|---|---|---|---|
| 初始状态 | - | - | - | {0},{1},{2},{3},{4} |
| [0,1] | 0 | 1 | 合并成功 | {0,1},{2},{3},{4} |
| [0,2] | 0 | 2 | 合并成功 | {0,1,2},{3},{4} |
| [0,3] | 0 | 3 | 合并成功 | {0,1,2,3},{4} |
| [1,4] | 0 | 4 | 合并成功 | {0,1,2,3,4} |
代码实现
C# 实现
public class Solution {
public bool ValidTree(int n, int[][] edges) {
// 检查边的数量是否等于节点数减一
if (edges.Length != n - 1) {
return false;
}
// 构建邻接表
List<int>[] graph = new List<int>[n];
for (int i = 0; i < n; i++) {
graph[i] = new List<int>();
}
foreach (int[] edge in edges) {
graph[edge[0]].Add(edge[1]);
graph[edge[1]].Add(edge[0]);
}
bool[] visited = new bool[n];
// DFS检查是否有环
if (!DFS(graph, 0, -1, visited)) {
return false;
}
// 检查是否所有节点都被访问到(连通性检查)
for (int i = 0; i < n; i++) {
if (!visited[i]) {
return false;
}
}
return true;
}
private bool DFS(List<int>[] graph, int node, int parent, bool[] visited) {
visited[node] = true;
foreach (int neighbor in graph[node]) {
// 跳过父节点,避免误判返回路径为环
if (neighbor == parent) {
continue;
}
// 如果邻居已被访问,说明存在环
if (visited[neighbor] || !DFS(graph, neighbor, node, visited)) {
return false;
}
}
return true;
}
}
Python 实现
class Solution:
def validTree(self, n: int, edges: List[List[int]]) -> bool:
# 检查边的数量是否等于节点数减一
if len(edges) != n - 1:
return False
# 构建邻接表
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = [False] * n
def dfs(node, parent):
visited[node] = True
for neighbor in graph[node]:
# 跳过父节点,避免误判返回路径为环
if neighbor == parent:
continue
# 如果邻居已被访问,说明存在环
if visited[neighbor] or not dfs(neighbor, node):
return False
return True
# DFS检查是否有环
if not dfs(0, -1):
return False
# 检查是否所有节点都被访问到(连通性检查)
return all(visited)
C++ 实现
class Solution {
public:
bool validTree(int n, vector<vector<int>>& edges) {
// 检查边的数量是否等于节点数减一
if (edges.size() != n - 1) {
return false;
}
// 构建邻接表
vector<vector<int>> graph(n);
for (const auto& edge : edges) {
graph[edge[0]].push_back(edge[1]);
graph[edge[1]].push_back(edge[0]);
}
vector<bool> visited(n, false);
// DFS检查是否有环
if (!dfs(graph, 0, -1, visited)) {
return false;
}
// 检查是否所有节点都被访问到(连通性检查)
for (int i = 0; i < n; i++) {
if (!visited[i]) {
return false;
}
}
return true;
}
private:
bool dfs(const vector<vector<int>>& graph, int node, int parent, vector<bool>& visited) {
visited[node] = true;
for (int neighbor : graph[node]) {
// 跳过父节点,避免误判返回路径为环
if (neighbor == parent) {
continue;
}
// 如果邻居已被访问,说明存在环
if (visited[neighbor] || !dfs(graph, neighbor, node, visited)) {
return false;
}
}
return true;
}
};
执行结果
C# 实现
- 执行用时:160 ms
- 内存消耗:44.3 MB
Python 实现
- 执行用时:92 ms
- 内存消耗:17.2 MB
C++ 实现
- 执行用时:12 ms
- 内存消耗:12.1 MB
性能对比
| 语言 | 执行用时 | 内存消耗 | 特点 |
|---|---|---|---|
| C# | 160 ms | 44.3 MB | 代码结构清晰,但性能相对较差 |
| Python | 92 ms | 17.2 MB | 代码简洁,性能适中 |
| C++ | 12 ms | 12.1 MB | 性能最佳,内存占用较小 |
代码亮点
- 🎯 使用边数与节点数的关系(e = n - 1)进行快速预判,提升效率
- 💡 DFS方法中使用parent参数避免将返回父节点误判为环,保证算法正确性
- 🔍 分别检查环的存在性和图的连通性,确保满足树的两个基本条件
- 🎨 合理使用邻接表表示图,优化存储和访问效率
常见错误分析
- 🚫 未检查边数与节点数的关系,导致不必要的复杂计算
- 🚫 在DFS中未传递parent参数,将返回父节点误判为环
- 🚫 忘记检查图是否完全连通(是否所有节点都被访问到)
- 🚫 混淆有向图和无向图的处理方式,导致逻辑错误
解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| DFS | O(n + e) | O(n + e) | 直观易懂,实现简单 | 递归可能导致栈溢出 |
| BFS | O(n + e) | O(n + e) | 避免递归栈溢出问题 | 实现略复杂,需要使用队列 |
| 并查集 | O(n + e * α(n)) | O(n) | 性能最佳,适合大规模图 | 实现相对复杂,需要理解并查集原理 |
相关题目
- LeetCode 323. 无向图中连通分量的数目 - 中等
- LeetCode 547. 省份数量 - 中等
- LeetCode 684. 冗余连接 - 中等
- LeetCode 1319. 连通网络的操作次数 - 中等
📖 系列导航
🔥 算法专题合集 - 查看完整合集
📢 关注合集更新:点击上方合集链接,关注获取最新题解!目前已更新第261题。
💬 互动交流
感谢大家耐心阅读到这里!希望这篇题解能够帮助你更好地理解和掌握这道算法题。
如果这篇文章对你有帮助,请:
- 👍 点个赞,让更多人看到这篇文章
- 📁 收藏文章,方便后续查阅复习
- 🔔 关注作者,获取更多高质量算法题解
- 💭 评论区留言,分享你的解题思路或提出疑问
你的支持是我持续分享的动力!
💡 一起进步:算法学习路上不孤单,欢迎一起交流学习!