☰
有向图树判断:入度、并查集与DFS的边界陷阱
2026/10/14 22:49:40 网站建设 项目流程

1. 从一道题说起:判断“它是不是一棵树”到底在考什么

“Is It A Tree?”这个题目名字看着简单,但它是我见过的最容易让人掉以轻心的图论入门题之一。很多人第一反应是“判断有没有环不就行了”,然后写完交上去,WA(Wrong Answer)到怀疑人生。这道题的核心不是考你会不会写并查集,而是考你有没有把“树”的定义吃透,以及能不能把定义翻译成严密的判断逻辑。

先把结论摆在这里:一张有向图是树,当且仅当它满足三个条件——只有一个根节点(入度为0的点恰好一个)、除根以外所有节点入度恰好为1、整张图连通且无环。这三个条件缺一不可,而且它们之间不是完全独立的,有些条件可以互相推导,但为了代码的健壮性,我建议全部显式检查。

这道题适合谁看?如果你正在学并查集、图的遍历、或者准备算法面试和编程竞赛,这道题是一个非常好的“定义理解训练”。它不需要复杂的算法,但需要你对边界情况有极强的敏感度。我见过太多人并查集模板背得滚瓜烂熟,结果在这道题上因为空树、森林、自环、多父节点这些情况翻车。

接下来我会从题目本质、核心判断逻辑、并查集与DFS两种实现路线、边界情况处理、常见错误排查这几个维度,把这道题彻底拆开讲清楚。每一部分都会附带我实际踩过的坑和验证过的代码,你可以直接拿去对照自己的实现。

2. 题目本质拆解:树的定义与判断条件推导

2.1 树的有向图定义到底是什么

在无向图里,树的定义很直观:n个节点、n-1条边、连通、无环。但在有向图里,事情变得微妙起来。这道题通常给出的输入是一系列有向边(u, v),表示从u指向v,然后问你这些边构成的图是不是一棵以某个节点为根的树。

有向图意义下的树(也叫有根树或有向树)需要满足:

  • 恰好一个根节点:这个节点的入度为0,其他所有节点入度都为1。
  • 从根出发能到达所有节点:也就是忽略方向后图是连通的,且从根沿有向边可以走到每一个节点。
  • 没有环:有向图中如果存在环,那么环上的节点入度至少为1,且无法从根到达,必然违反树的性质。

这里有个关键点:入度为0的节点恰好一个这个条件非常强。它直接排除了森林(多个根)、排除了所有节点入度都大于0的情况(必有环)。但它不能单独保证连通性,比如两个不相连的组件各自有一个入度为0的节点,那入度为0的节点就有两个,还是会被排除。但如果一个组件有根、另一个组件是一个环呢?环上所有节点入度都为1,没有入度为0的节点,此时入度为0的节点仍然只有一个,但图不连通且有环。所以光看入度不够,还需要并查集或遍历来保证连通无环。

2.2 三个判断条件的优先级与依赖关系

我在实际写题时总结出一个判断顺序,可以让你少写很多冗余代码:

  1. 先统计入度:遍历所有边,记录每个节点的入度。如果发现某个节点入度大于1,直接判定不是树。因为树中除根以外每个节点只有一个父节点。
  2. 再找根节点:统计入度为0的节点个数。如果恰好为1,这个节点就是候选根;如果为0,说明有环或者空图(空图特殊处理);如果大于1,说明是森林,不是树。
  3. 最后判连通无环:用并查集合并所有边,如果合并时发现两个节点已经在同一集合,说明有环,不是树。合并完成后,检查所有出现过的节点是否都在同一个集合中,如果不是,说明不连通。

这个顺序的好处是:入度检查是O(E)且常数极小,能快速排除大量非法情况;并查集负责处理环和连通性,逻辑清晰。你也可以用DFS从根遍历,看能否访问所有节点且无后向边,但并查集写起来更短,更适合竞赛场景。

注意:空图(没有任何边、没有任何节点)在这道题里通常被认为是树。这一点非常反直觉,但很多OJ的题目描述里会明确说“空树也是树”。如果你不确定,去看题目原文的样例,通常会有0 0这样的输入表示结束,而单独的0 0有时就是空树测试用例。

2.3 为什么不能只用并查集判断环

很多人觉得并查集能判环就够了,但并查集判环只能保证“无环”,不能保证“只有一个根”和“连通”。举个例子:节点1-2-3形成一条链,节点4-5-6形成另一条链,两条链之间没有边。并查集合并时不会发现环,但显然这是森林不是树。再比如,节点1指向2,2指向3,3指向1,这是一个环,并查集合并到第三条边时会发现3和1已经在同一集合,判定有环。但如果只有环没有其他节点,入度为0的节点个数为0,也能排除。

所以并查集必须配合入度统计一起使用,两者是互补的。

3. 并查集实现路线:从模板到AC的完整细节

3.1 并查集模板的微调与路径压缩

标准的并查集模板大家都会写,但在这道题里有两个细节需要调整。第一,节点编号不一定是连续的,可能很大或者很稀疏,所以不能用固定大小的数组,需要用哈希表(字典)来存储父节点。第二,需要记录所有出现过的节点,方便最后检查连通性。

先看核心的并查集结构:

parent = {} def find(x): if x not in parent: parent[x] = x return x # 路径压缩 if parent[x] != x: parent[x] = find(parent[x]) return parent[x] def union(x, y): rx, ry = find(x), find(y) if rx == ry: return False # 已经在同一集合,说明有环 parent[rx] = ry return True

这段代码里,find函数在遇到未记录的节点时会自动初始化,这样就不需要提前知道节点范围。路径压缩用递归实现,简洁但要注意递归深度,如果节点数上万,Python的递归限制可能触发,可以改成迭代版本:

def find(x): if x not in parent: parent[x] = x return x root = x while parent[root] != root: root = parent[root] # 路径压缩 while parent[x] != root: parent[x], x = root, parent[x] return root

迭代版本更稳妥,推荐在竞赛中使用。

3.2 入度统计与根节点判定

入度统计用一个字典in_degree即可。遍历每条边(u, v)时,in_degree[v] = in_degree.get(v, 0) + 1。同时把u和v都加入节点集合nodes。

遍历完所有边后,统计入度为0的节点:

roots = [node for node in nodes if in_degree.get(node, 0) == 0] if len(roots) != 1: return False # 不是树(空图除外)

这里有个坑:如果图是空图(没有任何边),nodes是空集,roots也是空列表,len(roots) != 1会返回False。但空图应该是树,所以需要单独处理:

if not nodes: return True # 空树

另外,如果某个节点入度大于1,也要直接返回False。这个检查可以在遍历边的时候顺便做:

if in_degree.get(v, 0) > 1: return False

3.3 合并过程中的环检测与最终连通性验证

合并所有边的时候,如果union(u, v)返回False,说明u和v已经在同一集合,加上这条边就会形成环,直接判定不是树。

合并完成后,需要验证所有节点是否在同一个集合。由于并查集可能有多棵树(每个集合一个根),我们只需要检查所有节点的find结果是否都相同:

root_set = set(find(node) for node in nodes) if len(root_set) != 1: return False # 不连通

这里有个优化:其实在入度检查通过后,如果并查集合并没有发现环,且所有节点都在同一集合,那么一定是一棵树。因为入度条件已经保证了只有一个根,连通性保证了从根可达所有节点,无环保证了路径唯一。

完整的并查集解法代码:

def is_tree(edges): parent = {} in_degree = {} nodes = set() def find(x): if x not in parent: parent[x] = x return x root = x while parent[root] != root: root = parent[root] while parent[x] != root: parent[x], x = root, parent[x] return root def union(x, y): rx, ry = find(x), find(y) if rx == ry: return False parent[rx] = ry return True for u, v in edges: nodes.add(u) nodes.add(v) in_degree[v] = in_degree.get(v, 0) + 1 if in_degree[v] > 1: return False if not nodes: return True roots = [n for n in nodes if in_degree.get(n, 0) == 0] if len(roots) != 1: return False for u, v in edges: if not union(u, v): return False root_set = set(find(n) for n in nodes) return len(root_set) == 1

这段代码我实测过多个OJ的测试数据,包括空树、自环、多父节点、森林、单节点等边界情况,都能正确通过。

4. DFS/BFS遍历路线:另一种思路与对比

4.1 从根出发的遍历逻辑

除了并查集,另一种自然的思路是从根节点出发做一次DFS或BFS,看能否访问到所有节点,同时检测是否有环。具体步骤:

  1. 统计入度,找到唯一的根节点(入度为0)。
  2. 从根开始DFS,标记访问过的节点。
  3. 如果DFS过程中遇到已经访问过的节点(且不是父节点),说明有环。
  4. DFS结束后,检查访问节点数是否等于总节点数,不等则说明不连通。

这个思路的难点在于“遇到已访问节点”的判断。在有向图中,如果从根出发遍历,遇到已访问节点不一定意味着环,因为可能存在两条路径到达同一节点(菱形结构)。但在树中,每个节点入度为1,不可能有两条路径到达同一节点。所以如果入度检查已经通过(每个非根节点入度恰好为1),那么DFS中遇到已访问节点就一定是环。

DFS实现:

def is_tree_dfs(edges): from collections import defaultdict graph = defaultdict(list) in_degree = {} nodes = set() for u, v in edges: graph[u].append(v) nodes.add(u) nodes.add(v) in_degree[v] = in_degree.get(v, 0) + 1 if in_degree[v] > 1: return False if not nodes: return True roots = [n for n in nodes if in_degree.get(n, 0) == 0] if len(roots) != 1: return False visited = set() stack = [roots[0]] while stack: node = stack.pop() if node in visited: return False # 有环 visited.add(node) for neighbor in graph[node]: stack.append(neighbor) return len(visited) == len(nodes)

注意这里用栈模拟DFS,每次弹出节点时检查是否已访问。如果已访问,说明有环。但这里有个细节:如果图中存在自环(节点指向自己),入度检查时该节点入度为1(自己指向自己),根节点可能是其他节点,DFS时从根出发可能根本到不了这个自环节点,最后连通性检查会失败。但如果自环节点就是根呢?根节点入度为0,自环会让它入度变成1,所以根节点不可能是自环节点。因此自环情况会被入度检查或连通性检查排除。

4.2 两种方法的性能与适用场景对比

对比维度并查集DFS/BFS
时间复杂度O(E α(N)),近似线性O(V+E)
空间复杂度O(V)O(V+E) 需要存图
代码长度中等中等
环检测合并时自然检测需要额外visited判断
连通性检测最后检查集合数遍历后检查访问数
适用场景边动态加入、在线判断图结构固定、需要遍历顺序
边界处理空图需特判空图需特判

从竞赛角度,并查集更通用,因为很多图论题都是动态加边。但这道题边是一次性给出的,两种方法都可以。我个人偏好并查集,因为不需要建图,内存占用更小,而且逻辑更集中。

实操心得:如果题目输入规模很大(比如10万条边),DFS递归版本可能爆栈,建议用迭代版或并查集。并查集的路径压缩在Python里用迭代写,性能很稳。

5. 边界情况全解析:那些让你WA到崩溃的坑

5.1 空树、单节点树与自环

空树:输入没有任何边,节点集合为空。很多人的代码会返回False,因为找不到根节点。但题目通常认为空树是树。处理方式就是在最前面加if not nodes: return True。

单节点树:输入只有1 1这样的自环?不对,单节点树应该是没有边的,但节点存在。如果输入是1 1,表示节点1指向自己,这是一个自环,不是树。如果输入是空边但题目给了节点?通常不会,节点都是从边里出现的。所以单节点树在输入里表现为空边,和空树一样处理。但如果题目允许孤立节点出现(比如先给节点数再给边),那需要单独判断。

自环:节点指向自己。入度检查时该节点入度为1,如果它是唯一入度为0的节点?不可能,因为它入度为1。所以自环会导致没有入度为0的节点(如果只有自环)或者根节点是其他节点但自环节点无法从根到达。两种情况都会被排除。

5.2 森林、多父节点与环的混合情况

森林:多个入度为0的节点。入度检查直接排除。

多父节点:某个节点入度大于1。入度检查直接排除。

环+树:一个连通分量是树,另一个是环。入度为0的节点只有树根一个,但并查集合并时环会被检测到,或者最后连通性检查发现环上的节点不在树的集合里。

菱形结构:节点1指向2和3,2和3都指向4。节点4入度为2,入度检查排除。

长链+自环:1->2->3->4,4->4。节点4入度为2(来自3和4),入度检查排除。

我整理了一个边界情况速查表:

输入情况入度检查并查集环检测连通性检查最终判定
空图无节点无无树(特判)
单条边 1->2根=1无环连通树
自环 1->1无根--非树
森林 1->2, 3->4根=1,3无环不连通非树
多父 1->3, 2->3根=1,2无环不连通非树
环 1->2, 2->3, 3->1无根有环-非树
树+环 1->2, 3->4, 4->3根=1有环不连通非树

5.3 输入格式的陷阱与处理技巧

这道题在不同OJ上的输入格式可能不同。常见的有两种:

第一种:每行两个整数u v,表示一条边,以0 0结束一组测试,以-1 -1结束所有输入。这种格式下,0 0本身可能表示空树,也可能只是组间分隔符,需要看题目具体说明。

第二种:先给节点数和边数,然后给边。这种更简单,但要注意节点编号可能从0或1开始。

处理多组测试时,记得每组之间重置并查集和入度字典。我见过有人忘记重置,导致上一组的数据污染下一组,结果莫名其妙WA。

import sys def solve(): case_num = 1 edges = [] for line in sys.stdin: line = line.strip() if not line: continue u, v = map(int, line.split()) if u == -1 and v == -1: break if u == 0 and v == 0: # 处理当前组 result = is_tree(edges) print(f"Case {case_num} is {'a tree' if result else 'not a tree'}.") case_num += 1 edges = [] else: edges.append((u, v))

这段代码处理了多组输入,注意0 0触发一组判断,-1 -1结束。如果题目要求空树也输出,那么当edges为空时is_tree返回True。

6. 常见问题与排查技巧实录

6.1 为什么我的并查集判环了还是WA

最常见的原因有三个。第一,忘记检查入度大于1的情况。并查集只能判环,不能判多父节点。比如1->3, 2->3,并查集合并时不会发现环,但节点3有两个父节点,不是树。第二,忘记检查连通性。并查集合并完所有边后,如果有多个集合,说明不连通。第三,空树没特判,返回了False。

排查方法:拿几个典型用例手动跑一遍。比如1 2 2 3 3 1 0 0应该输出not a tree;1 2 2 3 0 0应该输出a tree;0 0应该输出a tree(如果题目支持空树)。

6.2 入度统计时节点编号不连续怎么办

用字典而不是数组。字典的get方法可以处理不存在的键,非常方便。不要试图用max_node来开数组,因为节点编号可能很大(比如10^9),开数组会爆内存。

6.3 多组测试数据之间的状态重置

这是最容易忽略的坑。每组测试之间,parent、in_degree、nodes都要清空。建议把is_tree写成一个函数,每次调用时内部创建新的字典,这样天然隔离。不要用全局变量,否则重置起来容易漏。

6.4 递归DFS爆栈的解决方案

Python默认递归深度是1000,如果树是一条长链,1000个节点就会爆栈。解决方案有两个:一是用sys.setrecursionlimit(100000)提高限制,但治标不治本;二是改用迭代DFS,用栈模拟。我推荐迭代版,稳定可靠。

6.5 常见错误速查表

错误现象可能原因解决方法
空树WA没特判空图加if not nodes: return True
森林WA没检查根节点数量统计入度为0的节点数
多父WA没检查入度大于1遍历边时检查in_degree[v] > 1
环WA并查集没判环union返回False时直接判非树
不连通WA没检查集合数最后检查len(root_set) == 1
多组WA状态没重置每组新建字典或清空
爆栈RE递归太深改迭代或提高递归限制
超时TLE并查集没路径压缩加路径压缩

独家避坑技巧:在本地测试时,构造一个包含所有边界情况的测试文件,包括空树、单节点、自环、森林、多父、环、树+环、长链。每次修改代码后跑一遍,确保没有回归。这个习惯帮我省了大量提交次数。

7. 从这道题延伸出的图论判断思维

这道题表面考的是“是不是树”,实际上训练的是把自然语言定义翻译成代码逻辑的能力。树有三个等价定义:无环连通图、n-1条边的连通图、每个非根节点入度为1且根唯一。不同定义对应不同的判断策略,选择哪个定义决定了你的代码复杂度和边界处理难度。

我个人的经验是:优先选择能用最少状态判断的定义。比如入度条件只需要一个字典,并查集只需要一个父指针字典,两者结合就能覆盖所有情况。如果用DFS,还需要建图、visited集合、递归栈,状态更多,出错概率更大。

另外,这道题也提醒我,边界情况不是“特殊情况”,而是定义的一部分。空树是不是树?这取决于定义。如果定义说“树是连通无环图”,空图不连通,不是树。但如果定义说“树是n个节点n-1条边的连通图”,n=0时0个节点-1条边?不成立。所以空树是不是树,完全看题目怎么定义。做题前一定要读清楚题目对树的定义,不要凭直觉。

最后分享一个我常用的调试方法:对于图论题,写一个暴力验证函数,用邻接矩阵或边列表直接模拟定义,然后和你的优化算法对拍。随机生成小规模图(节点数5以内,边数10以内),跑1000组,看结果是否一致。这个方法能发现90%以上的逻辑错误。

import random def brute_force(edges): # 暴力判断:检查入度、连通性、环 if not edges: return True nodes = set() in_deg = {} for u, v in edges: nodes.add(u) nodes.add(v) in_deg[v] = in_deg.get(v, 0) + 1 # 入度检查 for n in nodes: if in_deg.get(n, 0) > 1: return False roots = [n for n in nodes if in_deg.get(n, 0) == 0] if len(roots) != 1: return False # 从根DFS检查连通和环 adj = {n: [] for n in nodes} for u, v in edges: adj[u].append(v) visited = set() stack = [roots[0]] while stack: node = stack.pop() if node in visited: return False visited.add(node) for nb in adj[node]: stack.append(nb) return len(visited) == len(nodes) # 随机对拍 for _ in range(1000): n = random.randint(0, 5) m = random.randint(0, 8) edges = [(random.randint(1, 5), random.randint(1, 5)) for _ in range(m)] r1 = is_tree(edges) r2 = brute_force(edges) if r1 != r2: print("Mismatch:", edges, r1, r2) break

这个对拍框架我用了很多次,每次都能快速定位逻辑漏洞。如果你在准备算法竞赛,强烈建议养成对拍的习惯。

这道题虽然叫“Is It A Tree?”,但它真正问的是“你能不能把树的定义完整地、无遗漏地翻译成代码”。把这道题吃透,以后遇到“判断是不是森林”“判断是不是DAG”“判断是不是二分图”这类题,你都会有一个清晰的判断框架:先明确定义,再找最小判断条件集,最后处理边界。这个思维模式比任何模板都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询