连通性问题
在给定的一张节点网络(也就是图)中,判断两个节点之是否可达的问题就是连通性问题。
场景:判断两个用户之间是否存在间接社交关系;判断两台计算机之间是否建立连接等。
定义数据结构
使用最基本的数组作为该算法的数据结构。数组下标 i 代表当前节点编号,id[i]的值表示与该节点连通的某一个节点。每个节点id[i]的值初始化为 i。
定义输入
输入是一系列整数对,一对整数(p, q)代表p和q是相连的。
例如:输入(3, 4)、(1,3)、(2, 5),那么3-4、1-3、1-4、2-5是连通的。
定义union-find算法API
public class UF{
void union(int p,int q) //在p和q之间建立连接
int find(int p) //p所在的分量的标识符
boolean connected(int p,int q) //p和q同在一个分量中则为true
}
初始状态,每个节点都是一个分量。两点之间建立连接后,union()方法会将两个分量合并。一个分量中各触点都相互连接。find()方法返回给定触点所在连通分量的标识符。
connected()方法即return find(p)==find(q); 所以关键是实现find()方法和union方法。
1 quick-find算法
quick-find算法保证在同一连通分量中所有触点id[]中的值必须相同。这种实现情况下:
- union()必须遍历数组,将一个连通分量中的id[]值变为另一个连通分量的id[]值
- find方法只需return id[p]
- connected()方法只需判断find[p]==find[q]即可
换一种思路,其实quick-find算法的核心是将每一个连通分量以背包的形式存放。
算法实现:
//union()方法用于合并两个连通分量
public void qf_union(int p,int q) {
int pID = qk_find(p);
int qID = qk_find(q);
if(pID == qID) return;
//因为不知道p所在的连通分量的所有节点,需要全扫描节点数组
for(int i = 0;i<id.length;i++)
if(id[i] == pID) id[i] = qID;
}
//find()方法实现简单,直接返回数组值
public int qf_find(int p) { return id[p]; }
//connected()方法返回是否相等
public boolean connected(int p, int q){ return find(p) == find(q); }
算法分析:
该算法的特点是union慢,find快。在quick-find算法中,每次find()调用访问一次数组,常数级别O(1)。归并两个分量的union()操作访问数组次数时间复杂度O(N)。
2 quick-union算法
quick-union算法中每个触点所对应的id[]元素都是另一个触点的名称(也可能是自己,如果是自己的画说明是根节点),触点之间循环这种关系直到到达根触点。当且仅当两个触点开始这个过程打到同一个根触点说明它们存在于一个连通分量中。这种实现情况下:
- find()方法就是沿着这条路径找到根节点
- union()方法只需将一个根节点链接到另一个上面就可实现合并分量
- connected()方法只需判断find[p]==find[q]即可
换一种思路,quick-union算法的核心是将连通分量以多叉树的形式存放。
算法实现:
//find()方法需要沿着多叉树向上找到自己的根节点
public int qu_find(int p) {
while(p!=id[p]) p=id[p];
return p;
}
//union()方法只需要将一个根节点连接到另一个根节点即可
public void qu_union(int p,int q) {
int pRoot = qu_find(p);
int qRoot = qu_find(q);
if(pRoot == qRoot) return;
id[pRoot] = qRoot;
}
//connected()方法简单比较find(p)和find(q)
public boolean connected(int p, int q){ return find(p) == find(q); }
算法分析:
该算法的特点是union快,find慢。find()方法访问数组次数是1+触点所在树的高度*2,即时间复杂度是O(logN);union()方法和connected()方法时间复杂度是常数级别O(1)。
3 加权quick-union算法:
对quick-union算法的改进,保证小的树链接在大树上。即给每一个连通分量添加权重,在需要将两个连通分量合并时,将权重小的连通分量连接到大的连通分量上。
算法实现:
在类中新建一个数组保存各根节点的权重。注意该数组中只有根结点对象的下标中的数据有效。然后修改union方法如下:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) return;
//将权重小的连通分量连接到权重大的上面
if (size[rootP] < size[rootQ]) {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}else {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
}
}
算法分析:
加权quick-union算法可以有效地降低生成的连通分量的树的高度,从而提高算法执行效率。当然这是一种用空间换时间的方法,因为使用了辅助数组保存节点权重,所以它的额外空间复杂度为O(N)。
4 路径压缩的加权quick-union算法:
加权quick-union算法在大部分整数对都是直接连接的情况下,生成的树依旧会比较高。所以可以进一步优化:每次计算某个节点的根结点时,将沿路检查的结点也指向根结点。尽可能的展平树,这样将大大减少find()方法遍历的结点数目。
算法实现:
//union()方法
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) return;
if (rank[rootP] < rank[rootQ]) parent[rootP] = rootQ;
else if (rank[rootP] > rank[rootQ]) parent[rootQ] = rootP;
else {
parent[rootQ] = rootP;
rank[rootP]++;
}
}
//find()方法
public int find(int p) {
while (p != parent[p]) {
parent[p] = parent[parent[p]]; //路径压缩减半
p = parent[p];
}
return p;
}
//connected()方法
public boolean connected(int p, int q) { return find(p) == find(q); }
算法分析:
路径压缩后基本上连通分量树的高度为2, 所以find()方法的时间复杂度接近O(1),union()方法的时间复杂度接近O(1)。
附:union-find算法和图的可达性问题
图的可达性问题一般采用深度优先遍历的思想来实现。理论上,深度优先算法解决图的可达性比union-find快,因为它能够保证所需时间是线性的。
但实际上,union-find算法更快,因为它不需要完整的构造并表示一张图。更重要的是union-find算法是一种动态算法,我们在任何时候都能用接近常数的时间检查两个顶点是否连通,甚至在添加一条边的时候,但深度优先算法必须对图进行预处理。