该博客的单源最短路算法要解决的就是在一个没有负权边的图上,找出所有点与源点 s s 的最短路径,这样一个问题。这里介绍迪杰斯特拉 O(n2) O ( n 2 ) 解法与堆优化 O(mlogm) O ( m log m ) 解法,其中 n n 为图上节点数量, m m 为图上边的数量。
不介绍也不推荐玄学复杂度的 spfa s p f a 解法。
从上面的算法可以看出,大循环要将所有点都加入集合一次,而每次寻找这个“不在集合中且 disx d i s x 最小的点”需要一次 O(n) O ( n ) 的循环,接着更新所有与 x x 邻接的点(最多有 n n 个点),这里也需要 O(n) O ( n ) ,所以大循环为 O(n) O ( n ) ,小循环为 O(n+n) O ( n + n ) ,整体时间复杂度为 O(n×(n+n))=O(n2) O ( n × ( n + n ) ) = O ( n 2 ) 。
const int maxn = 1000 + 100;
struct Node {
int pos;
int dis;
Node() {}
Node(int p, int d) {
pos = p;
dis = d;
}
};
int n;
int dis[maxn];
bool vis[maxn];
vector G[maxn];
void dij(int s) {
// 距离初始化
fill(dis, dis + n + 1, INT_MAX);
dis[s] = 0;
int cnt = n;
// cnt 表示不在集合中的节点个数
while(cnt > 0) {
int Min = INT_MAX;
int x;
// 寻找不在集合中的,距离 s 最近的节点
for(int i = 1; i <= n; ++i) {
if(!vis[i] && dis[i] < Min) {
Min = dis[i];
x = i;
}
}
// 将节点 x 加入集合
--cnt;
vis[x] = true;
// 更新节点 x 的邻接节点
int len = G[x].size();
for(int i = 0; i < len; ++i) {
int pos = G[x][i].pos;
int d = G[x][i].dis;
dis[pos] = min(dis[pos], dis[x] + d);
}
}
}
上面能够优化的时间复杂度是在“找到不在集合中的离 s s 最近的点”(每个点必须要进入集合才算更新结束,所以大循环的 O(n) O ( n ) 是无法减小的),我们能否通过一些排序操作,使得能够快速地找到这个点 x x 呢?
这里就可以用到一个“小顶堆”的数据结构,它能够在 O(logn) O ( log n ) (其中 n n 为堆中的元素数量)的时间复杂度内完成插入、删除最小值的操作,在 O(1) O ( 1 ) 的时间复杂度内完成取堆内最小值的操作。于是我们可以将上面的查找这一步操作放入到堆中,时间复杂度就能下降到 O(logn) O ( log n ) 。
但这里要注意一点,在我们查找之后,是可以更新这个点的最短距离的,但是小顶堆不允许访问、更改堆内元素,只能访问堆顶元素,所以如果将点与当前最短距离放入堆内,将存在一些多余的点(更新时该点还未从堆顶弹出),而这些所有数据最多有 m m 个, m m 为图上边的数量。
这里可以标记某个点 x x 已经在 Set S e t 集合中,这样在其他指向 x x 的点更新到 x x 的时候,就不必再重复判断。
但是这种做法是多余的,因为如果 x x 已经在 Set S e t 集合中,则指向 x x 的 y y 点必然无法更新 disx d i s x 的值,所以实际上可以放任不管。
从上面可以看出,这个代码应该类似于 bfs b f s 的代码,只是将队列改为优先队列(小顶堆),这样就能保证优先弹出的是距离 s s 最短的(但不能保证不在集合 Set S e t 中),整体的大循环应该是每个点都进入队列(不只一次)然后出队列,这个次数由图上的边数决定,为 O(m) O ( m ) (其中 m m 为图上边的数量),而在堆中,最多会被存放 m m 个数据点,所以小循环内应该是 O(logm) O ( log m ) ,所以整体的时间复杂度为 O(mlogm) O ( m log m ) 。
const int maxn = 1000 + 100;
struct Node {
int pos;
int dis;
Node() {}
Node(int p, int d) {
pos = p;
dis = d;
}
};
// 由于优先队列默认为大顶堆,所以重载小于号要用 a.dis > b.dis,来起到小顶堆的作用
bool operator<(const Node &a, const Node &b) {
return a.dis > b.dis;
}
int n;
int dis[maxn];
vector G[maxn];
priority_queue que;
void dij(int s) {
// 初始化距离
fill(dis, dis + n + 1, INT_MAX);
dis[s] = 0;
que.push(Node(s, 0));
while(!que.empty()) {
Node tmp = que.top();
que.pop();
// 更新节点 tmp 的邻接节点,若邻接节点被更新,则将节点和 dis 加入小顶堆
int len = G[tmp.pos].size();
for(int i = 0; i < len; ++i) {
int pos = G[tmp.pos][i].pos;
int d = G[tmp.pos][i].dis;
if(dis[pos] > tmp.dis + d) {
dis[pos] = tmp.dis + d;
que.push(Node(pos, dis[pos]));
}
}
}
}
两种写法最大的区别在于时间复杂度,第一种是 O(n2) O ( n 2 ) ,第二种是 O(mlogm) O ( m log m ) ,在大多数情况下都可以用第二种写法(例如题目给出 m m 的数据范围在 [1,106] [ 1 , 10 6 ] 内),但是在节点数 n n 比较大的完全图(完全图的边数为 m=C2n=n×(n−1)2 m = C n 2 = n × ( n − 1 ) 2 ,)下,第二种的时间复杂度就为 O(n2logn2) O ( n 2 log n 2 ) ,这样第二种写法的时间复杂度就会比第一种大,在 n n 超过 1000 1000 时,完全图情况下就只能用第一种写法,因此朴素写法并不是没有用的。
在刚学 dij d i j 的时候,可能大家都是从 bfs b f s 过渡过来的,所以看到别人的 dij d i j 思路时,都会用队列实现,但是很容易写挫,这里先搬上队列写法(和堆优化写法很像)再讨论讨论这种写法:
const int maxn = 1000 + 100;
struct Node {
int pos;
int dis;
Node() {}
Node(int p, int d) {
pos = p;
dis = d;
}
};
int n;
int dis[maxn];
vector G[maxn];
queue que;
void dij(int s) {
dis[s] = 0;
que.push(Node(s, 0));
while(!que.empty()) {
Node tmp = que.front();
que.pop();
int len = G[tmp.pos].size();
for(int i = 0; i < len; ++i) {
int pos = G[tmp.pos][i].pos;
int d = G[tmp.pos][i].dis;
if(dis[pos] > dis[tmp.pos] + d) {
dis[pos] = dis[tmp.pos] + d;
que.push(Node(pos, dis[pos]));
}
}
}
}
这段代码没有什么注释,是因为……我现在已经看不懂这段代码了(虽然是我写的并且过了题目),这段代码的实际时间复杂度的表达式我不知道是什么,最坏时间复杂度也不知道(数据构造得好的话运行次数将超过 n2 n 2 )。
这里给出一个四个节点的完全图邻接链表表示:
如果在上面这个邻接链表上跑,从 A A 点开始,点进入队列的顺序依次为