最近按专题刷图论,刷到第108天的打卡题时遇到了“冗余连接”这道经典题。说实话,第一次看到题面,我脑子里第一反应是“这不就是 DFS 数环吗”,结果动手推了两个用例才发现,这题如果想清楚“为什么返回这条边”比把代码写出来要难得多。真正有价值的部分,不是遍历,而是并查集的理解深度,以及一个特别容易被忽略的细节:当存在多个答案时,题目要求返回输入中最后出现的那条边。
今天这篇就把这道题从题目拆解、并查集原理、完整代码、边界情况到真实场景延伸一次性讲透。适合正在刷题准备面试、期末复习数据结构、或者第一次接触并查集想搞懂“判环”的朋友。我会尽量用大白话解释原理,并且把那些常规题解里不讲清楚、不写明白的坑全部标出来。
1. 破题:冗余连接到底在考什么
1.1 题面里的三个关键信息
题面看起来很简单,实际上有三个点会直接影响解题方向。
第一,输入是用 n 条边连接 n 个节点的无向图。注意这个数量关系:一棵正常的树有 n 个节点、n-1 条边;现在是 n 条边、n 个节点,说明恰好比树多一条。整个问题的起点,就是“多出来的这一条边”。很多解法写着写着忘了这个前提,跑去解决更一般的“任意图中找一条冗余边”问题,反而把自己绕晕了。
第二,题目说“这个图是由一棵树加上一条额外的边构成的”。这个限制比表面上看起来更强。它意味着图中恰好存在一个环,而不是多个环交织在一起。这一点非常重要,因为它直接决定了“并查集遇到已经连通的边直接返回”这种写法的正确性。如果图里本来就有多个环,按顺序直接返回第一条冲突边答案就可能是错的,必须做额外判断。这道题因为结构被限定死了,代码才能写得这么短。
第三,如果答案不止一个,返回输入中最后出现的边。这实际上是题目在给“删哪条边”定义一个排序规则。环上任意一条边被删掉之后,剩下的图都有可能是满足条件的树,但题目要求你选的是“按输入顺序来看,最后一个符合条件的边”。很多人会把注意力全部放在“怎么找环”上,忽略“最后出现”这层约束,最后删掉的边根本不是目标结果。这一点我会在第4节用完整的例子展开。
1.2 树的定义才是隐藏的判定标准
要判断“删掉这条边之后,剩下的图是不是一棵树”,最直接的办法是看三个性质:连通、无环、边数为 n-1。其实这三个条件里,满足任意两个,第三个通常会自动成立。原图是连通的,删除一条边之后如果仍然连通,并且边数变成 n-1,那么它一定是一棵树。
这个推理看起来平平无奇,但它是整个并查集解法的根基。因为并查集只能高效地回答“两个点是否在一个连通分量里”,它本身不会直接告诉你有几个环。但只要我们知道“连通 + n-1 条边”等价于树,就可以把问题转化为:按输入顺序往图里加边,找到哪一条边会让两个已经连通的点再次连通。一旦出现这种情况,说明加进去的这条边产生了环。由于题目保证只存在一个环,这条边就是我们要找的冗余边。
为了加深理解,我们可以做一个反证。假设删除一条边后,图仍然连通,但边数不是 n-1,那说明原图里还有别的环,这和题目“树加一条边”的前提矛盾。所以在当前前提下,“连通”这个条件已经足够,不需要额外再验证无环和边数。这也是为什么并查集解法只判断连通性,代码却能通过全部用例。
1.3 这道题适合什么样的人来练手
我的建议是,如果你正在学并查集,这道题是“并查集判环”的入门题;如果你在准备面试,这道题的变体在不少大厂题库里反复出现,弄懂它对理解 union-find 如何判断“合法性”帮助很大;如果你只是想坚持图论专题打卡,这道题也很适合作为转折点,因为从它开始,图论的“连通性问题”会反复出现,理解了这个套路,后面再看最小生成树、岛屿类问题、动态连通性问题都会轻松不少。
我个人的感觉是,这道题不能只满足于写对。要试着在写完之后,用一句话向别人解释清楚:为什么并查集能识别环,为什么返回的是最后一条边而不是第一条、也不是环里的任一条边。能把这件事讲明白,才算真正掌握。
2. 为什么并查集是判断“成环”的高效工具
2.1 并查集到底在干什么
并查集的全称是“不相交集合”,一种用来快速判断“两个元素是否在同一个集合里”的数据结构。它只支持两个操作:find 和 union。find 的作用是返回某个元素所在集合的“代表”,union 的作用是把两个元素所在的集合合并成一个。
我们可以这样类比:班里同学分成几个小组,每个小组有一个组长。要判断两个人是不是同组的,只需要看他们各自的组长是不是同一个人;如果有两个人需要建立联系,就把两个小组合并,重新指定一个组长。并查集就是把这个过程用数组和树形结构模拟出来。
在图论里,并查集天然适合处理连通分量问题。每遇到一条边,本质上就是在说“这两个节点应该在同一个连通分量里”。如果它们已经在同一个分量里,那这条边就会在分量内部形成环;如果不在,就把两个分量合并。这个过程和冗余连接题目的要求完全匹配。
2.2 时间复杂度为什么可以接近 O(1)
并查集有两种关键优化:路径压缩和按秩合并。
路径压缩发生在 find 阶段。查找某个节点所在集合的代表时,我们一路沿着父指针往上走,找到根节点之后,顺便把沿途遇到的所有节点直接挂到根节点下面。这样下次再查这些节点时,一步就能跳到根,不用再从长链底部慢慢爬。
按秩合并发生在 union 阶段。合并两个集合时,把“深度小”的树挂到“深度大”的树上,避免树退化成一条长链。如果两边深度一样,就任选一个作为根,并把它的深度加一。
有了这两种优化之后,单次 find 和 union 的平均复杂度可以近似看成 O(1)。严格的理论复杂度是反阿克曼函数,增长极慢,几乎可以当成常数。对于题目范围内 n 条边来说,并查集的整体复杂度就是 O(n α(n)),跑起来非常快。这也是为什么在“判断是否存在环”这类问题上,并查集比每加一条边就重新遍历一遍图要划算得多。
2.3 和 DFS 暴力解法对比一下
如果不用并查集,最自然的暴力想法是:枚举每条边,假设把它删掉,然后从任意一个节点做 DFS,看能不能遍历到全部 n 个节点。能遍历到全部节点,说明删掉这条边后图仍然连通,它就是合法候选答案;最后按“最后出现”的规则选一个输出。
思路完全正确,但复杂度不理想。一次 DFS 要 O(n) 的时间,枚举 n 条边,整体 O(n²)。当 n 比较小,比如一两百以内,完全能跑;但题目数据一提高到十万级别,O(n²) 就彻底崩盘。并查集的做法是在每条边上做两次 find 和一次 union,整体接近线性,在竞赛和面试环境里都是更优选择。
更直白地说,DFS 暴力解其实是“事后检查”:先把整张图建好,再删边验证。并查集是“事中判断”:边还没完全加入,就已经能判断出哪条边会制造环。这两种思路的差距,就是这道题最核心的算法思维差异。
3. 手写并查集:从代码到跑通的完整过程
3.1 初始化要特别注意下标
先给并查集分配父节点数组。节点的编号范围是 1 到 n,所以数组长度要开成 n+1,下标 0 空出来不用。初始化时,每个节点的父节点指向它自己,表示每个节点单独成为一个集合。
这里最常见的错误就是数组长度只开到 n,然后访问 parent[n] 时越界。在 LeetCode 上会直接报执行错误,但本地调试时因为内存布局的关系,有时候不立刻崩溃,反而更难排查。我的习惯是在注释里明确写“下标 0 不使用”,从源头杜绝这个隐患。
3.2 find 函数的递归写法和迭代写法
递归写法简洁且接近定义:
def find(x): if parent[x] != x: parent[x] = find(parent[x]) return parent[x]迭代写法稍长一些,但避免了递归深度的风险:
def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x两种写法都进行了路径压缩。递归版本通过回溯把路径上所有节点挂到根节点下面;迭代版本利用“跳到父节点的父节点”这一步,虽然没有一次把所有节点都直接挂到根,但也能显著缩短路径,并且不依赖调用栈。刷题时递归版本完全够用,但在处理十万、百万级数据的工程代码里,我更倾向于迭代版本,因为不需要担心递归深度爆栈。
3.3 union 的按秩合并
我习惯再维护一个 rank 数组,记录每棵树的深度估算值,用于按秩合并。合并时,让深度小的树挂到深度大的树下;如果两边深度相同,合并后树的深度加一。
def union(x, y): rx, ry = find(x), find(y) if rx == ry: return False if rank[rx] < rank[ry]: parent[rx] = ry elif rank[rx] > rank[ry]: parent[ry] = rx else: parent[ry] = rx rank[rx] += 1 return True严格来说,这道题数据量很小,不用 rank 数组也能通过所有用例。但加上按秩合并之后,并查集在各种数据分布下都不会出现极端长链,后面做更复杂的题也会更稳。这里先养成好习惯,后面会少踩很多坑。
3.4 主循环的判定逻辑
遍历每条边 [a, b],分别 find(a) 和 find(b)。如果两个根相同,说明 a 和 b 已经在同一个连通分量里,再加入这条边就会成环,所以当前边就是冗余边,直接返回。如果根不同,就把两个集合合并,继续处理下一条边。
题目保证一定有解,理论上循环结束后不会走到“没有返回”的分支。但为了代码稳妥,我通常会在最后加一个空数组兜底返回,避免编译告警或者逻辑漏判。
3.5 完整代码示例
下面是我用 Python 写的版本。换成 Java、C++、Go 思路完全一样,只是语法差异。
from typing import List class Solution: def findRedundantConnection(self, edges: List[List[int]]) -> List[int]: n = len(edges) parent = list(range(n + 1)) rank = [0] * (n + 1) def find(x: int) -> int: while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(x: int, y: int) -> bool: rx, ry = find(x), find(y) if rx == ry: return False if rank[rx] < rank[ry]: parent[rx] = ry elif rank[rx] > rank[ry]: parent[ry] = rx else: parent[ry] = rx rank[rx] += 1 return True for a, b in edges: if not union(a, b): return [a, b] return []这里 union 函数同时承担了判断和合并的功能。如果 find(a) 和 find(b) 相等,union 返回 False,主循环直接返回这条边;如果不相等,就完成合并,继续跑。
3.6 用官方示例验证结果
我用官方示例跑了一下。输入第一个示例:
edges = [[1,2],[1,3],[2,3]]输出是 [2,3];输入第二个示例:
edges = [[1,2],[2,3],[3,4],[1,4],[1,5]]输出是 [1,4]。两个结果都和预期一致。如果你本地跑出来不对,大概率不是算法问题,而是数组下标或者 find 的路径压缩逻辑写错了,这类问题在第5节我会专门讲怎么排查。
4. 边界情况与“最后出现”规则最容易踩坑
4.1 为什么“最后出现”这四个字会坑人
回到官方第二个示例。输入是:
[[1,2],[2,3],[3,4],[1,4],[1,5]]这个图里环是 1-2-3-4-1。去掉环上任意一条边,剩下的图都是树,所以合法候选边其实是四条:[1,2]、[2,3]、[3,4]、[1,4]。按输入顺序,最后出现的是 [1,4],所以正确答案是 [1,4]。
用并查集跑一遍,前四条边会依次完成合并,到 [1,4] 时 find(1) 和 find(4) 已经相等,于是直接返回 [1,4]。这个结果恰好就是题目要求的答案。但这里的“恰好”依赖一个重要前提:因为图只有一棵树加一条边,所以按顺序处理时,第一次 find 冲突的那条边,本身就是“把环补完整”的最后一条边,也是输入顺序中最后出现的一条合法候选边。
如果把这个实现搬到更一般的图里,比如存在多个环的图,按顺序遇到第一条冲突边就返回,答案可能不符合“最后出现”的规则。所以面试时如果被问到“这个解法为什么适用于本题”,一定要能说清楚这个前提,而不能只是背代码。
4.2 自环和重边怎么处理
题目约束通常不会出现 a 等于 b 的自环,但自己构造用例时要注意。如果存在 [a, a],因为 find(a) 一定等于 find(a),并查集会直接把它返回。从这个角度来说,自环天然就是冗余边,逻辑自洽。
重边指两条完全相同的边,比如 [1,2] 重复出现两次。第一次处理时正常合并,第二次再处理时,find(1) 和 find(2) 已经相等,于是算法返回第二条 [1,2]。这其实非常合理:原树只有一条 1-2 边,额外重复加的那条就是冗余边。理解了这个语义,遇到类似问题就不会慌。
4.3 两个节点的最小用例
n 等于 2 的时候,输入是 [[1,2],[1,2]]。第一次 union(1,2) 正常合并,第二次 find 发现两个节点已经在同一集合,返回第二条 [1,2]。这个极简用例非常适合用来验证本地环境、编辑器、日志输出是否正常。如果你调试这道题时发现结果总是偏早或偏晚,先跑这个最小用例,往往能一眼看出问题。
4.4 数组下标从 1 开始还是从 0 开始
这个题约定俗成是节点编号从 1 开始,所以父节点数组要开 n+1。如果你习惯从 0 开始编号,记得到处统一。我见过有同学在初始化 parent 时写 list(range(n)),结果遍历到边 [n, something] 时直接越界;也见过 find 函数里把 parent[x] 写成 parent[x-1],导致整个集合关系错乱。这类错误最烦人的地方在于,它不一定会立刻报错,而是会在某些特定输入上返回错误答案。最稳妥的办法就是写代码前先把编号范围写清楚。
5. 调试技巧和面试追问
5.1 排查并查集问题,优先打印什么信息
调试并查集时,我建议优先打印三个信息:当前正在处理哪条边、两个端点 find 后各自的根是谁、已经处理到第几条边。只要打印出来,大部分问题都能立刻定位。
比如说,如果某条边明明应该返回,但程序没有返回,打印后就会发现是 find 函数没有做路径压缩,导致长链上的节点在短期内重复查找,根节点看起来对不上。又比如返回结果偏早,往往是因为 union 时合并方向写反,把本来应该成为根的那个节点变成了子节点,导致后续 find 的结果不符合预期。
我在本地还喜欢额外打印 parent 数组的实时变化,尤其在处理小规模用例时,一眼就能看出合并过程是否正确。虽然打印日志会影响一点性能,但调试阶段完全值得。
5.2 面试官喜欢追问的三个点
第一个追问:为什么最后出现的合法边就是答案?你要能说清楚,因为题目给的是“一棵树加一条边”,环只有一个,按输入顺序处理边时,最后把环补上的那条边,自然就是输入里最后出现的合法解。这是这道题背后的关键逻辑。
第二个追问:如果输入不保证是一棵树加一条边,这个算法要改吗?要改。更一般的图可能存在多个环,直接返回第一次冲突边不满足“最后出现”的规则。需要先找出所有候选边,再结合连通性判断哪个删除后仍然合法。复杂度会上去,面试时可以主动提出这个区别,反而会给面试官留下好印象。
第三个追问:路径压缩和按秩合并可以只用其中一个吗?可以。只做路径压缩通常已经足够快;只做按秩合并也能避免长链,但不如两者同时用更稳。面试时最好能把两个优化的原理说出来,而不只是说“我加了两个优化”。
5.3 真实踩过的坑:闭包、递归深度和变量作用域
Python 里如果在函数内部再定义 find,并且 find 里使用到外部变量 parent,一定要确认 parent 在调用之前已经完成初始化。否则 Python 会按局部变量处理,报 UnboundLocalError。这种错误在本地写类方法时很容易遇到,但只要多跑几次用例就能发现。
C++ 和 Java 里则要关注 find 的递归深度。本题 n 最大也就 1000,不太会出问题,但并查集相关题目里 n 可以到十万、百万甚至更高,递归深度就可能爆栈。所以我在代码里一般优先用迭代写法,宁可多写几行,也不给运行环境添麻烦。
6. 从刷题到真实系统:并查集还能用在哪些地方
6.1 网络拓扑与环路检测
实际工程里,环路检测的需求从来不缺。网络设备配置了冗余链路时,交换机的生成树协议要持续识别桥接环路,避免广播风暴;数据库表结构出现循环引用时,迁移脚本可能无法确定执行顺序;包管理器的依赖图如果出现环,安装过程就会陷入死循环。这些场景不一定会直接用并查集,但“判断两个节点是否已经连通”的核心思想是完全通用的。
6.2 并查集的经典应用场景
并查集最经典的应用是动态连通性查询。朋友圈的合并与查询、巨大网络里的设备分组、无向图里的连通分量统计,都是并查集的主场。它还是 Kruskal 最小生成树算法的基础,Kruskal 在每一步排序选边时,都要判断当前边的两个端点是否已经连通,判断逻辑和这道题几乎一模一样。
如果你后续刷到“以图判树”“岛屿数量”“被围绕的区域”“账户合并”这些题,会发现很多解法都在用并查集。它们之间的差异是对“节点”和“边”的定义不同,底层模型是相通的。把一道题吃透,收益会扩散到一大片题目上。
6.3 我给自己安排的一个延伸任务
刷完这道题之后,我给自己加了一个小任务:把“并查集判环”的原理推广到有向图上,然后对比“有向图判环”和“无向图判环”的差异。无向图用并查集很自然,因为有向图里的环需要考虑方向,并查集只关心连通性,不关心边的方向,所以在有向图里通常更适合用拓扑排序。
这个对照过程帮我厘清了很多模糊的概念。以前我总觉得图论题是背模板,实际上每道题都在考“为什么这个算法适合这个图结构”。如果你也刷到类似瓶颈,建议不要急着开新题,先停一下,把已经做过的题的底层模型拿出来反复对照,收获会很大。
最后分享一点个人体会。冗余连接这道题,代码量几十行,看起来简简单单,但真正能给你加分的,是你能不能在被问到的一分钟内说清两件事:第一,并查集为什么能把“判断环”变成“判断两个点在不在同一集合”;第二,“最后出现的边”这个条件在代码里到底对应哪一步逻辑。能把这两点讲清楚,这道题就不是背下来的,而是真正消化了。接下来不管是继续刷 day61、去面试考算法,还是在实际项目里遇到环路检测的需求,这块基础都会稳稳地托住你。