前言
- 本文将直接使用邻接表的数组形式进行图的存储,邻接矩阵的存储方式在此算法的实现上较为简单,邻接表的链式存储较为麻烦,且申请释放内存会消耗大量时间,在此不再赘述。
- 本文使用的邻接表数组存储来源于《啊哈!算法》,由于我水平有限,只能暂时先使用这种包含5个数组的邻接表。
- 本文中的算法不记录路径只记录距离。
- 本文中所有数组的第一个存储单元即a[0],都没有被使用。
- 此算法不能解决带有负权边的图,因此我们规定本文中的图里面不存在负权边。
- 代码采用C++实现,不过只要有C语言的基础就可以看懂,没有用到很多C++的东西。
- 若对本文存在疑问、意见或建议,欢迎评论及与我本人联系。
- 算法的优化详见后文。
术语简介
- 权值:表示两点之间的距离、耗费等具有某种意义的数值
- 弧:有向的边,在图上用箭头表示,<x,y>表示从点x到点y有一条弧(请注意<y,x>与其是不同的两条弧)
- 弧尾:弧的起点,x
- 弧头:弧的终点,y
- 路径:由连续的弧构成的顶点序列,在图中,存在路径的两点之间不一定存在直达的弧
- 入边:以某点为弧头的边
- 出边:以某点为弧尾的边
Dijkstra入门
最短路径问题
在图论中,最短路径是一类十分常见的问题,常见的有SSSP(Single-source Shortest Paths,单源最短路径)和APSP(All Pairs Shortest Paths,多源最短路径。而今天讲到的Dijkstra算法,主要用来解决SSSP问题。它指的是以某个点为起点,从该点到其余各个顶点的最短路程/最小代价。此算法也可以解决APSP问题,只是它较为复杂,不推荐使用。请注意此类问题与最小生成树问题的区别与联系:最小生成树问题结果会涉及到图中的所有顶点,而最短路径问题的结果可能只涉及到图中的一部分顶点。
算法思想介绍
以此有向图为例,为了求解从顶点1开始到各顶点的最短路径,Dijkstra算法首先确定了两个集合A、B,其中A集合中包含已经确定最短路径的顶点,B集合包含未确定最短路径的点。其次我们需要一个数组dis来记录从起点到各个顶点的最短距离,一个数组book来记录顶点是否在A集合中。
接下来我们要对上述两个数组进行初始化,这里我们以1号顶点作为起点。先将book数组全部置为false,表示所有点都未确定最短距离。同时对dis数组进行初始化,访问邻接表,若起点与点i之间存在弧,则将dis[i]置为弧的权值,否则置为inf(无穷)。全部初始化完成后,将起点——1号顶点加入集合A中,即book[1]置为true,众所周知自己到自己的最短路径是0,因此将dis[1]置为0。至此准备工作完成。
此时数组情况如下:
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | false | 1 |
3 | false | 12 |
4 | false | inf |
5 | false | inf |
6 | false | inf |
下面正式开始算法!!!(敲黑板.jpg)
- 从除1号顶点之外的其他所有顶点中(即集合B中的顶点)找到离1号顶点最近的顶点,即从以1号顶点为弧尾/起点的弧中找到权值最小的那条弧(这里就是顶点2啦),并将其加入集合A中(即book[2]=true)
- 接下来对其余顶点进行处理。以在集合B中的顶点j为弧头/终点,访问以2号顶点为弧尾/起点的弧,观察以2号点为中转点,从1号到j号顶点的路径长度是否缩短,即dis[j]是否大于dis[2]+弧<2,j>权值 ,若大于,则进行松弛。此时我们首先发现,dis[3]>dis[2]+9,因此将dis[3]更新为10。此时数组情况如下:
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | true | 1 |
3 | false | 10 |
4 | false | inf |
5 | false | inf |
6 | false | inf |
紧接着我们又发现,dis[4]>dis[2]+3,因此将dis[4]更新为4。此时数组情况如下
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | true | 1 |
3 | false | 10 |
4 | false | 4 |
5 | false | inf |
6 | false | inf |
- 重复上述过程,直到所有的顶点都进入集合A中,算法结束。
接下来发现集合B中离1号顶点最近的顶点是4号,进行处理后数组如下:
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | true | 1 |
3 | false | 8 |
4 | true | 4 |
5 | false | 17 |
6 | false | 19 |
继续寻找,这次选择3号顶点,进行处理后数组如下
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | true | 1 |
3 | true | 8 |
4 | true | 4 |
5 | false | 13 |
6 | false | 19 |
继续寻找,这次选择5号顶点,进行处理后数组如下:
编号 | book | dis |
---|---|---|
1 | true | 0 |
2 | true | 1 |
3 | true | 8 |
4 | true | 4 |
5 | true | 13 |
6 | false | 17 |
继续寻找,这次选择6号顶点,将其加入集合A后,由于它没有出边,且此时所有顶点都已在集合A中,因此无需进行处理。
算法实现
构造出的输入数据如下:
6 9
1 2 1
1 3 12
2 3 9
2 4 3
3 5 5
4 3 4
4 5 13
4 6 15
5 6 4
六个顶点九条边
代码如下:
#include<iostream>//Dijkstra迪杰斯克拉算法模板
#define inf 999999999//定义inf
using namespace std;
int cur=1;//记录当前边的编号
int dis[10];//记录源点到各顶点的最短路径
bool book[10];//记录已经找到最短路径的点
int from[10],to[10],value[10];//分别表示起点,终点,权值
int start[10],nt[10];//分别表示起点和"链域"
//使用邻接表存储图
void reset(int n)
{
int i;
for (i=1;i<=n;i++)
start[i]=-1;
return;
}//邻接表初始化
inline void add(int a,int b,int c)//inline可提高此函数运行效率,多用于经常调用的函数
{
from[cur]=a;//起点/弧尾
to[cur]=b;//终点/弧头
value[cur]=c;//权值
nt[cur]=start[from[cur]];
start[from[cur]]=cur;//修改"指针"
cur++;
return;
}
inline int find(int st,int ed)
{
int k;
k=start[st];
while (k!=-1)//在未到最后一条弧时
{
if (to[k]==ed) break;//表示找到终点/弧头为i的弧
k=nt[k];//向后寻找
}
return k;
}
void dijkstra(int n)
{
int i,j,k,min,p;
for (i=1;i<=n;i++)
{
book[i]=false;//开始时都未找到最短路径
j=find(1,i);//取1号点(起点)
if (j==-1) dis[i]=inf;//未找到弧
else dis[i]=value[j];//找到相应弧
}//初始化
book[1]=true;//将起点加入集合
dis[1]=0;//起点到自己距离为0
for (i=2;i<=n;i++)//处理剩下的点
{
min=inf;//记录当前情况下权值最小的弧
for (j=1;j<=n;j++)
{
if (!book[j]&&dis[j]<min)
{
min=dis[j];
p=j;//记录最短弧的终点/弧头
}
}
book[p]=true;//将最短弧的终点/弧头加入集合
for (j=1;j<=n;j++)
{
if (!book[j])//如果顶点j在集合B中
{
k=find(p,j);
if (k!=-1&&dis[j]>dis[p]+value[k])//如果从p点到j点有弧,且从起点到j点的已有路径长度大于从起点经p点中转再到j点的路径长度
dis[j]=dis[p]+value[k];//则进行松弛(贪心的体现)
}
}
}
return;
}
int main()
{
int m,n,i,x,y,z;
cin>>n>>m;//n个点,m条边
reset(n);
for (i=0;i<m;i++)
{
cin>>x>>y>>z;
add(x,y,z);//添加弧
}
dijkstra(n);
for (i=1;i<=n;i++)
cout<<dis[i]<<" ";//输出结果
return 0;
}
邻接表数组简介
存储结构
我使用的邻接表数组包含了5个数组,分别是from ,to ,value ,start ,nt .它们分别表示某条路径的起点、终点、权值,start[i]表示顶点i的第一条边所在前三个数组中的位置/编号,相当于邻接链表中表示顶点的头结点,nt[i]表示编号为i的边的下一条边的编号,类似链表中的链域,-1相当于NULL。
注意,前三个数组和nt数组大小至少要为最大边数+1,start数组大小至少为总点数+1。还要有一个变量来记录当前弧的编号。
int cur=1;//记录当前边的编号
int from[10],to[10],value[10];//分别表示起点,终点,权值
int start[10],nt[10];//分别表示起点和"链域"
//使用邻接表存储图
初始化
void reset(int n)
{
int i;
for (i=1;i<=n;i++)
start[i]=-1;//将各顶点第一条边的编号置为-1,类似邻接链表中各顶点的头结点链域置为NULL
return;
}
按照题设,初始化后结果如下
num | start |
---|---|
1 | -1 |
2 | -1 |
3 | -1 |
4 | -1 |
5 | -1 |
6 | -1 |
存储方式
6 9
1 2 1
1 3 12
2 3 9
2 4 3
3 5 5
4 3 4
4 5 13
4 6 15
5 6 4
假设此时我们已经读入第一条弧1 2 1,即a=1,b=2,c=1,首先此时cur=1,第一条边编号为1,首先我们将编号为1的边的各项数据存入数组,接下来开始修改“指针”,这里使用的方法类似于链表插入中的头插法,即将新结点插入头结点与第一个结点之间,新结点将代替原来的第一个结点,原来的第一个结点位置后移。这里首先将新的弧与已存在的弧(或者是-1)相连接,再将其连接到头结点上。
inline void add(int a,int b,int c)
{
from[cur]=a;//起点/弧尾
to[cur]=b;//终点/弧头
value[cur]=c;//权值,全部存入数组中
nt[cur]=start[from[cur]];//修改"链域"
start[from[cur]]=cur;//与头结点相连接
cur++;
return;
}
处理后数组情况如下
num | from | to | value | nt | num | start | |
---|---|---|---|---|---|---|---|
1 | 1 | 2 | 1 | -1 | 1 | 1 |
接下来读取第二条弧,处理后结果如下
num | from | to | value | nt | num | start | |
---|---|---|---|---|---|---|---|
1 | 1 | 2 | 1 | -1 | 1 | 2 | |
2 | 1 | 3 | 12 | 1 | 2 | -1 |
以此类推,全部读入后,结果如下
num | from | to | value | nt | num | start | |
---|---|---|---|---|---|---|---|
1 | 1 | 2 | 1 | -1 | 1 | 2 | |
2 | 1 | 3 | 12 | 1 | 2 | 4 | |
3 | 2 | 3 | 9 | -1 | 3 | 5 | |
4 | 2 | 4 | 3 | 3 | 4 | 8 | |
5 | 3 | 5 | 5 | -1 | 5 | 9 | |
6 | 4 | 3 | 4 | -1 | 6 | -1 | |
7 | 4 | 5 | 13 | 6 | |||
8 | 4 | 6 | 15 | 7 | |||
9 | 5 | 6 | 4 | -1 |
访问方式
单个访问
这里以访问出边最多的顶点4号顶点为例,若想访问所有以4号顶点为起点/弧尾的弧,要首先访问start[4],得到8,则从编号为8的弧开始访问,得到弧4 6 15。之后访问nt[8],得到7,则访问编号为7的弧,得到4 5 13。再访问nt[7],得到6,则访问编号为6的弧,得到4 3 4。再访问nt[6],得到-1,至此访问结束。可以发现访问顺序与输入顺序正好相反,这也正是头插法的特性之一。
inline void find(int st,int ed)//st=4
{
int k;
k=start[st];
while (k!=-1)//在未到最后一条弧时
{
cout<<from[k]<<" "<<to[k]<<" "<<value[k]<<endl;//访问(这里用输出代替访问)
k=nt[k];//向后寻找
}
return;
}
遍历
遍历只要在上述算法的条件下,加上一层循环,即可实现顺序表的遍历
int k;
for (k=1;k<=n;k++)
{
k=start[st];
while (k!=-1)//在未到最后一条弧时
{
cout<<from[k]<<" "<<to[k]<<" "<<value[k]<<endl;//访问(这里用输出代替访问)
k=nt[k];//向后寻找
}
}
参考书目
- 《啊哈!算法》 ——啊哈磊 人民邮电出版社
- 《数据结构》 ——严蔚敏,李冬梅,吴伟民 人民邮电出版社
后记
- Dijkstra算法由于使用贪心策略,所以不能解决带有负权边的问题。若有一条不与起点直接相连的负权边,那么当扩展到这条边时,A中顶点到起点的路径长度可能会更短,而根据算法规定,集合A中的顶点到起点的距离不会被更新,这与此算法的前提——集合A中的点已找到最短路径 相矛盾,因此算法的正确性将无法保证,结果将会出错,这也是贪心算法的局限性所在。
- 由于使用邻接表存储路径,其按照权值是无序的,这将导致在查找边的时候浪费大量时间
inline int find(int st,int ed)
{
int k;
k=start[st];
while (k!=-1)//在未到最后一条弧时
{
if (to[k]==ed) break;//表示找到终点/弧头为i的弧
k=nt[k];//向后寻找
}
return k;
}
为了进一步提高效率,可以使用堆/优先队列进行优化,使边按照权值是有序的。这样每次取最短边时,只需要使队首元素出列即可,大大提高了时间效率。
- 未优化的Dijkstra算法时间复杂度为O(n^2)
- 最后挂一道例题 COGS 2 旅行计划,此题采用了以邻接矩阵为存储结构的Dijkstra算法,非常经典的一道题