DS图实战:从Prim到Kruskal,双算法构建最小生成树

1. 最小生成树:从生活场景到代码实战

想象一下,你是一个城市规划师,要在几个新建的居民区之间铺设光纤网络。每个居民区就是一个顶点,连接两个居民区的光纤线路就是一条边,铺设线路的成本就是边的权值。你的预算有限,目标是用最低的总成本,让所有居民区都能通过网络连通(任意两个区之间可以间接连通)。这个“用最省钱的方式把所有点连起来”的问题,就是图论中经典的最小生成树问题。

在数据结构与算法(DS图)的学习中,最小生成树是一个绕不开的实战课题。它不仅仅是理论,更是解决实际连通性优化问题的利器。今天,我就带你用两种最主流的算法——Prim算法Kruskal算法,亲手构建一棵最小生成树。我会用一个完整的代码实例,把两种算法的每一步掰开揉碎讲清楚,让你不仅知道它们怎么用,更能理解它们为什么这么用,以及它们构建树的“生长顺序”有何不同。很多初学者觉得这两种算法容易混淆,其实只要抓住它们的核心视角:Prim是**“从点出发,步步为营”,而Kruskal是“按边排序,合纵连横”**。接下来,我们就从一个具体的图数据输入开始,看看这两种思路迥异的算法,如何最终得到同一棵最经济的树。

为了让你有最直观的感受,我们全程会使用题目中的那个输入样例。这个图有6个顶点(v1到v6),10条边。我们的任务就是分别用Prim和Kruskal算法,找出连接这6个点的最小成本方案,并输出每一步具体添加了哪条边。你会发现,虽然最终的总权重都是15,但两条路径的“生长过程”截然不同,这其中的差异正是算法的精髓所在。

2. 环境与数据准备:构建我们的“施工地图”

在开始算法施工前,我们得先把“施工地图”,也就是图的数据结构搭建好。这里我们选择用邻接矩阵来存储这个无向网。邻接矩阵就像一个二维表格,行和列都代表顶点,表格里的值代表对应两个顶点之间边的权值。如果两点之间没有直接边,我们就用一个非常大的数(比如 0x3f3f3f3f,一个接近无穷大的数)来表示。

让我们看看代码中是如何构建这个图的。首先,我们定义了一个 MGraph 类。在它的构造函数里,我们按顺序读入顶点数 n、每个顶点的名称、边数 m,以及每一条边的具体信息(顶点1、顶点2、权值)。读入每一条边时,我们需要根据顶点名称,找到它们在顶点数组 vexs 中的下标位置,然后在邻接矩阵 edges 的对应位置存入权值。因为是无向图,所以 edges[pos1][pos2]edges[pos2][pos1] 都要赋值。

这里有个细节需要注意,也是新手容易踩坑的地方:图的存储一定要初始化。在代码里,我们用 memset(edges, 0x3f, sizeof(edges)) 把整个矩阵初始化为“无穷大”。这样,后续在判断两点是否连通时,只要看权值是否小于这个“无穷大”即可。最后,我们还会读入Prim算法的起点 v,并找到它的下标 startpos。这个起点可以是图中任意一个顶点,最终生成的最小生成树总权重是一样的,但树的形状和构建顺序可能会因起点不同而略有差异。

class MGraph {
public:
    int n; // 顶点数
    int m; // 边数
    string vexs[MAXN]; // 顶点名称数组
    int edges[MAXN][MAXN]; // 邻接矩阵
    string v;
    int startpos; // Prim算法起点下标

    MGraph() {
        cin >> n;
        for (int i = 0; i < n; i++) {
            cin >> vexs[i];
        }
        cin >> m;
        memset(edges, 0x3f, sizeof(edges)); // 初始化为“无穷大”
        for (int i = 0; i < m; i++) {
            string ch1, ch2;
            int weight;
            cin >> ch1 >> ch2 >> weight;
            int pos1 = -1, pos2 = -1;
            // 根据顶点名查找下标
            for (int j = 0; j < n; j++) {
                if (vexs[j] == ch1) pos1 = j;
                if (vexs[j] == ch2) pos2 = j;
            }
            // 无向图,矩阵对称赋值
            edges[pos1][pos2] = edges[pos2][pos1] = weight;
        }
        cin >> v;
        for (int i = 0; i < n; i++) {
            if (vexs[i] == v) {
                startpos = i;
                break;
            }
        }
    }
    // ... 后续算法函数
};

数据准备好后,我们的“地图”就清晰了。以输入样例为例,v1v6 这6个点,以及它们之间10条带权重的边,都被准确地记录在了 edges 矩阵里。有了这张地图,Prim和Kruskal两位“工程师”就可以开始他们的工作了。

3. Prim算法实战:像“种树”一样从起点生长

Prim算法的思路非常直观,它模拟的是一棵树从一颗种子(起点)开始,不断向外“生长”的过程。你可以把它想象成铺设网络:我们先选定一个机房作为核心(起点),然后每次都寻找距离当前已连通网络最近的一个未连通的站点,用成本最低的线路把它接进来。就这样,一个一个点,逐步把整个网络连通。

3.1 核心数据结构与初始化

Prim算法需要几个关键的辅助数组来记录状态:

  • visited:布尔数组,标记每个顶点是否已经加入最小生成树集合。
  • dis:整型数组,记录每个未加入的顶点到当前已生成树集合的最短距离。注意,这个距离不是到起点的距离,而是到整个已连通集合任意一点的最短边权。
  • adj:整型数组,记录当某个顶点通过一条边加入生成树时,它是从已生成树集合中的哪个顶点连接过来的。这用于最后输出边的构成。

算法开始时,我们把起点 startpos 标记为已访问 (visited[startpos] = true),并将它到自己的距离设为0 (dis[startpos] = 0)。对于其他所有顶点,我们初始化它们到生成树集合的距离 dis[i] 为起点到它们的直接边权(如果存在),否则就是无穷大 (INF)。同时,adj[i] 暂时指向起点(如果边存在)。

void prim() {
    vector<bool> visited(n, false);
    vector<int> dis(n, INF);
    vector<int> adj(n, -1); // 记录前驱顶点
    // 用于按顺序保存输出的边
    string vex1[100], vex2[100];
    int dist[100];
    int total = 0; // 总权重

    // 初始化起点
    dis[startpos] = 0;
    // 循环n次,每次加入一个顶点
    for (int i = 0; i < n; i++) {
        // 步骤1:从未加入的顶点中,选出距离生成树集合最近的那个
        int u = select(visited, dis, n);
        visited[u] = true;
        total += dis[u]; // 累加这条边的权重

        // 记录输出的边信息(起点不是第一个加入的顶点时)
        if (adj[u] != -1) {
            vex1[i] = vexs[adj[u]];
            vex2[i] = vexs[u];
            dist[i] = dis[u];
        }

        // 步骤2:用新加入的顶点u,更新其他未加入顶点到生成树集合的距离
        for (int v = 0; v < n; v++) {
            if (!visited[v] && edges[u][v] < dis[v]) {
                dis[v] = edges[u][v];
                adj[v] = u; // 记录v是通过u连接进来的
            }
        }
    }
    // 输出总权重和边的生长顺序
    cout << total << endl;
    cout << "prim:" << endl;
    for (int i = 1; i < n; i++) { // 第一个加入的起点没有对应的边,从i=1开始输出
        cout << vex1[i] << " " << vex2[i] << " " << dist[i] << endl;
    }
}

这里的 select 函数是一个简单的工具函数,负责从 dis 数组中找出未被访问且距离值最小的那个顶点的下标。

3.2 一步步拆解生长过程

让我们用输入样例,以 v1 为起点,手动推演一遍Prim算法的过程,这比看代码直观得多。

  • 初始状态:已生成树集合 S = {v1}dis 数组记录其他点到 S 的距离:v2:6, v3:1, v4:5, v5:INF, v6:INF
  • 第1轮:从未加入的顶点中,找到 dis 最小的 v3(距离为1)。将 v3 加入 S,添加边 (v1, v3, 1)。然后用 v3 更新其他点的 dis:发现 v3v6 的权值为4,小于 v6 当前的 INF,所以更新 dis[v6]=4, adj[v6]=v3v3v2 权值5,小于 v2 当前的6,更新 dis[v2]=5, adj[v2]=v3v3v4 权值5,等于 v4 当前的5,不更新;v3v5 权值6,更新 dis[v5]=6
  • 第2轮:当前 S = {v1, v3}dis 最小的是 v6(距离4)。加入 v6,添加边 (v3, v6, 4)。用 v6 更新:v6v4 权值2,小于 v4 当前的5,更新 dis[v4]=2, adj[v4]=v6v6v5 权值6,等于 v5 当前的6,不更新。
  • 第3轮S = {v1, v3, v6}dis 最小的是 v4(距离2)。加入 v4,添加边 (v6, v4, 2)。用 v4 更新,没有更优的边。
  • 第4轮S = {v1, v3, v6, v4}dis 最小的是 v2(距离5)。加入 v2,添加边 (v3, v2, 5)。用 v2 更新:v2v5 权值3,小于 v5 当前的6,更新 dis[v5]=3, adj[v5]=v2
  • 第5轮S = {v1, v3, v6, v4, v2}dis 最小的是 v5(距离3)。加入 v5,添加边 (v2, v5, 3)

至此,所有顶点加入完毕。总权重 = 1+4+2+5+3 = 15。输出的边顺序正是我们一步步“生长”出来的顺序:v1 v3 1 -> v3 v6 4 -> v6 v4 2 -> v3 v2 5 -> v2 v5 3。你可以看到,Prim算法构建的树,是从起点开始,像水波纹一样一圈圈扩散出去的。

4. Kruskal算法实战:像“拼图”一样合并连通块

如果说Prim算法是“种树”,那Kruskal算法就是“拼图”。它完全不关心起点,它的核心思想是:每次都从剩下的边里,挑选一条权重最小且不会让已选择的边形成环的边,加入到生成树中。直到挑选出 n-1 条边为止。这里的关键在于如何判断加入一条边是否会形成环,这就用到了并查集这个高效的数据结构。

4.1 核心思想与并查集的作用

Kruskal算法第一步是把所有的边按照权重从小到大排序。然后我们准备一个空的边集合(未来的最小生成树)。我们按顺序检查每一条边,如果这条边连接的两个顶点目前还不属于同一个连通分量(即加入这条边不会形成环),那么我们就选中这条边,并把这两个顶点所在的连通分量合并。如果它们已经属于同一个连通分量,那么加入这条边就会形成环,我们跳过它。

并查集就是用来高效管理“连通分量”的神器。初始时,每个顶点自成一个集合(父节点指向自己)。find 函数用来查找一个顶点所在集合的“根”(代表元)。union 操作(在代码里体现为 parent[x] = y)用来合并两个集合。判断两个顶点是否属于同一集合,就看它们的 find 结果是否相同。

4.2 代码实现与过程推演

我们来看Kruskal在代码中是如何实现的:

void kruskal() {
    // 步骤1:收集所有边,并按权值排序
    vector<Edge> allEdges;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) { // 无向图,只取上三角,避免重复
            if (edges[i][j] != INF) {
                allEdges.push_back({i, j, edges[i][j]});
            }
        }
    }
    sort(allEdges.begin(), allEdges.end());

    // 步骤2:初始化并查集,每个顶点都是独立的集合
    int *parent = new int[n];
    for (int i = 0; i < n; i++) parent[i] = i;

    int edgeCount = 0; // 已选边数
    cout << "kruskal:" << endl;
    // 步骤3:遍历排序后的边
    for (auto &edge : allEdges) {
        int rootU = find(edge.u, parent);
        int rootV = find(edge.v, parent);
        // 如果边的两端不在同一集合,则选中
        if (rootU != rootV) {
            // 按题目要求,输出时顶点按数组序号升序,这里vexs[edge.u]已经保证了顺序
            cout << vexs[edge.u] << " " << vexs[edge.v] << " " << edge.w << endl;
            parent[rootU] = rootV; // 合并集合
            if (++edgeCount == n - 1) break; // 已选够n-1条边,结束
        }
    }
    delete[] parent;
}

int find(int i, int parent[]) {
    // 递归查找根节点,带路径压缩的优化写法通常是 find(parent[i]),这里为清晰用了简单递归
    return i == parent[i] ? i : find(parent[i], parent);
}

现在,我们用同一张图来推演Kruskal算法的执行过程。首先,把所有边按权重排序: (v1,v3,1), (v4,v6,2), (v2,v5,3), (v3,v6,4), (v1,v4,5), (v2,v3,5), (v3,v4,5), (v1,v2,6), (v3,v5,6), (v5,v6,6)

  • 第1条边 (v1,v3,1)v1v3不在同一集合,选中。合并{v1,v3}。输出。
  • 第2条边 (v4,v6,2)v4v6不在同一集合,选中。合并{v4,v6}。输出。
  • 第3条边 (v2,v5,3)v2v5不在同一集合,选中。合并{v2,v5}。输出。
  • 第4条边 (v3,v6,4)v3属于集合{v1,v3},v6属于集合{v4,v6},不在同一集合,选中。合并后得到{v1,v3,v4,v6}。输出。
  • 第5条边 (v1,v4,5)v1v4现在都在大集合{v1,v3,v4,v6}中,属于同一集合,加入会形成环,跳过。
  • 第6条边 (v2,v3,5)v2属于集合{v2,v5},v3属于大集合{v1,v3,v4,v6},不在同一集合,选中。合并后所有顶点连通!输出。此时已选中5条边 (n-1=5),算法结束。

最终Kruskal输出的边顺序是:v1 v3 1 -> v4 v6 2 -> v2 v5 3 -> v3 v6 4 -> v2 v3 5。总权重同样是15。可以看到,Kruskal的输出顺序完全由边的权重排序决定,它是一块一块地合并连通分量,最后拼成整棵树。

5. 双算法对比与核心差异深度解析

通过上面的代码和推演,Prim和Kruskal的差异已经非常明显了。但为了让你在实战中能根据场景灵活选择,我们还得再深入对比一下。

特性维度Prim算法Kruskal算法
核心思想顶点驱动。从一个根开始,逐步扩张生成树。边驱动。全局贪心,每次选最小权边且不构成环。
数据结构需要邻接矩阵/邻接表存储图,并维护visiteddisadj数组。需要存储所有边的列表,并依赖并查集判断环。
时间复杂度邻接矩阵实现为 O(V²),适合稠密图。使用优先队列优化可达 O(E log V)排序边耗时 O(E log E),并查集操作近似常数,整体 O(E log E),适合稀疏图。
空间复杂度O(V²)(矩阵)或 O(V+E)(邻接表+堆)。O(E) 存储边,外加 O(V) 的并查集。
输出顺序特点顺序反映了树的生长过程,与起点和图的连接紧密相关。顺序基本由边权重大小决定,反映了贪心选择的次序。
适用场景更适合边多顶点少的稠密图,尤其是图本身用邻接矩阵给出时。更适合边少顶点多的稀疏图,或者边已经预先给出的情况。

为什么输出顺序不同? 这是由算法本质决定的。Prim的每一步都依赖于当前已构建的子树,它寻找的是“离树最近的点”,因此边的选择具有局部依赖性。而Kruskal的视野是全局的,它只关心边的权重和是否成环,因此它的选择顺序就是全局权重排序的顺序(在权重相同的情况下,可能因输入顺序或排序稳定性而不同,但题目通常假设唯一)。

在实战中如何选择? 我个人的经验是:如果题目给的是邻接矩阵,且顶点数不大(比如V<500),用Prim写起来非常直白,不容易出错。如果给的是边列表,或者顶点数巨大但边数相对不多(稀疏),Kruskal配合并查集是更优雅高效的选择。另外,Kruskal算法在需要按权重顺序处理边的场景下天然有优势。

6. 常见“坑点”与调试技巧

就算理解了原理,自己实现的时候也难免会遇到问题。这里我分享几个在实现这两个算法时最容易踩的“坑”,以及我是怎么调试的。

Prim算法的坑点:

  1. dis数组的含义:一定要明确,dis[i] 存储的是顶点 i 到当前已生成树集合的最短距离,而不是到起点 startpos 的距离。更新 dis 时,是用新加入的顶点 u 到邻居 v 的边权 edges[u][v] 去和 dis[v] 比较。
  2. 起点处理:第一个被选中的起点,其 dis[startpos] 为0,且 adj[startpos] 为-1。在输出边的时候,记得跳过这条不存在的“边”,所以循环通常从 i=1 开始输出。
  3. 图不连通:如果图不是连通的,Prim算法在某一轮会找不到 dis 有限的未访问顶点(select 函数返回-1)。一个健壮的实现应该检查这种情况并做出处理(比如提前结束循环)。

Kruskal算法的坑点:

  1. 并查集的实现与优化:上面代码中的 find 函数是简单递归,在数据量大时可能导致栈溢出或效率低。实战中强烈建议使用路径压缩按秩合并的优化版本。路径压缩就是在 find 时把沿途节点的父节点都直接指向根。
  2. 边的去重:对于无向图,邻接矩阵中 (i, j)(j, i) 是同一条边。在收集所有边时,务必只取一半(如 j > i),否则排序和遍历时会处理重复边,虽然因为并查集的存在不会导致错误结果,但浪费了时间。
  3. 输出顺序要求:题目有时会要求当边权相同时,按顶点编号字典序输出。这就需要我们在定义 Edge 结构体的比较运算符 < 时,不仅比较权重 w,在 w 相等时还要比较 uv

调试技巧:

  • 打印中间状态:在算法循环中,打印出每一轮选择的顶点或边,以及关键数组(如Prim的 disvisited,Kruskal的 parent 数组)的变化。这是最直接的调试方法。
  • 构造小样例:不要一上来就用复杂的数据。自己画一个4-5个顶点的小图,手动算出最小生成树和算法每一步应该的结果,然后用你的程序跑,对比输出。
  • 验证总权重:Prim和Kruskal跑出来的总权重必须相等。这是一个快速检验程序是否基本正确的有效方法。如果不相等,肯定是算法实现有根本性错误。

7. 从理解到应用:算法思想的延伸

掌握了这两种经典算法,你收获的不仅仅是两个模板。它们背后蕴含的贪心思想并查集的应用,在解决其他问题时威力巨大。

Prim算法的核心是 “局部最优扩展全局” 。这种思想在构建网络、聚类分析等领域都有应用。比如,在一些近似算法中,我们可能需要从一个点集逐步构建一个连接结构,Prim的思路就可以被借鉴。

Kruskal算法的核心是 “排序+并查集判环” 。这个组合拳是解决许多连通性问题的法宝。例如,判断一个图是否有环、求图的所有连通分量、甚至是解决一些离线查询问题,并查集都是首选数据结构。我印象很深的是在一次项目中,需要动态处理大量的“合并集合”和“查询是否同属一个集合”的操作,直接套用并查集模板,效率提升非常明显。

把这次实战的代码吃透,自己默写几遍,再找几道在线判题系统(OJ)上的题目练练手,比如“城市道路建设”、“网络布线成本”这类应用题,你就能真正把最小生成树从知识点变成解决实际问题的工具。算法的学习,最终是为了在合适的场景,写出高效可靠的代码。Prim和Kruskal,就是你这个工具箱里两把趁手的利器。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值