多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

并查集实战:LeetCode 684/685 冗余边问题全解析

并查集实战:LeetCode 684/685 冗余边问题全解析 算法训练营走到第五十四天今天这两道题算是并查集专题里最容易让人绕晕的组合108. 多余的边和109. 多余的边II对应 LeetCode 上的 684 和 685。光看名字很多人以为这就是一道题的简单变体实际做起来才会发现第二题的难度比第一题高了好几个档次。这两道题解决的是同一个问题一棵树里多给了一条边找出这条“多余的边”删掉之后整张图又能恢复成一棵树。区别在于108 给的是无向图109 给的是有向图。别小看这个方向差异正是这个差异让 109 的解法从“一遍并查集”升级成了“并查集 入度统计 分类讨论”的组合拳。如果你是正在跟代码随想录刷题、想搞懂并查集进阶应用或者已经刷过 684 想挑战 685 的读者这篇内容应该能帮你少走不少弯路。我会把两道题的核心思路、完整代码、为什么这么写、以及我自己踩过的坑一起讲清楚。1. 整体设计与思路拆解1.1 为什么这两道题要放在一起看先说一个很容易被忽略的事实树的定义在无向图和有向图里不是一回事。无向图里一棵树就是有 n 个节点、n-1 条边并且任意两个节点之间有且仅有一条路径。这时候多一条边唯一造成的后果就是出现一个环。所以 108 题的本质是在 n 条边里找出一条边删掉之后剩下的 n-1 条边构成无环连通图。有向图里树的约束更严格。一棵有向树除了要满足“忽略方向后连通、无环”之外还要保证每个节点入度最多为 1并且有且只有一个根节点入度为 0。现在多给一条有向边可能导致两种不同的坏结果第一种多出来的边指向一个已经有父节点的节点于是某个节点入度变成 2成了“两个爹”。这种情况在图的结构上不一定成环但一定违反了树的入度约束。第二种多出来的边没有破坏入度约束所有节点入度还是不超过 1但它让整张图在“忽略方向”后出现了环。这两种情况对应两种完全不同的排查思路所以 109 题必须分情况讨论。这也是它比 108 题难的核心原因。1.2 并查集比 DFS 合适在哪很多新手拿到这类题第一反应是 DFS 找环。DFS 当然能找环但问题是DFS 在无向图里找环需要记录访问状态在有向图里还要区分树边、回边、横叉边代码写起来比较重而且每次删一条边都要重新遍历一遍最坏情况下复杂度会到 O(n²)。并查集的思路就轻巧得多。它的核心能力是动态维护“哪些点已经连通”在遍历边的过程中就能判断“这条边的两个端点是不是已经连通了”。如果已经连通说明再加这条边就会成环答案当场就能确定下来。对于 108 题这个判断尤其直观按顺序把每条边加入并查集如果某条边两端的节点已经在同一个集合里那这条边就是导致成环的那条直接返回。不需要额外标记访问状态也不需要递归深搜代码短逻辑也容易验证。当然并查集只能处理无向的连通性问题。109 题里为什么还能用核心原因是有向树加一条边后形成的图忽略方向后一定存在无向环。要判断“删除某条候选边后整张图合不合法”最终还是要落到“是否存在环”这个问题上而并查集判断无向环的能力正好用得上。2. 并查集前置知识先把模板打扎实在做这两道题之前先把并查集模板写顺。这块代码看似简单但如果初始化、find、join 写得不严谨后面调试会非常痛苦。并查集的核心是维护一个 father 数组father[i] 表示节点 i 的父节点。一开始每个节点的父节点都是自己也就是每个节点单独成一个集合。find(u) 用来找节点 u 所在集合的根节点。最基本写法是顺着 father 一路往上走但这个写法在链状结构下会退化得很快。所以必须做路径压缩让 find 的过程中直接把沿途节点的父节点都指向根节点。模板里常用三目运算符一行搞定int find(int u) { return father[u] u ? u : father[u] find(father[u]); }这行代码在递归回溯时把路径压缩做了以后再来查这几个节点基本就是 O(1) 的时间。join(u, v) 的作用是把两个节点所在的集合合并。先找到各自的根如果不是同一个根就把其中一个根挂到另一个根下面。这个挂法随意不影响正确性但建议统一一个方向比如“右挂左”或者“左挂右”别每次写都不一样。isSame(u, v) 判断两个点是否已经连通本质就是 find(u) find(v)。还有一个特别容易踩的细节初始化时数组大小和下标范围。LeetCode 的这两道题节点编号是 1 到 n所以 father 数组要开 n1 大小下标从 1 开始。如果你习惯开 n 大小那所有的 i 都要减一反而容易弄混。3. 108. 多余的边无向图3.1 题目到底在问什么题目给一个有 n 个节点的无向图图里本来是一棵树但多给了一条边所以现在共有 n 条边。这条多余的边会让图出现一个环但题目不保证输入的边是按某种规律排好的只保证结果存在且唯一。要求返回一条可以删掉的边使得删除后剩余图成为一棵树。如果有多个答案返回输入数组中最后出现的那条。这句话里的“最后出现”很关键直接决定 108 题的解法写法。因为只要按顺序遍历边第一次遇到“两端点已经连通”的边这条边就是最后出现的那个会成环的边直接返回即可。3.2 思路和代码思路一句话初始化并查集遍历每条边如果两个端点已经在同一集合返回当前边否则把两个端点合并。class Solution { public: vectorint father; void init(int n) { father.resize(n 1); for (int i 1; i n; i) { father[i] i; } } int find(int u) { return father[u] u ? u : father[u] find(father[u]); } void join(int u, int v) { u find(u); v find(v); if (u ! v) { father[v] u; } } bool isSame(int u, int v) { return find(u) find(v); } vectorint findRedundantConnection(vectorvectorint edges) { int n edges.size(); init(n); for (const auto edge : edges) { if (isSame(edge[0], edge[1])) { return edge; } else { join(edge[0], edge[1]); } } return {}; } };这段代码有一个很巧的地方题目说节点数是 n边数也是 n所以直接int n edges.size();就是节点总数不需要额外传入 n。3.3 为什么边加边判断是对的想象一个简单场景节点 1、2、3边按顺序是 (1, 2)、(2, 3)、(1, 3)。第三轮遍历时1 和 3 已经在同一集合里了所以 (1, 3) 就是答案。如果反过来先处理了 (1, 3)再处理 (1, 2)、(2, 3)那答案就变成 (2, 3)。这说明答案取决于输入顺序但题目本来就要求“如果多个答案返回最后出现的那条”所以按顺序边加边判断第一次冲突即是正确结果。如果你想在遍历完所有边之后再去“挑”一条反而会引入额外判断还容易挑错。第 3.3 节也可以再补充一点虽然题目保证只有一个环但这个环上的每条边看起来都是“多余的边”实际上删除环上任意一条边都能让图无环。不过由于题目规定了返回最后出现的边按输入顺序遇到第一条冲突边直接返回天然满足这个要求。时间复杂度 O(n·α(n))α(n) 是反阿克曼函数近似常数空间复杂度 O(n) 存 father 数组。4. 109. 多余的边II有向图4.1 有向树多一条边后可能出现的情况这道题是最容易让人心态崩的。无向图的环用并查集一下就能抓到但有向树多一条边后可能出现两种截然不同的情况。先看一眼有向树的性质n 个节点有 n-1 条边每个节点入度不超过 1且只有一个根节点入度为 0。现在多给一条边变成 n 条边多出来的边要么让某个原本入度为 1 的节点变成入度 2要么让原本入度为 0 的根节点变成入度 1同时图中出现环。更具体地说情况一某个节点入度变成 2。原因是多出来的边指向了一个原本已经有父节点的节点。此时环不一定存在但入度约束被破坏。情况二所有节点入度都不超过 1但图里有环。原因是多出来的边指向根节点或者边本身构成一个封闭的回路。这两种情况要区分处理所以第一步必须先统计所有节点的入度。4.2 完整代码class Solution { public: vectorint father; void init(int n) { father.resize(n 1); for (int i 1; i n; i) { father[i] i; } } int find(int u) { return father[u] u ? u : father[u] find(father[u]); } void join(int u, int v) { u find(u); v find(v); if (u ! v) { father[v] u; } } bool isSame(int u, int v) { return find(u) find(v); } // 跳过 skipEdge 这条边检查剩余图中是否存在环 bool isValidAfterRemoving(int n, const vectorvectorint edges, int skipEdge) { init(n); for (int i 0; i edges.size(); i) { if (i skipEdge) continue; if (isSame(edges[i][0], edges[i][1])) { return false; } join(edges[i][0], edges[i][1]); } return true; } vectorint findRedundantDirectedConnection(vectorvectorint edges) { int n edges.size(); vectorint indegree(n 1, 0); int conflictNode -1; for (const auto edge : edges) { int v edge[1]; indegree[v]; if (indegree[v] 2) { conflictNode v; } } if (conflictNode -1) { // 情况二没有入度为2的节点直接找环 init(n); for (const auto edge : edges) { if (isSame(edge[0], edge[1])) { return edge; } join(edge[0], edge[1]); } } else { // 情况一找到指向 conflictNode 的两条边的下标 int first -1, second -1; for (int i 0; i edges.size(); i) { if (edges[i][1] conflictNode) { if (first -1) first i; else second i; } } // 优先尝试删除靠后的边因为题目要求返回最后出现的冗余边 if (isValidAfterRemoving(n, edges, second)) { return edges[second]; } return edges[first]; } return {}; } };4.3 细节一为什么只记录一个 conflictNode 就够了有 n 个节点原本有 n-1 条边现在多加 1 条边。这条额外的边只会影响一个节点的入度。所以即使某些节点的入度可能因为各种原因变化最多也只有一个节点的入度变成 2。在统计入度时一旦发现有节点的入度达到 2直接记录下这个节点后面的循环只要继续统计即可不需要再找第二个冲突节点。4.4 细节二两条候选边怎么选假设入度为 2 的节点是 v那么指向 v 的两条边分别是 first 和 second按在输入数组中出现的顺序记录。为什么优先尝试 second因为题目说了如果存在多个合法答案返回最后出现的那条。second 在输入数组里的位置比 first 靠后如果删掉 second 能让整张图合法当然优先返回它。只有在删掉 second 之后图仍然不合法说明 second 不是真正的冗余边的情况下才返回 first。这个“优先试靠后的”技巧本质上就是“返回到最后出现那条”需求的直接实现。4.5 细节三为什么无向并查集能判断有向图的合法性这是很多人的困惑点。说到底一棵有向树除了要满足每个节点入度不超过 1 之外忽略方向后必须是无环的连通图。当我们把入度约束单独拎出来处理之后剩下的问题就变成了“当前这组边忽略方向后是否成环”。并查集处理这个场景正好是强项。所以 isValidAfterRemoving 里虽然 edges 是有向边但我们只关心连通性用并查集判断是否会在无向视角下成环。一旦成环说明删除这条边后图仍然不是一棵合法的有向树。4.6 空间与时间复杂度时间上最多调用两次 isValidAfterRemoving每次都要跑一遍所有边每次的并查集操作近似常数所以总体复杂度 O(n·α(n))跟 108 题是一个量级。空间复杂度 O(n)。5. 常见问题与排查技巧实录5.1 108 题没有边加边判断导致返回错误有人会先把所有边都加入并查集然后遍历所有边再挑一条“两段已连通”的边。这样做的问题在于一旦并查集里已经包含所有边几乎所有非桥边都会被判定为“冗余”你找到的不一定是题目要求的“最后出现”的那条边。正确做法就是遍历过程中一旦发现冲突立即返回。这里的关键是后面还没遍历到的边不要参与判断。5.2 109 题只找环或者只查入度如果只按 108 的思路找环遇到“入度为 2 但没有环”的情况就会返回错误如果只查入度遇到“没有入度为 2 但有环”的情况也会漏。两种情况的处理逻辑是完全分开的必须先判断是否存在冲突节点再走对应的分支。我自己第一次做的时候就是先写了找环逻辑直接因为一个入度为 2 的测试用例挂了。5.3 109 题候选边顺序搞反前面讲过first 是第一条指向 conflictNode 的边second 是第二条。有人会下意识认为 first 是“先出现的边”所以优先返回 first这就和题目的“返回最后出现那条”矛盾了。可以这样记在存在入度为 2 且删除 second 不合法时答案只能是 first而删除 second 合法时直接返回 second 才是满足题目要求的。所以代码里的判断顺序不能反过来。5.4 并查集初始化从 1 开始LeetCode 这两道题的节点编号从 1 到 nfather 数组一定要 resize 成 n1。初始化时从 1 循环到 n不要从 0 开始。否则 find(1) 会在数组下标 0 上操作下标越界或者产生奇怪结果。5.5 调试技巧拿小样例手动推演建议先用一个简单用例验证 isValidAfterRemoving 的行为。比如edges [[1,2],[2,3],[3,1],[4,1]]统计入度1 的入度是 2来自 3 和 4所以 conflictNode 1first 对应 [3,1]second 对应 [4,1]。先删 second剩下 [1,2]、[2,3]、[3,1]形成 1-2-3-1 的环不合法。再删 first剩下 [1,2]、[2,3]、[4,1]形成 4-1-2-3 的链无环合法所以答案是 [3,1]。这个用例同时验证了“候选边顺序”和“两种情况分支”是否写对非常实用。5.6 109 题为什么不检查所有点连通也能过有人在 isValidAfterRemoving 里加了“检查所有点是否在同一个集合”的步骤这样更严谨。实际上对于这道题题目保证原图是一棵有向树多一条边删除正确的那条边后n 个节点、n-1 条边且忽略方向无环必然连通所以只查无环已经足够。加上连通性检查不会影响正确性但会让代码多一层 if 判断。我建议初学时保留这个检查能帮你理解“树的判定需要连通 无环”两个条件熟练之后再简化。6. 做完这两道题后的一点体会这两道题给我最大的启发是图论里的“树”不是一个模糊概念它背后是几条非常明确的约束条件。无向树的约束是“连通 无环”所以 108 题用一个并查集就能拿下。有向树在此基础上还加了一条“入度约束”所以 109 题必须先处理入度问题再用并查集解决剩下的环问题。我最初刷 109 题时第一反应是拓扑排序因为拓扑排序天然处理有向图和入度。但实际写起来会发现拓扑排序能判断图里有没有环却不好回答“哪条边才是多余的”这个具体问题。反而是并查集 候选边排除法逻辑更直接代码也更好调试。如果你现在正在刷这两道题建议先自己把 108 的模板敲熟再对照 109 的分类讨论自行实现一遍。就算第一次没有通过所有测试用例也没关系把每一种失败的用例在上面提到的几个细节里对号入座很快就能把整个逻辑彻底理顺。
返回列表