Kruskal算法是解决最小生成树的经典算法,其思想比较直接:按权从小到大遍历所有边,有用的就留着。从整体上看,该算法会用选出的边,把图中所有的点逐渐连通起来。
这里的难点其实在于连通性的记录与维护。我们选择数组和单向链环的复合结构来解决:数组的角度看,可以快速查找任意编号点的记录;从单向链环的角度看,可以快速合并两个连通域。
type node struct {
next *node //指向连通域中下一节点,以构成单向链环
mark int //正数表示所属连通域编号(连通域首节点编号)
} //负数表示以此点为首的连通域中点的计数
我们用一个编号来来记录点所属的连通域,并在编号与此相同的点的记录中保存域中点的数目。
首先,对输入的边进行排序,以便于按权从小到大遍历。接着,把每个点视为独立连通域,再将其聚合至一个包含所有点的连通域。在此过程中,我们把规模小的连通域并入规模大的连通域。
func Kruskal_v2(roads []graph.Edge, size int) (uint, error) {
if size < 2 || len(roads) < size-1 { return 0, errors.New("illegal input") }
sort.Less[graph.Edge](edgeLess).Sort(roads) //对边集排序
nodes := make([]node, size)
for i := 0; i < size; i++ { //初始化点的记录
nodes[i].mark, nodes[i].next = -1, &nodes[i]
}
trace := func(id int) int {
if nodes[id].mark < 0 {
return id
} else {
return nodes[id].mark
} }
sum := uint(0)
for _, path := range roads {
active, another := trace(path.A), trace(path.B)
if active == another {
continue
}
sum += path.Weight //加入此边
if -nodes[active].mark < -nodes[another].mark {
active, another = another, active
}
nodes[active].mark += nodes[another].mark //并少入多
tail := nodes[active].next //两连通域
nodes[active].next, nodes[another].next = nodes[another].next, tail
for u := nodes[active].next; u != tail; u = u.next {
u.mark = active
}
if -nodes[active].mark == size { return sum, nil }
}
return sum, errors.New("isolated part exist")
}
这里记E为边数,V为点数。我们知道,对边排序的复杂度是O(ElogE),对边遍历的复杂度是O(E)。至于连通操作,其实是O(VlogV)级的。E显然不小于V,故整体的复杂度是O(ElogE)。
我们回来探究一下连通操作的复杂度:
设f(X)为连通操作在最坏情况下的复杂度,A是不大于1/2的系数,则 f(X) = MAX( f(AX) + f((1-A)X) + AX )
若 f(X) = O(XlogX),有 f(AX) + f((1-A)X) + AX = X(logX + (AlogA + (1-A)log(1-A) + A))
其中,令 B = 1/A >= 2,有 AlogA + (1-A)log(1-A) + A = A((1 - ( logB + (B-1)log(1/(1-A)) )
因 B-1 > 0,1/(1-A) > 1,有 logB + (B-1)log(1/(1-A)) > logB >= 1
进而 AlogA + (1-A)log(1-A) + A <= 0,即有 f(AX) + f((1-A)X) + AX <= XlogX
最后,易归纳得 f(X) = O(XlogX) 有效
Kruskal算法在稀疏图上有不错的性能,可惜下一节的Prim算法将会超越它。
Prepare Graph [1000000 vertexes & 15172126 edges]
Kruskal: 2.45385937s
Prim: 662.08132ms