引入
最短路问题是一类经典的图论问题,主要是求解有权图上两点间的最短距离。
我们今天主要的内容是dij和bf两种单源最短路算法。
树
树有无环、联通的特点,所以任意两点间简单路径唯一,以起点作为根节点DFS/BFS求距离即可。
无(等)权图
无权图上每条边的边权相同,所以bfs即可找到从起点出发到任意一点的最短距离。
非负权图
对于非负权图,我们一般使用dijkstra算法求解单源最短路。
过程
dij是一种点核心的单源最短路算法,核心思想是:
1、找到没有确定最短路的点里面,距离起点最近的点u。
2、用起点到点u的距离dis[u],尝试更新u的邻居们的最短距离。
重复以上操作直到所有点的最短距离都被确定。
记dis[i]dis[i]dis[i] 表示当前情况下从起点出发到iii的最短距离,假设有一条从uuu到vvv,边权为www的边,那么我们就可以尝试用dis[u]+wdis[u] + wdis[u]+w来更新dis[v]dis[v]dis[v],这个操作就叫松弛。
证明
记从起点到点u的最短路为D(u)D(u)D(u),如果我们每次确定的点当时的dis[u]=D(u)dis[u] = D(u)dis[u]=D(u),那么最终所有的dis[i]=D(i)dis[i] = D(i)dis[i]=D(i)。证明这一点,就可以证明dij算法的正确性。
使用反证法证明:
假设存在若干个点被确定时disdisdis大于DDD,记其中第一个被确定的点为xxx。
xxx显然不为起点,因为dis[st]=D(st)=0dis[st] = D(st) = 0dis[st]=D(st)=0
假设到xxx的最短路径是st→a→b→xst \rightarrow a \rightarrow b\rightarrow xst→a→b→x,一定存在这条路径,否则dis[x]=D(x)=INFdis[x] = D(x) = INFdis[x]=D(x)=INF
其中bbb是sss到xxx的最短路径上第一个未被确定的点
如果路径上所有点都被确定,就有dis[x]=D(x)dis[x] = D(x)dis[x]=D(x),所以路径上一定有尚未被确定的点。
aaa是ststst到bbb的最短路径上的前驱节点
因为bbb是第一个未被确定的点,所以aaa一定被确定。(aaa可能等于ststst)
因为xxx是第一个disdisdis大于DDD的点,所以已经被确定的aaa满足dis[a]=D(a)dis[a] = D(a)dis[a]=D(a)
因为aaa是到bbb的最短路径上的前驱节点,所以D(b)=dis[b]=D(a)+w(a,b)D(b) = dis[b] = D(a) + w(a, b)D(b)=dis[b]=D(a)+w(a,b)
因为xxx是被确定的点,所以此时dis[x]dis[x]dis[x]应当小于等于所有未被确定的点的disdisdis
又有D(x)=D(b)+w(b,x)D(x) = D(b) + w(b, x)D(x)=D(b)+w(b,x),其中w(b,x)>=0w(b, x) >= 0w(b,x)>=0,
综上,dis[x]>D(x)=D(b)+w(b,x)>=D(b)=dis[b]dis[x] > D(x) = D(b) + w(b, x) >= D(b) = dis[b]dis[x]>D(x)=D(b)+w(b,x)>=D(b)=dis[b]
推出矛盾,所以不存在点xxx被确定时dis[x]>D[x]dis[x] > D[x]dis[x]>D[x],原结论成立。
实现
朴素dij
vector <int> dis, vis;
vector <pair<int, int> > ve[N];
void dij(int st) {
dis = vector<int>(n + 1, INF);
vis = vector<int>(n + 1, 0);
dis[st] = 0;
for (int _i = 0; _i < n; _i++) {
int id = 0;
for (int i = 1; i <= n; i++) {
if (vis[i] == 0 && dis[i] < dis[id]) {
id = i;
}
}
if (id == 0) return;
vis[id] = 1;
for (auto p : ve[id]) {
int nxt = p.first, w = p.second;
dis[nxt] = min(dis[nxt], dis[id] + w);
}
}
}
堆优化dij
vector <int> dis;
vector <pair<int, int> > ve[N];
void dij(int st) {
dis = vector<int>(n + 1, INF);
dis[st] = 0;
priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > > q;
q.push({0, st});
while (q.size()) {
int d = q.top().first;
int now = q.top().second;
q.pop();
if (dis[now] < d) continue;
for (auto p : ve[now]) {
int w = p.first, nxt = p.second;
if (dis[nxt] > dis[now] + w) {
dis[nxt] = dis[now] + w;
q.push({dis[nxt], nxt});
}
}
}
}
他们的时空复杂度分别是多少?
朴素 时间O(n2+m)O(n^2+m)O(n2+m)
堆优化 时间O((n+m)logm)O((n+m)logm)O((n+m)logm)
所以什么情况下用朴素dij,什么情况下用堆优化dij?
稀疏图 堆优化dij
稠密图 朴 素 dij
一般图
如果图上有负权边,dij算法的正确性就不能保证了,这时我们可以使用bellman-ford算法来求解单源最短路,我们常说的SPFA算法就是bellman的队列优化。
过程
bellman算法是一种边核心的最短路算法。
和点核心的dij不同,bellman在松弛时不是以点作为单位来松弛,而是在一轮松弛中依次松弛所有边,直到找到所有点的最短路(或发现有负环)为止。
松弛操作与dij中相同,假设有一条从uuu到vvv,边权为www的边,那么我们就可以尝试用dis[u]+wdis[u] + wdis[u]+w来更新dis[v]dis[v]dis[v]。
在每一轮循环中,我们尝试用每一条边更新这条边的终点的最短路,复杂度O(m)O(m)O(m)
如果起点不能到达负环,存在最短路,那么最短路最多包括n−1n-1n−1条边
每轮松弛都会使最短路至少多一条边。
所以最多需要进行n−1n-1n−1轮循环,最终复杂度O(nm)O(nm)O(nm)
如果需要判断是否存在负环,比较简单的做法是新加一个超级源点S,从S出发建nnn条边权为000的边连向其他nnn个节点,然后以S作为起点执行bellman。
实现
vector <array<int, 3> > path;
bool bellman(int st) {
dis[st] = 0;
for (int i = 1; i <= n; i++) {
bool ed = 1;
for (auto p : path) {
int u = p[0], v = p[1], w = p[2];
if (dis[u] == inf) continue;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
ed = 0;
}
}
if (ed) return 0;
}
return 1;
}
SPFA
SPFA是基于bellman算法的队列优化,在随机图和一些有特殊性质的图上表现优秀,但复杂度上界和bellman相同,也是O(nm)O(nm)O(nm)
SPFA是基于这样一种思想:只有上一次被松弛过的终点,才有作为起点松弛的价值。所以可以用一个队列存被松弛过的终点的集合,每次取出队头松弛该点的出边。
bool spfa(int st) {
queue<int> q;
q.push(st);
for (int i = 1; i <= n; i++) dis[i] = inf, vis[i] = 0;
dis[st] = 0;
cnt[st] = 0;
vis[st] = 1;
while(q.size()) {
int now = q.front(); q.pop();
vis[now] = 0;
for (auto p : ve[now]) {
int nxt = p.first, w = p.second;
if (dis[nxt] > dis[now] + w) {
dis[nxt] = dis[now] + w;
cnt[nxt] = cnt[now] + 1;
if (cnt[nxt] >= n) {
return 1;
}
if (!vis[nxt]) {
q.push(nxt);
vis[nxt] = 1;
}
}
}
}
return 0;
}
每个点的入队次数是O(度) 的,复杂度上界仍然是O(nm)O(nm)O(nm)