目录
前言
图作为一种高阶的数据结构,由于其结构的多样性,因此在实际应用中,许多问题都可以抽象为图的模型。图的实际应用中,最常遇到的就是低成本连通、高效直达问题,低成本连通可以转化为以最小的权重和连通所有节点,对应图的最小生成树;高效直达则可转化为求图两点之间的最短路径问题。最小生成树旨在用最少的代价让全图节点相连,无需关心任意两点的具体距离,构造最小生成树较为经典的就是Kruskal和Prim算法,二者均采取贪心策略来构造最小生成树。最短路径则专注于特定起点与终点间的距离最小化,其中Dijkstra、Bellman-Ford算法用于求解单源的最短路径,Floyd算法用于求解多源条件下的最短路径问题。本文将深入剖析以上算法的实现思路,领悟数形结合的独特之美。
一、最小生成树
1、概念
连通图中的每一棵生成树,都是原图的一个极大无环子图,即从其中删去任何一条边,生成树就不在连通,反之,在其中引入任何一条新边,都会形成一条回路。
若连通图由n个顶点组成,则生成树必含n个顶点和n-1条边,构造最小生成树有以下三条准则:
1、只能使用图中的边来构造最小生成树
2、只能使用恰好n-1条边来连接图中的n个顶点
3、选用的n-1条边不能构成回路
构造最小生成树的两种经典算法:Kruskal算法和Prim算法,这两个算法都采用了逐步求解的贪心策略。
贪心:是指在问题求解时,总是做出当前看起来最好的选择,也就是说贪心算法做出的不是整体最优的选择,而是某种意义上的局部最优解,贪心并不是对所有的问题都能得到整体最优解。
2、Kruskal算法
任给一个有n个顶点的连通网络N={V,E},如下所示:

Kruskal算法的过程可概括为:
首先构造一个由这个n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量。
其次不断从E中取出权值最小的一条边(若有多条任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到G中,如此重复,直到所有顶点在同一个连通分量上为止。
算法核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。
Kruskal算法具体过程如下所示:



加了阴影的边属于不断增长的森林A,Kruskal算法按照边的权重大小依次进行考虑。箭头指向的边是算法每一步所考察的边。如果该条边将两棵不同的树连接起来,它就被加入到森林里,从而完成对两棵树的合并。
Kruskal算法实现:
cpp
#pragma once
#include<vector>
#include<map>
#include<set>
#include<string>
#include<queue>
#include<functional>
#include"UnionFindSet.hpp"
namespace matrix
{
template<class K,class W,W MAX_NUM=INT_MAX,bool direction=false>
class graph
{
typedef graph<K,W,MAX_NUM,direction> self;
public:
struct edge
{
size_t _src;
size_t _dst;
W _w;
edge(size_t src,size_t dst,const W& w)
:_src(src)
,_dst(dst)
,_w(w)
{}
bool operator>(const edge& e1) const
{
return _w>e1._w;
}
};
void _addedge(size_t src,size_t dst,const W& w)
{
_vv[src][dst]=w;
if(direction==false)
{
_vv[dst][src]=w;
}
}
W Kruskal(self& mintree)
{
size_t n=_v.size();
mintree._v=_v;
mintree._idxmp=_idxmp;
mintree._vv.resize(n);
for(int i=0;i<n;i++)
{
mintree._vv[i].resize(n,MAX_NUM);
}
priority_queue<edge,vector<edge>,greater<edge>> minpq;
for(int i=0;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(i<j && _vv[i][j]!=MAX_NUM)
{
minpq.push(edge(i,j,_vv[i][j]));
}
}
}
int size=0;
Unionfindset ufs(n);
W totalw=W();
while(!minpq.empty())
{
edge eg=minpq.top();
minpq.pop();
if(!ufs.inset(eg._src,eg._dst))
{
mintree._addedge(eg._src,eg._dst,eg._w);
ufs.unionset(eg._src,eg._dst);
++size;
totalw+=eg._w;
}
}
if(size==n-1)
{
return totalw;
}
else
{
return W();
}
}
private:
vector<K> _v;
map<K,int> _idxmp;
vector<vector<W>> _vv;
};
}
priority_queue<edge,vector<edge>,greater<edge>> minpq,minpq为最小堆,通过两层for循环,由于邻接矩阵关于主对角线对称,因此只需收集主对角线上半部分的边即可,即if(i<j && _vvij!=MAX_NUM),minpq.push(edge(i,j,_vvij),收集邻接矩阵中所有不重复的边,将按权值大小进行排序。Unionfindset ufs(n),初始化并查集ufs,用于检测环路,edge eg=minpq.top(),minpq.pop(),通过while循环从minpq最小堆中弹出最小权值的边,if(!ufs.insert(eg._src,eg._dst),mintree._addedge(eg._src,eg._dst,eg._w),如果该边的两个顶点不在并查集中则加入到最小生成树中,ufs.unionset(eg._src,eg._dst),并合并这两个顶点所在的集合,++size,totalw+=eg._w,同时累加总权值和已选边数。当处理完所有边或已选出n-1条边时循环结束。if(size==n-1),如果最终选出的边数等于n-1,return totalw,则返回最小生成树的总权值,return W(),else则返回默认值,表示无法生成最小生成树。
总结:
Kruskal算法通过全局贪心地每次选择当前权值最小且不构成环的边来构建最小生成树,其正确性依赖于贪心选择性质和并查集来高效地检测环路。
3、Prim算法

与Kruskal算法类似,Prim算法所具有的一个性质是集合A中的边总是构成一棵树。这棵树从一个任意的根结点r开始,一直长大到覆盖V中的所有结点时为止。算法每一步在连接集合A和A之外的结点的所有边中,选择一条轻量级边加入到A中。可知这条规则所加入的边都是对A安全的边。因此,当算法终止时,A中的边形成一棵最小生成树,Prim算法属于局部贪心策略,因为每一步所加入的边都必须是使树的总权重增加量最小的边。
Prim算法的具体过程如下所示:


初始的根结点为a,加阴影的边和黑色的结点都属于树A。在算法每一步,树中的结点就决定了图的一个切割,横跨该切割的一条轻量级边被加入到树中。例如,在上面图的第二步,该算法既可以选择将边(b,c)加入到树中,也可以选择将边(a,h)加入到树中,因为这两条边都是横跨该切割的轻量级边。
Prim算法实现:
cpp
#pragma once
#include<vector>
#include<map>
#include<set>
#include<string>
#include<queue>
#include<functional>
#include"UnionFindSet.hpp"
namespace matrix
{
template<class K,class W,W MAX_NUM=INT_MAX,bool direction=false>
class graph
{
typedef graph<K,W,MAX_NUM,direction> self;
public:
struct edge
{
size_t _src;
size_t _dst;
W _w;
edge(size_t src,size_t dst,const W& w)
:_src(src)
,_dst(dst)
,_w(w)
{}
bool operator>(const edge& e1) const
{
return _w>e1._w;
}
};
void _addedge(size_t src,size_t dst,const W& w)
{
_vv[src][dst]=w;
if(direction==false)
{
_vv[dst][src]=w;
}
}
W Prim(self& mintree,size_t src)
{
size_t n=_v.size();
mintree._v=_v;
mintree._idxmp=_idxmp;
mintree._vv.resize(n);
for(int i=0;i<n;i++)
{
mintree._vv[i].resize(n,MAX_NUM);
}
vector<bool> x(n,false);
vector<bool> y(n,true);
x[src]=true;
y[src]=false;
priority_queue<edge,vector<edge>,greater<edge>> minpq;
for(int i=0;i<n;i++)
{
if(_vv[src][i]!=MAX_NUM)
{
minpq.push(edge(src,i,_vv[src][i]));
}
}
size_t size=0;
W totalw=W();
while(!minpq.empty())
{
edge mineg=minpq.top();
minpq.pop();
if(x[mineg._dst])
{
cout<<"构成环";
}
else
{
mintree._addedge(mineg._src,mineg._dst,mineg._w);
x[mineg._dst]=true;
y[mineg._dst]=false;
++size;
totalw+=mineg._w;
if(size==n-1)
{
break;
}
for(int i=0;i<n;i++)
{
if(_vv[mineg._dst][i]!=MAX_NUM && y[i])
{
minpq.push(edge(mineg._dst,i,_vv[mineg._dst][i]));
}
}
}
}
if(size==n-1)
{
return totalw;
}
else
{
return W();
}
}
private:
vector<K> _v;
map<K,int> _idxmp;
vector<vector<W>> _vv;
};
}
W Prim(self& mintree,size_t src),src为指定的源点,vector<bool> x(n,false),vector<bool> y(n,true),xsrc=true,ysrc=false,维护两个顶点集合X、Y,其中X表示已加入最小生成树的顶点,Y表示尚未加入的顶点,初始时只有源点src在X中。priority_queue<edge,vector<edge>,greater<edge>> minpq,minpq为最小堆,if(_vvsrci!=MAX_NUM),minpq.push(edge(src,i,_vvsrci)),用来存储当前X中顶点到Y中顶点的所有边,edge mineg=minpq.top(),minpq.pop(),通过while循环每次从minpq中取出权值最小的边,如果该边指向的目标顶点不在X中,mintree._addedge(mineg._src,mineg._dst,mineg._w),则将该边加入最小生成树,xmineg._dst=true,ymineg._dst=false,并把目标顶点从y集合移到x中,if(_vvmineg._dsti!=MAX_NUM && yi),minpq.push(edge(mineg._dst,i,_vvmineg._dsti)),通过for循环将mineg._dst顶点连接到y中其他顶点的所有边加入堆中。循环继续直到x包含了所有顶点或堆为空。if(size==n-1),如果最终选出的边数等于n-1,return totalw,则返回最小生成树的总权值,else则返回默认值,return W()。
总结:
Prim算法的核心思想是从一个起始顶点开始,逐步扩大最小生成树的规模,每一步都选择连接"已选顶点集合"与"未选顶点集合"之间权值最小的一条边,并将该边及其对面的顶点加入生成树。这种局部贪心策略保证了每一步的局部最优选择最终能导向全局最优。
二、最短路径
最短路径问题:从在带权有向图G中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。
1、Dijkstra算法
Dijkstra算法适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。
针对一个带权有向图G,将所有结点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空,初始时可以将源节点s放入,源节点s到自身的距离为0,Q为其余未确定最短路径的结点集合,每次从Q中找出一个起点到该结点代价最小的结点u,将u从Q中移出,并放入S中,对u的每一个相邻结点v进行松弛操作。松弛即对每一个相邻结点v,判断源节点s到结点u的代价与u到v的代价之和是否比原来s到v的代价更小,若代价比原来小,则将s到v的代价更新为s到u与u到v的代价之和,否则维持原样。如此一直循环直至集合Q为空,即所有结点都已经查找过一遍并确定了最短路径。Dijkstra算法每次都是选择V-S中最小的路径结点来进行更新,并加入到S中,采取局部贪心策略。
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。
Dijkstra算法的具体过程如下所示:


Dijkstra算法实现:
cpp
#pragma once
#include<vector>
#include<map>
#include<set>
#include<string>
#include<queue>
#include<functional>
#include"UnionFindSet.hpp"
namespace matrix
{
template<class K,class W,W MAX_NUM=INT_MAX,bool direction=false>
class graph
{
typedef graph<K,W,MAX_NUM,direction> self;
public:
struct edge
{
size_t _src;
size_t _dst;
W _w;
edge(size_t src,size_t dst,const W& w)
:_src(src)
,_dst(dst)
,_w(w)
{}
bool operator>(const edge& e1) const
{
return _w>e1._w;
}
};
void _addedge(size_t src,size_t dst,const W& w)
{
_vv[src][dst]=w;
if(direction==false)
{
_vv[dst][src]=w;
}
}
size_t getindex(const K& k)
{
auto it=_idxmp.find(k);
if(it!=_idxmp.end())
{
return it->second;
}
else
{
throw invalid_argument("顶点不存在");
return -1;
}
}
void Dijkstra(const K& k,vector<W>& dst,vector<int>& path)
{
int src=getindex(k);
int n=_v.size();
dst.resize(n,MAX_NUM);
path.resize(n,-1);
dst[src]=0;
path[src]=src;
vector<bool> s(n,false);
for(int j=0;j<n;j++)
{
int u=0;
W minw=MAX_NUM;
for(int i=0;i<n;i++)
{
if(!s[i] && dst[i]<minw)
{
u=i;
minw=dst[i];
}
}
s[u]=true;
for(int i=0;i<n;i++)
{
if(!s[i] && _vv[u][i]!=MAX_NUM && dst[u]+_vv[u][i]<dst[i])
{
dst[i]=dst[u]+_vv[u][i];
path[i]=u;
}
}
}
}
private:
vector<K> _v;
map<K,int> _idxmp;
vector<vector<W>> _vv;
};
}
int src=getindex(k),getindex(k)用于获取源点k对应的内部索引src,dst.resize(n,MAX_NUM),path.resize(n,-1),随后初始化dst距离数组为MAX_NUM、path路径数组为-1,dstsrc=0,pathsrc=src,并将src的距离设为0。vector<bool> s(n,false),使用一个bool数组s标记顶点是否已确定最短路径,if(!si && dsti<minw),u=i,minw=dsti,通过for循环从未标记的顶点里选出当前距离最小的顶点作为中间结点,su=true,将其标记为已确定,接着通过for循环遍历该顶点的所有邻接顶点,if(!si && _vvui!=MAX_NUM && dstu+_vvui<dsti),dsti=dstu+_vvui,pathi=u,如果通过当前顶点能让其他顶点得到更短的距离,就更新距离数组dst,并记录路径前驱。整个过程重复进行,直到所有可达顶点都被处理完毕。
总结:
Dijkstra算法的核心思想就是从源点出发,每次在尚未确定最短路径的顶点中,选择当前距离源点最近的那个顶点,将其标记为已确定,然后尝试通过该顶点去松弛它所有邻接顶点的距离。这个过程不断重复,直到所有顶点的最短距离都被确定。Dijkstra算法保证了每一步选择的局部最优解最终能导向全局最优解,但Dijkstra算法无法处理图中存在负权边的情况。
2、Bellman-Ford算法
Dijkstra算法只能用来解决正权图的单源最短路径问题,而Bellman-Ford算法可以用来解决负权图的单源最短路径问题。Bellman-Ford算法优点在于可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。但Bellman-Ford算法的时间复杂度O(N*E)普遍是要高于Dijkstra算法O(N^2)的,如果使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),可以看出Bellman-Ford就是一种暴力求解更新。
Bellman-Ford算法的具体过程如下:

Bellman-Ford算法实现:
cpp
#pragma once
#include<vector>
#include<map>
#include<set>
#include<string>
#include<queue>
#include<functional>
#include"UnionFindSet.hpp"
namespace matrix
{
template<class K,class W,W MAX_NUM=INT_MAX,bool direction=false>
class graph
{
typedef graph<K,W,MAX_NUM,direction> self;
public:
struct edge
{
size_t _src;
size_t _dst;
W _w;
edge(size_t src,size_t dst,const W& w)
:_src(src)
,_dst(dst)
,_w(w)
{}
bool operator>(const edge& e1) const
{
return _w>e1._w;
}
};
void _addedge(size_t src,size_t dst,const W& w)
{
_vv[src][dst]=w;
if(direction==false)
{
_vv[dst][src]=w;
}
}
size_t getindex(const K& k)
{
auto it=_idxmp.find(k);
if(it!=_idxmp.end())
{
return it->second;
}
else
{
throw invalid_argument("顶点不存在");
return -1;
}
}
bool bellmanford(const K& k,vector<W>& dst,vector<int>& path)
{
int src=getindex(k);
int n=_v.size();
dst.resize(n,MAX_NUM);
path.resize(n,-1);
dst[src]=W();
for(int k=0;k<n;k++)
{
bool update=false;
for(int i=0;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(_vv[i][j]!=MAX_NUM && dst[i]+_vv[i][j]<dst[j])
{
update=true;
dst[j]=dst[i]+_vv[i][j];
path[j]=i;
}
}
}
if(!update) break;
}
for(int i=0;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(_vv[i][j]!=MAX_NUM && dst[i]+_vv[i][j]<dst[j])
{
return false;
}
}
}
return true;
}
private:
vector<K> _v;
map<K,int> _idxmp;
vector<vector<W>> _vv;
};
}
dst.resize(n,MAX_NUM),path.resize(n,-1),dstsrc=W(),首先初始化距离数组dst为MAX_NUM,路径数组path为-1, 并将源点src的距离设为默认值。通过for循环进行最多n-1轮的松弛操作,每一轮都遍历所有的边,if(_vvij!=MAX_NUM && dsti+_vvij<dstj),如果发现通过某个中间顶点i可以让目标顶点的距离变短,update=true,dstj=dsti+_vvij,pathj=i,就更新距离并记录前驱顶点。if(!update) break,如果某一轮没有任何更新,则所有顶点的最短路径都已确定,后续轮次也都不会更新,算法可提前结束。在所有松弛轮次完成后,还需通过for循环再遍历一次所有边,if(_vvij!=MAX_NUM && dsti+_vvij<dstj),如果还能更新出最短路径,则说明图中存在负权环,return false,反之则说明所有顶点的最短路径都已确定。
总结:
Bellman-Ford算法的核心思想是进行最多顶点数减一轮的松弛操作,每一轮都尝试遍历图中的每一条边,如果发现通过某个顶点能让另一个顶点的距离变短,就更新那个顶点的最短距离,重复这个过程,每一轮都能保证至少多确定一个顶点的最短路径。如果在完成所有轮次后,还能通过某条边继续缩短距离,就说明图中存在负权环,无法求出真正的最短路径,因为负权环可以无限绕环,最短路径可以无限小,反之算法成功并得到源点到所有顶点的最短距离。虽然Bellman-Ford算法的时间复杂度为O(N^3)较高,但它可以处理负权边的最短路径,同时也能对负权环是否存在进行检测。
3、Floyd-Warshall算法
Floyd-Warshall算法用于解决图中任意两点间的最短路径问题。
Floyd算法考虑的是一条最短路径的中间节点,即简单路径p={v1,v2,...,vn}上除v1和vn的任意结点。
设k是p的一个中间结点,那么从i到j的最短路径p就被分成i到k和k到j的两段最短路径p1,p2,p1是从i到k且中间结点属于{1,2,...,k-1}取得的一条最短路径,p2是从k到j且中间结点属于{1,2,...,k-1}取得的一条最短路径。

路径p是从结点i到结点j的一条最短路径,结点k是路径p上编号最大的中间结点,路径p1是路径p上从结点i到结点k之间的一段,其所有中间结点取自集合{1,2,...,k-1}。从结点k到结点j的路径p2也遵循同样的规则。
原理:
Floyd-Warshall算法的核心原理是动态规划。
设Di,j,k为从i到j的只以(1..k)集合中的结点为中间结点的最短路径的长度。
1、若最短路径经过点k,则Di,j,k=Di,k,k-1+Dk,j,k-1;
2、若最短路径不经过点k,则Di,j,k=Di,j,k-1。
因此,Di,j,k=min(Di,j,k-1,Di,k,k-1+Dk,j,k-1)。
为了节省空间,可以直接在原来空间上进行迭代,这样空间可降至二维。
Floyd算法本质是三维动态规划,Dijk表示从点i到点j只经过0到k个点最短路径,然后建立起转移方程,通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得到所有点的最短路径。
Floyd-Warshall算法的具体过程如下:



Floyd-Warshall算法实现:
cpp
#pragma once
#include<vector>
#include<map>
#include<set>
#include<string>
#include<queue>
#include<functional>
#include"UnionFindSet.hpp"
namespace matrix
{
template<class K,class W,W MAX_NUM=INT_MAX,bool direction=false>
class graph
{
typedef graph<K,W,MAX_NUM,direction> self;
public:
struct edge
{
size_t _src;
size_t _dst;
W _w;
edge(size_t src,size_t dst,const W& w)
:_src(src)
,_dst(dst)
,_w(w)
{}
bool operator>(const edge& e1) const
{
return _w>e1._w;
}
};
void _addedge(size_t src,size_t dst,const W& w)
{
_vv[src][dst]=w;
if(direction==false)
{
_vv[dst][src]=w;
}
}
size_t getindex(const K& k)
{
auto it=_idxmp.find(k);
if(it!=_idxmp.end())
{
return it->second;
}
else
{
throw invalid_argument("顶点不存在");
return -1;
}
}
void floydwarshall(vector<vector<W>>& dst,vector<vector<int>>& path)
{
size_t n=_v.size();
dst.resize(n);
path.resize(n);
for(int i=0;i<n;i++)
{
dst[i].resize(n,MAX_NUM);
path[i].resize(n,-1);
}
for(int i=0;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(_vv[i][j]!=MAX_NUM)
{
dst[i][j]=_vv[i][j];
path[i][j]=i;
}
if(i==j)
{
dst[i][j]=W();
}
}
}
for(int k=0;k<n;k++)
{
for(int i=0;i<n;i++)
{
for(int j=0;j<n;j++)
{
if(dst[i][k]!=MAX_NUM && dst[k][j]!=MAX_NUM && dst[i][k]+dst[k][j]<dst[i][j])
{
dst[i][j]=dst[i][k]+dst[k][j];
path[i][j]=path[k][j];
}
}
}
}
}
private:
vector<K> _v;
map<K,int> _idxmp;
vector<vector<W>> _vv;
};
}
size_t n=_v.size(),dst.resize(n),path.resize(n),dsti.resize(n,MAX_NUM),pathi.resize(n,-1),首先根据顶点数量初始化距离矩阵和路径矩阵,if(_vvij!=MAX_NUM),dstij=_vvij,pathij=i,将直接相连的边距离填入距离矩阵dst,路径矩阵path记录对应的前驱顶点,if(i==j),dstij=W(),将对角线距离设为默认值。通过三重for循环,依次尝试将每个顶点作为中间点,if(dstik!=MAX_NUM && dstkj!=MAX_NUM && dstik+dstkj<dstij),如果从起点i经过中间点k到终点j的距离比当前记录的直接距离更短,dstij=dstik+dstkj,pathij=pathkj,就更新距离矩阵dst并修正路径矩阵中的前驱结点。最终,距离矩阵dst就存储了每对顶点之间的最短路径长度,路径矩阵path可用于还原具体的最短路径。
总结:
Floyd-Warshall是一种用于求解加权图中所有顶点对之间最短路径的动态规划算法。其核心思想是逐步允许更多的顶点作为路径中的中间点,通过不断松弛来改进任意两点之间的已知最短距离。Floyd-Warshall算法能够一次性求出所有点对的最短路径,也能求出负权边,但时间复杂度较高,为O(N^3),空间复杂度为O(N^2)。
结语
最小生成树和最短路径问题是图论中最经典的两类问题,最小生成树关注的是用最小的总代价连通所有顶点,并不关心顶点之间的具体路径,Kruskal算法通过采取全局贪心来选最小边,Prim算法通过采取局部贪心来扩展最小邻边,都展现了贪心在最小生成树这类问题上的正确性。最短路径问题则关注两点之间的最优路线,Dijkstra算法通过逐步贪心扩展邻近点,但不能处理负权问题,Bellman-Ford算法则通过逐轮松弛来规划最优路线,可处理负权问题,Floyd-Warshall算法作为多源最短路径算法,通过逐步允许中间结点的动态规划方式,一次性解决所有点对之间的距离,也是动态规划的经典应用。掌握最小生成树和最短路径两类经典算法,就抓住了图论优化问题的两大基石。