概述
定义
图的生成树时它的一棵含有其所有顶点的无环连通子图。
加权图的最小生成树是它的一棵权重和最小的生成树。
从上面的定义可以看出,生成树就是一个极小连通子图,而最小生成树是针对加权图而言的。
引例
首先考虑无权图这一简单情况:如果未定义权重,则存在多个生成树,此时的生成树没有最小最大之分,毕竟是一个无权图的生成树,无论怎么选择都是n个点,n-1条边。
无向无权图的生成树可以直接利用图的遍历算法解决(DFS, BFS)
下面首先给出一个最简单的求无权图生成树的算法示例。
#include <stdio.h>
#include <string.h>
#define V 50
int edges[V][V];
int vis[V];
int tree[V];
int next[V];
int vcnt;
void dfs(int v)
{
int i;
vis[v] = 1;
for(i = 0; i < vcnt; i++) {
if(edges[v][i] && !vis[i]) {
next[i] = tree[v];
tree[v] = i;
dfs(i);
}
}
}
void tree_print(int v)
{
printf("%d ", v);
int child;
for(child = tree[v]; child!=-1; child = next[child])
{
tree_print(child);
}
}
int main()
{
int ecnt, i;
memset(tree, -1, sizeof(tree));
scanf("%d%d", &vcnt, &ecnt);
for(i = 0; i < ecnt; i++)
{
int x, y;
scanf("%d%d", &x, &y);
edges[x][y] = 1;
edges[y][x] = 1;
}
dfs(0);
tree_print(0);
}
从上面的代码可以看出,对一个无权图进行dfs,每次找到新的节点,则把它加入到父节点的子树下,最后先序遍历整棵树即可。做算法题和数据结构课中的程序的最大区别就是只要空间占用不过分,怎么简单怎么来。直接使用两个数组tree[]
, next[]
来表示树,这种方法不仅简单而且不容易错。
有权图的最小生成树
求最小生成树主要有两种算法:
- Prim算法
- Kruskal算法
Kruskal算法原理
Kruskal算法的基本步骤
Kruskal算法按照边的权重大小处理,每一次从待选边中选出最小的边,企图加入到生成树当中。但此时存在一个问题,若当前边加入生成树后存在环路,则该边废弃。一直重复这一过程,直到所有的点都已经加入生成树,或含有V-1条边,V为顶点个数。
可以看出,Kruskal算法是一种贪心算法,而贪心算法最关键的是必须证明该算法得到的是最优解,而非次优解。
算法证明
首先引入两个概念
切分
图的切分将一副图中所有顶点分为两个非空且不相交的子集。
横切边是一条连接两个不同集合中的顶点的边。
切分定理
在一幅加权图中,给定任意的切分,横切边中权重最小的边一定属于最小生成树。
简单证明一下切分定理:
若存在一条权重最小的横切边e不属于最小生成树T,则把e加入T中一定构成环,且该环一定跨越两个子集(显而易见,e本身就跨越两个子集)。该环中一定存在另一横切边f,且f权重大于e(由e为权重最小的横切边可得),此时去掉f,则最小生成树的权重和更小,与假设矛盾。
切分定理的引入使得我们非常方便的证明Kruskal算法的正确性。
根据算法,每一次从待选边集中选择一条不与当前生成树构成环且权重最小的边e加入到最小生成树T中,则一定存在3种情况,设e的端点为a,b,则a,b或者都不在T中,或者其中之一在T中,或者a,b虽然在T中,但ab之前并不连通。则不难看出不管哪一种情况下,都可以构建一种切分,使得ab在两个不同的集合中,而e是我们能找到的最小权重边,当然也是最小权重的横切边,则可证明e一定输入T。
若e与T构成环,则无论怎么切分,都找不到一种能让e成为最小权重横切边的方案,因为ab已经存在再T中,无论怎么划分,都找不到只存在e一条横切边的方案,而其他横切边中一定存在比e权重小的边(早在e之前被选中的边)。
Kruskal算法实现
Kruskal算法不经过优化前的复杂度较高,原因有两个:
1。 每次选中一个边时需要检测边加入后是否存在环,也就是两个端点不在同一子图中。
2。 每次选择需要找到权重最小的边。
对于第一个问题,使用并查集(Union-Find Set)可以解决这一问题。
对于第二个问题,可以对边按权重排序或使用优先队列(priority queue)。
并查集
并查集顾名思义,是一种支持高效集合并、集合查找的数据结构,这里不深入阐述并查集的详细原理,简单来说并查集中每一个集合都看做一棵树,例如存在集合A(1,2,3), B(4,5),则看成树状之后,可描述成:A:(1(2,3)) B:(4(5))。
当描述成树之后,集合并就是找到两个节点的根节点,将任意根节点的父节点改成另一根节点;查找则变成了查找两个节点的根节点是否一致。不难看出,通过这种优化,若树只有两层,则并和查找的速度就是O(1)的。而如何维护树,使得该树能够保持层数最少,这里不详细阐述,我们的代码也不会对这一部分进行优化。
优先队列
优先队列是一种按照优先级进行出队操作的数据结构,最常见的就是二叉堆。
二叉堆的实现不说了,事实上很多编程语言的标准类库中都包含了优先队列,方便开发者使用,例如C++STL的priority_queue,Java类库里的PriorityQueue,这里使用STL的priority_queue。
示例程序
有了上面的基础,Kruskal算法非常简单就能够写出来了。
先贴出代码
#include <iostream>
#include <queue>
#include <cstring>
#define V 128
#define E 256
struct Edge
{
int x, y;
int weight;
bool operator<(const Edge &e) const {
return weight > e.weight;
}
Edge(int x, int y, int w)
{
this->x = x;
this->y = y;
this->weight = w;
}
};
int uf[V];
std::priority_queue<Edge> queue;
int uf_find(int x)
{
for(; uf[x] != -1; x = uf[x])
;
return x;
}
bool uf_union(int x, int y)
{
int xroot = uf_find(x);
int yroot = uf_find(y);
if(xroot == yroot)
return false;
uf[yroot] = xroot;
return true;
}
void kruskal()
{
int n = 0;
while(!queue.empty() && n < V-1)
{
Edge e = queue.top();
queue.pop();
if(uf_union(e.x, e.y)) {
std::cout << e.x << " " << e.y << " " << e.weight << std::endl;
n++;
}
}
}
int main()
{
int v, e;
std::cin >> v >> e;
int x, y, w;
for(int i = 0; i < e; i++) {
std::cin >> x >> y >> w;
queue.push(Edge(x, y, w));
}
memset(uf, -1, sizeof(uf));
kruskal();
}
可以看出,当并查集和优先队列都写好后,Kruskal算法的实现非常简单明了,每次从中拿出一个权重最小的边(优先队列管理),判断加入后是否为环(并查集管理),若能加入则输出信息,直到队列为空或选中V-1条边(树有且仅有V-1条边)。
由于C++已经一年多没用了,最近为了刷题才重新使用,刷题基本上用不到操作符重载、泛型等,所以以上代码不保证正确性。