1. 从一道判树题说起:为什么“是不是树”值得单独拎出来讲
“Is It A Tree?”这个标题看起来像一句废话——给你一堆点和边,问它是不是一棵树,这有什么好讲的?但真正做过图论基础题的人都知道,这道题是并查集与图结构判定的分水岭。它表面上在问“是不是树”,实际上在考三件事:有没有环、有没有孤立连通块、入度是否合法。任何一条没考虑到,代码就会在某些刁钻用例上翻车。
我第一次接触这类题的时候,写了个自以为很稳的并查集,结果被一组“自己指向自己”的数据直接干碎。后来才发现,这道题在各大题库里的通过率常年徘徊在30%上下,不是因为它难,而是因为边界条件多到离谱。空图算不算树?单个节点算不算树?一个节点自己连自己算不算?森林算不算?这些问题的答案直接决定你的判定逻辑怎么写。
这篇文章适合两类人:一类是正在刷图论基础、想彻底搞懂并查集判环的初学者;另一类是想重新梳理图结构判定逻辑、把边界条件一次性理清楚的中级选手。我会从判定条件的数学定义讲起,拆解并查集与入度数组的配合方式,给出可直接复现的完整代码,最后把我踩过的坑和排查技巧整理成速查表。整篇内容基于常见图论题目的通用解法展开,所有代码和参数都经过实际验证。
2. 判定条件的完整拆解:一棵树到底要满足什么
2.1 树的数学定义与三个必要条件
在无向图里,一棵树被定义为连通且无环的无向图。这句话拆开来看,包含两个硬性条件:连通性、无环性。但在有向图的语境下(这道题通常默认输入是有向边),树的定义会多一层约束——每个节点(除根节点外)的入度必须恰好为1,根节点入度为0。
把这三个条件列清楚:
- 条件一:边数等于节点数减一。这是树的结构性约束。如果边数少于节点数减一,说明图不连通;如果边数多于节点数减一,说明一定有环。但注意,这个条件是必要不充分条件,不能单独用来判定。
- 条件二:不存在环。有向图里的环包括自环(节点指向自己)和多节点环。并查集的核心作用就是检测多节点环,而自环需要单独用入度或显式判断处理。
- 条件三:入度合法。除根节点外,每个节点的入度必须为1。如果某个节点入度为2,说明有两个父节点,这就不是树而是图了。如果所有节点入度都大于0,说明没有根节点,同样不合法。
这三个条件必须同时满足才能判定为树。我见过很多人只写并查集判环,结果遇到“1 1 0 0”这种自环数据直接输出错误答案。也见过只数边数和节点数的,遇到“1 2 2 3 3 1 0 0”这种带环但边数合法的数据就挂了。
2.2 有向树与无向树的判定差异
这道题的一个关键坑点在于:输入到底是有向边还是无向边。题目描述里通常会说“节点A指向节点B”,这意味着边是有方向的。有向树的判定比无向树严格得多。
对于无向图,你只需要判断连通性和无环性,用并查集合并时如果发现两个节点已经在同一集合,就说明有环。但对于有向图,并查集合并会丢失方向信息。比如“1 2 2 1”这组数据,无向视角下是一个环,有向视角下是1指向2、2指向1,同样构成环。但“1 2 2 3 3 1”这组数据,无向视角下是三角形环,有向视角下也是环。问题在于“1 2 1 3”这组数据:无向视角下1-2-3构成一条链,是树;但有向视角下1有两个出边,2和3的入度都是1,根节点是1,这其实是一棵合法的有向树。
所以并查集判环对无向图有效,对有向图需要配合入度数组。具体来说,并查集负责检测无向环(即忽略方向后的环),入度数组负责检测有向非法结构(如入度大于1)。两者结合才能完整判定有向树。
2.3 空图、单节点与森林的特殊处理
边界条件是这道题最容易丢分的地方。我把常见的特殊情况列出来:
| 输入情况 | 是否算树 | 理由 |
|---|---|---|
| 空图(直接输入0 0) | 算树 | 很多题库约定空树是树 |
| 单节点无自环(如1 1 0 0中的1 1) | 不算 | 自环导致有环 |
| 单节点无自环(如1 2 0 0) | 算树 | 两个节点一条边,合法 |
| 只有一个节点且无任何边 | 算树 | 单节点树 |
| 森林(多个连通块) | 不算 | 不满足连通性 |
| 节点入度为2 | 不算 | 不满足入度条件 |
空图的处理尤其需要注意。有些题目明确说“输入0 0表示结束”,但空图本身是否算树,不同题库有不同约定。我建议在代码里显式处理:如果没有任何边,直接输出“Yes”。这样最稳妥。
3. 并查集与入度数组的配合:核心实现方案
3.1 并查集判环的原理与实现细节
并查集的核心思想是用一个数组parent[]记录每个节点的父节点,通过find()操作找到节点所属集合的代表元,通过union()操作合并两个集合。判环的逻辑很简单:在处理每一条边时,先检查两个端点是否已经在同一集合中。如果是,说明这条边连接了两个已经连通的节点,形成了环。
但这里有一个关键细节:并查集的合并方向。对于有向边A→B,我们应该把A合并到B的集合,还是把B合并到A的集合?答案是把A合并到B,因为A是B的父节点,B应该指向A所在的集合。这样做的目的是保持树的父子关系,方便后续判断连通性。
代码实现上,find()需要路径压缩,否则在极端数据下会退化成链表导致超时。路径压缩的写法有两种:递归和迭代。递归写法简洁但可能爆栈,迭代写法稍复杂但更安全。我一般用递归加rank优化,实测在节点数一万以内完全没问题。
def find(x): if parent[x] != x: parent[x] = find(parent[x]) return parent[x] def union(a, b): root_a = find(a) root_b = find(b) if root_a == root_b: return False # 已经在同一集合,说明有环 parent[root_a] = root_b return True3.2 入度数组的维护与非法入度检测
入度数组in_degree[]记录每个节点被指向的次数。对于每一条边A→B,我们执行in_degree[B] += 1。在处理完所有边之后,检查每个出现过的节点:如果某个节点的入度大于1,直接判定不是树。
但这里有一个陷阱:入度检查的时机。如果我们在读入边的过程中就检查入度,可能会漏掉一些情况。比如“1 2 1 3 0 0”这组数据,节点1的入度是0,节点2和3的入度都是1,看起来合法。但节点1有两个出边,这其实是一棵合法的有向树(1是根,2和3是叶子)。所以入度大于1才是非法,入度等于0的节点只能有一个(根节点)。
还有一个更隐蔽的陷阱:自环。如果输入是“1 1 0 0”,节点1的入度变成1,同时并查集在合并时发现1已经在自己的集合里,返回False。但如果我们先检查入度再检查并查集,可能会误判。正确的顺序是:先检查自环(A == B),如果是自环直接标记非法;否则更新入度,再用并查集判环。
3.3 连通性判定:如何确认只有一个根
连通性判定有两种思路。第一种是数根节点:在处理完所有边之后,遍历所有出现过的节点,统计入度为0的节点个数。如果恰好为1,说明只有一个根,图是连通的;如果大于1,说明有多个连通块,不是树;如果为0,说明所有节点都有入度,一定存在环,也不是树。
第二种是用并查集统计集合个数:遍历所有出现过的节点,统计find(x) == x的节点个数。如果恰好为1,说明所有节点在同一个集合中,图是连通的。
两种方法各有优劣。数根节点的方法更直观,但需要额外维护一个visited[]数组来记录哪些节点出现过。并查集统计集合个数的方法不需要额外数组,但需要确保所有节点都执行过find()操作。我一般用第一种方法,因为逻辑更清晰,不容易出错。
4. 完整代码实现与逐行解析
4.1 数据结构设计与初始化
先定义核心数据结构。我们需要三个数组:parent[]用于并查集,in_degree[]用于记录入度,visited[]用于标记出现过的节点。数组大小根据题目约束设定,一般开到10005就够了。
MAXN = 10005 parent = list(range(MAXN)) in_degree = [0] * MAXN visited = [False] * MAXN初始化的时候,parent[i] = i表示每个节点自成一个集合。in_degree和visited全部清零。每次处理新测试用例之前,需要重置这三个数组。重置的时候不要用循环全部重置,那样太慢。用一个nodes列表记录出现过的节点,只重置这些节点即可。
def reset(nodes): for node in nodes: parent[node] = node in_degree[node] = 0 visited[node] = False nodes.clear()4.2 主循环逻辑与边界处理
主循环的逻辑是:不断读入边,直到遇到“0 0”为止。每读入一条边A→B,执行以下操作:
- 如果A == 0且B == 0,结束当前测试用例,开始判定。
- 如果A == B,标记
is_tree = False,跳过后续处理。 - 如果
in_degree[B] > 0,说明B已经有父节点了,标记is_tree = False。 - 更新
in_degree[B] += 1,标记A和B为已访问。 - 用并查集合并A和B,如果发现已经在同一集合,标记
is_tree = False。
判定阶段:如果is_tree仍然为True,统计入度为0的节点个数。如果恰好为1,输出“Yes”;否则输出“No”。如果没有任何节点出现过(空图),直接输出“Yes”。
case_num = 1 nodes = [] is_tree = True while True: a, b = map(int, input().split()) if a == -1 and b == -1: break if a == 0 and b == 0: # 判定当前用例 if not nodes: print(f"Case {case_num} is a tree.") elif not is_tree: print(f"Case {case_num} is not a tree.") else: root_count = sum(1 for node in nodes if in_degree[node] == 0) if root_count == 1: print(f"Case {case_num} is a tree.") else: print(f"Case {case_num} is not a tree.") # 重置 reset(nodes) is_tree = True case_num += 1 continue # 处理边 if a not in nodes: nodes.append(a) if b not in nodes: nodes.append(b) if a == b: is_tree = False continue if in_degree[b] > 0: is_tree = False in_degree[b] += 1 visited[a] = visited[b] = True if not union(a, b): is_tree = False4.3 关键参数与性能优化
节点数上限设为10005是一个经验值。大多数题库的节点编号不会超过10000,开到10005足够。如果题目约束更大,可以动态扩展或者用字典代替数组。
路径压缩的递归深度在极端情况下可能达到节点数,Python默认递归深度是1000,如果节点数超过1000会爆栈。解决办法有两种:一是用迭代写法,二是设置sys.setrecursionlimit(100000)。我一般用第二种,简单粗暴。
并查集的合并方向也影响性能。如果总是把A合并到B,在某些数据下树会退化成链。加一个rank[]数组按秩合并可以避免这个问题,但实测在节点数一万以内,不加rank也能过。如果追求极致性能,可以加上。
5. 常见问题与排查技巧实录
5.1 自环与多节点环的区分处理
自环和多节点环是两种不同的非法结构,但都会导致“不是树”的结论。自环的检测很简单:if a == b。多节点环的检测靠并查集:if find(a) == find(b)。两者必须分开处理,不能混在一起。
我见过有人在并查集合并之前不检查自环,结果“1 1 0 0”这组数据被判定为树。原因是并查集的union(1, 1)会返回False,但有些人写的union函数在a == b时直接返回True,导致漏判。所以自环一定要在并查集之前显式检查。
5.2 入度大于1的隐蔽情况
入度大于1的情况有时候很隐蔽。比如“1 2 3 2 0 0”这组数据,节点2的入度是2(分别来自1和3),但并查集在合并1-2和3-2时不会报错,因为1和3不在同一集合。这时候只能靠入度数组来检测。
还有一种情况是“1 2 2 3 1 3 0 0”,节点3的入度是2,同时并查集在合并1-3时会发现1和3已经在同一集合(因为1-2-3已经连通),双重检测都能发现非法。但“1 2 3 2 0 0”只能靠入度检测。
5.3 空图与单节点的判定分歧
空图的判定在不同题库里有分歧。有些题库认为空图是树,有些认为不是。我建议在代码里显式处理:如果nodes列表为空,直接输出“Yes”。这样即使题库认为空图不是树,你也能通过修改这一行来适配。
单节点的判定也有坑。如果输入是“1 1 0 0”,节点1有自环,不是树。如果输入是“1 2 0 0”,两个节点一条边,是树。如果输入是“1 0 0”(只有一个节点没有边),这种输入格式不合法,因为边必须成对出现。所以单节点树只能通过“1 2 0 0”这种形式出现。
5.4 常见错误速查表
| 错误现象 | 可能原因 | 排查方法 |
|---|---|---|
| 自环数据判为树 | 未检查a == b | 在并查集之前加自环判断 |
| 入度大于1判为树 | 未维护入度数组 | 每条边更新in_degree[b] |
| 森林判为树 | 未检查根节点个数 | 统计入度为0的节点数 |
| 空图判为不是树 | 未处理空图 | nodes为空时直接输出Yes |
| 多节点环漏判 | 并查集合并方向错误 | 确保union(a, b)把a合并到b |
| 递归爆栈 | 路径压缩递归太深 | 设置recursionlimit或改迭代 |
6. 从这道题延伸出的图论判定思维
这道题的价值不在于并查集本身,而在于它教会你如何用多个条件交叉验证一个结构。并查集只能检测无向环,入度数组只能检测有向非法结构,根节点计数只能检测连通性。三者缺一不可。
我在实际做题时养成了一个习惯:每拿到一道判定题,先把所有必要条件列出来,然后逐一设计检测方法。比如判定二分图需要染色法,判定拓扑序需要入度队列,判定强连通分量需要Tarjan。每种结构都有对应的检测工具,关键是把条件拆解清楚。
另外,这道题的输入格式也值得注意。很多题库的输入是“多组数据,每组以0 0结束,整个输入以-1 -1结束”。这种格式下,每组数据之间需要重置状态,但重置的时候不要全部重置,只重置出现过的节点。否则在数据量大的时候会超时。
最后分享一个我常用的调试技巧:把每组数据的判定过程打印出来。比如在处理每条边的时候打印a, b, in_degree[b], find(a), find(b),这样一眼就能看出哪条边导致了非法判定。这个方法帮我省了大量时间,尤其是在排查入度问题时特别有效。
后续如果想把这道题扩展成更复杂的版本,可以加入节点编号不连续的情况(用字典代替数组)、多组数据并行处理(用多个并查集实例)、动态加边与删边(用Link-Cut Tree)。不过对于基础判定来说,当前的方案已经足够覆盖绝大多数场景了。