第24章 单源最短路径
24.1 Bellman-Ford算法
24.1-4
思路:
先做|V|-1遍松弛操作,然后再做一遍松弛操作,对于这次松弛操作中dist值被更新的点,必然包含了每个负环中的至少一个点。对于这些点做dfs查找它们能够在图中到达哪些点,所有被搜索到的点即为题目要求找的点
部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ...;
const int inf = 0x3f3f3f3f;//正无穷
struct E{
int x,y,z;//三元组(x,y,z)表示一条有向边。从x出发到y,权值为z。
}
vector<E> es;//存边
vector<int> e[maxn];//模拟邻接链表
vector<int> vec;//存起始点
void bellman(int s){
for(int i = 1; i<=n; i++)d[i]=inf;
d[s] = 0;
for(int t = 1; t<n; t++){
for(auto e:es){
if(d[e.x]!=inf && d[e.x]+e.z<d[e.y])d[e.y] = d[e.x] + w;
}
}
for(auto e:es){
if(d[e.x]!=inf && d[e.x]+e.z<d[e.y]){
vec.push_back(y);
}
}
}
int v[maxn];
void dfs(int x){
v[x] = 1;
for(auto y: e){
if(!v[y]) dfs(y);
}
}
void solve(int s){
bellman(s);
for(auto x:vec){
if(!v[x]) dfs(x);
}
for(int i = 1; i<=n; i++){
if(v[i]) cout<<"负无穷"<<endl;
else if(d[i]==inf) cout<<"不可达"<<endl;
else cout<<d[i]<<endl;
}
}
24.1-5
思路:
跑一遍Bellman-Ford算法,具体做法如下:
1、初始化\(\forall v\in V ,d[v] = 0\)。
2、对于边(x,y,z),如果d[y]>d[x]+z,更新d[y]。
证明:
首先,一个点对自身的距离为0,所以\(\forall v\in V,\delta^*(v)\leq 0\),可以将每个点d[v]初始化为0
接下来证明更行操作的正确性:
设\(\delta_i(u,v)\)为从u到v边数不超过I的路径的最小值\(u,v\in V\)
同时,设\(\delta_i^*(v) = min_{u\in V}\{\delta_i(u,v)\}\)
这样我们便有 \(\delta^*(v)=\delta_{n-1}^*(v)\)
只要我们证明,对于\(0\leq i<n\),均有\(d[v]\leq \delta_i^*(v)\)且最后\(d[v] = \delta_{n-1}^*(v)\)即可
1、i==0时,\(d[v]\leq \delta^*(v) = 0\)
2、假设前i次迭代中\(d[v]\leq \delta_i^*\)成立
对于第i+1次迭代,
如果\(\delta_{i+1}^* = \delta_{i}^*\),
\(d[v] \leq \delta_{i+1}^*(v) = \delta_i^*(v)\)仍成立
否则,在此轮中边(u,v)被松弛,有\(d[v] = min\{d[u]+w(u,v)\}\)\(\leq min\{\delta_i^*(u)+w(u,v)\} = \delta_{i+1}^*(v)\),仍成立。
综上,所以有\(d[v]\leq \delta_{n-1}^*\)
因为d[v]为两顶点路径长,所以\(d[v] = \delta_{n-1}^*(v)\)
(以上证明前提是图中无负环,实际程序中将负环判掉了)
部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ...;
struct E{
int x,y,z;//三元组(x,y,z)表示一条有向边。从x出发到y,权值为z。
}
vector<E> es;//存边
int d[maxn];
bool bellman(){
memset(d,0,sizeof(d));
for(int t = 1; t<n; t++){
for(auto e:es){
if(d[e.y]>d[e.x]+z) d[e.y] = d[e.x] + z;
}
}
for(auto e:es){
if(d[e.y]>d[e.x]+z) return false;
}
return true;
}
void solve(){
if(bellman()){
for(int i = 1; i<=n; i++)cout<<d[i]<<endl;
} else{
cout<<"有负环";
}
}
24.3 Dijkstra算法
24.3-6
思路;
做n遍Dijkstra算法。用数组d[x][y]表示从结点u到结点v最可靠通信链路的不失效概率。此外,因为题目要求找到这些通信链路,我们可以设一个pre数组,递归打印出道路。
时间复杂度:
加入堆优化后时间复杂度为O(nmlogn)
部分c++代码:
#include <bits/stdc++.h>
#define mp make_pair
using namespace std;
const int maxn = ...;
double d[maxn][maxn];//存u->v最可靠通信链路不失效概率
int v[maxn];//结点是否访问过
int n,m;//结点数、边数
struct E{
int y; double p;
E(int a = 0, double b = 0){y = a; p = b;}
}
vector<E> es[maxn];
priority_queue<pair<double,int>> q;
int pre[maxn][maxn];//存前一个结点,用于打印
void dijkstra(int t){
memset(v,0,sizeof(v));
d[t][t] = 1.0; pre[t][t] = -1;
q.push(mp(1,0,t));
while(!q.empty()){
int x = q.top().second; q.pop();
if(v[x]) continue;
v[x] = 1;
for(auto e: es[x]){
int y = e.y; double z = e.p;
if(d[t][x]*z > d[t][y]){
pre[t][y] = x;
d[t][y] = d[t][x]*z;
q.push(mp(d[t][y],y));
}
}
}
}
void print(int t,int x){
if(pre[t][x] != -1) print(t,pre[t][x]);
printf("%d ",x);
}
void solve(){
for(int i = 1; i<=n; i++) for(int j = 1; j<=n; j++)d[i][j]=-1;
for(int i = 1; i<=n; i++){
dijkstra(i);
for(int j = 1; j<=n; j++){
if(d[i][j] == -1) continue;//两点之间无路可走
printf("%d->%d:",i,j);
print(i,j);
printf("p:%lf\n",d[i][j]);
}
}
}
24.3-8
思路:
从优化“贪心寻找dist最小点”的操作入手。由于边权\(\leq W\),那么一个点的dist值不会超过VW。基于这个条件,我们可以抽象出一个长度为VW的队列数组。每个数组的队列存储着“dist值为该数组序号”的点。然后抽象出一个指针,这个指针指向的队列,是我们贪心取点的队列。由于一个点被取出后,被他更新的点只会被push进该队列或者该队列之后的队列,所以指针只会从左到右扫描一遍数组。
时间复杂度:
最多扫描一遍数组,复杂度为O(VW)。此外,最多会有E次入队、出队操作,复杂度为O(E)。总时间复杂度为O(VW+E)。
部分c++代码:
#include <bits/stdc++.h>
#define mp make_pair
using namespace std;
const int N = ..., M = ..., W = ...;
queue<int> qs[N*W];
int head[N], ver[M], edge[M], Next[M], tot = 0;//采用数组模拟邻接链表的方式
void add(int x,int y,int z){
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
int d[N], v[N];
int n, m, w;
void dijkstra(int s){
memset(d,0x3f,sizeof(d));//初始化为正无穷
memset(v,0,sizeof(v));
d[s] = 0; qs[0].push(s);
for(int k = 0; k<=n*w; k++){
while(!qs[k].empty()){
int x = qs[k].front(); qs[k].pop();
if(v[x]) continue;
v[x] = 1;
for(int i = head[x]; i; i = Next[i]){
int y = ver[i], z = edge[i];
if(d[y] > d[x] + z){
d[y] = d[x] + z;
qs[d[y]].push(y);
}
}
}
}
}
24.3-9
思路:
类比堆优化Dijkstra算法,我们可以将dist值丢入堆里,每次取堆顶的dist值。查询那些dist值为该值且未被访问的点,用这些点来更新。
时间复杂度:
假设起始堆满,即0~W,共W+1个值,取0,它更新的结点的dist值在0~W之间,不会使堆变大。以此类推,任意时刻,堆的大小不会超过W+1,所以维护堆的复杂度为lgW。其他操作同普通堆优化的Dijkstra算法。复杂度为O((V+E)logW)。
部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int N = ..., M = ..., W = ...;
queue<int> qs[N*W];
int head[N], ver[M], edge[M], Next[M], tot = 0;
void add(int x,int y,int z){
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
int d[N], v[N];
priority_queue<int> q;
int inh[N*W];//记录该值是否在堆中。
void dijkstra(int s){
memset(d,0x3f,sizeof(d));
memset(v,0,sizeof(v));
d[s] = 0; qs[0].push(s);
q.push(0);
while(!q.empty()){
int k = q.top(); q.pop(); inh[k] = 0;
while(!qs[k].empty()){
int x = qs[k].front(); qs[k].pop();
if(v[x]) continue;
v[x] = 1;
for(int i = head[x]; i; i = Next[i]){
int y = ver[i], z = edge[i];
if(d[y] = d[x] + z){
d[y] = d[x] + z;
qs[d[y]].push(y);
if(!inh[d[y]]){
q.push(d[y]);
inh[d[y]]=1;
}
}
}
}
}
}
24.3-10
证明:
首先,Dijkstra的算法思路是:对于任意一个从队列取出来的点x,如果它没有被标记过,那么d[x]一定是源结点s到x的最短路径,然后我们不断地进行贪心扩展,最终可以得到源结点s到每个结点的最短路径。它的本质是一个贪心+BFS算法。
现在,题目中给出的限制是:“只有从源结点s出发的边权重可以为负,且图中无负环”。
源结点s一定是在一开始被取出,更新完之后被丢弃。因为只有源结点s出发的边权重可以为负,所以我们在后面更新由“s以外的其他点”更新“s以外的其他点”时,仍旧是在一个无负边权的图中的更新,所以Dijkstra仍正确。而对于这些点,当他们尝试更新s的时候,因为图中无负环,所以无法更新s。此外,由于无负环,s也不会出现有一个权值为负的子环无限更新自身的情况。综上,Dijkstra基于贪心的性质没有发生改变,每次从队列中取出的一个点更新完其他点被丢弃后,这个点在后面一定不会被更新。所以Dijkstra仍旧可以正确运行。
得证。
思考题
24-1
a.
对于\(G_f\)它的所有边都是由一个具有较小索引的结点指向一个具有较大索引的结点。所以我们无法找到一个结点v,使得对于该点存在边(u,v),u是“到v含有至少一条路径的点组成的点集”中的点。因为这样的点集中的点的索引必定小于v,而从v出发的边所到达的点的索引均大于v,从而可以得知\(G_f\)是无环的。此外,因为\(G_f\)中不会存在由索引高的点指向索引低的点的边,所以\(<v_1,v_2,...,v_{|V|}>\)一定是一个合法的拓扑序。
同理,\(G_b\)也是无环的,且其拓扑序为\(<v_{|V|},...,v_1>\)。
b.
对于s存在一条路径到v,如果这个路径是图\(G_f\)中的,此时路径结点的索引是上升的,那我们对\(G_f\)做一遍松弛松弛操作就得到了正确结果。如果最终结果中有一个“拐点”,即这条路径结点索引原本是上升的后来又下降了,那我们一遍松弛操作也可以得到正确结果(因为我们在两个图\(G_f,G_b\)都做了松弛操作)。不难发现,按照题目的操作方式,如果发生了更新,必然会出现新的“拐点”。相应地,我们可以说,只有更新后出现我们的到路径出现了新的“拐点”,这个松弛操作才会有效,否则,更新已经完成。我们知道,对于最终的结果,我们考虑一种最差的情况,应该是上升、下降、上升、下降……或者下降、上升、上升、下降……。
(索引\(s<v_1<v_2<...\))
对于这两种最差的情况,只会有\(\lfloor |V|/2\rfloor\)。此外,还有个要考虑的是,可能开始\(s\)->\(v_1\)原本是上升的,后来在更新后又变成下降了,这样应该需要做一次松弛操作,但是我们在最终结果图中会忽略掉,所以实际上有\(\lceil |V|/2\rceil\)次操作。综上,我们最差也只需做\(\lceil |V|/2\rceil\)遍松弛操作。
c.
直接做Bellman-Ford复杂度为O(V(V+E)),用该思路来做应该为V/2(2V+E)=V*(V+E/2)。由于往往\(V\leq E\),我们将其分别写为\(O(VE),O(\frac{1}{2}VE)\)。可以看出这种方法只会使得前面的系数发生变化,所以渐进复杂度没有发生改变。
24-2
a.
假设有盒子\(x = <x_1,...,x_d>\),\(y = <y_1,...,y_d>\),\(z = <z_1,...,z_d>\)。且x嵌套在y中,y嵌套在z中。我们有\(x_{\pi(1)}<y_1,...,x_{\pi(d)}<y_d\),\(y_{\delta(1)}<z_1,...,y_{\delta(d)}<z_d\)。那么,便有\(x_{\pi(\delta(1))}<z_1,...,x_{\pi(\delta(d))}<z_d\),即x嵌套在z里,所以嵌套关系是传递的。
b.
将两个盒子里的元素排序没,只要对应位置元素满足偏序关系,即可得到两个盒子有嵌套关系,复杂度为O(dlogd)(反证法可以证明)。
c.
将每个盒子的元素排序,然后对于每对有嵌套关系的盒子连边,然后跑DFS即可,复杂度为$O(ndlogd+dn^2)。
此外,因为要找到这个序列,我用一个to数组来记录这条链。
部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ..., maxd = ..., maxm=...;
int head[maxn], ver[maxm], edge[maxm], Next[maxm], tot = 0;
void add(int x,int y,int z){
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
int box[maxn][maxd];
int n,d;
int dp[maxn], to[maxn];
bool judge(int a[maxd],int b[maxd]){
for(int i = 1; i<=d; i++){
if(a[i]<b[i]) continue;
return false;
}
return true;
}
void dfs(int x){
if(dp[x]) return ;
dp[x] = 1;
for(int i = head[x]; i; i = Next[i]){
int y = ver[i]; dfs(y);
if(dp[y]+1>d[x]) to[x] = y;
}
}
void solve(){
//盒子与盒子元素的索引均从1开始
for(int i = 1; i<=n; i++) sort(box[i]+1,box[i]+1+d,cmp);
for(int i = 1; i<=n; i++){
for(int j = 1; j<=n; j++){
if(judge(box[i],box[j]))add(i,j)
}
}
memset(dp,0,sizeof(dp));
int ans = 0, p;
for(int i = 1; i<=n; i++){
if(dp[i] == 0) dfs(i);
if(dp[i] > ans){
ans = dp[i];
p = i;
}
}
do {
printf("%d",p);
p = to[p];
}while(p);
}
24-3
思路:
用归约的思想:
由于 \(R[i_1,i_2]*...*R[i_k,i_1] > 1\)
取对数,有\(lg(R[i_1,i_2]+ ... +lg(R[i_k,i_1]) > 0\)
取负,有\((-lg(R[i_1,i_2]) + ... + (-lg(R[i_k,i_1])) < 0\)
所以,我们将\(R[i_{k},i_{k+1}]\)这样原来的边权进行两步处理后,问题即转化为在图中找负环。
问题a要求判断是否存在负环,可以直接用bfs版的spfa,在随机图上运行效率为O(k|E|),k是一个较小的常数,在特殊图上运行效率为O(|V||E|)。
问题b要求打印负环,由于已知存在负环,dfs版的spfa会在多数情况下更快一点,特殊图上运行效率为O(|V||E|)。
a,部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ..., maxm = ...;
const int inf = 0x3f3f3f3f;//正无穷
int head[maxn], ver[maxm], Next[maxm], tot = 0;
double edge[maxm];
void add(int x,int y,int z){
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
double d[maxn]; int n,m,s;
int vis[maxn], cnt[maxn];//vis记录是否在队列中,cnt记录更新了几遍。
bool spfa(int s){
queue<int> q;
for(int i = 1; i<=n; i++) d[i] = inf;
d[s] = 0; vis[s] = 1; cnt[s] = 1; q.push(s);
while(!q.empty()){
int x = q.front(); q.pop();
vis[x] = 0;
for(int i = head[x]; i; i=Next[i]){
int y = ver[i]; double w = edge[i];
if(d[y] > d[x] + w){
d[y] = d[x] + w;
cnt[y] = cnt[x] + 1;
if(cnt[y] > n) return false;
if(!vis[y]){
q.push(y);
vis[y] = 1;
}
}
}
}
return true;
}
void solve(int s){
for(int x = 1; x<=n; x++){
for(int i = head[x]; i; i = Next[i]){
edge[i] = -log(edge[i]);
}
}
if(spfa(s)) cout<<"不存在这样的货币序列"<<endl;
else cout<<"存在这样的货币序列"<<endl;
}
b,部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ..., maxm = ...;
const int inf = 0x3f3f3f3f;//正无穷
int head[maxn], ver[maxm], Next[maxm], tot = 0;
double edge[maxm];
void add(int x,int y,int z){
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
double d[maxn]; int n,m,s;
int Stack[maxn], top = 0;
int ins[maxn];//记录是否在栈中
int ans[maxn], cnt = 0;
bool spfa(int s){
ins[x] = 1; Stack[++top] = x;
for(int i = head[x]; i; i = Next[i]){
int y = ver[i]; double w = edge[i];
if(d[y] > d[x] + w){
if(ins[y]){
int t;
do{
t = Stack[top--];
ans[++cnt] = t;
}while(t!=y);
return false;
}
d[y] = d[x] + w;
}
if(!spfa(y)) return false;
}
ins[y] = 0; top--;
return true;
}
void solve(int s){
for(int x = 1; x<=n; x++){
for(int i = head[x]; i; i = Next[i]){
edge[i] = -log(edge[i]);
}
}
for(int i = 1; i<=n; i++) d[i] = inf;
d[s] = 0;
if(spfa(s)) cout<<"不存在"<<endl;
else {
while(cnt!=1)cout<<ans[cnt--]<<'->';
cout<<ans[cnt]<<endl;
}
24-6
思路:
该题与24-1思路相似,只是条件改为路径上的边权具有单调的性质。之前的结论在此仍有效,即每次松弛更新有效当且仅当产生了新的“拐点”。题目中给出限制最多只能有1个“拐点”,需要注意一点是在第一遍松弛操作可能掩盖掉上一次的拐点(在24-1中也讨论过这种情况),所以我们要做2遍操作,即4次松弛操作(因为每遍要做2次松弛操作)。
时间复杂度:
由于我们事先要对边按权值排序,所以复杂度为O(ElogE)。(松弛操作的复杂度为O(E),被省略)。
部分c++代码:
#include <bits/stdc++.h>
using namespace std;
const int maxn = ..., maxm = ...;
const int inf = 0x3f3f3f3f;//正无穷
struct E{int x,y,z;}e1[maxm],e2[maxm];
int m,n;
bool cmp1(E a,E b){return a.z < b.z;}
bool cmp2(E a,E b){return a.z > b.z;}
int d[maxn];
void update(E *e){
for(int j = 0; i<m; j++){
int x = e[j].x, y = e[j].y, z e[j].z;
dp[y] = min(dp[y],dp[x]+z);
}
}
void solve(){
memset(d,0x3f,sizoef(d));
sort(e1,e1+m,cmp1), sort(e2,e2+m,cmp2);//e1与e2开始时相同。
for(int i = 1; i<=2; i++){
update(e1), update(e2);
}
}