NeFut Logo NeFut
EN 管理员登录

深入解析 Prim 算法:稠密图中的最小生成树构建

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #Data Structure #Graph

核心逻辑与数学原理

Kruskal 算法虽然在稀疏图中表现优异,但在完全图极稠密图(边数 $M \approx N^2$)中,其排序的时间开销 $O(M \log M)$ 会发生灾难性膨胀。而基于点集拓扑扩张的 Prim 算法,其状态转移的核心不依赖于全局边的排序,因此在稠密图场景下拥有统治级的时空优势。

1. 割边定理与切分空间(Cut Property)

Prim 算法的正确性严格建立在图论的割边定理之上:

2. 状态转移的两种物理实现

根据图的规模,Prim 算法有两种截然不同的状态维护方式:

方案甲:朴素 Prim 算法(针对极稠密图 $M \approx N^2$)

直接使用邻接矩阵存储图。每一轮通过 $O(N)$ 的暴力扫描,从白点集中找出距离当前整棵树面最近的点。总时间复杂度严格为 $O(N^2)$。当 $M$ 趋近于 $N^2$ 时,该算法由于没有优先队列的逻辑开销和堆调整的常数,运行效率极高。

方案乙:堆优化 Prim 算法(针对常规稠密图)

利用小根堆(优先队列)维护所有白点到当前树面的最小距离。每次出堆的操作为 $O(\log N)$,刷新周围点的边权代价为 $O(M \log N)$,总时间复杂度为 $O((N+M)\log N)$。


算法推导与状态设计

设计一个动态数组 $dist[i]$,其物理含义必须严格界定:表示未加入树的节点 $i$ 到当前已建立的生成树面 $S$ 的绝对最短距离
注意:这与 Dijkstra 算法中 dist 表示“到源点的距离”有本质逻辑区别。

状态状态转移方程

设当前新并入树的蓝点为 $u$,对于所有与其直接相连的白点 $v \in (V-S)$:

$$dist[v] = \min(dist[v], w(u, v))$$


C++ 标准源码(两套工业级模板)

模板一:朴素 Prim 算法($O(N^2)$ 极限稠密图神器)

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

const long long INF = 0x3f3f3f3f3f3f3f3fLL;
const int MAXN = 5005;

long long graph[MAXN][MAXN]; // 邻接矩阵存储稠密图
long long dist[MAXN];        // 存储白点到当前树面的最短距离
bool vis[MAXN];              // 标记是否已并入生成树 (蓝点集)

void prim_dense(int n) {
    // 初始化物理边界
    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
        vis[i] = false;
    }

    dist[1] = 0; // 自行选定 1 号点作为生成树的拓扑根节点
    long long mst_weight = 0;
    int node_count = 0;

    for (int step = 1; step <= n; ++step) {
        int u = -1;
        long long min_d = INF;

        // O(N) 暴力扫描,寻找距离当前树面最近的白点
        for (int i = 1; i <= n; ++i) {
            if (!vis[i] && dist[i] < min_d) {
                min_d = dist[i];
                u = i;
            }
        }

        // 拓扑连通性校验:若找不到合法的点,说明图不连通
        if (u == -1) {
            std::cout << "orz\n";
            return;
        }

        vis[u] = true; // 划归为蓝点
        mst_weight += min_d;
        node_count++;

        // 用新加入的蓝点 u 刷新所有剩余白点到树面的距离
        for (int v = 1; v <= n; ++v) {
            if (!vis[v] && graph[u][v] < dist[v]) {
                dist[v] = graph[u][v]; // 严格遵循:dist[v] = min(dist[v], w(u,v))
            }
        }
    }

    std::cout << mst_weight << "\n";
}

模板二:堆优化 Prim 算法($O(M \log N)$ 泛用型模板)

#include <iostream>
#include <vector>
#include <queue>

const long long INF = 0x3f3f3f3f3f3f3f3fLL;
const int MAXN = 100005;

struct Edge {
    int to;
    long long weight;
};

struct Node {
    long long d;
    int u;
    bool operator>(const Node& rhs) const { return d > rhs.d; }
};

std::vector<Edge> adj[MAXN];
long long dist[MAXN];
bool vis[MAXN];

void prim_heap(int n) {
    std::priority_queue<Node, std::vector<Node>, std::greater<Node> > pq;

    for (int i = 1; i <= n; ++i) {
        dist[i] = INF;
        vis[i] = false;
    }

    dist[1] = 0;
    pq.push(Node{0, 1});

    long long mst_weight = 0;
    int count = 0;

    while (!pq.empty()) {
        Node curr = pq.top();
        pq.pop();

        int u = curr.u;
        if (vis[u]) continue; // 拦截堆内过期的脏数据

        vis[u] = true;
        mst_weight += curr.d;
        count++;

        for (size_t i = 0; i < adj[u].size(); ++i) {
            int v = adj[u][i].to;
            long long w = adj[u][i].weight;

            if (!vis[v] && w < dist[v]) {
                dist[v] = w;
                pq.push(Node{dist[v], v}); // 将更优的边权及节点压入堆
            }
        }
    }

    if (count < n) std::cout << "orz\n";
    else std::cout << mst_weight << "\n";
}

NOIP 实战避坑指南

  1. 混淆 Prim 与 Dijkstra 的松弛方程: 这是初学者最高频的翻车点。Dijkstra 的松弛是基于路径累加的:dist[u] + w < dist[v]。而 Prim 维护的是点到树面的距离,绝对不需要累加前驱权值,其方程严格为 w < dist[v]。如果在 Prim 中错误地写成了 Dijkstra 的累加形式,求出的将是单源最短路树,而非最小生成树。
  2. 初始化未规避重边对邻接矩阵的破坏: 在使用朴素 Prim 算法(邻接矩阵)时,必须在读入边时执行重边拦截:graph[u][v] = min(graph[u][v], w)。如果没有做 min 过滤,后续读入的较大重边会无情覆盖掉较小的有效边,导致整个最小生成树的代数基石被摧毁。

经典 NOIP/洛谷 真题

1. 洛谷 P1265 公路网络

2. 洛谷 P2504 [HAOI2006] 聪明的猴子

原文链接: local://11.2

[h] 返回首页