图相关文章:
1. 图的建立 - 邻接矩阵与邻接表https://blog.csdn.net/m15253053181/article/details/127552328?spm=1001.2014.3001.55012. 图的遍历 - DFS与BFS
https://blog.csdn.net/m15253053181/article/details/127558368?spm=1001.2014.3001.55013. 顶点度的计算
https://blog.csdn.net/m15253053181/article/details/127558599?spm=1001.2014.3001.55014. 最小生成树 - Prim与Kruskal
https://blog.csdn.net/m15253053181/article/details/127589852?spm=1001.2014.3001.55015. 单源最短路径 - Dijkstra与Bellman-Ford
https://blog.csdn.net/m15253053181/article/details/127630356?spm=1001.2014.3001.55016. 多源最短路径 - Floyd
https://blog.csdn.net/m15253053181/article/details/128039852?spm=1001.2014.3001.55017. 拓扑排序AOV网
https://blog.csdn.net/m15253053181/article/details/128042358?spm=1001.2014.3001.5501
目录
最短路径
一、 问题背景
最短路径问题是图论研究中的一个经典算法问题, 旨在寻找图中两结点之间的最短距离与最短路径。
最短路径问题大致可以分为两类:
① 单源最短路径问题,例如:求顶点0到顶点4的最短距离与最短路径;代表有Dijkstra算法、Bellman-Ford算法。
② 多源最短路径问题,例如:求任意两顶点间的最短距离与最短路径;代表有Floyd算法。
二、单源最短路径问题
1 Dijkstra算法
Dijkstra算法是采用贪心策略,使得源点到某点的距离dist[i]不断地逼近真实最短路径。
适用范围:
Dijkstra算法对于有向图无向图都适用,但是不可以有负权边。因为算法基于贪心策略,如果有负权边,则会反复遍历以减少dist,造成死循环。
1.1 算法思想
构造3个辅助数组:
辅助数组 | 作用 |
---|---|
dist | dist[u]表示源点s到顶点u的距离上界 |
visited | visited[u] = 1 表示该点的最短路径已确定,0为不确定 |
pred | pred[u] = 顶点u的最短路径中的前驱顶点 |
算法的核心思想如下:
首先初始化所有顶点dist = ∞,visited = 0,pred = -1;源点的dist[s] = 0。
① 在未确定最短路径的点中选出dist最小(源点到该顶点距离最小)的顶点u。
② 对于顶点u的所有邻接点vi,如果dist[u] + w(u, vi) < dist[vi],那么就把dist[vi]更新为dist[u] + w(u, vi),将vi的前驱pred[vi] = u。
③ 将visited[u] = 1。
④ 重复①~④,直至所有顶点的visited = 1。
让我们来简单思考一下算法的正确性。
简单画了一张示意图,便于下述解释的时候直观理解。
(1)对于dist数组的解释
首先,dist[u]在算法结束前,并不能确定是最短路径,而第②步的作用是确定“局部最优”。“局部最优”是什么意思呢?首先dist[vi]表示的是目前源点s到顶点vi的一种可能路径的长度,我们将这条路径记为L1。同样,dist[u]也是源点s到顶点u的一种可能路径的长度,路径记为L2。如果说从源点沿着L2这条路到顶点u,再到其邻接点vi的距离是比沿着路径L1直接到顶点vi的距离更近的话,那么就把vi从L1这条路拽到L2这条路中。
为什么说dist数组中的值在算法结束前,并不一定是最短路径呢?因为如果所有可能的路没有走完的话,上述“把一个顶点从原先的路径拽到新的路径”这一现象对于同一个顶点可能会重复出现。而一旦所有可能的路都走完了(所有顶点都已经做为路径的末尾结点尝试把它邻接顶点拽过来),dist便是最小的了。
(2)对于visited数组的解释
刚才我们提到“走完所有可能的路”,其实依靠的就是visited数组。在判断dist[u] + w(u, vi) < dist[vi]这一步中,实际上就是在以u为路径的末尾结点尝试把它邻接顶点拽过来的过程,而对于u的所有邻接顶点都判断完之后,以u为起点的所有可能就都尝试完了,当所有顶点的visited都等于1时,就是“走完了所有可能的路”。
其实,上述的算法中还有一个问题没有解释,那就是每次都是局部最短,怎么合到一起就是全局最短了呢?
其实这个问题也是比较显然的,我们给出一个定理并证明,读者就明白了。
定理:最短路径中的任意一条子路径都是最短的。
证明:
假设路径<s, ...x, u, v>为s到v的最短路径,不失一般性,有以下证明:
由定理知,<x, u>为x到u的最短路径。假设存在x与u之间存在一条更短路径<x, y, u>,使得距离小于<x, u>的距离,则s到v的最短路径为<s, ...x, y, u, v>,与题设矛盾,故不存在这样的路径。
1.2 实现
使用邻接表的数据结构便于寻找邻接点,因此使用邻接表实现。邻接矩阵的实现与其类似。
测试用例:
由顶点1到5:
源码:
为了模块化设计,将Dijkstra的结果打了个包,结构如下:
/* 单源最短路径Dijkstra */
typedef struct DijkstraPackage
{
AdjList L; // 图
int source; // 起点
int *dist; // 距离上界
int *visited; // 已访问
int *pred; // 顶点前驱
} * DijkstraResult;
Dijkstra算法实现:
/* Dijkstra算法 */
DijkstraResult Dijkstra(AdjList L, int source)
{
int i, j;
int minDist, rec; // dist最小的点
LGNode pMove = NULL;
DijkstraResult result = (DijkstraResult)malloc(sizeof(struct DijkstraPackage));
result->L = L;
result->source = source;
result->dist = (int *)malloc(sizeof(int) * L->numV);
result->visited = (int *)malloc(sizeof(int) * L->numV);
result->pred = (int *)malloc(sizeof(int) * L->numV);
// 初始化
for (i = 0; i < L->numV; i++)
{
result->dist[i] = INF;
result->visited[i] = 0;
result->pred[i] = -1;
}
result->dist[source] = 0;
// 执行单源最短路径算法
for (i = 0; i < L->numV; i++)
{
// 找到dist最小的那个点
minDist = INF;
for (j = 1; j < L->numV; j++)
{
if (result->visited[j] == 0 && result->dist[j] < minDist)
{
minDist = result->dist[j];
rec = j;
}
}
// 对于每个邻接点,尝试更新dist
pMove = L->list[rec].next;
while (pMove)
{
if (result->dist[rec] + pMove->dis < result->dist[pMove->v])
{
result->dist[pMove->v] = result->dist[rec] + pMove->dis;
result->pred[pMove->v] = rec;
}
pMove = pMove->next;
}
// 标记为已访问
result->visited[rec] = 1;
}
return result;
}
输出函数:
void fineShortestPath(DijkstraResult result, int goal)
{
int i, j;
int count = 0;
int predV = goal;
int path[MaxVertexNum];
printf("Source: %c(%d)\tGoal: %c(%d)\n", result->L->nameV[result->source], result->source, result->L->nameV[goal], goal);
printf("Shortest distance: %d\n", result->dist[goal]);
printf("Shortest path:\n");
while (1)
{
path[count] = predV;
if (result->pred[predV] == -1)
break;
predV = result->pred[predV];
count++;
}
for (i = 0; i <= count; i++)
{
printf("%c(%d)", result->L->nameV[count - i], path[count - i]);
if (i != count)
printf(" -> ");
}
printf("\n");
}
1.3 算法分析
伪代码如下:
所以整体的时间复杂度为O(V²+E)
优化:
使用最小堆来进行dist最小的顶点的查询,可以将O(V)降到O(logV),因此优化后的总体时间复杂度为O(ElogV)
2 Bellman-Ford算法
刚才已经提到,图中如果存在负权边,则Dijkstra算法不再适用,那么有什么解决方案呢?
首先,我们需要定义一下存在负权边的图中最短路径是什么。
① 当图中存在负环时
如果说图长这样:
从源点s到顶点a的最短距离是多少?
显然,这是无法计算的,路径:s->b->a->s->...->b->a...,只要沿着这个环走下去,我可以让s到a的距离为负无穷。
此时,我们说s到a存在负环。
② 当图中不存在负环时
可以看到,只要我们规定不可以走例如s->j->i->j->i->...j->k这样反复刷负权边的路径,最短路径的含义就与无向图中相同了。
那么如何解决这类问题,我们下面介绍Bellman-Ford算法。
算法适用范围:
该算法适用于有向图(无论带不带负权边),不适用于带负权边的无向图,但对于不含负权边的无向图还是可以使用的,下面我们会介绍为什么。
2.1 算法思想
首先我们给出一种操作的定义:松弛。
其实在Dijkstra算法中我们已经使用到了这种操作,不过为了减少概念,我没有给出这个定义。而此处引入概念后可以使得表述变得简洁,因此我们来描述一下什么叫做松弛。
给出以下两个示例,圈中的值表示dist[v]。
我们先给出结论:
(1)每轮对所有边进行松弛,迭代|V|-1轮。
(2)进行第|V|次松弛,如果松弛成功,则存在源点s可达的负环;否则已经找到单源最短路径。
让我们以一个动图先来直观看一下算法的整个过程:
现在回答Bellman-Ford算法适用范围的问题:
对于有向图,只需要不存在负环即可;而对于无向图,不妨假设有一条负权边w(u, v) < 0,试想,在每轮松弛的时候,dist[u]与dist[v]是不是都会松弛成功?其本质原因就是因为u与v之间可以来回走,因此,无向图中的一条负权边其实就等价于有向图中的一个负环,故不适用与带负权边的无向图。
现在来解释下Bellman-Ford算法的正确性:
为什么需要松弛|V|-1次?其实这是基于最坏情况的分析。
考虑最坏情况,即为一条直线的时候。想要对V_i松弛成功,就必须对V_(i-1)松弛成功,那么到最后一个顶点时,就需要松弛|V|-1次。如gif所示:
实际上情况一般不会这么极端,即如果没有负环存在,在小于|V|-1次的松弛成功后,就已经找到了单源最短路径。在之后的松弛操作中,都不会松弛成功。
那么为什么还要松弛第|V|次呢?因为如果存在负环,实际上是可以松弛成功无数次的,这多出来的最后一次松弛操作,就是为了判断是否存在负环的。如果第|V|次松弛成功,就说明存在负环。
2.2 实现
请读者根据2.3算法分析中的伪代码自行实现(很详细),如果确实有困难,可以在评论区留言,我再实现。
2.3 算法分析
可以看到,Bellman-Ford算法的时间复杂度为O(VE),且没有优化空间。