数据结构:最短路径
游戏寻路
推荐你看个视频未来科技开发日记#2。
我们在玩游戏中经常会使用自动寻路功能啦,例如在魔兽争霸中右键点击一个地方,系统就会自动帮我们寻路过去啦。那么我们会发现,系统帮我们找的路往往是一条较短、省时的路径,这么做是很合理的,因为没有人会喜欢无时不刻都在赶路、跑图的游戏(除非有其他特色,或者本身就是在享受跑图游戏,在此不列举了)。例如在一张地图上不存在任何障碍物,那么最短路径一定是走直线啦,两点之间线段最短嘛!但是例如魔兽争霸、环世界等游戏地图就复杂多了,因此路径的选择肯定是有算法的支撑。
我们来考虑一个较为简易的情景,假设我这张地图使用顶点和路径描述,那我要从某个顶点到达另一个顶点,肯定是愿意选择最短的路径了,此时要去考虑什么样的路径,边的权值相加最小?
Dijkstra 算法
算法介绍
算法思想
Dijkstra 算法解决的是单源点最短路径问题:给定带权有向图 G 和源点 v0,求从 v0 到 G 中其余个顶点的最短路径,即一个出发点到达其他顶点的最短路径。Dijkstra 算法的思想是按照路径长度递增的次序产生最短路径的算法。
艾兹格·W·迪科斯彻
艾兹格·W·迪科斯彻(Edsger Wybe Dijkstra,1930年5月11日~2002年8月6日)荷兰人。 计算机科学家,毕业就职于荷兰Leiden大学,早年钻研物理及数学,而后转为计算学。曾在1972年获得过素有计算机科学界的诺贝尔奖之称的图灵奖,之后,他还获得过1974年 AFIPS Harry Goode Memorial Award、1989年ACM SIGCSE计算机科学教育教学杰出贡献奖、以及2002年ACM PODC最具影响力论文奖。——百度百科
迪杰斯特拉的成就有:
- 提出 “goto 有害论”;
- 提出信号量和 PV 原语;
- 解决了“哲学家聚餐”问题;
- Dijkstra 最短路径算法和银行家算法的创造者;
- 第一个 Algol 60 编译器的设计者和实现者;
- THE 操作系统的设计者和开发者;
算法流程
对于图结构 N = (V,E),首先将 N 中的顶点分为 2 组:
- S:求出最短路径的终点集合(初始化只有出发点 v0);
- V - S:尚未求出最短路径的顶点集合(初始化仅 v0 不被包含)
分组之后,按照各个顶点和 v0 间最短路径长度递增的顺序,将集合 V - S 中的点加入到 S 集合中。此时集合 S 将保证从 v0 出发到 S 中的各个顶点的路径长不大于到集合 V - S 中的各顶点的路径长度。
证明
算法的正确性来源于:下一条最短路径(设终点为 x)或者边 (v,x),或者是中间只经过 S 中的顶点而最后的到达顶点 x 的路径。
要证明这个算法,可以使用反证法。假设路径上有一个顶点不属于 S,则说明存在一条终点不在 S 而长度比此路径段的路径。由于算法是按照路径的递增次序来产生最短路径,因此长度比此路径短的所有路径均已经产生,终点必存在于 S。因此这个假设是不成立的,从而证明算法成立。
算法结构设计
在使用带权值的邻接矩阵实现的条件下,需要引入如下结构辅助设计:
- 一维数组 S[i]:记录源点 v0 到终点 vi 是否已经确定最短路径,用 bool 变量来表示;
- 一维数组 Path[i]:记录源点 v0 到终点 vi 的当前最短路径上 vi 的前驱序号,初始化的时候若 v0 到 vi 的弧存在则为 v0,不存在则将值设为 -1。
- 一维数组 D[i]:记录点 v0 到终点 vi 的当前最短路径长度。初始化的时候若 v0 到 vi 有弧,则 D[i] 为弧上的权值,否则为 ∞。
我们很明白两点之间线段最短,因此第一组最短路径为 (v0,vk),当我确定了最短路径之后,将对应的顶点加入到顶点集 S 中。每当加入了一个新的顶点,对于第二组剩余的各个顶点而言意味着多了一条可选的中转点,那就有可能出现一条新的路径,因此需要对第二组剩余的各个顶点进行修正。也就是说,对于原来 v0 到 vi 的最短路径是 D[i],当 vk 被添加之后,最短路径将会修正为 D[k] + G.edges[k][i],若这个修正值更小就把它保存下来。接下来就选择数组 D 中值最小的顶点加入到第一组顶点集 S 中,重复这个操作直至所有顶点都加入 S 为止。
模拟实现
假设有如图所示网结构,现求解从 v0 出发的最短路径。
首先我们看一下各个结构在初始化的时候发生的数据变更:
我们稍微解释一下发生了什么,首先由于是从 v0 出发,因此初始化的时候仅分析 v0,此时 v0 直达的点分别为 2、4、5,因此这 3 个距离就直接更新到 D 数组去,且到达这 3 个点的前驱是点 v0,因此把 0 填充到数组 Path 中去。对于其他 2 个顶点,由于不能直达,因此距离被认为是无穷大,因此在 D 中填充的是 ∞,Path 填充的是 -1 表示没有前驱路线可以到达。最后由于分析了 v0 点,因此在 S 数组中要把值修正为 true,其余点由于没分析就设为 false。
接着我们看看每一步各个参数发生了什么:
根据参数我们是怎么读取最短路径的?例如选择 v0 到 v3,读取手法为:
Path[3] = 4、Path[4] = 0
因此最短路径就反过来,是 0 -> 4 -> 3,长度为 50。
代码实现
void ShortestPath_DIJ(MGraph g, int v0)
{
int v;
int min;
int Path[MAXV];
int S[MAXV];
int D[MAXV];
/*对各个辅助结构初始化*/
for(int i = 0; i < g.n; i++)
{
S[i] = false; //S 初始化为全部单元 false,表示空集
D[i] = g.edges[v0][i]; //将 v0 到各个终点的最短路径初始化为直达路径
if(D[i] < INFINITY) //v0 到 i 的弧存在,设置前驱为 v0
{
Path[i] = v0;
}
else //v0 到 i 的弧不存在,设置前驱为 -1
{
Path[i] = -1;
}
}
S[v0] = true; //将 v0 加入集合 S
D[v0] = 0; //出发到到其本身的距离为 0
/*初始化结束,开始循环添加点*/
for(int i = 1; i < g.n; i++) //依次考虑剩余的 n - 1 个顶点
{
min = INFINITY;
for(int j = 0; j < g.n; j++)
{
if(S[j] == false && D[j] < min) //若 vj 未被添加入集合 S 且路径最短,拷贝信息
{
v = j; //表示选择当前最短路径,终点是 v
min = D[j];
}
}
s[v] = true; //将点 v 加入集合 S
for(int j = 0; j < g.n; j++)
{
if(S[j] == false && (D[v] + g.edges[v][j] < D[j])) //判断是否要修正路径
{
D[j] = D[v] + g.edges[v][j];
Path[j] = v; //修改 vj 的前驱为 v
}
}
}
}
输出辅助函数
void PrintMGraph(MGraph g)
{
int i,j;
for(i=0;i<g.n;i++)
{
for(j=0;j<g.n;j++) cout<<g.edges[i][j]<<" ";
cout<<endl;
}
for(i=0;i<g.n;i++)
delete[] g.edges[i];
}
复杂度分析与优化
时间复杂度
算法中添加顶点的循环执行 n - 1 次,每次执行的时间复杂度为 O(n),所以总时间复杂度为 O(n2)。如果用带权的邻接表来存储,则虽然修改 D 数组的时间可以被降下来,但由于在 D 中选择最小分量的时间不变,所以时间复杂度仍为O(n2)。我们往往只希望找到从源点到某一个特定终点的最短路径,但是这个问题和求源点到其他所有顶点的最短路径一样复杂,也得用迪杰斯特拉算法来解决。
堆优化
我们看到迪杰斯特拉算法的时间复杂度还是比较大的,我们有没有手法可以使其时间复杂度可以降下来?左转博客——堆、优先级队列和堆排序。
Floyd 算法
算法介绍
算法思想
Floyd 算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法。——Floyd算法
罗伯特·弗洛伊德
计算机科学家,图灵奖得主,前后断言法的创始人,堆排序算法和Floyd-Warshall算法的创始人之一。历届图灵奖得主基本上都有高学历、高学位,绝大多数有博士头衔。这是可以理解的,因为创新型人才需要有很好的文化素养,丰富的知识底蕴,因而必须接受良好的教育。但事情总有例外,1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德就是一位“自学成才的计算机科学家”(a Self-Taught Computer Scientist)。——罗伯特·弗洛伊德
这位大咖不是那位心理学家弗洛伊德哦,而且他的故事也值得我们学习,详情可以看更多的资料。
算法结构设计
Floyd 算法使用邻接矩阵来存储图结构,需要引入 2 个辅助结构。
- 二维数组 Path[i][j]:最短路径上顶点 vj 的前一顶点的序号;
- 二维数组 D[i][j]:记录顶点 vi 和 vj 之间的最短路径长度;
算法流程
算法的时间复杂度为 O(n2),和执行 n 次的迪杰斯特拉算法一样。由于比较难理解,我引用一下资料对这个过程的描述。
首先要初始化各个结构,将 D 所存储的 vi 到 vj 的最短路径长初始化为邻接矩阵 G 中的各个边信息。
第一步在 vi 和 vj 之间加入顶点 v0,比较 (vi,vj) 和 (vi,v0,vj) 的路径长,取其中较短的路径作为新的最短路径;
第二步在 vi 和 vj 之间加入顶点 v1,得到 vi 到 v1 且中间顶点序号不大于 1 - 1 = 0 的最短路径 (vi,…,v1),和 v1 到 vj 且中间顶点的序号不大于 1 - 1 = 0 的最短路径 (v1,…,vj),其实这两个路径在上一步已经求出。比较 (vi,…,v1,…,vj) 与上一步求出的 vi 到 vj 的中间顶点序号不大于 0 的最短路径,取其中较短的作为 vi 到 vj 的中间序号不大于 1 的最短路径;
……
第 k 步在 vi 和 vj 之间加入顶点 vk,得到 vi 到 vk 且中间顶点序号不大于 k - 1 的最短路径 (vi,…,v1),和 vk 到 vj 且中间顶点的序号不大于 k - 1 的最短路径 (vk,…,vj),其实这两个路径在上一步已经求出。比较 (vi,…,vk,…,vj) 与上一步求出的 vi 到 vj 的中间顶点序号不大于 0 的最短路径,取其中较短的作为 vi 到 vj 的中间序号不大于 k 的最短路径;
经过 n 次比较后,最后求得的必是 vi 到 vj 的最短路径,可以同时求出各对顶点间的最短路径。——《数据结构(C语言版)》
代码实现
void ShortestPath_Floyd(MGraph g)
{
int Path[MAXV][MAXV]; //最短路径上顶点 vj 的前一顶点的序号
int [MAXV][MAXV]; //记录顶点 vi 和 vj 之间的最短路径长度
for(int i = 1; i < g.n; i++)
{
for(int j = 1; j < g.n; j++)
{
D[i][j] = g.edges[i][j];
if(D[i][j] < MAXINT && i != j)
{
Path[i][j] = i; //若 i 和 j 之间有弧,则将 j 的前驱置为 i;
}
else
{
Path[i][j] = -1; //若 i 和 j 之间有弧,则将 j 的前驱置为 -1;
}
}
}
for(int k = 0; k < g.n; k++)
{
for(int i = 1; i < g.n; i++)
{
for(int j = 1; j < g.n; j++)
{
if(D[i][k] + D[k][j] < D[i][j]) //从 i 经过 k 到 j 的路径更短
{
D[i][j] = D[i][k] + D[k][j]; //更新路径长
Path[i][j] = Path[k][j]; //更改 j 的前驱为 k
}
}
}
}
}
实例:旅游规划
情景需求
输入样例
4 5 0 3
0 1 1 20
1 3 2 30
0 3 4 10
0 2 2 20
2 3 1 20
输出样例
3 40
情景分析
思路还是比较明确的,抛开价格问题的话就是个最短路径模板。所以我们来审视一下价格,首先当最短路径只有一条时,跟价格没啥关系,因为这个情景的需求就是最短路径为主导。那么当最短路径出现多分支时,价格来主导,也就是说价格和路径长都是选择路径的依据,但是价格的优先级小于路径长的优先级。因此我们的想法是,在出现相同路径长的时候对价格进行修正,若出现更低的价格就保存。
当然这道题没有对最终选择出的路径提出输出的要求,不然直接修正是有歧义的,你怎么知道你求出的最省费用和最短路径是对应的?其实还有一种思路就是直接以价格为权做最短路径,这样就可以选出最省钱的路径选择方案,因此这道题完全可以映射成一个生活问题:你旅游是要尽量省钱还是尽量缩短日程,二者在统一中其实还有对立的方面嘞。
伪代码
代码
road ShortestPath_DIJ(MGraph g, int v0,int des)
{
int v;
int min;
int Path[MAXV];
int S[MAXV];
road D[MAXV];
/*对各个辅助结构初始化*/
for (int i = 0; i < g->n; i++)
{
S[i] = false; //S 初始化为全部单元 false,表示空集
D[i].length = g->edges[v0][i].length; //将 v0 到各个终点的最短路径初始化为直达路径
D[i].money = g->edges[v0][i].money; //将 v0 到各个终点的最短路径初始化为直达路径
if (D[i].length < INFINITY) //v0 到 i 的弧存在,设置前驱为 v0
{
Path[i] = v0;
}
else //v0 到 i 的弧不存在,设置前驱为 -1
{
Path[i] = -1;
}
}
S[v0] = true; //将 v0 加入集合 S
D[v0].length = D[v0].money = 0; //出发到到其本身的距离为 0
/*初始化结束,开始循环添加点*/
for (int i = 1; i < g->n; i++) //依次考虑剩余的 n - 1 个顶点
{
min = INFINITY;
v = -1;
for (int j = 0; j < g->n; j++)
{
if (S[j] == false && D[j].length <= min) //若 vj 未被添加入集合 S 且路径最短,拷贝信息
{
v = j; //表示选择当前最短路径,终点是 v
min = D[j].length;
}
}
if (v == -1)
{
break;
}
S[v] = true; //将点 v 加入集合 S
for (int j = 0; j < g->n; j++)
{
if (S[j] == false && g->edges[v][j].length < INFINITY)
{
if (D[v].length + g->edges[v][j].length < D[j].length) //判断是否要修正路径
{
D[j].length = D[v].length + g->edges[v][j].length;
D[j].money = D[v].money + g->edges[v][j].money;
Path[j] = v; //修改 vj 的前驱为 v
}
else if (D[v].length + g->edges[v][j].length == D[j].length
&& D[v].money + g->edges[v][j].money < D[j].money)
{
D[j].money = D[v].money + g->edges[v][j].money;
}
}
}
}
return D[des];
}
参考资料
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
堆、优先级队列和堆排序
艾兹格·W·迪科斯彻
Floyd算法
罗伯特·弗洛伊德