一、前置知识
1.图的定义
首先简单回顾一下图的定义,这是我们之前的文章中提到的:
图 (graph) 是一个二元组 G = ( V ( G ) , E ( G ) ) G=(V(G), E(G)) G=(V(G),E(G))。其中 V ( G ) V(G) V(G) 是非空集,称为点集 (vertex set),对于 V V V 中的每个元素,我们称其为顶点 (vertex) 或节点 (node),简称点; E ( G ) E(G) E(G) 为 V ( G ) V(G) V(G) 各结点之间边的集合,称为边集 (edge set)。1
简单来说,图就是由数个点和边构成的一种结构,两个点之间通过边连接。
边可分为有向边和无向边,两者的区别在于有向边限定了两点之间行进的方向,而无向边没有做出限定。同时,对于某些场景(例如最短路的计算),每一条边都可以被赋予一个值,使得不同的边具有不同的权重,叫做边的边权。
2.树以及生成树的定义
树(tree)在形象上与我们日常认知的树相符,即从一个根节点向上不断分化出枝条,并在末端形成若干树叶。
在定义上,树是n(n≥0)个结点的有限集,当n=0时,称为空树。在任意一棵树非空树中应满足:
- 有且仅有一个特定的称为根 (root) 的结点;
- 当n>1时,其余结点可分为m(m>0)个有限集 T T T1 , T T T2…… T T Tm,其中每一个集合本身又是一颗树,并且称为根的子树(SubTree)。2
这种定义是树的递归定义法,看上去可能比较难以理解;更简单地:
树是一种无环且连通的特殊图,n个结点的树(n≥1)有n-1条边。
而生成树则是一种特殊的树,生成树是原图的一种子图,其顶点数量和原图一致,且边数量为原图边集的子集。
3. 最小生成树问题引入
要在n个城市之间铺设光缆,主要目标是要使这 n 个城市的任意两个之间都可以通信,但铺设光缆的费用很高,且各个城市之间铺设光缆的费用不同,因此另一个目标是要使铺设光缆的总费用最低。这就需要找到带权的最小生成树。3
最小生成树问题就是要将图中所有点相连,并使花费的费用最小。
在最小生成树的问题中,我们一般默认原图是无向连通图。
二、最小生成树算法
最简单且常用的最小生成树算法有两个:Kruskal算法以及Prim算法。
1. Kruskal
K r u s k a l Kruskal Kruskal 算法是一种贪心算法,其核心思想是优先加边,再考虑加边后的图是否是一棵树。
该算法的基本流程是:
-
初始化:
将原图所有边的两个端点以及边权大小的信息共同存储到 w y wy wy 数组中,并按照权重大小进行排序。
-
加入边并判断是否成环:
创建一个空集合 T T T,向集合中加入未加入过的权重最小的边,同时判断此时 T T T 集合中的所有边是否形成了环,如果形成了环,则将这条边踢出。
-
循环:
重复第 2 2 2 步直到加入边的数量达到 N N N - 1。如果直到循环过了所有边, T T T 中边的数量依旧不是 N N N - 1,则说明原图是不连通的,不存在最小生成树。
在该算法中有判断成环的过程,这里我们介绍一种使用并查集的方法,并查集可以用很快的速度通过判断两个节点的根节点是否一致来判断这两个节点是否已经连通,而将两个已经连通的点相连则一定会形成环。
该算法的时间瓶颈在于对所有边的排序,故时间复杂度为 O ( m ∗ l o g m ) O(m * logm) O(m∗logm)。
算法证明:
K r u s k a l Kruskal Kruskal 算法利用了树是无环图的特性。因为循环中所有点一定没有出现环,且循环终止的条件是所有连接了所有点,满足树的定义,因此容易证明最后的结果一定是一种生成树。
而要证明这颗生成树是最小的,可以使用归纳法:
首先,设原图的最小生成树为 T T T( T T T 一定存在,如果原图不存在最小生成树,说明原图是不连通的,与最小生成树问题的定义相悖),每次加入的边为 e e e:
每一次循环,如果 e e e不属于 T T T,则 e e e+ T T T一定会形成环,此时假设 T T T1为已被选中的边集:
-
如果 e e e+ T T T1已经形成了环,则根据算法 e e e会被踢出,正确。
-
如果 e e e+ T T T1还没有形成环,因为该算法是将边按照权重从小到大依次加入,这就说明在 e e e+ T T T所成环中一定存在一条权重大于 e e e的边,设为 m m m,那么删除 m m m加入 e e e之后的生成树的权重和小于 T T T的权重和,和假设相悖。因此这是不可能的。
而如果 e e e属于 T T T,根据上述证明,在 e e e之前所有加入的边都是 T T T的边,因此 e e e会被加入,正确。
故该算法最终一定能得到 T T T。
参考核心代码
int n, m, ans;
int fa[MAXN]; //并查集的父亲节点
struct nod{
int val;
int fr, to;
};
nod wy[MAXM];
bool cmp(nod x, nod y) //按边权大小排序
{
return x.val < y.val;
}
int find(int x)
{
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
int kruskal() //返回值是最小生成树的代价
{
int num = 0; //目前已经加入的边的数量
int ans = 0; //总代价
sort(wy + 1, wy + m + 1, cmp);
for(int i = 1; i <= n; i++) fa[i] = i;
for(int i = 1; i <= m; i++)
{
if(num == n-1) break;
if(find(wy[i].fr)==find(wy[i].to)) continue;
else{
ans += wy[i].val;
fa[find(wy[i].fr)] = find(wy[i].to);
num++;
}
}
if(num != n - 1) return -1; //-1代表图不连通
else return ans;
}
2. Prim算法
P r i m Prim Prim 算法同样是一种贪心算法。其核心思想与最短路的 D i j k s t r a Dijkstra Dijkstra 算法相似,不断地加入距离最近的点,使生成的图形同时满足最小和树两个性质。
该算法的基本流程是:
-
初始化:
首先,我们用一个二维 v e c t o r vector vector 的来存储原图中的所有边,并初始化一个数组 d i s dis dis 来记录目前与已有的生成树 T T T 相邻的点的距离,以及一个标记数组 v i s vis vis 来统计 T T T 中已经有了哪些点(初始所有的点都标记为 f a l s e false false)。
之后,我们在原图中随意选择一个节点(一般都是节点1)来作为我们生成树的根节点,将根节点的 d i s dis dis 修改为0。
-
加入点:
我们选择与 T T T 相邻的所有点中距离最近的点 e e e(如果 T T T 是空集,那么这个点就是我们选择的根节点),通过 v i s vis vis 数组判断 e e e 是否已经在 T T T 中,如果不在,将 e e e 加入到 T T T 中,并将 e e e 和 T T T 相连(连接的边就是距离最短的那条)同时 v i s vis vis 变为 t r u e true true 。
-
松弛:
我们将和 e e e 相邻的其他点都进行一次松弛。
也就是说,对于所有和 e e e 相邻的点 u u u ,如果满足 d i s [ u ] dis[u] dis[u] 小于 e e e 和 u u u 相连边的边权,则将 d i s [ u ] dis[u] dis[u] 改为其边权。
-
循环:
循环直到所有点都加入到 T T T 中,如果直到循环过了所有点, T T T 中点的数量依旧不是 N N N ,则说明原图是不连通的,不存在最小生成树。
在选择距离最近的点这一步中,我们可以暴力查找所有和 T T T 相邻的点,也可以使用优先队列的优化方式。
在这种方式下,我们将所有和 T T T 相邻的点加入到一个优先队列 q q q 中,并按照距离大小排序(小根堆排序),此时输出堆顶元素,就是距离最近的点。
在进行了优先队列优化后,该算法的时间复杂度是 O ( m ∗ l o g n ) O(m * logn) O(m∗logn)
可以发现, P r i m Prim Prim 算法在理论的时间复杂度上似乎更加优秀,但在大部分实际情况中, K r u s k a l Kruskal Kruskal 算法的速度更快。
算法证明
P r i m Prim Prim 算法的证明与 K r u s k a l Kruskal Kruskal 非常相似。
在 P r i m Prim Prim 算法中,所有点只会被加入到集合中一次,也就是说这同样满足了最终的图形是连通且无环的,因此一定是一种生成树。
而要证明这颗生成树是最小的,可以使用归纳法:
首先,设原图的最小生成树为 T T T( T T T 一定存在,如果原图不存在最小生成树,说明原图是不连通的,与最小生成树问题的定义相悖),每次加入的边为 e e e,加入的点为 v v v:
每一次循环,如果 e e e不属于 T T T,此时假设 T T T1为已被选中的点集,因为该算法会加入权重最小的边,这就说明在 v v v与 T T T1相连接的边中一定存在一个权重大于 e e e的边,设为 m m m,那么删除 m m m加入 e e e之后的生成树的权重和小于 T T T,和假设相悖。因此这是不可能的,所以 e e e一定属于 T T T。
故该算法最终一定能得到 T T T。
参考核心代码
struct nod{
int to, val;
};
vector<vector<nod>> wy(MAXN); //这里用二维vector的方式来存储边集
int n, m;
struct nod_d{
int x, val;
};
//用符号重载的方式来重载优先队列
bool operator<(const nod_d &x, const nod_d &y){
return x.val > y.val;
}
priority_queue<nod_d> q; //优先队列
int dis[MAXN]; //记录目前的生成树到所有未连接点的最短距离
int vis[MAXN]; //记录目前的生成树中已有哪些点
int prim() {
int num = 0; //目前已经加入的点的数量
int ans = 0; //总代价
for(int i = 1; i <= n; i++) dis[i] = 0x3f3f3f3f; //初始化,将所有边的距离都赋值为近似于正无穷
for(int i = 1; i <= n; i++) vis[i] = 0;
dis[1] = 0;
q.push({1, 0}); //假设1点为生成树的根节点(这个节点也可以是任何点)
while(!q.empty()){
if(num == n) break; //已经构建完成了最小生成树
int x = q.top().x, val = q.top().val; //取距离最近的点
q.pop();
if (vis[x]) continue;
vis[x] = 1;
num++;
ans += val;
for (int i = 0; i < wy[x].size(); i++) {
int to = wy[x][i].to, v = wy[x][i].val;
if (v < dis[to]) { //通过新加入的点来松弛相邻的边
dis[to] = v;
q.push({to, v});
}
}
}
if(num < n) return -1; //-1代表图不连通
else return ans;
}
三、练习题
严蔚敏,吴伟民.数据结构 (C语言版).北京.清华大学出版社.2009 ↩︎