一、大纲
本周作业与实验题目如下:
- TT的魔法猫(Floyd算法)
- TT的旅行日记(Dijsktra算法)
- TT的美梦(SPFA负环优化)
二、逐个击破
1.TT的魔法猫
题目描述
魔法猫告诉 TT,它其实拥有一张游戏胜负表,上面有 N 个人以及 M 个胜负关系,每个胜负关系为 A B,表示 A 能胜过 B,且胜负关系具有传递性。即 A 胜过 B,B 胜过 C,则 A 也能胜过 C。
TT 不相信他的小猫咪什么比赛都能预测,因此他想知道有多少对选手的胜负无法预先得知,你能帮帮他吗?
- Input
第一行给出数据组数。
每组数据第一行给出 N 和 M(N , M <= 500)。
接下来 M 行,每行给出 A B,表示 A 可以胜过 B。
- Output
对于每一组数据,判断有多少场比赛的胜负不能预先得知。注意 (a, b) 与 (b, a) 等价,即每一个二元组只被计算一次。
题目分析
Floyd-Warshall算法是解决任意两点间的最短路径的一种经典算法,可以正确处理有向图或负权(不能有负环)的最短路径问题,同时也被用于计算有向图的传递闭包。
- 原理
设 D i , j , k D_{i,j,k} Di,j,k为从i到j的只以(1…k)集合中的节点作为中间节点的最短路径的长度。
1. 若最短路径经过点k,则 D i , j , k = D i , k , k − 1 + D k , j , k − 1 D_{i,j,k}=D_{i,k,k-1}+D_{k,j,k-1} Di,j,k=Di,k,k−1+Dk,j,k−1
2. 若最短路径不经过点k,则 D i , j , k = m i n ( D i , j , k − 1 , D i , k , k − 1 + D k , j , k ) D_{i,j,k}=min(D_{i,j,k-1},D_{i,k,k-1}+D_{k,j,k}) Di,j,k=min(Di,j,k−1,Di,k,k−1+Dk,j,k)
因此 D i , j , k = m i n ( D i , j , k − 1 , D i , k , k − 1 + D k , j , k ) D_{i,j,k}=min(D_{i,j,k-1},D_{i,k,k-1}+D_{k,j,k}) Di,j,k=min(Di,j,k−1,Di,k,k−1+Dk,j,k)
在实际算法中,为了节约空间,可以直接在原来空间上进行迭代,将空间降维为二维。
分析这道具体题目,因为胜负关系存在传递性,那么根据Floyd算法的解决目标可以知道可以求出任意两点的胜负关系(传递闭包)
- d i s [ i ] [ j ] = 1 dis[i][j]=1 dis[i][j]=1 表示 i i i 比 j j j 强
-
d
i
s
[
i
]
[
j
]
=
0
dis[i][j]=0
dis[i][j]=0 而且
d
i
s
[
j
]
[
i
]
=
0
dis[j][i]=0
dis[j][i]=0 表示胜负关系无法预先判断
则本题Floyd算法的相关代码如下:
void floyd(int n)
{
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
if(dis[i][k]!=0)
for(int j=1;j<=n;j++)
if(dis[k][j]!=0) dis[i][j] = 1;
}
然后下面是题目的全部代码:
#include<iostream>
using namespace std;
const int MAXN = 550;
int N,M,cnt,dis[MAXN][MAXN];
void floyd(int n)
{
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
if(dis[i][k]!=0)
for(int j=1;j<=n;j++)
if(dis[k][j]!=0) dis[i][j] = 1;
}
int main()
{
scanf("%d",&cnt);
getchar();
for(int i=0;i<cnt;i++)
{
int sum=0;
scanf("%d%d",&N,&M);
getchar();
for(int i=1;i<=N;i++)
for(int j=1;j<=N;j++)
dis[i][j]=0;
for(int i=0;i<M;i++)
{
int A,B;
scanf("%d%d",&A,&B);
getchar();
dis[A][B]=1;
}
floyd(N);
for(int i=1;i<=N;i++)
for(int j=i+1;j<=N;j++)
if(dis[i][j]==0 && dis[j][i]==0) sum++;
printf("%d\n",sum);
}
return 0;
}
2.TT的旅行日记(Dijsktra算法)
题目描述
TT 从家里出发,准备乘坐猫猫快线前往喵星机场。猫猫快线分为经济线和商业线两种,它们的速度与价钱都不同。当然啦,商业线要比经济线贵,TT 平常只能坐经济线,但是今天 TT 的魔法猫变出了一张商业线车票,可以坐一站商业线。假设 TT 换乘的时间忽略不计,请你帮 TT 找到一条去喵星机场最快的线路,不然就要误机了!
- Input
多组数据,对于每组测试数据:
第一行为两个整数n和m( n = m = 0 n = m = 0 n=m=0表示输入结束,不需要处理),n是学生的数量,m是学生群体的数量。 0 < n < = 3 × 1 0 4 0 < n <= 3\times10^4 0<n<=3×104 , 0 < n < = 5 × 1 0 2 0 < n <= 5\times10^2 0<n<=5×102
输入包含多组数据。每组数据第一行为 3 个整数 N, S 和 E ( 2 ≤ N ≤ 500 , 1 ≤ S , E ≤ 100 ) (2 ≤ N ≤ 500, 1 ≤ S, E ≤ 100) (2≤N≤500,1≤S,E≤100),即猫猫快线中的车站总数,起点和终点(即喵星机场所在站)编号。
下一行包含一个整数 M ( 1 ≤ M ≤ 1000 ) (1 ≤ M ≤ 1000) (1≤M≤1000),即经济线的路段条数。
接下来有 M 行,每行 3 个整数 X, Y, Z ( 1 ≤ X , Y ≤ N , 1 ≤ Z ≤ 100 ) (1 ≤ X, Y ≤ N, 1 ≤ Z ≤ 100) (1≤X,Y≤N,1≤Z≤100),表示 TT 可以乘坐经济线在车站 X 和车站 Y 之间往返,其中单程需要 Z 分钟。
下一行为商业线的路段条数 K ( 1 ≤ K ≤ 1000 ) (1 ≤ K ≤ 1000) (1≤K≤1000)。
接下来 K 行是商业线路段的描述,格式同经济线。
所有路段都是双向的,但有可能必须使用商业车票才能到达机场。保证最优解唯一。
- Output
对于每组数据,输出3行。第一行按访问顺序给出 TT 经过的各个车站(包括起点和终点),第二行是 TT 换乘商业线的车站编号(如果没有使用商业线车票,输出"Ticket Not Used",不含引号),第三行是 TT 前往喵星机场花费的总时间。
题目分析
Dijsktra算法是解决图上带权的单源最短路径问题,但不适用于带负权边的图。该算法的核心在于比较 d i s t [ u ] dist[u] dist[u] 和 d i s t [ v ] + l e n g t h ( u , v ) dist[v]+length(u,v) dist[v]+length(u,v) ,这一过程称为松弛操作,具体的操作原理见如下伪代码。由于图中只有正边(负边该算法不成立),因此每个点只会被优先级队列弹出一次,即一旦某个点被弹出,则不会再松弛, d i s t [ i ] dist[i] dist[i] 记录的就是单源最短路径。
- 伪代码
function Dijsktra(Graph,source):
dist[source]=0//初始化
create vertex priority queue Q
for(vertex v in Graph):
if v!=source
dist[v]=INF
prev[v]=UNDEFINED
Q.add_with_priority(v,dist[v]);
while(!Q.empty):
u=Q.extract_min()
for(neighber v of u):
alt=dist[u]+length(u,v)
if(alt<dist[v])
dist[v]=alt
prev[v]=u
Q.decrease_priority(v,alt)
return dist,prev
现在具体分析这一道题目,由于题目中路线添加了若干条商业线,但如果选择商业线也至多选择一条,为了保证满足这个条件,自然无法将所有的商业线作为普通路线加入到图中去进行Dijsktra的算法求取最短路径,为了解决这个问题,那么我们可以不添加商业线,然后在所有普通路线的图中分别在起点和终点进行Dijsktra求最短路径,得到 d i s 1 dis1 dis1 和 d i s 2 dis2 dis2 两个数组,可能会出现如下几种可能(未添加商业线):
- 原图已经连通,即两次求最短路径起点和终点的结果可以互相到达,且结果相同,但存在更短路径的商业线选择
- 原图已经连通,且不存在更短路径的商业线选择
- 原图起点和终点未连通,一定需要商业线进行连接
基于对于如上几种情况的思考,我们后面对于输入的每一条商业线 E d g e ( u , v , w ) Edge(u,v,w) Edge(u,v,w),取 m i n ( d i s 1 [ u ] + d i s 2 [ v ] + w , d i s 1 [ v ] + d i s 2 [ u ] + w ) min(dis1[u]+dis2[v]+w, dis1[v]+dis2[u]+w) min(dis1[u]+dis2[v]+w,dis1[v]+dis2[u]+w),然后最后再与不走商业线的情况进行比较取较小者即为最终答案所求保证至多走一次商业线的最短路径。
综上所述,该题全部解决代码如下:
#include<iostream>
#include<queue>
#include<vector>
#include<string.h>
#define inf 5*1e8
using namespace std;
const int MAXN = 500+10;
const int MAXM = 2000+10;
int N,S,E,M1,M2;
int head[MAXN],tot;
int vis[MAXN],dis1[MAXN],dis2[MAXN];
int pre1[MAXN],pre2[MAXN];
int p1,p2;
struct edge
{//链式前向星
int to,nxt,w;
}e[MAXM];
void add(int x,int y,int w)
{
e[tot].to = y;
e[tot].nxt = head[x];
e[tot].w = w;
head[x] = tot;
tot++;
}
//最大堆放负数,变为最小堆
priority_queue<pair<int ,int>>q;
void init(int n)
{//一定要初始化
tot=0,p1=0,p2=0;
for(int i=0;i<n;i++) head[i]=-1;
memset(pre1,-1,sizeof(pre1));
memset(pre2,-1,sizeof(pre2));
}
void dijsktra(int s,int *dis,int *pre)
{
while(q.size())q.pop();
for(int i=1;i<=N;i++)
vis[i] = 0,dis[i] = inf;
dis[s] = 0;
q.push(make_pair(0,s));
while(q.size())
{
int x = q.top().second;//堆顶元素,即队中dis最小值
q.pop();
//保证每个点被堆弹出一次
if(vis[x])continue;
vis[x] = 1;//x从堆中弹出过
for(int i=head[x];i!=-1;i = e[i].nxt)
{
int y = e[i].to,w = e[i].w;
if(dis[y]>dis[x]+w)
{
pre[y]=x;
dis[y] = dis[x] + w;
q.push(make_pair(-dis[y],y));//负号不要忘记
}
}
}
}
void output(int *pre,int s,int e)
{
if(s==e)
{//这里要注意
printf("%d",s);
return;
}
vector<int> path;
int i=e;
path.push_back(e);
while(pre[i]!=s)
{
path.push_back(pre[i]);
i=pre[i];
}
path.push_back(s);
for(int i=path.size()-1;i>=0;i--)
{
if(i==0)printf("%d",path[i]);
else printf("%d ",path[i]);
}
}
int main()
{
int flag=0;
while(~scanf("%d%d%d",&N,&S,&E))
{
if(flag!=0) printf("\n");
flag++;
scanf("%d",&M1);
init(MAXN);
while(M1--)
{
int X,Y,Z;
scanf("%d%d%d",&X,&Y,&Z);
add(X,Y,Z);
add(Y,X,Z);
}
dijsktra(S,dis1,pre1);
dijsktra(E,dis2,pre2);
int min_time=inf;
scanf("%d",&M2);
while(M2--)
{
int X,Y,Z;
scanf("%d%d%d",&X,&Y,&Z);
if(min_time>min(dis1[X]+dis2[Y]+Z,dis1[Y]+dis2[X]+Z))
{//不走商业线
if(dis1[X]+dis2[Y]+Z<dis1[Y]+dis2[X]+Z)
{
min_time=dis1[X]+dis2[Y]+Z;
p1=X,p2=Y;
}
else
{
min_time=dis1[Y]+dis2[X]+Z;
p1=Y,p2=X;
}
}
}
if(min_time>=dis1[E])
{//不走商业线
output(pre1,S,E);
printf("\nTicket Not Used\n");
printf("%d\n",dis1[E]);
}
else
{//走商业线
output(pre1,S,p1);
printf(" ");
int i=p2;
while(i!=E)
{
printf("%d ",i);
i=pre2[i];
}
printf("%d\n",i);
printf("%d\n%d\n",p1,min_time);
}
}
return 0;
}
3.TT的美梦(SPFA负环优化)
题目描述
在梦中,TT 的愿望成真了,他成为了喵星的统领!喵星上有 N 个商业城市,编号 1 ~ N,其中 1 号城市是 TT 所在的城市,即首都。
喵星上共有 M 条有向道路供商业城市相互往来。但是随着喵星商业的日渐繁荣,有些道路变得非常拥挤。正在 TT 为之苦恼之时,他的魔法小猫咪提出了一个解决方案!TT 欣然接受并针对该方案颁布了一项新的政策。
具体政策如下:对每一个商业城市标记一个正整数,表示其繁荣程度,当每一只喵沿道路从一个商业城市走到另一个商业城市时,TT 都会收取它们(目的地繁荣程度 - 出发地繁荣程度)^ 3 的税。
TT 打算测试一下这项政策是否合理,因此他想知道从首都出发,走到其他城市至少要交多少的税,如果总金额小于 3 或者无法到达请悄咪咪地打出 ‘?’。
- Input
第一行输入 T,表明共有 T 组数据。 ( 1 ≤ T ≤ 50 ) (1 \leq T \leq 50) (1≤T≤50)
对于每一组数据,第一行输入 N,表示点的个数。 ( 1 ≤ N ≤ 200 ) (1 \leq N \leq 200) (1≤N≤200)
第二行输入 N 个整数,表示 1 ~ N 点的权值 a[i]。 ( 0 ≤ a [ i ] ≤ 20 ) (0 \leq a[i] \leq 20) (0≤a[i]≤20)
第三行输入 M,表示有向道路的条数。 ( 0 ≤ M ≤ 100000 ) (0 \leq M \leq 100000) (0≤M≤100000)
接下来 M 行,每行有两个整数 A B,表示存在一条 A 到 B 的有向道路。
接下来给出一个整数 Q,表示询问个数。 ( 0 ≤ Q ≤ 100000 ) (0 \leq Q \leq 100000) (0≤Q≤100000)
每一次询问给出一个 P,表示求 1 号点到 P 号点的最少税费。
- Output
每个询问输出一行,如果不可达或税费小于 3 则输出 ‘?’。
题目分析
前面讲解的 Floyd 算法用动态规划的思想解决了多源最短路径问题,Dijkstra 算法也给出了对于单源最短路径问题一种不错的解法。
思考一下它们的局限性?
Floyd 算法解决的是多源最短路径问题,对于单源最短路径问题有些许小题大做
Dijkstra 算法在图中存在负权边时不能保证结果的正确性
在这种情况下,一种新的单源最短路径算法呈现在我们眼前,那就是Bellman-ford 算法及其队列优化 (SPFA),下面对于该种算法和其优化进行简单描述。Bellman-ford 算法可以给出源点 S 至图内其他所有点的最短路以及对应的前驱子图,Bellman-ford 算法的正确性基于以下事实:
- 最短路经过的路径条数小于图中点的个数(若大于等于点的个数,则意味着存在某点被重复经过)
- 当松弛边 (u, v) 时,如果 dis[u] 已经是最短路且 u 在 v 的最短路上,则松弛结束后 dis[v] 也是最短路,并且从此以后其 dis 值不会发生变化
其具体操作为对图中的每一条边进行松弛操作,由于最短路的路径条数 E s Es Es 小于点的个数 P s Ps Ps ,故松弛点数 P s − 1 Ps-1 Ps−1轮即可,第 i i i 轮松弛完毕后,所有经过 i i i条边的最短路均被确定,其伪代码如下:
for(int i=1;i<=n;i++)
{
dis[i]=INF;
pre[i]=0;
}
dis[s]=0;
for(int k=1;k<n;k++)
for(int i=1;i<=m;i++)
if(dis[v[i]]>dis[u[i]+w[i]])
{
dis[v[i]]=dis[u[i]]+w[i];
pre[v[i]]=u[i];
}
for(int i=1;i<=m;i++)
{
if(dis[v[i]]>dis[u[i]]+w[i])
//存在负环,进行处理
}
Bellman-ford 算法完美地解决了负权边的问题,但是它的复杂度过高,堪比复杂度为
O
(
n
3
)
O(n^3 )
O(n3) 的 Floyd 算法,令人无法接受,观察 Bellman-ford 算法中的松弛过程。在第一轮松弛的时候,最短路上的第一条边被确定,在第二轮松弛时,最短路上的第二条边被确定,在第三轮松弛的时候,最短路上的第三条边被确定。在 Bellman-ford 算法中,每一轮有很多无效的松弛操作,怎样才能避免?——SPFA的引入进行算法的优化。
这样我们不妨每次只做有效的松弛操作:
- 建立一个队列
- 队列中存储被成功松弛的点
- 每次从队首取点并松弛其邻接点
- 如果邻接点成功松弛则将其放入队列
但是还有一个较为棘手的问题就是如何解决负环的问题,因为如果存在负环,那么需要经过负环上的点的最短路就一定不存在,因为可以在负环中不断减小,不难发现我们使用Bellman-ford算法的时候就基于最短路的路径条数一定小于图中点数,所以如果存在负环那么路径条数
E
s
>
=
P
s
Es>=Ps
Es>=Ps ,也就意味着如果在第
P
s
Ps
Ps次松弛操作时还存在边可以被松弛,那么图中就一定存在负环。
有了这些基础知识的铺垫,我们发现这一道题就是一道带负环的求取单源最短路的题目,所以这道题目具体的代码如下:
#include<iostream>
#include<queue>
#include<algorithm>
#include<cstring>
#define inf 1e8
using namespace std;
const int N = 200+5;
const int M = 1e5+5;
int w[N],vis[N],cnt[N],dis[N];
//vis-点在不在队列中
//从cnt[x]-点x最短路径上的边数
//dis[x]-距离
int head[N],tot,n;
bool pas[N];
struct edge
{
int to,nxt,w;
}e[M];
void add(int x,int y,int w)
{
e[tot].to = y;
e[tot].nxt = head[x];
e[tot].w = w;
head[x] = tot;
tot++;
}
void init(int n)
{
tot=0;
for(int i=0;i<n;i++)
{
head[i]=-1;
dis[i]=inf;
pas[i]=false;
}
memset(vis,0,sizeof(vis));
memset(cnt,0,sizeof(cnt));
}
void dfs(int s)
{//用于标记负权影响的边
pas[s]=true;
for(int i=head[s];i!=-1;i=e[i].nxt)
if(!pas[e[i].to])
dfs(e[i].to);
}
queue<int> q;
void spfa(int s)
{
//队列中加入初始点
dis[s] = 0,vis[s] = 1;
q.push(s);
while(!q.empty())
{
int x = q.front(); q.pop();
//x出队列,vis[x] = 0
vis[x] = 0;
//松弛操作
for(int i=head[x];i!=-1;i = e[i].nxt)
{
int y = e[i].to;
if(dis[y]>dis[x]+e[i].w)
{
dis[y] = dis[x] + e[i].w;
cnt[y] = cnt[x] + 1;
if(cnt[y] >=n) dfs(y); //标记负环影响的点
if(!vis[y] && !pas[y])
{
vis[y] = 1;
q.push(y);
}
}
}
}
}
int main()
{
int t,sum=1;
scanf("%d",&t);
while(t--)
{
scanf("%d",&n);
int m,q;
for(int i=1;i<=n;i++) scanf("%d",&w[i]);
scanf("%d",&m);
init(N);//初始化一定要放在add()之前 !!!
while(m--)
{
int A,B;
scanf("%d%d",&A,&B);
int d=w[B]-w[A];
add(A,B,d*d*d);
}
//SPFA
spfa(1);
scanf("%d",&q);
printf("Case %d:\n",sum);
sum++;
while(q--)
{
int p;
scanf("%d",&p);
if(dis[p]<3 || dis[p]==inf || pas[p]) printf("?\n");
else printf("%d\n",dis[p]);
}
}
return 0;
}