AIGC标识 【数据结构】【学习笔记】最小生成树

笔者偷懒中。。。最小生成树的实现方法有两种,稀疏图时用Kruskal算法,用并查集和排序来获得;稠密图时用Prim算法,带权的广度优先搜索。

最小生成树(Minimum Spanning Tree, MST)是在一个带权无向连通图中,用总权重最小的边把所有节点连接起来且不形成环树状子图

现实类比:铺设光纤
假设要把 5 个城市用光纤连接起来,两两之间铺设的成本不同。最省钱且能保证任意两座城市都能相互通信的铺设方案,就是这 5 个城市构成的最小生成树。

⚙️定义

  • 生成树(Spanning Tree):在一个包含 \(V\) 个节点的无向连通图中,任意一个包含全部 \(V\) 个节点、且仅用 \(V - 1\) 条边使所有节点相连的无环连通子图。
  • 生成树的权值:生成树中所有被选中的边的权重加和(\(\sum w_i\))。
  • 最小生成树(MST):在原图的所有可能生成的树中,边的权值和最小的那棵(或几棵)生成树。

🔎特性

  • 覆盖全节点:必须包含原图中的所有节点(假设为 \(V\) 个)。
  • 边数固定:刚好包含 \(V - 1\) 条边(少一条会导致节点不连通,多一条必然形成环)。
  • 无环结构:是一棵树,节点之间存在唯一路径,绝对不包含回路。
  • 权重和最小:所有被选中的边的权重之和,在所有可行的连通方案中是最小的(注:最小生成树可能不唯一,但最小权重和是唯一的)。

🤓实现

两大经典求解算法

算法名称 核心策略 适合场景 关键数据结构
Kruskal 算法 加边法:按边权从小到大排序,优先选小边,只要不形成环就加入树中 稀疏图(边数较少) 并查集(Union-Find)
Prim 算法 加点法:从一个初始节点开始,每次挑一条连接“树内”和“树外”的最小边,把新节点拉入树中 稠密图(边数极多) 优先队列 / 最小堆

1. Kruskal 算法

Kruskal 的精髓在于:贪心(按边权排序) + 并查集(高效检测回路)

核心实现 (C++)

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// 1. 定义图的边
struct Edge {
    int u, v, weight;
    // 重载小于号,按边权从小到大排序
    bool operator<(const Edge& other) const {
        return weight < other.weight;
    }
};

// 2. 并查集结构(用于判断加入某条边后是否会形成环)
class DSU {
private:
    vector<int> parent;
public:
    DSU(int n) {
        parent.resize(n + 1);
        for (int i = 0; i <= n; ++i) parent[i] = i;
    }

    int find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find(parent[i]); // 路径压缩
    }

    bool unite(int i, int j) {
        int rootI = find(i);
        int rootJ = find(j);
        if (rootI != rootJ) {
            parent[rootI] = rootJ; // 合并两个集合
            return true;
        }
        return false; // 两个节点已连通,若再连边会形成环!
    }
};

// 3. Kruskal 求解最小生成树
int kruskal(int n, vector<Edge>& edges) {
    // 步骤 A:按边权从小到大排序
    sort(edges.begin(), edges.end());

    DSU dsu(n);
    int mstWeight = 0; // 累计最小生成树的总权重
    int edgesCount = 0; // 统计已选中的边数

    // 步骤 B:贪心遍历每一条边
    for (const auto& edge : edges) {
        // 如果 u 和 v 不在同一个连通块中,说明加入此边不会产生环
        if (dsu.unite(edge.u, edge.v)) {
            mstWeight += edge.weight;
            edgesCount++;
            // 只要收集到了 V - 1 条边,最小生成树构建完成,提前退出
            if (edgesCount == n - 1) break;
        }
    }

    // 若边数不足 n - 1,说明原图不连通,无法构成生成树
    return (edgesCount == n - 1) ? mstWeight : -1;
}

2. Prim 算法

核心

感觉有点像选择排序和广度优先搜索。
Prim 算法本质上就是一个“加权版的 BFS”。
在人工智能和图论中,这种基于优先队列的广度优先搜索也被称为 均等代价搜索(Uniform Cost Search)。

  1. 起点初始化:任意选择一个起始节点放入“已建好的树内”,其余节点为“树外”。
  2. 切分选择(贪心):维护所有连接“树内”与“树外”的切分边,借由小顶堆(优先队列)每次取出权值最小且目标节点未访问过的边。
  3. 扩展树体:将该边的目标节点拉入“树内”,同时将其引出的所有连接“树外”的邻边推入优先队列。
  4. 循环终止:重复上述步骤,直到所有 \(V\) 个节点均已加入树内。
#include <iostream>
#include <vector>
#include <queue>

using namespace std;

// 邻接表边节点定义:{weight, toNode}
using pii = pair<int, int>;

/**
 * Prim 算法求解最小生成树
 * @param n 节点总数 (节点编号 1 ~ n)
 * @param adj 邻接表,adj[u] 存储 {weight, v}
 * @param startNode 起始节点编号,默认为 1
 * @return 最小生成树的总权重,若图不连通则返回 -1
 */
int prim(int n, const vector<vector<pii>>& adj, int startNode = 1) {
    vector<bool> visited(n + 1, false); // 标记节点是否已在“树内”
    
    // 小顶堆:自动按边权从小到大排序,元素格式为 {weight, node}
    priority_queue<pii, vector<pii>, greater<pii>> pq;

    int mstWeight = 0;   // 累加最小生成树总边权
    int visitedCount = 0; // 记录已拉入树内的节点数量

    // 1. 将起始节点入堆(到起始节点自身的边权设为 0)
    pq.push({0, startNode});

    while (!pq.empty()) {
        auto [weight, u] = pq.top();
        pq.pop();

        // 若当前节点已在树内,忽略该冗余边(防止重复加入/形成环)
        if (visited[u]) continue;

        // 2. 将节点 u 正式划入树内
        visited[u] = true;
        mstWeight += weight;
        visitedCount++;

        // 3. 遍历节点 u 的所有邻边,将连接到“树外”的边压入小顶堆
        for (const auto& edge : adj[u]) {
            int edgeWeight = edge.first;
            int neighbor = edge.second;

            if (!visited[neighbor]) {
                pq.push({edgeWeight, neighbor});
            }
        }
    }

    // 若连通节点数等于总节点数,说明 MST 构建成功
    return (visitedCount == n) ? mstWeight : -1;
}

❔题目

1. 找到最小生成树里的关键边和伪关键边

解题思路

分析边种类定义

  • 关键边:存在于所有最小生成树中的边。一旦把它从图中删掉,算出来的 MST 总权重必然增加,或者图直接断开无法连通
  • 伪关键边:存在于至少一个最小生成树中、但并非所有 MST 都有的边。这意味着它有等价的边,用它能凑出最小生成树,不用它也能用别的边凑出来。
  • 无关边:任何最小生成树都绝对用不上的边。

解法——“删边法” 与 “强选法” 测试

核心:先构建最小生成树,获得最小权值,然后从头查看边是否可被替换。

因为 \(n\) 和边数 \(m\) 的范围都不大,最标准且最不容易出错的解法是对过程进行测试:

  1. 基准线先对原图跑一遍 Kruskal 算法,算出标准的最小生成树总权重 stdWeight。
  2. 逐条测试每一条边 edges[i]
    • 第一步:测是不是【关键边】
      • 在图中彻底删掉 edges[i],对剩下的边跑 Kruskal 算法。
      • 如果算出的新权重 newWeight > stdWeight,或者图断开无法连通 ➔ edges[i] 是关键边。
    • 第二步:测是不是【伪关键边】
      • 如果它不是关键边,我们强制第 1 步就选中 edges[i],然后再对其余边跑 Kruskal 算法。
      • 如果算出的新权重 forceWeight == stdWeight,说明把它强行塞进生成树里依然能拿到最优解 ➔ edges[i] 是伪关键边。

实现

class DSU {
    private parent: number[];

    constructor(n: number) {
        this.parent = Array.from({ length: n }, (_, i) => i);
    }

    find(i: number): number {
        if (this.parent[i] === i) return i;
        return (this.parent[i] = this.find(this.parent[i])); // 路径压缩
    }

    unite(i: number, j: number): boolean {
        const rootI = this.find(i);
        const rootJ = this.find(j);
        if (rootI !== rootJ) {
            this.parent[rootI] = rootJ;
            return true;
        }
        return false;
    }
}

function findCriticalAndPseudoCriticalEdges(n: number, edges: number[][]): number[][] {
    const m = edges.length;
    // 带有原始下标的边列表:[from, to, weight, originalId]
    const sortedEdges: number[][] = [];
    for (let i = 0; i < m; i++) {
        sortedEdges.push([edges[i][0], edges[i][1], edges[i][2], i]);
    }

    // 按边权升序排序
    sortedEdges.sort((a, b) => a[2] - b[2]);

    // 计算 MST 权重的辅助函数
    function getMSTWeight(ignoreId: number, forceId: number): number {
        const dsu = new DSU(n);
        let weight = 0;
        let count = 0;

        // 优先强制选择 forceId 对应的边
        if (forceId !== -1) {
            for (const edge of sortedEdges) {
                if (edge[3] === forceId) {
                    dsu.unite(edge[0], edge[1]);
                    weight += edge[2];
                    count++;
                    break;
                }
            }
        }

        // 按顺序选择其余边
        for (const edge of sortedEdges) {
            if (edge[3] === ignoreId || edge[3] === forceId) continue;
            if (dsu.unite(edge[0], edge[1])) {
                weight += edge[2];
                count++;
            }
        }

        return count === n - 1 ? weight : Infinity;
    }

    // 1. 获取基准 MST 权重
    const stdWeight = getMSTWeight(-1, -1);

    const critical: number[] = [];
    const pseudoCritical: number[] = [];

    // 2. 对每条原始边做“删边”和“强选”实验
    for (let i = 0; i < m; i++) {
        // 忽略该边后权重增大/断联 ➔ 关键边
        if (getMSTWeight(i, -1) > stdWeight) {
            critical.push(i);
        }
        // 强制选用该边后仍可构成最优 MST ➔ 伪关键边
        else if (getMSTWeight(-1, i) === stdWeight) {
            pseudoCritical.push(i);
        }
    }

    return [critical, pseudoCritical];
}
using System;
using System.Collections.Generic;

public class DSU {
    private int[] parent;

    public DSU(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
    }

    public int Find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = Find(parent[i]); // 路径压缩
    }

    public bool Unite(int i, int j) {
        int rootI = Find(i);
        int rootJ = Find(j);
        if (rootI != rootJ) {
            parent[rootI] = rootJ;
            return true;
        }
        return false;
    }
}

public class Solution {
    private int GetMSTWeight(int n, List<int[]> sortedEdges, int ignoreId, int forceId) {
        DSU dsu = new DSU(n);
        int weight = 0;
        int count = 0;

        // 优先强行选择指定边
        if (forceId != -1) {
            foreach (var edge in sortedEdges) {
                if (edge[3] == forceId) {
                    dsu.Unite(edge[0], edge[1]);
                    weight += edge[2];
                    count++;
                    break;
                }
            }
        }

        // 正常按权值跑 Kruskal
        foreach (var edge in sortedEdges) {
            if (edge[3] == ignoreId || edge[3] == forceId) continue;
            if (dsu.Unite(edge[0], edge[1])) {
                weight += edge[2];
                count++;
            }
        }

        return count == n - 1 ? weight : int.MaxValue;
    }

    public IList<IList<int>> FindCriticalAndPseudoCriticalEdges(int n, int[][] edges) {
        int m = edges.Length;
        List<int[]> sortedEdges = new List<int[]>();

        // 保存原始下标:[from, to, weight, original_id]
        for (int i = 0; i < m; i++) {
            sortedEdges.Add(new int[] { edges[i][0], edges[i][1], edges[i][2], i });
        }

        // 按边权升序排列
        sortedEdges.Sort((a, b) => a[2].CompareTo(b[2]));

        // 1. 基准 MST 权重
        int stdWeight = GetMSTWeight(n, sortedEdges, -1, -1);

        List<int> critical = new List<int>();
        List<int> pseudoCritical = new List<int>();

        // 2. 依次测试每条原始边
        for (int i = 0; i < m; i++) {
            // 删边测试:权重增加则为关键边
            if (GetMSTWeight(n, sortedEdges, i, -1) > stdWeight) {
                critical.Add(i);
            }
            // 强选测试:仍能构成最优生成树则为伪关键边
            else if (GetMSTWeight(n, sortedEdges, -1, i) == stdWeight) {
                pseudoCritical.Add(i);
            }
        }

        return new List<IList<int>> { critical, pseudoCritical };
    }
}
#include <vector>
#include <algorithm>
#include <climits>

using namespace std;

// 1. 并查集 (DSU) 数据结构
class DSU {
    vector<int> parent;
public:
    DSU(int n) {
        parent.resize(n);
        for (int i = 0; i < n; ++i) parent[i] = i;
    }

    int find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find(parent[i]); // 路径压缩
    }

    bool unite(int i, int j) {
        int rootI = find(i);
        int rootJ = find(j);
        if (rootI != rootJ) {
            parent[rootI] = rootJ;
            return true;
        }
        return false; // 已在同一连通块中,跳过以防产生环
    }
};

class Solution {
private:
    // 计算 MST 权重的通用辅助函数
    // ignoreId: 需要强制忽略(删除)的原始边 ID
    // forceId:  需要强制首先加入树中的原始边 ID
    int getMSTWeight(int n, const vector<vector<int>>& sortedEdges, int ignoreId, int forceId) {
        DSU dsu(n);
        int weight = 0;
        int count = 0;

        // 【强选逻辑】:若指定了 forceId,优先将该边压入 MST
        if (forceId != -1) {
            for (const auto& edge : sortedEdges) {
                if (edge[3] == forceId) {
                    dsu.unite(edge[0], edge[1]);
                    weight += edge[2];
                    count++;
                    break;
                }
            }
        }

        // 【正常贪心】:按权值升序遍历其余边
        for (const auto& edge : sortedEdges) {
            // 跳过已被删去或已被强制选中的边
            if (edge[3] == ignoreId || edge[3] == forceId) continue;

            if (dsu.unite(edge[0], edge[1])) {
                weight += edge[2];
                count++;
            }
        }

        // 若选中的边数不足 n - 1,说明无法构成连通生成树,返回极大值
        return count == n - 1 ? weight : INT_MAX;
    }

public:
    vector<vector<int>> findCriticalAndPseudoCriticalEdges(int n, vector<vector<int>>& edges) {
        int m = edges.size();
        vector<vector<int>> sortedEdges(m);

        // 绑定原始下标:[from, to, weight, original_id]
        for (int i = 0; i < m; ++i) {
            sortedEdges[i] = {edges[i][0], edges[i][1], edges[i][2], i};
        }

        // 按边权从小到大排序
        sort(sortedEdges.begin(), sortedEdges.end(), [](const vector<int>& a, const vector<int>& b) {
            return a[2] < b[2];
        });

        // 1. 求解不加干涉时的标准最小生成树权重
        int stdWeight = getMSTWeight(n, sortedEdges, -1, -1);

        vector<int> critical;
        vector<int> pseudoCritical;

        // 2. 依次测试每一条原始边
        for (int i = 0; i < m; ++i) {
            // 测试 A:删去边 i 后,MST 权重变大或无法构成树 ➔ 关键边
            if (getMSTWeight(n, sortedEdges, i, -1) > stdWeight) {
                critical.push_back(i);
            }
            // 测试 B:强选边 i 后,仍能达到与最优解相同的总权重 ➔ 伪关键边
            else if (getMSTWeight(n, sortedEdges, -1, i) == stdWeight) {
                pseudoCritical.push_back(i);
            }
        }

        return {critical, pseudoCritical};
    }
};

复杂度分析

  • 时间复杂度\(O(M^2 \cdot \alpha(N))\),其中 \(M\) 为边数。
    排序耗时 \(O(M \log M)\),后续共进行了 \(2M + 1\) 次 Kruskal 运行,每次运行为 \(O(M \cdot \alpha(N))\)。由于题目数据规模通常满足 \(M \le 200\),该算法可在数十毫秒内快速通过。
  • 空间复杂度\(O(N + M)\)
    主要用于保存附带原始 ID 的边列表以及并查集的数组空间。

2. 连接所有点的最小费用

解题思路

因为每个点都有边,所以使用Prim算法,不过,对于完全图,有一个经典优化技巧:放弃优先队列,改用普通的 minDist 数组做线性扫描(即“朴素 Prim”)。这样可以进一步提升性能。

实现

function minCostConnectPoints(points: number[][]): number {
    const n = points.length;
    const minDist = new Array(n).fill(Infinity);
    const visited = new Array(n).fill(false);

    minDist[0] = 0; // 从 0 号节点开始构建生成树
    let mstWeight = 0;

    for (let i = 0; i < n; i++) {
        // 1. 寻找当前不在树内、且距离树最近的节点 u
        let u = -1;
        for (let j = 0; j < n; j++) {
            if (!visited[j] && (u === -1 || minDist[j] < minDist[u])) {
                u = j;
            }
        }

        // 2. 将 u 正式划入树内,并累加边权
        visited[u] = true;
        mstWeight += minDist[u];

        // 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
        for (let v = 0; v < n; v++) {
            if (!visited[v]) {
                const dist = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
                if (dist < minDist[v]) {
                    minDist[v] = dist;
                }
            }
        }
    }

    return mstWeight;
}
public class Solution {
    public int MinCostConnectPoints(int[][] points) {
        int n = points.Length;
        int[] minDist = new int[n];
        Array.Fill(minDist, int.MaxValue);
        bool[] visited = new bool[n];

        minDist[0] = 0; // 从 0 号节点开始构建生成树
        int mstWeight = 0;

        for (int i = 0; i < n; i++) {
            // 1. 寻找当前不在树内、且距离树最近的节点 u
            int u = -1;
            for (int j = 0; j < n; j++) {
                if (!visited[j] && (u == -1 || minDist[j] < minDist[u])) {
                    u = j;
                }
            }

            // 2. 将 u 正式划入树内,并累加边权
            visited[u] = true;
            mstWeight += minDist[u];

            // 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
            for (int v = 0; v < n; v++) {
                if (!visited[v]) {
                    int dist = Math.Abs(points[u][0] - points[v][0]) + Math.Abs(points[u][1] - points[v][1]);
                    minDist[v] = Math.Min(minDist[v], dist);
                }
            }
        }

        return mstWeight;
    }
}
using pii = pair<int, int>;

class Solution {
public:
    int minCostConnectPoints(vector<vector<int>>& points) {
        int n = points.size();
        vector<bool> visited(n+1, false);  // 标记节点是否已在树内

        priority_queue<pii, vector<pii>, greater<pii>> pq;

        int mstWeight = 0;
        int visitedCount = 0;

        pq.push({0, 0});

        while(!pq.empty()){
            auto [weight, u] = pq.top();
            pq.pop();

            if(visited[u]) continue;

            visited[u] = true;
            mstWeight += weight;
            visitedCount++;

            for(int i = 0; i < n; i++){
                if(i != u && !visited[i]){
                    int edgeWeight = abs(points[i][0] - points[u][0]) + abs(points[i][1] - points[u][1]);
                    pq.push({edgeWeight, i});
                }
            }
        }
        return (visitedCount == n) ? mstWeight : -1;
    }
};
class Solution {
public:
    int minCostConnectPoints(vector<vector<int>>& points) {
        int n = points.size();
        // minDist[i] 表示节点 i 到当前“已建好的树”的最短距离
        vector<int> minDist(n, INT_MAX); 
        vector<bool> visited(n, false);  // 标记节点是否已加入树中

        minDist[0] = 0; // 从 0 号点开始建树
        int mstWeight = 0;

        for (int i = 0; i < n; ++i) {
            // 1. O(N) 寻找当前不在树内、且距离树最近的节点 u
            int u = -1;
            for (int j = 0; j < n; ++j) {
                if (!visited[j] && (u == -1 || minDist[j] < minDist[u])) {
                    u = j;
                }
            }

            // 2. 将 u 拉入树内,并累加边权
            visited[u] = true;
            mstWeight += minDist[u];

            // 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
            for (int v = 0; v < n; ++v) {
                if (!visited[v]) {
                    int dist = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1]);
                    minDist[v] = min(minDist[v], dist);
                }
            }
        }

        return mstWeight;
    }
};

复杂度分析

  • 通用Prim
    • 时间复杂度\(O(N^2 \log N)\)
    • 空间复杂度\(O(N^2)\)
  • 朴素Prim
    • 时间复杂度\(O(N^2)\)
    • 空间复杂度\(O(N)\)

引用

[1] 力扣探索模式


注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

posted @ 2026-08-31 14:48  SEHOD  阅读(8)  评论(0)    收藏  举报