以下内容部分来自陈小玉老师的算法ppt,将图论中常见的算法进行汇总。包括图的存储以及图论的经典算法(最短路径、最小生成树)等。
部分代码转自陈小玉老师的《算法训练营》。
主要汇总了常用的图的存储和算法,建议初学者直接看B站的相关视频
图的存储方法很多,最常见的除了邻接矩阵、邻接表和边集数组外,还有链式前向星。链式前向星是一种静态链表存储,用边集数组和邻接表相结合,可以快速访问一个顶点的所有临界点。
链式前向星存储包括两种结构:(要理解清除两个数组的含义)
int n,m,x,y,w; //顶点数、边数、(弧头、弧尾、权重)
int cnt; //边计数器
int head[maxn]; //头结点数组
struct node{
int to,next,w;
}edge[maxe]; //边集数组,一般设置比maxn*maxn大的数,如果题目有要求除外
void init(){//初始化
memset(head,-1,sizeof(head)); //引入头文件 cstring
cnt=0;
}
void add(int u,int v,int w){//添加一条边
edge[cnt].to=v;
edge[cnt].w=w;
edge[cnt].next=head[u];
head[u]=cnt++;
}
注意:如果是有向图,每输入一条边,执行一次add(u,v,w)即可;如果是无向图,则需要执行add(u,v,w),add(v,u,w);
for(int i=head[u] ; i!=-1 ; i=edge[i].next){ //这里的i!=-1可以用~i代替,结果相同
int v=edge[i].to;
int w=edge[i].w;
//...
}
int main(){
cin>>n>>m;
//初始化
init();
//创建图
for(int i=1;i<=m;i++){
cin>>x>>y>>w;
add(x,y,w);
//add(y,x,w);
}
return 0;
}
和邻接表一样,因为采用头插法进行链接,所以边输入顺序不同,创建的链式前向星也不同。
对于无向图,每输入一条表,需要添加两条边,互为反向边。
这两条边互为反向边,可以通过与1的异或运算得到其反向边,一条边的下标为i,则i^1是其反向边的下标。这个特性应用在网络流中非常方便。
链式前向星具有边集数组和邻接表的功能,属于静态链表,不需要频繁地创造结点。
边集数组表示,通过数组存储每条边的起点和重点,如果是网,则增加一个权值域。
struct Edge{
int u,v,w;
}e[N*N];
#include
#include //使用其中的sort函数
using namespace std;
const int N=100;
int fa[N];
int n,m;//节点数,边数
struct Edge{
int u,v,w;
}e[N*N];
bool cmp(Edge x,Edge y){//排序中使用,按照边权从小到大排序
return x.w>n>>m;
Init(n);
for(int i=0;i>e[i].u>>e[i].v>>e[i].w;
cout<<"最小花费是"<
优点:可以对边按权值排序,方便对边进行处理。
缺点:不便于判断两点之间是否有边,不便于访问所有邻接点,不便于计算各顶点的度。
邻接矩阵是表示顶点之间关系的矩阵。
邻接矩阵存储方法:
存储顶点之间邻接关系得到二维数组成为邻接矩阵。
能够表示顶点信息有多种方式:(本质上就是将非整数映射到整数)
一维数组vex[] 或者 map映射到整数 或者 通过其他方式映射到整数
初始化+矩阵赋值
const int maxn=10005;//结点数最大值
const int inf=0x3f3f3f3f;//ACM等竞赛中通常使用0x3f3f3f3f来表示无穷大
int E[maxn][maxn];//邻接矩阵
int n,m;//结点数,边数
void createAM(){
int u,v; //(带权图)网: int u,v,w;
cin>>n>>m;
for(int i=0;i>u>>v; //网: cin>>u>>v>>w;
E[u][v]=E[v][u]=1; //网: E[u][v]=w;
}
}
优点:快速判断两顶点之间是否有边,方便计算各顶点的度
缺点:不便于增删顶点,不便于访问所有邻接点,空间复杂度高
邻接表是图的一种链式存储方法。邻接表包含两部分:顶点和邻接点。
顶点:包含顶点信息data和指向第一个邻接点的指针first。
邻接点:包括邻接点的存储下标v和指向下一个邻接点的指针next,如果是网的邻接点,则还需增加一个权值域w。
顶点vi的所有邻接点构成一个单链表。
typedef struct AdjNode{//定义邻接点类型
int v;//邻接点下标
struct AdjNode *next;//指向下一个邻接点
}AdjNode;
typedef struct VexNode{//定义顶点类型
VexType data;//VexType为顶点的数据类型,根据需要定义
AdjNode *first;//指向第一个邻接点
}VexNode;
typedef struct{
VexNode Vex[Max Vnum];//节点表
int vexnum,edgenum;//节点数,边数
}ALGraph;
由于在算法竞赛中,往往只有一个图,所以在竞赛中,通常不定义ALGraph,而是将节点表和节点数,边数等单独写在外部,并且通常使用vector和map。
vector E[maxn];//每个节点定义一个vector,存储其邻接点
int n,m;//节点数,边数
void createVec(){//用vector存储无向图
int u,v;
cin>>n>>m;
for(int i=0;i>u>>v;
E[u].push_back(v);
E[v].push_back(u);
}
}
vector E[MaxVnum];//引入头文件#include ,定义数组E[]
mapmp; //map映射,字符串映射到一个整数编号
void createVec(){
string s1,s2;
int k=1;
cin>>n>>m;
for(int i=0;i>s1>>s2;//一条边的两个结点字符串
if(mp[s1]==0) //如果这个string在map中没有,就新建一个string-int
mp[s1]=k++;//映射到一个结点编号,
if(mp[s2]==0)
mp[s2]=k++;
E[mp[s1]].push_back(mp[s2]);//邻接表中存放的都是结点的编号
}
}
map访问不存在的key值时,会在map中添加key-value,value会被赋予默认值
优点:便于增删顶点,便于访问所有邻接点,空间复杂度低
缺点:不便于判断两顶点之间是否有边,不便于计算各顶点的度
总体上,邻接表比邻接矩阵效率更高
Dijkstra算法:单源最短路径
Floyd算法:各个顶点之间最短路径,没有负环
Bellman-Ford算法:负权边、判断负环,单源最短路径
SPFA算法:对Bellman-Ford算法的优化
Dijkstra算法是解决单源最短路径问题的贪心算法,它先求出长度最短的一条路径,再参照该路径求出长度次短的一条路径,直到求出源点到其他各个节点的最短路径。
Dijkstra算法基本思想:将节点集合V划分为两部分:集合S和集合V-S,其中S中的节点到源点的最短路径已经确定,V-S中的节点到源点的最短路径待定。
(常常使用一个额外的数组来划分集合)
从源点触发只经过S中的节点到达V-S中的节点的路径成为特殊路径。Dijkstra算法的贪心策略是选择最短的特殊路径长度dist[t],并将节点t加入到集合S中,同时借助t更新数组dist[]。一旦S包含了所有节点,dist[]就是从源点到其他节点的最短路径长度。
存储:可以使用邻接矩阵,邻接表,链式前向星
找最小:可以遍历,或者使用优先队列
输出路径:可以采用栈实现翻转,或者递归实现翻转
#include
#include
#include
using namespace std;
const int N=1005;
const int INF=0x3f3f3f3f;//无穷大
int G[N][N],dist[N];//G[][]为邻接矩阵,dist[i]表示源点到i的最短路径长度
int p[N]; //p[i]表示源点到结点i的最短路径上i的前驱
int n,m; //n为结点数,m为边数
bool flag[N]; //如果flag[i]等于true,说明节点i已经加入到S集合,否则i属于V-S
//初始化+找最小+松弛操作
void dijkstra(int u){
//初始化
for(int i=1;idist[t]+G[t][j])){
//利用新加入的t节点,对V-S集合中剩余的所有点,进行松弛操作
dist[j]=dist[t]+G[t][j];
p[j]=t;
}
}
}
}
void print(){//输出源点到其他节点的最短距离
for(int i=1;i<=n;i++){
if(i!=1) cout<<" ";
if(dist[i]==INF)
cout<<"impossible";
else
cout< s;
cout<<"源点为:"<
时间复杂度:找最小值和松弛操作本身各执行n次,需要重复n-1次,总执行次数均为n2,时间复杂度为O(n2)
空间复杂度:包含数组flag[]、p[],空间复杂度为O(n)
找最小值。按照贪心策略查找V-S集合中dist[]最小的节点,其时间复杂度为O(n),如果使用优先队列,则每次找最小值时间复杂度降为O(logn),找最小值的总时间复杂度为O(nlogn)。
松弛操作。如果采用邻接表或链式前向星存储,松弛操作就不用每次执行n次,而是执行节点t的邻接点数(t的出度),所有节点的出度之和等于边数m,松弛操作的总时间复杂度为O(m)。
#include
#include
#include
using namespace std;
const int N=1005;
const int INF=0x3f3f3f3f;//无穷大
int G[N][N],dist[N]; //G[][]为邻接矩阵,dist[i]表示源点到结点i的最短路径长度
int n,m; //n为结点数,m为边数
bool flag[N]; //如果flag[i]等于true,说明结点i已经加入到S集合;否则i属于V-S集合
struct node{
int u,dis;//结点u,源点到u的最短路径长度dis
node(){};
node(int _u,int _dis){//构造函数,使得赋值方便
u=_u; dis=_dis;
}
bool operator < (const node &a)const{ //重载<,优先队列优先级,dis越小越优先
return dis>a.dis; //注意优先队列内部是从大到小输出
}
};
void dijkstra(int u){
priority_queueque; // 优先队列优化
//初始化
for(int i=1;i<=n;i++){
dist[i]=INF; // 初始化所有距离为无穷大
flag[i]=false;
}
dist[u]=0;
node vs=node(u,0);//创建源点node
que.push(vs);
while(!que.empty()){
node it=que.top();//优先队列队头元素为dist最小值
que.pop();
int t=it.u;
if(flag[t])//说明已经找到了最短距离,该结点是队列里面的重复元素
continue;
flag[t]=true;//将t加入到S集合中
for(int j=1;j<=n;j++){//松弛操作
if(!flag[j]&&dist[j]>dist[t]+G[t][j]){
dist[j]=dist[t]+G[t][j];
que.push(node(j,dist[j])); //把更新后的最短距离压入优先队列,注意:里面的元素有重复
}
}
}
}
void print(){//输出源点到其它节点的最短距离
for(int i=1;i<=n;i++){
if(i!=1) cout<<" ";
if(dist[i]==INF)
cout<<"impossible";
else
cout<
Dijkstra算法用于求从源点到其他各个节点的最短路径。如果求解任意两个节点之间的最短路径,则需要以每个节点为源点,重复调用n次Dijkstra算法。
Floyd算法可用于求解任意两个节点间的最短路径。Floyd算法又被称为插点法,其算法核心是在节点i和节点j之间插入节点k,看看是否可以缩短节点i与节点j之间的距离(松弛操作)。
数据结构。邻接矩阵G[][]存储图,dist[i][j]记录从节点i到节点j的最短路径长度,p[i][j]记录节点i到节点j的最短路径上节点j的直接前驱。
初始化。dist[i][j]=G[i][j],如果节点i到节点j有边相连,p[i][j]=i,否则p[i][j]=-1。
插点。其实就是在节点i、j之间插入节点k,看是否可以缩短节点i、j之间的距离(松弛操作)。
如果dist[i][j]>dist[i][k]+dist[k][j],则dist[i][j]=dist[i][k]+dist[k][j],并记录节点j的前驱,p[i][j]=p[k][j]。
时间复杂度:三层for循环,时间复杂度为O(n3)。
空间复杂度:数组dist[][]、p[][],空间复杂度为O(n2)。
#include
#include
using namespace std;
const int N=100;
const int INF=0x3f3f3f3f;
int G[N][N],dist[N][N];//G[][]为邻接矩阵,dist[i][j]表示i到j的最短路径长度
int p[N][N];//p[i][j]表示i到j的最短路径上j的前驱
int n,m;//n表示节点数,m表示边数
void Floyd(){
//初始化
for(int i=0;i";
}
}
用于求解单源最短路径问题。优点是边的权值可以为负数、实现简单,缺点是时间复杂度过高。但是,对该算法可以进行若干种优化,以提高效率。
Bellman-Ford算法与Dijkstra算法类似,都以松弛操作为基础。Dijkstra算法以贪心法选取未被处理的具有最小权值的节点,然后对其邻接点进行松弛操作。Bellman-Ford算法对所有边进行松弛操作,共n-1次,因为负环可以无限制的减少最短路径长度,所以如果第n次操作仍可以松弛,则一定存在负环。
#include
#include
using namespace std;
const int N=1005;
const int INF=0x3f3f3f3f; //无穷大
struct node{
int a,b,w;
}e[N*N]; //边数要设置为N*N
int dist[N];
int n,m,cnt;
void add(int u,int v,int w){//添加一条边
e[cnt].a=u;
e[cnt].b=v;
e[cnt++].w=w;
}
bool bellman_ford(int u){//求源点u到其他各个顶点的最短路径长度
//初始化
memset(dist,0x3f,sizeof(dist));//初始化为无穷大,注意memset按照字节进行初始化
dist[u]=0;
for(int i=1;idist[e[j].a]+e[j].w){
dist[e[j].b]=dist[e[j].a]+e[j].w;
flag=true;
}
}
if(!flag) //不能进行松弛操作了,提前结束
return false;
}
for(int j=0;jdist[e[j].a]+e[j].w)
return true;
return false;
}
}
时间复杂度:算法中对每条边进行松弛操作,重复n-1次,时间复杂度O(nm)。
空间复杂度:包含数组e[]、dist[],空间复杂度为O(n+m)
提前退出循环,在实际操作中,Bellman-Ford算法经常会在未到达n-1次时就求解完毕,可以在循环中设置判定,在某次循环不再进行松弛时,直接退出循环。通过上段代码中的if(!flag)就可以提前退出循环。
队列优化。松弛操作,必定只会发生在最短路径松弛过的前驱节点上,用一个队列记录松弛过的节点,可以避免冗余计算。这就是队列优化的Bellman-Ford算法,又被称为SPFA算法。
SPFA算法是Bellman-Ford算法的队列优化算法,通常用于求解包含负权边的单源最短路径,以及判负环。在最坏情况下,SPFA算法的时间复杂度和Bellman-Ford算法相同,为O(nm),但在稀疏图上运行效率比较高,为O(km),其中k是一个比较小的常数。
数据结构。链式前向星存储图,dist[i]记录从源点到节点i的最短路径长度,vis[i]标记节点i是否在队列中,sum[i]记录节点i入队次数。
创建一个队列,源点u入队,标记u在队列中,u的入队次数加1。
松弛操作,取出队头,标记x不在队列中,考察x的所有出边i(x,v,w),,如果dist[v]>dist[x]+e[j].w,则dist[v]=dist[x]+e[i].w,如果节点v不在队列中,如果v的入队次数加1后大于或等于n,则说明有负环,退出;否则v入队,标记v在队列中。
重复松弛操作,直到队列为空。
#include
#include
#include
using namespace std;
const int N=1005;
const int INF=0x3f3f3f3f;
int n,m,cnt;
int head[N],dist[N],sum[N];
bool vis[N];//标记是否在队列中
struct node{
int to,next,w;
}e[N*N];
void add(int u,int v,int w){
e[cnt].to=v;
e[cnt].w=w;
e[cnt].next=head[u];
head[u]=cnt++;
}
bool spfa(int u){
queue q;
//初始化
memset(head,-1,sizeof(head));
memset(vis,0,sizeof(vis));
memset(sum,0,sizeof(sum));
memset(dist,0x3f,sizeof(dist));
vis[u]=1;
dist[u]=0;
sum[u]++;
q.push(u);
while(!q.empty()){
int x=q.front();
q.pop();
vis[x]=0;
for(int i=head[x];i!=-1;i=e[i].next){
int v=e[i].to;
if(dist[v]>dist[x]+e[i].w){
dist[v]=dist[x]+E[i].w;
if(!vis[v]){
if(++sum[v]>=n)
return true;//说明有负环
vis[v]=1;
q.push(v);
}
}
}
}
return false;
}
时间复杂度:最坏情况下时间复杂度是O(nm),对于稀疏图的时间复杂度为O(km),其中k是一个较小的常数。
空间复杂度:包含数组e[]、disst[],空间复杂度为O(n+m)。
SPFA算法两个优化策略:SLF,LLL
SLF策略:如果待入队的节点是j,队首元素为结点i,若dist[j] LLL策略:设队首元素为结点i,队列中所有dist[]的平均值为x,若dist[i]>x,则将节点i插入队尾,查找下一元素,直到找到某一节点i满足dist[i]<=x,将节点i出队,进行松弛操作 。 找出n-1条权值最小的边很容易,那么怎么保证无回路呢?如果在一个图中社恩度搜索或者广度搜索没有回路,是一件繁重的工作。有一个很好的办法----集合避圈法。 把已经在生成树中的节点看作一个集合,剩下节点看作另一个集合,从连接两个集合的边中选择一条权值最小的边。 直观地看图很容易找出U到V-U集合的边中哪条边是最小的,但是程序中如果穷举这些边,再找最小值就太麻烦了,那怎么办? 可以设置两个数组巧妙解决这个问题: closest[j]:表示V-U中的顶点j到集合U中的最邻近点 lowcost[j]:表示V-U中的顶点j到集合U中的最邻近点的边值,即边(j,closest[j])的权值 初始化:领集合U={u0},u0∈V,并初始化数组s[]、closest[]、lowcost[] 在V-U集合中找lowcost[]值最小的顶点t,即lowcost[t]=min{lowcost[j]|j∈V-U},满足该公式的顶点t就是集合V-U中连接集合U的最邻近点 将顶点t加入集合U 如果V-U为空,算法结束,否则,继续 对集合V-U中的所有顶点j,更新其lowcost[]和closest[],重复以上操作 其实:就是不断将两个集合的连接边中的最小值找到,利用新加入U的t更新这些最小值 时间复杂度:两层for循环,时间复杂度为O(n2) 空间复杂度:辅助数组closest[]、lowcost[]、s[],空间复杂度为O(n) Kruskal算法将n个顶点看成是n个孤立的连通分支,首先将所有的边按权值从小到大排序,然后做贪心选择。 在边集E中选取权值最小的边(i,j),如果将边(i,j)加入集合TE中不产生回路,则将边(i,j)加入到边集TE中,否则将继续选择下一条最短边。 Kruskal用了非常聪明的办法,即集合避圈法: 如果待选边的起点和终点都在T的集合中,就可以判定形成回路。待选择边的两个端点不能属于同一集合。 初始化:将图G的边集E中的所有边按权值从小到大排序,边集TE={},每个顶点初始化一个集合号。(采用边集数组存储) 在E中寻找权值最小的边(i,j)。 如果顶点i和j位于两个不同的连通分支,则将边(i,j)加入边集TE,并将两个连通分支进行合并。 将边(i,j)从集合E中删去,即E=E-{(i,j)}。 如果选取边数小于n-1,重复,否则,算法结束。 时间复杂度:边排序为O(mlogm),合并为O(n2)。 空间复杂度:辅助数组nodeset[],空间复杂度为O(n)。 时间复杂度:边排序为O(mlogm),合并为O(nlogn) 空间复杂度:O(n)三、最小生成树
1、Prim算法
算法设计
示例代码
#include
算法分析
2、Kruskal算法
算法设计
示例代码
#include
#include
算法分析
算法优化