一、SPFA算法
分析:
本题解题逻辑比较复杂,但是一旦理顺了思路,也是可以很快\(AC\)的。首先分析下题意,城镇之间有两种路径,双向、边权非负的道路,以及单向、边权可能是负数的航线,并且航线不存在环。抽象成图模型就是有两类边,正权的双向边和可以是负权的单向边,若存在从\(a\)到\(b\)的单向边,则\(b\)不可能通过一些单向边或者双向边到达\(a\)。如果只当普通的含负权边的最短路问题,只需要用\(spfa\)算法就可以求解,但是本题测试数据会卡掉\(spfa\),卡成\(O(nm)\)后,\(25000*150000\)(因为双向边,所以是两倍,而航线还可能有\(50000\),所以是\(150000\)),显然会超时,因此需要采取更加高效的解法去求解。
SPFA写法
#include <bits/stdc++.h>
using namespace std;
const int N = 25005, M = 150005, INF = 0x3f3f3f3f;
typedef pair<int, int> PII;
/* SPFA直接干,结果
通过了 14/16个数据
*/
//存图
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
int T; // 城镇数量
int R; // 道路数量
int P; // 航线数量
int S; // 出发点
int dist[N]; //从A点出发,到达每个点的最大距离
bool st[N]; //点i是不是已经进入队列
//从start出发
void spfa(int start) {
queue<int> q;
dist[start] = 0;
q.push(start);
st[start] = true; //标识在队列中
while (q.size()) {
int u = q.front(); //取出当前要处理的节点
q.pop();
st[u] = false; // u节点出队列
//枚举u节点的每一条出边
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (dist[j] > dist[u] + w[i]) {
dist[j] = dist[u] + w[i];
if (!st[j]) {
st[j] = true;
q.push(j);
}
}
}
}
}
int main() {
//初始化邻接表表头
memset(h, -1, sizeof h);
//初始化最短距离
memset(dist, 0x3f, sizeof dist);
//城镇数量,道路数量,航线数量,出发点
cin >> T >> R >> P >> S;
dist[S] = 0; //出发点距离自己的长度是0
int a, b, c;
//读入道路
while (R--) {
cin >> a >> b >> c;
add(a, b, c), add(b, a, c); //城镇是无向图
}
//处理完无向图,再来考虑有向边,航线
while (P--) {
cin >> a >> b >> c;
add(a, b, c);
}
spfa(S);
//从S到达城镇i的最小花费
for (int i = 1; i <= T; i++) {
if (dist[i] == INF)
printf("NO PATH\n");
else
printf("%d\n", dist[i]);
}
return 0;
}
二、SPFA+SLF优化
#include <bits/stdc++.h>
//在比赛中,如果我们不能第一时间想到最佳的办法,或者想到了最优的办法,
//但无法在规定时间内完成复杂冗长的代码调试,那么只能取巧,采用 SPFA+优化试试
//祼的SPFA可以14/16,如果采用双端队列优化一下可以AC本题。
using namespace std;
const int N = 25010;
const int M = 150010;
const int INF = 0x3f3f3f3f;
int e[M], h[N], idx, w[M], ne[M];
void addd(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
int n, r, p, s;
int st[N];
int dist[N];
deque<int> q; //双端队列
void spfa() {
memset(dist, 0x3f, sizeof dist);
memset(st, 0, sizeof(st));
st[s] = 1;
q.push_back(s);
dist[s] = 0;
while (q.size()) {
int t;
t = q.front();
q.pop_front();
st[t] = 0;
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (dist[j] > dist[t] + w[i]) {
dist[j] = dist[t] + w[i];
if (!st[j]) {
st[j] = 1;
// SLF优化
if (q.size() && dist[q.front()] > dist[j])
q.push_front(j);
else
q.push_back(j);
}
}
}
}
}
int main() {
//优化读入
ios::sync_with_stdio(false);
memset(h, -1, sizeof h);
int a, b, c;
cin >> n >> r >> p >> s;
for (int i = 1; i <= r; i++) {
cin >> a >> b >> c;
addd(a, b, c), addd(b, a, c);
}
for (int i = 1; i <= p; i++) {
cin >> a >> b >> c;
addd(a, b, c);
}
spfa();
for (int i = 1; i <= n; i++) {
if (dist[i] == INF) {
printf("NO PATH\n");
continue;
}
printf("%d\n", dist[i]);
}
return 0;
}
三、缩点+DAG求最短路+Dijkstra求最短路
首先讲下预备知识,我们知道,拓扑排序可以求一个\(DAG\)(有向无环图)的拓扑序列,从而确定任务完成的先后关系,但是容易忽略的是拓扑排序也可以求\(DAG\)的最短路径长度,不管存不存在负权边。
证明也很简单,考虑一般的数学归纳法即可证明,假设一个点的前驱节点离起点的最短距离都确定了,则这个节点离起点的最短距离可以通过前驱结点加上到该节点的边权的最小值决定。边界情况是起点的入度为\(0\)时,其后继节点的最短路就可以直接通过比较确定下来。虽然本题并没有用这种办法去求\(DAG\)的最短路径,但是节点最短路的求解顺序却是按照拓扑序来的。
直接说下解题思路,道路连成的顶点构成若干个连通块,我们将每个连通块看成一个大的节点,则原图就抽象为了由这些大节点构成的\(DAG\)了。
连通块内都是正权边,可以用\(dijkstra\)求最短路,我们按照拓扑序的顺序依次对各个连通块内的节点作\(dijkstra\),来求出连通块内部节点的最短路径,本题就解决了。(这里偷懒就不把样例的图画出来了)
如果只是简单地描述下思路很多人仍是云里雾里,但是只要走一遍代码执行的流程,算法的正确性便显而易见了。
首先,读入所有的道路信息(双向边),我们要统计出有多少个连通块,并且每个点属于哪个连通块,每个连通块有哪些节点,这就相当于\(flood\) \(fill\)问题,做一遍\(DFS\)即可解决。
void dfs(int u){
id[u] = bcnt,block[bcnt].push_back(u);
for(int i = h[u];~i;i = ne[i]){
int j = e[i];
if(!id[j]){
dfs(j);
}
}
}
id[u]
表示u
节点属于的连通块编号,其中id[j]
为0
表示还没有j
还没有加入本连通块,加入即可,\(DFS\)的过程很简单,不再赘述。
然后再读入航线信息,此时把航线的单向边都加上也不会影响连通块的统计了,这就是巧妙之处,同时统计各个连通块的入度信息。
最后做拓扑排序,完成后如果某个节点离起点的距离还是很大,就表明没有路径,因为含负权边,一般超过INF / 2就视为没有路径了。
算法的核心在于拓扑排序,遍历各个连通块,将入度为0
的连通块编号加入到队列中,队列非空时取队头连通块,对该连通块做\(dijkstra\)求最短路。
在对连通块内节点做\(dijkstra\)时,由于不知道哪个节点离起点最近,所以将所有的点都放入小根堆中,取堆顶元素即可。
设堆顶元素为u
,如果u
已经出过优先级队列了,就continue
,否则,对周边点执行松弛操作。这里的松弛操作与常规的松弛操作不同的是要判断周围的点是否与u
在同一连通块内,如果不在,就将u
指向的连通块入度减一,减到0
的时候加入到拓扑排序的队列中去。
不管周围的点与u
是否在同一连通块内,都要去松弛这个点,但是只有同一个连通块内的点被松弛了才需要加入到堆中。
因此,对连通块做\(dijkstra\)的效果是这个连通块内部点的最短距离都求出来了,同时也松弛了相邻连通块的点的距离,更新了周围连通块的入度信息。
算法到这里就结束了,两点需要注意:
其一是虽然dijkstra算法一开始是将连通块内所有点都加入堆,类似于求多源最短路,但是却不是求多源最短路,只是确定下最小的距离而已;
其二是在做拓扑排序的过程中,只有起点\(S\)所在的连通块做完\(dijkstra\)后其他连通块的距离才会被更新,或者说更新才有意义,那么能否在拓扑排序时直接忽略\(S\)所在连通块出队前的出队连通块编号呢?答案是否定的。因为\(dijkstra\)的作用不仅是更新连通块内点的最短路和相邻连通块点的距离,还要更新相邻连通块的入度信息,在\(S\)所在的连通块出队前不用求最短路,但是却要更新周围连通块的入度信息,\(dijkstra\)顺带做了这件事,所以还是有必要的。
#include <bits/stdc++.h>
using namespace std;
const int N = 25005;
const int M = 150005;
const int INF = 0x3f3f3f3f;
typedef pair<int, int> PII;
//存图
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
int T; // 城镇数量
int R; // 道路数量
int P; // 航线数量
int S; // 出发点
//下面两个数组是一对
int id[N]; //节点在哪个连通块中
vector<int> block[N]; //连通块包含哪些节点
int bcnt; //连通块序号计数器(用于下标临时运算)
int d[N]; //最短距离(结果数组)
int in[N]; //每个DAG(节点即连通块)的入度
bool st[N]; // dijkstra用的是不是在队列中的数组
queue<int> q; //拓扑序用的队列
//将u节点加入连通块中,Flood Fill
void dfs(int u, int bid) {
id[u] = bid; // u节点, 属于bid连通块
block[bid].push_back(u); // bid连通块包含u节点
//枚举u城镇的每一条出边,将对端的城镇也加入到bid这个团中
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!id[j]) dfs(j, bid); //没有加入到某个连通块的,才Flood Fill
}
}
//计算得到bid这个连通块中最短距离
//问题:和S起点的最短距离吗?
void dijkstra(int bid) {
priority_queue<PII, vector<PII>, greater<PII>> heap;
/*
因为不确定连通块内的哪个点可以作为起点,所以就一股脑全加进来就行了,
反正很多点的dist都是inf(这些都是不能成为起点的),
那么可以作为起点的就自然出现在堆顶了
*/
for (auto u : block[bid]) heap.push({d[u], u}); // C++11写法
while (heap.size()) {
int u = heap.top().second;
heap.pop();
if (st[u]) continue;
st[u] = true;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (st[j]) continue;
/*如果u和j不在同一个连通块中,说明我们遍历到的是一条单向边
当id[j]指向的连通块入度为0时,则需要进入拓扑排序的队列.
相当于删除了前序节点
*/
if (id[u] != id[j] && --in[id[j]] == 0) q.push(id[j]);
//如果能通过u节点更新j节点的最小长度,则更新
//其实这里是同时在用拓扑排序+三角不等式更新最小距离
//也是Dijkstra在更新最小距离
if (d[j] > d[u] + w[i]) {
d[j] = d[u] + w[i];
//如果是同一连通块,那么需要再次进入Dijkstra的堆
if (id[u] == id[j]) heap.push({d[j], j});
}
}
}
}
//拓扑序
void topsort() {
//找出所有入度为0的连通块
for (int i = 1; i <= bcnt; i++)
if (!in[i]) q.push(i);
//拓扑排序
while (q.size()) {
int bid = q.front();
q.pop();
//在bid这个标号的连通块内部跑一遍dijkstra
dijkstra(bid);
}
}
int main() {
//他想找到从发送中心城镇S把奶牛送到每个城镇的最便宜的方案
//初始化邻接表表头
memset(h, -1, sizeof h);
//初始化最短距离
memset(d, 0x3f, sizeof d);
//城镇数量,道路数量,航线数量,出发点
cin >> T >> R >> P >> S;
d[S] = 0; //出发点距离自己的长度是0,其它的最短距离目前是INF
int a, b, c; //起点,终点,权值
//读入道路
while (R--) {
cin >> a >> b >> c;
add(a, b, c), add(b, a, c); //连通块内是无向图
}
// 通过Flood Fill算法,得到每个节点属于哪个连通块
for (int i = 1; i <= T; i++)
if (!id[i])
dfs(i, ++bcnt); //从i入手,找到它同一连通块的所有节点,标识连通块号为bcnt
//航线
while (P--) {
cin >> a >> b >> c;
add(a, b, c); //单向边
in[id[b]]++; // b节点所在连通块的入度+1
}
/*拓扑排序:利用缩点的思想,将图划分为大图和小图形式
大图:DAG
采用拓扑排序+三角不等式获取最短路径,DAG是有向无环图,可以支持负权计算。
小图:无向正权图
采用堆优化的Dijkstra算法计算内部最短路
算法思路:
在整体拓扑排序的大框架下,找出每个连通块的拓扑序,
先计算每个连通块中的最短路径(Dijkstra+堆优化),再用三角不等式计算连通块间的
最短距离,两者结合,就可以计算出起点S到所有点的最短距离了。
*/
topsort();
//从S到达城镇i的最小花费
for (int i = 1; i <= T; i++) {
if (d[i] > INF / 2)
printf("NO PATH\n");
else
printf("%d\n", d[i]);
}
return 0;
}