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

文章详情

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

leetcode 题解:785. 判断二分图(Is Graph Bipartite)——着色法(DFS)与并查集两种解法详解

leetcode 题解:785. 判断二分图(Is Graph Bipartite)——着色法(DFS)与并查集两种解法详解 leetcode 题解785. 判断二分图Is Graph Bipartite——着色法DFS与并查集两种解法详解【免费下载链接】leetcodeLeetCode Solutions: A Record of My Problem Solving Journey.( leetcode题解记录自己的leetcode解题之路。)项目地址: https://gitcode.com/gh_mirrors/le/leetcode本篇文章基于 problems/785.is-graph-bipartite.md 展开系统讲解二分图Bipartite Graph的判定问题从题目给出的邻接表输入出发先后实现「着色法 DFS」与「并查集Union-Find」两种经典算法并深入分析各自的时间、空间复杂度。读完本文你将掌握二分图判定的完整套路并能将同一套思路无缝迁移到 886. 可能的二分法 等同类题目上。题目地址与前置知识题目785. 判断二分图Is Graph Bipartite前置知识图的遍历、DFS深度优先搜索本题与 886 题同属二分图判定套路本文末尾的「相关问题」小节会给出两题联动练习的指引。此外仓库中的 thinkings/graph.md 专题系统梳理了建图邻接矩阵、邻接表与图的遍历方法是理解本题解法的基础thinkings/union-find.md 则对并查集的 find / union / connected 三个核心操作做了详细推导可作为并查集解法的原理支撑。题目描述给定一个无向图graph当这个图为二分图时返回true。如果我们能将一个图的节点集合分割成两个独立的子集 A 和 B并使图中的每一条边的两个节点一个来自 A 集合一个来自 B 集合我们就将这个图称为二分图。graph会以邻接表方式给出graph[i]表示图中与节点i相连的所有节点。每个节点都是一个在0到graph.length - 1之间的整数。这图中没有自环和平行边graph[i]中不存在i并且graph[i]中没有重复的值。示例 1输入: [[1,3], [0,2], [1,3], [0,2]] 输出: true 解释: 无向图如下: 0----1 | | | | 3----2 我们可以将节点分成两组: {0, 2} 和 {1, 3}。示例 2输入: [[1,2,3], [0,2], [0,1,3], [0,2]] 输出: false 解释: 无向图如下: 0----1 | \ | | \ | 3----2 我们不能将节点分割成两个独立的子集。注意graph的长度范围为[1, 100]。graph[i]中的元素的范围为[0, graph.length - 1]。graph[i]不会包含i或者有重复的值。图是无向的如果j在graph[i]里边那么i也会在graph[j]里边。从数据结构角度可以这样理解题目给出的graph本身就是邻接表Adjacency List而解法一中的grid则是把邻接表翻译成邻接矩阵Adjacency Matrix。邻接矩阵graph[i][j] 1表示顶点 i 与顶点 j 之间有边0表示无边这一约定与 thinkings/graph.md 中「图的建立」一节的描述一致。解法一着色法 DFS求二分图有两种经典思路一个是着色法另外一个是并查集。本节先介绍着色法。思路着色法的核心直觉是给每个节点染上两种颜色之一比如 1 和 -1并且强制要求相邻节点的颜色必须不同。如果整个图能够被这样染色且不产生冲突那么染成颜色 1 的节点集合与染成颜色 -1 的节点集合就构成了二分图的两个独立子集 A 和 B。具体算法设置一个长度为 N 的数组colorscolors[i]表示节点i的颜色0表示无颜色1表示一种颜色-1表示另一种颜色。初始化colors全部为0。构图这里是邻接矩阵使得grid[i][j]表示i和j是否有连接0表示无1表示有。遍历图如果当前节点未染色则染色不妨染为颜色1递归遍历其邻居如果邻居没有染色则染为另一种颜色即color * -1其中color为当前节点的颜色否则判断当前节点和邻居的颜色是否一致一致则说明冲突返回False不一致则继续返回True。为什么用0 / 1 / -1而不是0 / 1 / 2因为当某个节点无法分配当前颜色时尝试分配另一组颜色只需要乘以 -1即可完成颜色翻转代码更简洁。这一点在 886 题文档中有同样的说明。关键点图的建立和遍历colors数组同时承担了 visited 数组的职责见下文分析代码版本一先转为邻接矩阵与 886 题保持一致性class Solution: def dfs(self, grid, colors, i, color, N): colors[i] color for j in range(N): if grid[i][j] 1: if colors[j] color: return False if colors[j] 0 and not self.dfs(grid, colors, j, -1 * color, N): return False return True def isBipartite(self, graph: List[List[int]]) - bool: N len(graph) grid [[0] * N for _ in range(N)] colors [0] * N for i in range(N): for j in graph[i]: grid[i][j] 1 for i in range(N): if colors[i] 0 and not self.dfs(grid, colors, i, 1, N): return False return True复杂度分析令 v 和 e 为图中的顶点数和边数。时间复杂度$O(ve)$空间复杂度$O(v)$其中递归栈深度为 $O(v)$colors数组长度为 $O(v)$注意上面版本构造的邻接矩阵grid是 $O(v^2)$ 的空间开销但这里按 DFS 遍历的主流程计算渐进复杂度。正如原文档指出该版本并不优雅其价值在于与 886 题的 DFS 函数保持一致——事实上可以直接把 886 的dfs函数原封不动拿过来用只需调整graph的构造方式即可。若把邻接矩阵的空间也算进去最坏稠密图可达 $O(v^2)$886 题的复杂度分析小节给出了这一更精确的说明。代码版本二直接利用邻接表更优雅一个更加优雅的方式是不建立grid而是直接利用题目给的graph邻接表。class Solution: def isBipartite(self, graph: List[List[int]]) - bool: n len(graph) colors [0] * n def dfs(i, color): colors[i] color for neibor in graph[i]: if colors[neibor] color: return False if colors[neibor] 0 and not dfs(neibor, -1 * color): return False return True for i in range(n): if colors[i] 0 and not dfs(i, 1): return False return True关键点为什么不需要 visited 数组很多遍历场景需要visited数组来防止环导致的死循环thinkings/graph.md 的「图的遍历」一节专门强调过这一点。但在本题中colors数组已经同时充当了 visited 的角色如果colors[i] 0说明节点尚未被访问过等价于visited[i] False否则说明节点已被访问visited[i] True。因此不需要额外的 visited 数组代码也避免了「遇到环就死循环」的问题。关于起始染色的颜色选择主循环中dfs(i, 1)的初始颜色改成-1是否影响结果不影响。假设改用-1后的染色分布已知那么它等价于使用1的情况的反色颜色 1 与颜色 -1 互换对是否二分图的判断没有任何影响。那有没有可能使用颜色 1 推出矛盾而使用颜色 -1 则成立呢没有可能。因为一次 dfs 处理的是一个连通子图多次开启 dfs 处理的是互不相交的子图彼此之间不会产生干扰。原文档给出的验证方法是把主循环随机化起始颜色例如for i in range(n): if random.random() 0.5: if colors[i] 0 and not dfs(i, -1): return False else: if colors[i] 0 and not dfs(i, 1): return False两种随机策略下答案始终一致读者可以自行在本地验证这一性质。解法二并查集思路并查集Union-Find解法基于「二分图的互补连通性」这一观察在二分图中节点 i 的所有邻居必须处于同一集合即与 i 互补的那个集合而 i 自身必须处于另一个集合。具体算法遍历图对于每一个顶点i将其所有邻居进行合并合并到同一个连通域中。这样当发现某个顶点i和其邻居已经在同一个连通分量的时候说明它被错误地分到了与邻居相同的集合直接返回false。全部遍历完没有冲突返回true。这里使用并查集的原因在于并查集提供find查找集合代表、union合并两个集合、connected判断两个元素是否在同一集合三个近乎 $O(1)$ 的操作非常适合表达邻居们必须同属一个集合这类等价关系。thinkings/union-find.md 中详细推导了这三个操作的实现与优化find的递归写法会在向上查找的过程中进行路径压缩把树的高度压到接近 2union采用按秩合并小树挂到大树上保持树的平衡。两者结合后单次操作的时间复杂度可趋近 $O(1)$这也是并查集解法在稠密图下依然高效的原因。代码代码支持Python3、Java。Python3 Codeclass UF: def __init__(self, n): self.parent {} for i in range(n): self.parent[i] i def union(self, i, j): self.parent[self.find(i)] self.find(j) def find(self, i): if i self.parent[i]: return i self.parent[i] self.find(self.parent[i]) return self.parent[i] def is_connected(self, i, j): return self.find(i) self.find(j) class Solution: def isBipartite(self, graph: List[List[int]]) - bool: n len(graph) uf UF(n) for i in range(n): for neibor in graph[i]: if uf.is_connected(i, neibor): return False uf.union(graph[i][0], neibor) return TrueJava Codeweighted quick-union with path compressionclass Solution { class UF { int numOfUnions; // number of unions int[] parent; int[] size; UF(int numOfElements) { numOfUnions numOfElements; parent new int[numOfElements]; size new int[numOfElements]; for (int i 0; i numOfElements; i) { parent[i] i; size[i] 1; } } // find the head/representative of x int find(int x) { while (x ! parent[x]) { parent[x] parent[parent[x]]; x parent[x]; } return x; } void union(int p, int q) { int headOfP find(p); int headOfQ find(q); if (headOfP headOfQ) { return; } // connect the small tree to the larger tree if (size[headOfP] size[headOfQ]) { parent[headOfP] headOfQ; // set headOfPs parent to be headOfQ size[headOfQ] size[headOfP]; } else { parent[headOfQ] headOfP; size[headOfP] size[headOfQ]; } numOfUnions - 1; } boolean connected(int p, int q) { return find(p) find(q); } } public boolean isBipartite(int[][] graph) { int n graph.length; UF unionfind new UF(n); // i is what node each adjacent list is for for (int i 0; i n; i) { // is neighbors for (int neighbor : graph[i]) { // i should not be in the union of its neighbors if (unionfind.connected(i, neighbor)) { return false; } // add into unions unionfind.union(graph[i][0], neighbor); } } return true; } }Java 版本中的find实现了路径压缩parent[x] parent[parent[x]]把节点直接挂到祖父节点上union则按树的大小size把小树挂到大树上这正是 thinkings/union-find.md 中路径压缩 按秩合并模板的工程化体现。复杂度分析令 v 和 e 为图中的顶点数和边数。时间复杂度$O(ve)$。使用 weighted quick-union with path compression 时union、find和connected均近似 $O(1)$构建并查集本身需要 $O(v)$。空间复杂度$O(v)$用于辅助的并查集空间int[] parent与int[] size。两种解法对比维度着色法 DFS并查集核心思想相邻节点必须异色冲突即非二分图所有邻居必须同集合邻居与自身必须异集合数据依赖邻接矩阵或邻接表均可邻接表即可无需额外建图时间复杂度$O(ve)$$O(ve)$近似空间复杂度$O(v)$另有递归栈若算邻接矩阵则为 $O(v^2)$$O(v)$实现难度简单直观依赖并查集模板需理解 find/union 优化从源码结构看原文档建议将 886 题与本题成对练习两题共享同一套 DFS 染色逻辑唯一的差异在于图的输入形式本题直接给邻接表886 题给的是互不喜欢的边对列表需要先自行构建邻接矩阵。把 886 的dfs函数移植到本题几乎不需要改动这也从侧面印证了二分图判定 建图 染色遍历的统一套路。仓库中的相关资源题目文档problems/785.is-graph-bipartite.md关联题目886. 可能的二分法图论专题thinkings/graph.md图的建立、邻接矩阵/邻接表、图的遍历、二分图染色法模板并查集专题thinkings/union-find.mdfind / union / connected 实现、路径压缩、按秩合并题库索引collections/medium.md本题收录于中等难度题单、SUMMARY.md相关问题886. 可能的二分法输入为互不喜欢的边对列表需要先把边对转成邻接矩阵再套用与本题完全一致的 DFS 染色逻辑。强烈建议两道题一起练习巩固二分图判定的完整套路。【免费下载链接】leetcodeLeetCode Solutions: A Record of My Problem Solving Journey.( leetcode题解记录自己的leetcode解题之路。)项目地址: https://gitcode.com/gh_mirrors/le/leetcode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表