最短路径算法
最短路径问题
这是一类最基本的图论问题,给定一个图,求从某一个源节点到某一个目的节点的最短路径。
比较常见的算法有 dijkstra, floyd, SPFA。
在开始之前我们先说一说“松弛”这个词。
在描述最短路径算法的时候,我们经常可以看到松弛(relaxation)一词,通常来说,所有的最短路径算法都可通过边的松弛求得,以下的所有算法中也都用到了松弛操作。
松弛是一种数学术语,描述的是一些求解方法,这些方法会通过逐步接近的方式获得相关问题的最佳解法。在最短路径算法中,松弛的含义是检查并更新某个顶点到源点的估计距离,使其满足三角不等式约束。也就是说,如果从源点经过另一个顶点到达目标顶点的距离比直接到达目标顶点的距离要短,那么就用前者替换后者,这样就可以逐渐缩小误差,得到最短路径。(from New Bing)
可能这种降低节点之间距离的操作很像将绷紧的皮筋放松,算法创造者因此赋予了它松弛的名字。
dijkstra
int n; //节点数量
int[n][n] map; //邻接矩阵
int path[n] = {0}; //最短路径上该节点的前驱节点
int dijkstra(int s) {
char found[n] = {0}; //标记该节点是否找到
int weight[n];
for (int i = 0; i < n; i++) {
weight[i] = INFINITY;
}
for (int i = 0; i < n; i++) {
weight[i] = map[s][i];
path[i] = s;
}
found[s] = 1;
weight[s] = 0;
//初始化完成
for (int i = 0; i < n - 1; i++) {
int minWeight = INFINITY;
int v = -1;
for (int j = 0; j < n; j++) {
if (found[j] == 0 && weight[j] < minWeight) {
minWeight = weight[j];
v = j;
}
}
if (v != -1) {
found[v] = 1;
for (int j = 0; j < n; j++) {
if (found[j] == 0 && weight[j] > weight[v] + map[v][j]) {
weight[j] = weight[v] + map[v][j];
path[j] = v;
}
}
}
else {
return -1;
}
}
return 0;
}
这是一种朴素的 dijkstra 算法,也是数据结构课上的内容。观察朴素 dijkstra 算法,我们会发现如下特征:
- 集中的体现了贪心算法的思想,最短路径的规划是选择当前可达的所有节点中最短的边
- 采用了邻接矩阵来存储图,适用于稠密图,而对于稀疏图,开销比较大
- 寻找最短边时采用了遍历的办法,开销比较巨大,但实际上只需要寻找到一个最小权重路径即可,可以考虑维护一个小顶堆
朴素的 dijkstra 算法比较适合稠密图的情况,时间复杂度为 O(n2)。
我们可以基于 dijkstra 算法进行很多改进
//按边遍历的 dijkstra
const int M; //边上限
const int N; //点上限
const int INFINITY;
typedef struct Edge {
int next; //所有以同一节点作为起点的边串成一个链,起点存储在 head[] 中
int to; //边的终点
int w; //权重
} Edge;
Edge edge[M]; //边
int head[N]; //存储以 i 为起点的第一条边的编号
int weight[N];
char found[N];
int tot; //自增量,用于给边编号
void add(int form, int to, int w) {
edge[tot].to = to;
edge[tot].w = w;
edge[tot].next = head[from]; //将新边插入到最前面,如果 next 为 -1, 则其代表了该节点的最后一条有效边
head[from] = tot++;
}
int dijkstra(int s) {
int min, v;
while (1) {
min = INFINITY;
v = -1;
for (int i = 0; i < n; i++) {
if (found[i] == 0 && weight[i] < min) {
min = weight[i];
v = i;
}
}
if (v == -1) {
break; //跳出条件
}
found[v] = 1;
for (int i = head[v]; i != -1; i = edge[i].next) { //i是边的编号,当i=-1时,即到了最后一条边的下一条边,需要跳出循环
int to = edge[i].to;
if (found[to] == 0 && weight[to] > weight[v] + edge[i].w) {
weight[to] = weight[v] + edge[i].w; //更新最短路径长度
}
}
}
}
这种 dijkstra 采用了所谓“链式向前星”的数据结构来存储图,实际上存储的是图的边的信息,比较适合稀疏图或者两点之间有多条路径的情况。
链式向前星可以实现和邻接表类似的效果,但是实现起来比邻接表简单,应用比较广泛。
//堆优化版的 dijkstra
const int M;
const int N;
const int INFINITY;
typedef struct Node {
int to;
int w;
} Node; //用于构建小顶堆
Node heap[M * M]; //没有办法便捷的动态增加,一次性申请比较大的堆容量,如果使用 C++ 或者 java 可以直接使用 priority_queue/PriorityQueue
int size; //堆的大小
void adjust(int i) {
int left = i * 2 + 1;
int right = i * 2 + 2;
int min;
int index;
if (right > size - 1) {
min = heap[left].w;
index = left;
}
else {
min = (heap[left].w < heap[right].w)? heap[left].w : heap[right].w;
index = (heap[left].w < heap[right].w)? left : right;
}
if (min < heap[i].w) {
swap(index, i);
adjust(index);
}
}
typedef struct Edge {
int next;
int to;
int w;
} Edge;
Edge edge[M];
int head[N];
int weight[N];
char found[N];
int tot;
void add(int form, int to, int w) {
edge[tot].to = to;
edge[tot].w = w;
edge[tot].next = head[from];
head[from] = tot++;
}
void dijkstra(int s) {
int min, v;
for (int i = 0; i < n - 1; i++) {
while (1) {
min = heap[0].w;
v = heap[0].to;
if (found[v] == 1) {
swap(0, size - 1);
size--;
adjust(0);
}
else {
break;
}
}
found[v] = 1;
weight[v] = min;
swap(0, size - 1);
size--;
adjust(0);
for (int i = head[v]; i != -1; i = edge[i].next) {
heap[size].to = edge[i].to;
heap[size].w = egde[i].w + min;
size++;
swap(0, size - 1);
adjust(0);
}
}
}
这是一种综合了边遍历和堆优化的 dijkstra 算法。可以看见即使没有到完全能够运行的程度,其码量也已经显著增加了。这样的 dijkstra 算法更适用于稀疏图,时间复杂度为 O((n + m)log(n))。当图十分稀疏时,m可以忽略不计,于是其具有近似的时间复杂度 O(nlog(n))。但当图十分稠密时,其性能可能不如朴素的 dijkstra。
floyd
int n;
int map[][];
int path[][];
int floyd() {
for (int i = 0; i < n; i++) { //被选中的中间值
for (int j = 0; j < n; j++) {
for (int k = 0; k < n; k++) {
if (map[j][k] > map[j][i] + map[i][k]) {
map[j][k] = map[j][i] + map[i][k]; //更新路径值
path[j][k] = i //更新需要经过的中间值
}
}
}
}
}
floyd 算法的能够处理多源最短路径,其核心思想可以这样总结:
最开始只允许通过 1 号节点进行中转,然后允许通过 1,2 号节点进行中转......最终允许通过 1,2,...,n 号节点进行中转,实际上是求得了从 i 到 j 节点只通过前 k 个节点的最短路径,本质上是动态规划的思想。
floyd 算法的正确性并不那么容易见得,但是我们可以直观的感受到:两个点之间要么直接相连就是最短路径,要么可以通过多个中转点连接形成最短路径。floyd 算法完成之后,路径的信息存放在 path 当中。如:path[i][j] = k(其中 k != i,j) 表明从 i 到 j 的最短路径或通过中间点 k,但是从 i 到 k 和从 k 到 j 的最短路径并不能确定是直接连通的,需要再检查 path[i][k] 和 path[k][j] 确定。
floyd 算法可以容忍有负边,但是不能容忍有负环;
floyd 算法的时间复杂度为 O(n3)
SPFA
const int INFINITY;
const int M;
const int N;
int dis[N]; //记录最小路径的数组
bool vis[N]; //标记点是否在优先队列队列中
int pre[N]; //记录前驱结点,用于输出路径
int head[N];
int tot;
typedef struct Edge {
int next;
int to;
int w;
} Edge;
Edge edge[M];
void add(int form, int to, int w) {
edge[tot].to = to;
edge[tot].w = w;
edge[tot].next = head[from];
head[from] = tot++;
}
void spfa(int s) {
for(int i = 0; i < N; i++) {
vis[i] = 0;
dis[i] = INFINITY;
}
dis[s]=0;
vis[s]=1;
queue<int> q;
q.push(s);
while(!q.empty()){
int u = q.front();
q.pop();
vis[u] = 0;
for(int i = head[u]; i != -1; i = edge[i].next){
int v = edge[i].to;
int w = edge[i].w;
if(dis[v] > dis[u] + w){
dis[v] = dis[u] + w;
if(!vis[v]){
vis[v] = 1;
pre[v] = u;
q.push(v);
}
}
}
}
}
算法步骤:
- 将除源点之外的所有的点当前距离初始化为无穷,并标记为未入队。源点的当前距离为0,将源点入队。
- 取出队首u,遍历u的所有出边,检查是否能更新所连接的点v的当前距离。如果v的当前距离被更新并且v不在队中,则将v入队。重复2操作直到队列为空。
SPFA 可以检查是否存在负权环:记录每个点的入队次数,如果超过V-1次说明存在负权环,因为最短路径上除自身外至多V-1个点,故一个点不可能被更新超过V-1次。
SPFA 是一种对于 bellmon-ford 算法的优化。bellmon-ford 是通过不断的对于所有边进行松弛操作最终找到最短路径的,这种做法存在盲目性,所以 SPFA 维护了一个队列,队列中存储了可能具有可松弛边的所有点,只需要对这些点所连接的所有边进行松弛操作即可。
SPFA 的时间复杂度为 O(mn)