图论筑基——最小生成树及单源/全源最短路径的贪心本质

一、引入


给定\(n\)个点,\(m\)条边的无向图,求最小生成树使得树的边之和最小。

给定\(n\)个点,\(m\)条边的有向非负图,求从\(s\)点出发到任意点的最短距离。


这两问分别对应 最小生成树单源最短路径。其中含有类似的思维和代码结构,这也是本篇博客试图将它们放在一起理解记忆的原因。

二、核心思想

两者解法的共同结构在于:

维护一个“已经正确”的点集\(S\),每一步从边界上选择一个当前最优的对象加入\(S\),并证明这个选择是安全的,于是\(S\)不断扩张直到覆盖全部点。


这是一种很典型的贪心扩张法。这类算法将点集分为两部分:

  • \(S\):已经确定的不会再更改的集合
  • \(T\):还没有确定的点

过程可以概括为:

  1. 先给定一个很小的初始正确集合\(S\)
  2. 定义每个外部点相对于\(S\)的代价
  3. 每次从外部选取一个当前最优的点加入\(S\)
  4. 由新加入的点重新更新\(S\),并更新剩余外部点的代价
  5. 重复直到结束。

它们实质有着贪心的本质:


从一个局部正确的解出发,将局部正确逐步扩展为全局正确


三、证明

下面尝试分别证明这种思想在最小生成树 \(prim\) 和单源最短路径 \(dijkstra\) 的正确性。

\(prim\)

表述

\(prim\) 中:

  • \(S\):已经并入当前生成树的点集
  • \(dis[v]\):由 \(S\) 连接到 \(v\) 所需的最短路径

每一次扩展 \(S\)

\[u = \arg\min_{v \notin S} dis[v] \]

证明

为什么下一步扩展是安全的?

当前 \(S\) 和外部点 \(T\) 构成一个割 \(S|T\)\(prim\) 选择的是跨过这个割的最小边 \(e\)
假设某颗最小生成树 \(X\) 不包含 \(e\),因为 \(X\) 联通了 \(S\)\(T\),所以必然包含另一条跨越这个割的边 \(e'\)
又因为 \(e\) 是割上的最小边,所以必然有:

\[ w(e) \le w(e') \]

\(X\) 中的 \(e\) 换成 \(e'\),图仍然联通,且边之和不会变大。
于是得到了一颗更优,且包含 \(e\) 的最小生成树。所以 \(e\) 是可取的。

\(dijkstra\)

表述

\(dijkstra\) 中:

  • \(S\):已经确定从源点出发最短路的点集
  • \(dis[v]\):当前从源点经过 \(S\) 到达 \(v\) 的最短距离估计

每一次扩展 \(S\)

\[u = \arg\min_{v \notin S} dis[v] \]

即在所有尚未加入 \(S\) 的点中,选择当前 \(dis\) 最小的点 \(u\) 加入 \(S\)

证明

设当前选出的点为 \(u\),我们要证明:\(dis[u]\) 已经等于从源点到 \(u\) 的真实最短路长度。

还是考虑反证法。假设不是这样,即存在一条从源点到 \(u\) 的更短路径 \(p\)

那我们可以考虑这条路径 \(p\)\(S\) 内部到 \(S\) 外部的那条边。设这条边为 \((x,y)\),其中:

  • \(x \in S\)
  • \(y \notin S\)

由于 \(x\) 已经在正确的点集 \(S\) 里了,根据假设,\(dis[x]\) 已经是真实的最短路长度。
而在添加 \(x\) 时,算法一定尝试过用边 \((x,y)\) 更新 \(dis[y]\),但是由于没有最终采用 \((x,y)\) 这条边,因此一定有:

\[dis[y] \le dis[x] + w(x,y) \]

而后者正是我们假设的更短路径 \(p\)\(x\)\(y\) 的路径。
到达 \(u\) 包含 \(x\)\(y\) 的路径,所以 \(p\) 是一定大于 \(dis[u]\) 的,这与 \(p\) 是更短的真实最短路径矛盾。

因此假设不成立,\(dis[u]\) 必然已经是从源点到 \(u\) 的最短路长度。
所以扩张的过程中把 \(u\) 加入 \(S\) 是安全的。

四、实现

边的存储

用vector 实现邻接表。

struct Node{
    int v,d;
};
vector <Node> Q[MAXN];

Q[u].push_back((Node){v,w});
Q[v].push_back((Node){u,w});

\(prim\)

每一次取出跨越正确集合 \(S\) 和剩余点集 \(T\) 的最短边,并根据最短边 \(T\) 一侧的点 \(v\) 重新更新 新的点集 \(S\) 到剩余点的最短距离。

inline ll prim(){
    memset(dis,INF,sizeof(dis));
    pq.push((Node){1,0});
    dis[1]=0;//初始化,随机以1为已知的正确集合S
    ll ans = 0;
    int cnt = 0;
    while(!pq.empty()){
        auto [u,d] = pq.top();pq.pop();
        if(mem[u]) continue;//确认u是 S 之外的剩余点集 T
        mem[u] = true;
        ans += d;cnt++;
        for(Node p : Q[u]){//遍历点u,更新新的dis数组
            int v = p.v,w = p.d;
            if(!mem[v] && dis[v]>w){
                pq.push((Node){v,w});
                dis[v] = w;
            }
        }
    }
    if(cnt<n) return -1;//没有成功联通n个点
    return ans;
}

\(dijkstra\)

#include<bits/stdc++.h>
using namespace std;


inline int read(){
	int w=1,q=0;char ch=' ';
	while(ch!='-' && ch<'0' || ch>'9') ch=getchar();
	if(ch=='-') w=-1,ch=getchar();
	while(ch<='9' && ch>='0') q=q*10+ch-'0',ch=getchar();
	return w*q;
} 

const int MAXN = 2e5+25;
int n,m,s,cnt,head[MAXN];

struct Edge{
	int nxt,to,w;
}E[MAXN<<2];
inline void addedge(int u,int v,int w){
	E[++cnt].to=v,E[cnt].w=w;
	E[cnt].nxt=head[u],head[u]=cnt;return;
}

struct Node{
	int u,d;
	friend bool operator < (Node a,Node b){
		return a.d>b.d;
	}
};
priority_queue <Node> Q;
int dis[MAXN];bool vis[MAXN];

inline void dijkstra(int s){
	memset(dis,0x3f3f3f3f,sizeof(dis));
	memset(vis,false,sizeof(vis));
	Q.push((Node){s,0});dis[s]=0;
	while(!Q.empty()){
		Node temp=Q.top();Q.pop();
		int u=temp.u;
		if(vis[u]){continue;}vis[u]=true;
		for(int i=head[u]; i; i=E[i].nxt){
			int v=E[i].to,w=E[i].w;
			if(dis[v]>dis[u]+w){
				dis[v]=dis[u]+w;
				if(!vis[v]) Q.push((Node){v,dis[v]});
			}
		}
	}
	return;
}

signed main(){
	n=read(),m=read(),s=read();
	for(int i=1; i<=m; i++){
		int u=read(),v=read(),w=read();
		addedge(u,v,w);
	}
	dijkstra(s);
	for(int i=1; i<=n; i++){
		printf("%d ",dis[i]);
	}
}
 
posted @ 2026-04-22 19:07  smy2006  阅读(52)  评论(0)    收藏  举报