搜索与图论(2)最短路
知识结构
图的复杂程度
图的复杂程度分为两种:稀疏图和稠密图。稀疏图是指在一张有 \(n\) 个结点的图中,边的条数与 \(n\) 属于同一个数量级;稠密图则是边的条数与 \(n^2\) 属于同一个数量级。稀疏图一般用邻接表来存储,稠密图一般用邻接矩阵来存储。
一些新概念
源点又称起点,汇点又称终点,所以单源最短路指的是求一个起点到其它点的最短路,多源汇最短路是求不同起点到其它点的最短路。
以上这么多算法分别是面对不同情况时使用。其中稀疏图用堆优化版的 Dijkstra 算法,稠密图用朴素 Dijkstra 算法,存在负权边时用 SPFA 算法,求不超过 \(k\) 条边的最短路时用 Bellman-Ford 算法,多源汇最短路用 Floyd 算法。
一定要用对应的算法写对应的题!
最短路问题
考察的侧重点为建图,即如何把原问题抽象成为一个最短路问题,所以我们要想清楚如何定义图中的点和边,使得它变成一个最短路问题,然后再套用模板来将它完成。
1.Dijkstra 算法
- 1.朴素版本
首先初始化距离,将 \(1\) 到其他节点的距离都初始化成正无穷(一个比较大的数),接着定义一个集合 \(s\) ,用于记录当前最短距离已经确定的节点,然后遍历每一个节点,如果此节点不属于集合 \(s\) 且在所有除起点的点中它到起点的距离最小,那么我们用变量 \(t\) 将其记录,此时由于它是距离最小的点,所以它的最短距离也已经确定,因此可以把它放入集合 \(s\) 中。既然它的最短距离已经确定,那我们就能用它来更新其他点的最短距离,以此类推,我们就能找到所有点距离源点的最短距离。
实现代码如下:
void dijkstra() {
memset(dis, 0x3f, sizeof(dis)); //初始化成正无穷
dis[1] = 0; //起点与自己的距离为1
for(int i = 1; i < n; i++) { //已经加入一个点了,那么就只剩n - 1
个点了
int t = -1;
for(int j = 1; j <= n; j++) { //找不属于集合s且在所有除
起点的节点中它到起点的距
离最小的节点
if(!vis[j] && (t == -1 || dis[j] < dis[t])) t = j;
}
vis[t] = true; //标记已经确定最短距离
for(int j = h[t]; j != -1; j = ne[j]) { //遍历与j相连的节点
int k = e[j];
dis[k] = min(dis[k], dis[t] + w[j]); //更新距离
}
}
if(dis[n] == 0x3f3f3f3f) printf("-1"); //若不能到达则输出-1
else printf("%d\n", dis[n]); //否则输出最小距离
}
- 2.堆优化版本
看一下算法的时间复杂度:
for(i:1 ~ n)//n次
{
t <- 没有确定最短路径的节点中距离源点最近的点;//每次遍一遍历dist数组,
n次的复杂度是O(n^2)
state[t] = 1;
更新 dist;//每次遍历一个节点的出边,n次遍历了所有节点的边,复杂度为O(e)
}
算法的主要耗时的步骤是从 \(dist\) 数组中选出:没有确定最短路径的节点中距离源点最近的点 \(t\) 。只是找个最小值而已,没有必要每次遍历一遍 \(dist\) 数组。
在一组数中每次能很快的找到最小值,可以使用堆来进行优化。可以使用库中的小根堆(推荐)或者手写堆。
减少慢步骤的运行时间,就能达到优化整个时间的效果,这和加快化学反应速率多么地相似!
代码如下:
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>//堆的头文件
using namespace std;
typedef pair<int, int> PII;//堆里存储距离和节点编号
const int N = 1e6 + 10;
int n, m;//节点数量和边数
int h[N], w[N], e[N], ne[N], idx;//链式前向星存储图
int dist[N];//存储距离
bool st[N];//存储状态
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++ ;
}
int dijkstra() {
memset(dist, 0x3f, sizeof dist);//距离初始化为无穷大
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;//堆
heap.push({0, 1});//插入距离和初始节点编号
while(heap.size()){
PII t = heap.top();//取距离源点最近的点
heap.pop();
int ver = t.second;//ver:节点编号
if(st[ver]) continue;//如果距离已经确定,则跳过该点
st[ver] = true;
for(int i = h[ver]; i != -1; i = ne[i]) {//更新ver所指向的节点距离
int j = e[i];
if(dist[j] > dist[ver] + w[i]) {
dist[j] = dist[ver] + w[i];
heap.push({dist[j], j});//距离变小,则入堆
}
}
}
if(dist[n] == 0x3f3f3f3f) printf("-1");
printf("%d\n", dist[n]);
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
for(int i = 1; i <= m; i++) {
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
dijkstra();
return 0;
}
使用堆后,找到 \(t\) 的耗时从 \(O(n^2)\) 将为了 \(O(1)\)。每次更新 \(dist\) 后,需要向堆中插入更新的信息。所以更新 \(dist\) 的时间复杂度由 \(O(e)\) 变为了 \(O(elogn)\)。总时间复杂度由 \(O(n^2)\) 变为了 \(O(n + elogn)\)。适用于稀疏图。
2.Bellman-Ford 算法
以下是主要思路:
代码:
int bellman_ford() {
memset(dist, 0x3f, sizeof dist);
for(int i = 1; i <= k; i++) { //当迭代k次时,可求经过边为k时的最
短路
memcpy(backup, dist, sizeof dist); //备份,因为每次只能
调用之前的dist
for(int j = 1; j <= m; j++) {
int a = edge[j].a, b = edge[j].b, w = edge[j].w;
dist[b] = min(dist[b], backup[a] + w); //松弛操作
}
}
if(dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
3.SPFA 算法
SPFA 算法是对 Bellman-Ford 算法的一个优化。
(话说为什么 Dijkstra 优化后叫堆优化 Dijkstra,而 Bellman-Ford 优化后叫 SPFA)
Bellman-Ford 算法会遍历所有的边,但是有很多的边遍历了其实没有什么意义,我们只用遍历那些到源点距离变小的点所连接的边即可,只有当一个点的前驱结点更新了,该节点才会得到更新。因此考虑到这一点,我们将创建一个队列每一次加入距离被更新的结点。这种优化方式有点像广搜。
代码:
int spfa() {
queue <int> q;
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
q.push(1);
vis[1] = true; //标记1已经放入,防止重复放入
while(!q.empty()) {
int t = q.front();
q.pop();
vis[t] = false; //队首已经弹出,将它标记成false
for(int i = h[t]; i != -1; i = ne[i]) { //遍历每条边
int j = e[i];
if(dist[j] > dist[t] + w[i]) { //松弛操作
dist[j] = dist[t] + w[i];
if(!vis[j]) { //如果j未被放入,则放入
q.push(j);
vis[j] = true;
}
}
}
}
return dist[n];
}
如何判断负环?
前一篇文章说过,根据抽屉原理,如果路径上至少存在 \(n\) 条边,就说明至少存在 \(n + 1\) 个点,但是只有 \(n\) 个点,所以必定有两个点是同一个点。又由于只有当距离更小时才会更新,所以一定是一个负环。
代码:
bool spfa() { //cnt用于记录走过的边数
queue <int> q;
for(int i = 1; i <= n; i++) {
q.push(i);
vis[i] = true;
} //由于从每个点出发都可能遇到负环,所以将每个点都放入队列
while(!q.empty()) {
int t = q.front();
q.pop();
vis[t] = false;
for(int i = h[t]; i != -1; i = ne[i]) {
int j = e[i];
if(dist[j] > dist[t] + w[i]) {
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1; //走过去
if(cnt[j] >= n) return true; //如果边数大于n,则有负环
if(!vis[j]) {
q.push(j);
vis[j] = true;
}
}
}
}
return false; //否则则无
}
4.\(Floyd\)算法
基于动态规划。状态表示是三维,\(dist[k, i, j]\) 表示从第 \(i\) 个点出发,只经过第 \(1\sim k\) 的点到达第 \(j\) 个点的最短距离。状态转移方程为
dist[k, i, j] = dist[k - 1, i, k] + dist[k - 1, k, j];
其中第一维可以优化掉,变成
dist[i, j] = min(dist[i, k] + dist[k, j])
就可以看成是看从 \(i\) 到 \(k\) 再到 \(j\) 和直接从 \(i\) 到 \(j\) 哪个更短。
代码:
void floyd() {
for(int k = 1; k <= n; k++) {
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
//k为中间点,看从i到k再到j和直接从i到j哪个更短
}
}
}
}
学了三天最短路,大脑都快短路了(lll¬ω¬)