最小生成树--kruskal算法(并查集+贪心)

本文深入解析Kruskal算法原理及其在最小生成树问题中的应用,结合并查集数据结构优化算法效率,附带实例代码,助力读者掌握核心算法思想。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

忘了定义的来复习一下吧:树是指没有环的连通无向图(这样的树也叫无向树)。生成树是指连通无向图的极小(极小是指边数目最小)连通子图。最小生成树,就是对于一个加权图来说,所有生成树中边的权值之和最小的那一个。显然,n个顶点的图,生成树有n-1条边。我们先说一个重要定理(有兴趣的自己去看证明):
一个无向图的最小生成树。必然包含权值最小的边;必然不包含环路中权值最大的边(或者至少有一条这样的边不被包含)。
什么意思呢?
在这里插入图片描述
比如上图,它的最小生成树必然包含边1,不能同时包含三个6。

最小生成树有两个非常漂亮的算法:prim算法和kruskal算法。我们今天来讨论后者。
它的基本思想就是:每次选取不成环的最小边,直到选出n-1条边。这是贪心思想,每次只取当前最小。
比如上图,先选1(想一下上面的定理,第一步为什么是对的)。然后是2、3、4,都不成环。
在这里插入图片描述
选到5的时候,要注意了,有两条权值为5的边是成环的。所以只能选取v2和v3之间的边。
在这里插入图片描述发现这时已经选了5条边,已经是最小生成树。所以,最小权值之和为15。
问题的关键就在于,在算法实现中如何选取不成环的边呢?
有些算法看起来简单,实际上想要彻底理解还需要一番功夫。

查找最小边比较简单,把边集存储下来,排序,每次找最小。但是判断成环就比较麻烦了,要检查这条边连接的两个点是否在一个连通分量中。(如果两者在同一连通分量,直接连接必然成环)。如何判断呢?

答案就是并查集(union-find set)。顾名思义,就是快速查询和合并。并查集不支持删除。

我们可以这样想,选取一条边,那么这两个顶点就是“父子”关系。这样的关系绝对不成环(乱伦哈哈哈)对吧,所以每次选取一条边,它两个顶点所在的两个不同的并查集就形成父子关系。如果它们在同一并查集中,那这条边就不予考虑。
我们可以用一个数组father[]来表示图的并查集。数组的下标就是顶点编号,内容就是这个顶点的“父亲”。如果内容为负数说明它是“祖先”。这就是一个树形结构。
合并也很简单,把某个顶点的father[i]改成它的“父亲”,O(1)时间。

回到刚才的图,我们先开始选取的1,2,3,三条边,它们形成三棵树(马上住,滑稽~)。
v1—v3
v2—v5
v6—v4(我故意4、6反过来写,待会就会发现这里有优化的地方)

然后选取4的时候,有两棵树被合并了。
v1—v3—v6—v4
v2—v5

再选取边,1-3和3-4已经在一个并查集中,不能再选。
判断两个顶点是否在一个并查集就很简单了。因为每一个并查集只有一个“祖先”,所以要有一个“寻根函数”root()。它是一个递归函数,如果father[i]是负数就返回 i 本身,否则返回 root(father[i])。如果两个顶点的root值相等,那就表明它们在同一并查集。

int root(int x)
{
    if(father[x]<0) return x;
    else return root(father[x]);
}

看起来完成了,但是真的完成了?

精益求精嘛,刚才看到,合并v1—v3 和 v6—v4两棵树的时候,树的深度达到了4。如果并查集的规模再大一点,比如100万,那就是“退化树”,相当于一个100万长度的链表。如果要查找两个顶点是否在一个并查集,平均查找长度达到了O(n)。这显然是不可接受的。

为了解决这个问题,我们再次用到寻根函数。每次合并时,把两个并查集的“祖先”形成父子(这回不能叫父子了,应该叫长幼哈哈)。反正并查集不考虑具体连接关系,只要能表示连通分量即可。这样,每次合并后,就大大缩短了树的深度。

比如刚才的v1—v3和v6—v4,祖先分别为1和6。那么我们就合并成
v1—v6—v4
|
v3

这样深度就是3了。别小看减少一层,如果多次合并,可是减小成千上万层的。

那还能不能优化呢?能!合并的时候,我们说把两个祖先形成父子,那么谁当爸爸呢?当然是牛B的一方当爸爸了!什么意思呢?谁的深度大,谁当爸爸。
骚年,你深度不够,你只配做我儿子~

如果刚才是v1—v3(深度为2)和v6—v4—v5(深度为3)的话,我们看两种情况
v1—v6—v4—v5 (深度为4)
|
v3

v6—v4—v5 (深度为3)
|
v1
|
v3

所以当然是深度大的当爸爸,这样就可以保持整个并查集的深度最小。

那如何表示深度呢?注意到刚才说的“祖先”的father[i]是负数了吧,我们可以利用它,用它的绝对值来表示以它为根的并查集的深度(负数比较大小的时候要千万注意,注意,注意!重要的事情说三遍)。假设将要合并的两个根的深度分别为d1,d2(d1<d2,换成father值,也就是d1,d2的相反数,就是father[1]>father[2]),合并的时候,d2将要“当爸爸”,成为新的并查集的根,它的深度就是max{d2 , d1+1},father就是min{ father[2] , father[1]-1 }

合并函数也出来了:

inline int Min(int x,int y)
{
    return x<y?x:y;
}
bool Merge(int x,int y)
{
    x=root(x);
    y=root(y);
    if(x==y) return false;//在同一并查集中,合并失败
    if(father[x]>father[y])
        father[y]=Min(father[x]-1,father[y]),father[x]=y;
        //deep(x)<deep(y),y当爸爸
        //注意两个不能写反,想想为什么
    else
        father[x]=Min(father[y]-1,father[x]),father[y]=x;
    return true;
}

一开始都是单个结点,所以都是祖先,深度都为1。把它们都赋初始值-1。
最后考虑图的存储。因为kruskal算法只考虑边,结点仅仅用于判断成环,所以邻接矩阵和邻接表都是多余的。只需要存储边集合。边要表示出权值和所连接的两顶点。

例题:一个v(2<=v<=10000)台计算机组成的阵列,编号为1~v,需要连成局域网。有e(1<=e<=600000)个可能的连接,每个连接能连通两台计算机i,j(i,j<=v),需要成本price(int范围内)。现在要保证这v台计算机都在一个局域网中,求满足这样条件的最小成本。没有自环,可能有重边。
输入:e+1行。第一行为两个正整数v,e。接下来e行,每行三个正整数 i j price,含义见题目描述。
输出:一行,为最小成本。
时间限制:1000ms 内存限制:64MB

贴上完整代码

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<algorithm>
using namespace std;
typedef struct
{
    int v1;
    int v2;
    int weight;
}edge;
edge elist[600000];
int father[10001];
int cmp(edge a,edge b)
{
    return a.weight<b.weight;
}
int root(int x)
{
    if(father[x]<0) return x;
    else return root(father[x]);
}
inline int Min(int x,int y)
{
    return x<y?x:y;
}
bool Merge(int x,int y)
{
    x=root(x);
    y=root(y);
    if(x==y) return false;
    if(father[x]>father[y])
        father[y]=Min(father[x]-1,father[y]),father[x]=y;
    else
        father[x]=Min(father[y]-1,father[x]),father[y]=x;
    return true;
}
int main(int argc,char *argv[])
{
    int v,e,i,cnt=0;
    long long totalw=0;
    scanf("%d %d",&v,&e);
    for(i=0;i<e;i++)
        scanf("%d %d %d",&elist[i].v1,&elist[i].v2,&elist[i].weight);
    sort(elist,elist+e,cmp);
    memset(father,-1,sizeof(father));
    i=0;
    while(cnt<v-1)
    {
        if(Merge(elist[i].v1,elist[i].v2))
            totalw+=(long long)elist[i].weight,cnt++;
        i++;
    }
    printf("%lld\n",totalw);
    return 0;
}

小长假最后一天,2019撸起袖子加油干,我们都是追梦人!

### Kruskal算法最小生成树问题中的实现详解 Kruskal算法是一种用于求解连通无向最小生成树的经典算法。它基于贪心算法的思想,通过将中的边按照权值从小到大排序,并使用并查集来避免生成环路,从而逐步构建出最小生成树。 #### 贪心算法的核心思想 贪心算法是一种在每一步选择中都采取当前状态下最优策略的算法设计方法。在Kruskal算法中,贪心策略体现在每次选取权值最小的边加入生成树时,确保该边不会导致环路的形成。这种局部最优的选择最终能够保证全局最优解的实现[^1]。 #### 并查集的作用 并查集(Union-Find Set)是一种用于处理动态连通性问题的数据结构。在Kruskal算法中,它被用来判断添加某条边是否会形成环路。具体来说,通过查找两个顶点是否属于同一个集合,可以快速判断它们之间是否存在路径。如果两个顶点已经连通,则说明加入这条边会导致环路;否则,可以安全地将这条边加入生成树,并将两个顶点所在的集合合并[^4]。 #### Kruskal算法的实现步骤 以下是Kruskal算法的具体实现步骤: 1. **初始化**:将中的所有顶点视为独立的集合,即每个顶点单独构成一棵树。 2. **排序**:将中的所有边按照权值从小到大进行排序。 3. **遍历边**:依次检查每条边,判断其连接的两个顶点是否属于不同的集合: - 如果属于不同集合,则将这条边加入最小生成树,并合并这两个集合。 - 如果属于同一集合,则跳过这条边以避免形成环路。 4. **终止条件**:当最小生成树中包含 `n-1` 条边(`n` 为顶点数)时,算法结束。 #### 代码实现 以下是一个基于C语言的Kruskal算法实现示例: ```c #include <stdio.h> #include <stdlib.h> typedef struct { int u, v, weight; } Edge; int findSet(int parent[], int x) { if (parent[x] != x) parent[x] = findSet(parent, parent[x]); return parent[x]; } void unionSet(int parent[], int rank[], int x, int y) { int xRoot = findSet(parent, x); int yRoot = findSet(parent, y); if (rank[xRoot] < rank[yRoot]) parent[xRoot] = yRoot; else if (rank[xRoot] > rank[yRoot]) parent[yRoot] = xRoot; else { parent[yRoot] = xRoot; rank[xRoot]++; } } int compare(const void* a, const void* b) { return ((Edge*)a)->weight - ((Edge*)b)->weight; } void kruskalMST(Edge edges[], int numEdges, int numVertices) { Edge result[numVertices - 1]; int e = 0, i = 0; qsort(edges, numEdges, sizeof(Edge), compare); int parent[numVertices], rank[numVertices]; for (int node = 0; node < numVertices; node++) { parent[node] = node; rank[node] = 0; } while (e < numVertices - 1 && i < numEdges) { Edge next_edge = edges[i++]; int x = findSet(parent, next_edge.u); int y = findSet(parent, next_edge.v); if (x != y) { result[e++] = next_edge; unionSet(parent, rank, x, y); } } printf("Minimum Spanning Tree Edges:\n"); for (int j = 0; j < e; j++) printf("%d - %d : %d\n", result[j].u, result[j].v, result[j].weight); } ``` #### 算法复杂度分析 - **时间复杂度**:Kruskal算法的主要开销在于对边的排序和并查集操作。假设中有 `m` 条边和 `n` 个顶点,则排序的时间复杂度为 `O(m log m)`,而并查集的操作平均复杂度接近于常数 `O(α(n))`,其中 `α` 是阿克曼函数的反函数。因此,总体时间复杂度为 `O(m log m)`[^3]。 - **空间复杂度**:需要存储边的列表和并查集数据结构,空间复杂度为 `O(m + n)`。 ### 总结 Kruskal算法通过结合贪心策略和并查集数据结构,能够在连通无向中高效地求解最小生成树问题。其核心在于通过排序选择权值最小的边,并利用并查集确保生成树的无环性[^2]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值