理论篇
动态连通性问题
假设用 0~N-1 的整数来表示一组对象,整数对(p,q)可以理解为“对象p和对象q是相连的”,相连是一种对等关系,意味着:
- 自反性:p和p是相连的;
- 对称性:p和q相连那么q和p也相连;
- 传递性:p和q相连,q和r相连,则p和r也是相连的;
对等关系能够将对象分为多个等价类,当且仅当两个对象相连时它们才属于同一个等价类。动态连通性问题或说等价类划分问题需要设计一种数据结构来保存所有对象间的连通/等价关系,并用它来判断一对新的对象是否是连通/等价的。
逻辑结构
并查集(Union Find Set)是解决动态连通性问题的一种非常高效的树型数据结构,并查集是由一组树构成的森林,每棵树都描述了一个等价类,树中所有节点都是相连的。
称谓 | 整数 | 整数对 | 相连的整数 | 标识符 |
---|---|---|---|---|
集合观点 | 元素 | 是否属于同一集合 | 等价类 | 集合名 |
树的观点 | 节点 | 是否连通 | 连通分量/树 | 根节点 |
存储结构
通常用树的双亲表示法作为并查集的存储结构,这种存储方式采用一组连续的空间来存储每个节点,同时在每个节点中增设一个伪指针,指示其双亲节点在数组中的下标。通常用数组元素的下标代表元素名,根节点的下标代表集合名,根节点的双亲节点为其本身或负数。
基本运算
运算定义
Union-Find算法API定义(图论语言描述):
- uf(int n):用整数标识(0~N-1)初始化N个节点,每个节点单独构成一个连通分量;
- int find(int p):查找节点p所在分量的标识符;
- void union(int p,int q):如果p和q在不同分量,union会融合两个分量;
- bool connected(int p,int q):如果p和q存在于同一分量则返回True;
- int count():连通分量的数量;
运算实现
1)quick-find 实现
find 的时间复杂度取决于树的高度,因此为了加快find的速度需要在合并两个分量时尽量减小合并后树的高度,一种方式是在每次合并不同分量时,统一分量的标识符,这种方式虽然使find时间复杂度变为O(1),但union时需要表里整个数组,对于大量数据并不适用:
1 | class Uf(object): |
分析:
- find:所有节点的父节点就是根节点,$O(1)$
- union:$O(n)$
- 处理N对整数,$O(n^2)$
2)quick-union 实现
quick-union不去统一union的两个分量的标识符,通过链式追溯找到节点的标识符。
1 | class Uf(object): |
分析:
- find:比较次数等于树的高度,最坏情况下O(n);
- union:两次find,树的高度O(n)
- 处理N对整数所需的所有find()操作访问数组的总次数在最坏情况下是平方级别的
3)终极优化——带路径压缩的加权 quick-union
find操作是整个并查集操作的核心,要加快find操作效率需尽量减小树的高度(扁平化),我们可以在find或union操作时实现这一点:
- 路径压缩:在find的同时将节点直接链接到根节点;
- 加权quick-union:在union时总是将较小子树链接到较大子树;
1 | class Uf(object): |
示例:依次添加[(1,2),(3,4),(0,9),(4,7),(6,5),(5,8),(3,9),(1,8)]的过程
分析:同时使用路径压缩、按秩(rank)合并优化的程序每个操作的平均时间仅为 $O(\alpha (n))$,其中 $ \alpha (n)$ 是 $ {\displaystyle n=f(x)=A(x,x)}$ 的反函数, ${\displaystyle A}$ 是急速增加的阿克曼函数。因为 $\alpha (n)$ 是其反函数,故 $ \alpha (n) $在 n 十分巨大时还是小于 5。因此,平均运行时间是一个极小的常数。实际上,这是渐近最优算法:Fredman 和 Saks 在 1989 年解释了 $ \Omega (\alpha (n))$ 的平均时间内可以获得任何并查集。
- find:均摊成本接近O(1);
- union:均摊成本接近O(1);
- 保存所有连接关系:O(n),n为边的个数;
- 空间复杂度O(n);
- 带路径压缩的加权quick-union算法是最优的算法,但并非所有操作都能在常数时间内完成;
- 值得注意的是,路径压缩并不能保证最终所有节点都能直接作为根节点的孩子节点,除非最后对每个节点都find一遍。find过程确实将路径上的所有节点都放在了根节点下,但是Union过程会破坏这一点,导致最终的树高度变得未知。因此加权quick-union效率会更高,而且统计了每个分量中节点个数。
- 加权quick-union已经使得树变得很低,进一步的路径压缩很难再对其进一步改进了。
quick-union和加权quick-union算法对比:
三种算法性能对比:
三种算法均摊成本对比(灰色点代表累积成本,红色点代表均摊成本):
关于并查集更详细内容,Algorithms一书的Section 1.5有非常精彩的讨论。
典型应用
划分等价类
- 问题:给定一组对象的对等关系,对该组对象进行划分,使得具有对等关系的对象归为一类;
- 思路:典型的等价类划分问题,遍历所有对等关系,union两个节点,即完成了等价类的划分;
1 | def eq_class(vertices,edges): |
Kruskal算法
- 问题:寻找带权图的最小生成树
- 思路:贪心策略,从小到大依次选取不构成环的边,直至所有顶点都被纳入
- 代码:
1 | def kruskal(graph): |
- 分析:采用优先队列存放边的集合,每次选取最小权值的边需$O(lg|E|)$,每次使用并查集判断是否构成环需$O(1)$,最坏情形需要遍历所有边,所以时间复杂度为$O(|E|lg|E|)$。
实战篇
实战技巧
- 实战的难点在于问题的定义和转化,是否可以转化为对象间连通性的讨论
- 抽象出节点:节点代表了某种相互联系的对象
- 抽象出节点之间的边:代表了节点之间的某种等价性
- “动态连通问题”的衍生问题包括:
- 判断两个节点是否连通:判断两个节点是否属于同一类
- 添加一组新的连通关系
- 统计等价类、连通分量个数,每个等价类中的节点数
- 并查集的实现:
- 节点用整数来指示,我们可以将对象存储在一个数组中,通过其下标来指示该对象
- 建议使用带路径压缩的quick-union,代码简单,性能最优
- 在节点合并时,需要遍历所有边,输入可以是边表或邻接表,时间复杂度为边的个数
- 并查集能解决的问题通过DFS也都能解决
2.2 经典LeetCode题目
[684.medium] 识别无向图中的冗余边
- 问题:给定含n个节点的无向图(用边表表示),该图由一棵树和一条多余边构成,返回一条可以删去的边,如果有多个答案则返回最后出现的那条边。
1 | 输入: [[1,2], [2,3], [3,4], [1,4], [1,5]] |
- 思路:无向图中冗余边必定构成环,只需要识别出无向图的环,从环中去掉最后一条边即可。构建n个节点的并查集,依次合并边表中的边的两个顶点,如果在合并时发现这两个顶点在并查集中连通,则说明该边是多余的,返回该边;
- 代码:
1 | class Solution(object): |
[685.hard] 识别有向图中的冗余边
- 问题:将问题684中的无向图换做有向图,问题不变
1 | 输入: [[1,2], [2,3], [3,4], [4,1], [1,5]] |
- 思路:有向图的冗余边情况稍复杂一些,需分三种情况讨论。
1)如果所有节点最多都只有一个父亲:则必在根节点处形成环,只需返回最后一个成环的边即可(类似于无环题)
2)如果某节点由两个父亲,但该节点未形成环,则返回两个父亲最后一条边;
3)如果某节点由两个父亲,且该节点在环中,则需返回在环中的那条父亲边;
- 代码:
1 | class Solution(object): |
[547.medium] 朋友圈
- 问题:班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M[i][j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。
1 | 输入: |
思路:每名学生作为一个节点,朋友关系作为边(传递性-对称性),构建并查集,统计并查集中联通分量的个数
代码:
1 |
|
[200.medium] 岛屿的个数
- 问题:给定一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,计算岛屿的数量。一个岛被水包围,并且它是通过水平方向或垂直方向上相邻的陆地连接而成的。你可以假设网格的四个边均被水包围
1 | 输入: |
- 思路:并查集求连通分量个数;增加哑结点存放所有无关节点’0’,相连关系具有传递性,因此只需要向上和向左合并节点即可;
- 代码:
1 | class Solution(object): |
- 分析:
- 时间复杂度:保存所有相连关系花费O(m*n)
- 空间复杂度:O(m*n)
[765.hard] 情侣牵手
- 问题:N 对情侣坐在连续排列的 2N 个座位上,想要牵到对方的手,计算最少交换座位的次数,以便每对情侣可以并肩坐在一起。人和座位用 0 到 2N-1 的整数表示,情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推
1 | 输入: row = [0, 2, 1, 3] |
- 思路:将每对情侣看做一个节点并编号0~N-1,其中(2i,2i+1)是编号为i的两位情侣,如果初始时2j和2j+1位置坐着来自不同情侣u=row[2j]/2和v=row[2j+1]/2的两个人,则认为u节点和v节点是相连的,保存所有连通关系后,每个连通分量必定构成一个交换环,含有n个节点的交换环最少需要n-1次交换可将交换环拆解为n个自环,即所有情侣相遇,故总共需要的交换次数为sum(n-1)=N-k,k代表并查集中的连通分量个数。
- 代码:
1 | class Solution(object): |
- 分析:建立并查集保存所有连接关系需要O(n)
[130.medium] 被围绕的区域
- 问题:给定一个二维的矩阵,包含 ‘X’ 和 ‘O’(字母 O),找到所有被 ‘X’ 围绕的区域,并将这些区域里所有的 ‘O’ 用 ‘X’ 填充
1 | 示例: |
- 思路:只需要判断哪些为O的元素不与边界的O连通。节点(i,j)可用整数in+j来指示,遍历所有O顶点,可以将边界的所有O节点合并到一个哑结点mn下,将O节点与左侧和上侧的O节点连通;最后判断不与哑结点相连的节点,改为X。
- 代码:
1 | class Solution(object): |
[721.medium] 账户合并
- 问题:给定一个列表 accounts,每个元素 accounts[i] 是一个字符串列表,其中第一个元素 accounts[i][0] 是 名称 (name),其余元素是 emails 表示该帐户的邮箱地址。如果两个帐户都有一些共同的邮件地址,则两个帐户必定属于同一个人,即使两个帐户具有相同的名称,它们也可能属于不同的人,因为人们可能具有相同的名称。一个人最初可以拥有任意数量的帐户,但其所有帐户都具有相同的名称。现在,我们想合并这些帐户,按以下格式返回帐户:每个帐户的第一个元素是名称,其余元素是按顺序排列的邮箱地址。accounts 本身可以以任意顺序返回。
1 | Input: |
- 思路:同一个account内的email具有连通关系(属于同一个人),只需要找到所有email的连通分量,绑定到对应的人输出即可。将email作为节点,同时存在于同一个acount的email具有连通关系,数组收集所有的email,使用其数组下标作为email的指示,构建并查集,可以得到属于不同人的连通分量,再由连通分量找到对应的email和人。在此过程,我们需要email到id,id到email,email到人的三种映射,我们使用三个hash表来建立它们的映射关系。
- 代码:
1 | class Solution(object): |
- 分析:建立哈希表需要O(n),n代表email个数,建立并查集需要O(n),故总的时间复杂度为O(n)
[128.hard] 最长连续序列
- 问题:给定一个未排序的整数数组,找出最长连续序列的长度,要求算法的时间复杂度为 O(n)。
1 | 输入: [100, 4, 200, 1, 3, 2] |
- 思路:节点表示nums数组下标,整数相邻代表他们之间相连,统计并查集中节点最多的连通分量,用rank记录以每个节点为根节点的子树节点树,求最大值
- 代码:
1 | class Solution(object): |
[778.hard] Swim in Rising Water
- 问题:给定长宽为N的二维方阵grid,记grid[x][y] = z表示当时刻t >= z时,x, y可达。在grid上的移动可以瞬间完成,求从0, 0出发,到达N - 1, N - 1的最短时刻
1 | Input: [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]] |
- 思路:并查集+二分查找,判断连通性。本题是要找到最小限高使得(0,0)与(n-1,n-1)能够连通,连通性关于限高具有单调性,可以采用二分查找的思路,每次将问题规模减半;
- 代码:
1 | class Solution(object): |
- 分析:时间复杂度 $O(nlgn)$
参考
[1] Algorithms 一书的Section 1.5
[2] LeetCode 并查集