程序员阿沛
发布于 2026-06-27 / 0 阅读
0
0

数据结构与算法十图的连通性问题UnionFind并查集和最小生成树算法

数据结构与算法(十)图的连通性问题——Union-Find 并查集 和 最小生成树算法

[ 程序员阿沛 ](javascript:void(0);)

01 并查集算法

并查集解决什么问题

如果给你一个图里面有一些顶点,并且告诉你图中所有顶点的连接关系,你如何才能快速的找出任意给出的两个顶点是否具有连通性呢?

例如:下面的图结构给出了顶点与顶点之间的连接关系,那么,我们如何快速知道 (0, 3) , (1, 5), (7, 8) 是否相连呢?

并查集就可以解决这样的问题:用来快速判断网络中两个节点的连通性。

并查集算法 和 数据结构

并查集在逻辑上是一个用森林(若干棵互相独立的树)表示的图,在物理上使用一个一维数组parent表示这片森林,parent[i]表示节点i的父节点。

如上图所示,整个图结构包含4棵 独立的树。

算法主要需要实现这两个 API:

class UF {    /* 将 p 和 q 连接 */    public function union(int p, int q);
    /* 判断 p 和 q 是否连通 */    public function connected(int p, int q);
    /* 返回图中有多少个连通分量,即多少棵独立的树 */    public function count();}

并查集有2种实现方式:

Quick Find 实现方式:指的是实现「并查集」时,findRoot 函数时间复杂度为 O(1),union 函数的时间复杂度为 O(N)。

Quick Union 实现方式:指的是实现「并查集」时,findRoot 函数时间复杂度为 O(logN),union 函数的时间复杂度也为
O(logN)。

Quick Union 实现方式

1、初始化init

初始化UF时,需要指定节点个数。初始状态下的每一个节点就是一棵树,节点的父节点就是自己,所有节点都不连通。

type UF struct{   Parent []int   // 一维数组存储并查集的所有树(森林)   Count int     // 记录连通分量}
func (uf *UF) Init(n int){ // n是图的节点个数   uf.Parent = make([]int, n)   for i:=0; i<n; i++{      uf.Parent[i] = i   }   uf.Count = n}

2、连通两个节点connect

连通某两个节点 p 和 q ,其本质是将 p节点所在树的根节点P 与 q节点所在树的根节点Q
连通,即P的指针指向Q(Q作为父节点)或者Q的指针指向P(P为父节点)。

谁做父节点都一样,根据连通的传递性,连通p和q后,P与Q这两棵树的所有节点都会连通,都能互相到达。

// 连接 p 和 q 两个节点func (uf *UF) Union(p int, q int){   P := uf.findRoot(p)   Q := uf.findRoot(q)   if P == Q{  // 说明 p 和 q已经在同一棵树下,已经连通       return      }   uf.count--   uf.Parent[P] = Q   // Q作为父节点}
// 寻找某节点的父节点func (uf *UF) findRoot(node int) int{   for uf.Parent[node] != node{   // 当遍历到某个节点的父节点是自己时,该节点就是node所在的树的根节点      node = uf.Parent[node]   }      return node}

3、判断两个节点是否连通

只要两个节点在同一棵树上(判断p和q根节点是否为同一个根节点),他们就是相连的。

// 判断p和q是否连通func (uf *UF) Connected(p, q int) bool{   return uf.findRoot(p) == uf.findRoot(q)}

Quick Union
实现方式下的Union()和Connected()的性能都取决于findRoot的性能,findRoot的平均时间复杂度为O(logn),即树高。

Quick Find实现方式

Quick Find为了实现 findRoot
为O(1)的复杂度,需要保持森林中的每棵树最多保持2层的高度。因此连通p和q这2个节点时,需要将p节点所在的树上所有节点打散后,添加到q所在树下。

// 连接 p 和 q 两个节点func (uf *UF) Union(p int, q int){  P := uf.findRoot(p)  Q := uf.findRoot(q)  if P == Q{  // 说明 p 和 q已经在同一棵树下,已经连通    return  }
  // 将p所在的树的所有节点都打散添加到q所在的树(的根节点)下  for i:=0; i<len(uf.Parent);i++{    if uf.findRoot(i) == P{  // 如果某个节点在P树下,让其改投到Q树下      uf.Parent[i] = Q    }  }  uf.Count--}
// 寻找某节点的父节点func (uf *UF) findRoot(node int) int{  return uf.Parent[node]}

Quick Find 实现方式下的Connected()的性能取决于findRoot的性能,即O(1),Union()性能为O(n)。

平衡性优化——按秩合并

Quick Union
实现方式下,除了初始化Parent数组需要O(n)复杂度之外,UF算法的性能取决于findRoot的性能。findRoot要做的是从某一个子节点顺着向上指针找到父节点,因此复杂度是树的高度,即O(logn)。

然而这不是一定的,如果恰好每次调用 Union
都把一个大树的父指针指向一棵小树的根节点,那么树的高度会不断增大,最坏会退化为一个链表,此时findRoot的复杂度最坏退化为O(n)。

为了findRoot稳定维持在O(logn),需要想办法每次Union()完之后,树依旧保持平衡(树的所有分支的高度差近似相等)。

具体做法是Union时让小树的根节点P指向大树的根节点Q,不要让大树指向小树,这样树的高度就不会增加。

假如大树P的最大高度为10,小树Q最大高度为5:

小树P -> 大树Q, 最大高度还是10

大树Q -> 小树P,最大高度变成11

为此我们需要一个Size数组记录每个树的节点个数,并且优化Union():

// 连接 p 和 q 两个节点func (uf *UF) Union(p, q int){   P := uf.findRoot(p)   Q := uf.findRoot(q)   if P == Q{ // 说明 p 和 q已经在同一棵树下,已经连通      return   }
   if uf.Size[P] > uf.Size[Q]{    // P是大树      uf.Parent[Q] = P   // P作为父节点      uf.Size[P] += uf.Size[Q]  // Size数组记录每个树的节点个数   }else{      uf.Parent[P] = Q   // Q作为父节点      uf.Size[Q] += uf.Size[P]   }   uf.count--}

size大的树就是大树,这样树的高度和findRoot的复杂度就维持在O(log(n))。

我还见过其他方法是用size数组存储每个节点的高度,而不是树的节点个数,初始高度为1。

// 连接 p 和 q 两个节点func (uf *UF) Union(p int, q int){  P := uf.findRoot(p)  Q := uf.findRoot(q)  if P == Q{  // 说明 p 和 q已经在同一棵树下,已经连通    return  }
  if uf.Size[P] > uf.Size[Q]{    uf.Parent[Q] = P  }else if uf.Size[P] < uf.Size[Q]{    uf.Parent[P] = Q  }else{    uf.Parent[Q] = P    uf.Size[P] += 1  }}

所谓的按秩合并指按小树根节点指向大树根节点的秩序合并两棵树,“秩”具体是指树的高度或者树的节点数。按秩合并的并查集,其findRoot方法的复杂度可以稳定在O(logn)。

路径压缩

路径压缩是指进一步缩短树的高度,使每棵树从高瘦变成胖矮(增加根节点的分支数,即广度;减少树的高度),从而使得树的高度一直维持在常数级别(具体来说,是维持在3层以内),这样findRoot()的复杂度就会降至O(1),Connected和Union方法也会降到O(1)。

做法有2种:

1、执行findRoot时把遍历到的节点的向上指针从父节点指向祖父节点,相当于把遍历到的每个节点往上提,每个树最多只有3层。

func (uf *UF) findRoot(node int) int{   for uf.Parent[node] != node{   // 当遍历到某个节点的父节点是自己时,该节点就是node所在的树的根节点      uf.Parent[node] = uf.Parent[uf.Parent[node]]   // node的向上指针指向其祖父节点,7指向9,9再指向5      node = uf.Parent[node]    // 从7找起,则会略过4和2   }
   return node}

2、执行findRoot时把遍历到的节点的向上指针全部指向根节点(使用后序遍历),每个树最多只有2层。

func (uf *UF) findRoot(node int) int{  if uf.Parent[node] == node{    return node  }
  root := uf.findRoot(uf.Parent[node])  uf.Parent[node] = root  return root}

Union-Find算法完整版

type UF struct{   Parent []int   // 一维数组存储并查集的所有树(森林)   Size []int    // 每个节点作为根节点的树下的节点总个数   count int     // 记录连通分量}
func (uf *UF) Init(n int){ // n是图的节点个数   uf.Parent = make([]int, n)   uf.Size = make([]int, n)   for i:=0; i<n; i++{      uf.Parent[i] = i      uf.Size[i] = 1   }   uf.count = n}
// 连接 p 和 q 两个节点func (uf *UF) Union(p, q int){   P := uf.findRoot(p)   Q := uf.findRoot(q)   if P == Q{ // 说明 p 和 q已经在同一棵树下,已经连通      return   }
   if uf.Size[P] > uf.Size[Q]{    // P是大树      uf.Parent[Q] = P   // P作为父节点      uf.Size[P] += uf.Size[Q]   }else{      uf.Parent[P] = Q   // Q作为父节点      uf.Size[Q] += uf.Size[P]   }   uf.count--}
// 寻找某节点的父节点func (uf *UF) findRoot(node int) int{   for uf.Parent[node] != node{   // 当遍历到某个节点的父节点是自己时,该节点就是node所在的树的根节点      uf.Parent[node] = uf.Parent[uf.Parent[node]]   // node的向上节点指向其祖父节点      node = uf.Parent[node]   }
   return node}
// 判断p和q是否连通func (uf *UF) Connected(p, q int) bool{   return uf.findRoot(p) == uf.findRoot(q)}
func (uf *UF) Count() int{   return uf.count}

既然有了路径压缩,size数组的按秩平衡还需要吗?这个问题很有意思,因为路径压缩保证了树高为常数(不超过
3),那么树就算不平衡,高度也是常数,基本没什么影响。

论时间复杂度的话,确实,不按秩平衡也是 O(1)。但是如果加上size数组辅助,效率还是略微高一些,比如下面这种情况:

如果按秩平衡优化,一定会得到情况一,而不按秩平衡,可能出现情况二。情况一根本不会触发路径压缩,而情况二会多执行很多次路径压缩,将第三层节点压缩到第二层。

也就是说按秩平衡可以避免多余的路径压缩发生。

02

最小生成树算法

最小生成树算法的作用是将一个图的多余边去除,使得图中所有顶点满足连通性的同时,边的数量减小到最小边数,或者说使边的权重总和最小,此时这个取出多余边的图我们称为最小连通子图,最小连通子图没有回路,因此是一个树形结构,又称为最小生成树。

最小生成树的应用场景很多,小到我们要来个欧洲十国游,怎么规划路径让交通费用最低,大到国家的电力网、公路网、通信网,怎么规划路径可以让建设成本最低,学习了最小生成树后,你就可以通过算法来计算出最佳路径了,不仅如此,所有涉及连接网络中所有节点的最优路径问题,都可以通过最小生成树来处理。

如果我们将每条边看成是一个顶点到达另一个顶点的成本,最小生成树算法的目的是将整个图所有节点连通的成本最小化。而最短路径算法则是算出任意两个节点连通的最小成本。

例如一个图原本的样子是这样

变成最小生成树后是这样

最小生成树算法采用贪心算法:

1、创建一个“已连通集合(已触达顶点集合)”用于存放已经遍历过并且加入到生成树的顶点。创建一个一维数组arr表示最小生成树,顶点 i 的父节点为
arr[i]。生成树arr有一个虚拟根节点 -1。

2、从图中任意选择一个顶点A加入到“已连通集合”,节点A作为 arr 的下标0,arr[0] = -1,即顶点A的父节点是虚拟节点 -1。

3、从“已连通集合”中选择一个from节点,要求from节点和from的未访问过的相邻节点to节点的距离最小。将to顶点加入到“已连通集合”和最小生成树中。

4、重复上述过程,直到原图的所有顶点都加入到“已连通集合”和最小生成树中。

所生成的最小生成树的边的条数 = 总顶点数 - 1 = n - 1

代码实现如下:

// 最小生成树算法func buildMinTree(g [][]int) []int{  num := len(g)    // 顶点数  minTree := make([]int, num)  visited := make(map[int]struct{})  // 已连通集合
  curVext := 0  // 当前遍历到的,即将放入集合内的顶点  minTree[curVext] = -1    // 将 0 号节点放入集合中  visited[curVext] = struct{}{}
  for len(visited) < num{    minWeight := math.MaxInt32    // 已连通集合的某节点 和 未连通的某节点 之间最小距离    var from, to int    for nextFromVext, _ := range(visited){  // 从已连通集合中选择一个from节点      for nextToVext:=0; nextToVext<num; nextToVext++{  // 从所有from节点的相邻节点选择一个未访问的且距离最小的to节点作为下一个生成树节点        if _, ok := visited[nextToVext]; !ok && g[nextFromVext][nextToVext] < minWeight{ // nextFromVext 到 nextToVext的距离小于 minWeight 既说明 这两个顶点是相邻的(因为他们的距离 比 MaxInt 小),又说明本nextToVext顶点比上一次的nextToVext顶点离 nextfromVext顶点近          from = nextFromVext          to = nextToVext          minWeight = g[nextFromVext][nextToVext]        }      }    }
    visited[to] = struct{}{}    minTree[to] = from  }  return minTree}

// 测试用例(用的是最短路径那个例子)func TestBuildMinTree(t *testing.T) {  m := math.MaxInt32  matrix := [][]int{    {0,5,2,m,m,m,m},    {5,0,m,1,6,m,m},    {2,m,0,6,m,8,m},    {m,1,6,0,1,2,m},    {m,6,m,1,0,m,7},    {m,m,8,2,m,0,3},    {m,m,m,m,7,3,0},  }  fmt.Println(buildMinTree(matrix))    // [-1 0 0 1 3 3 5]}

评论