最短路笔记

好用的

算法

记号

为了方便叙述,这里先给出下文将会用到的一些记号的含义。

  • \(n\) 为图上点的数目,\(m\) 为图上边的数目;
  • \(s\) 为最短路的源点;
  • \(D(u)\)\(s\) 点到 \(u\) 点的 实际 最短路长度;
  • \(dis(u)\)\(s\) 点到 \(u\) 点的 估计 最短路长度。任何时候都有 \(dis(u) \geq D(u)\)。特别地,当最短路算法终止时,应有 \(dis(u)=D(u)\)
  • \(w(u,v)\)\((u,v)\) 这一条边的边权。

\(Dijkstra\)

注:在使用此算法时应默认存在条件:图的边权非负

算法思想

贪心或\(DP\)

算法流程

把当前这个图分为两个集合,一个为已确定最短路的\(S\)集合,另一个为没有确定最短路的\(T\)集合。算法最开始时所有节点都在\(T\)集合,\(dis(s)=0\),其余节点均设为正无穷。每次从\(T\)集合取出\(dis\)值最小的节点\(u\)把它放入\(S\)集合中,确定这个节点的最短路\(D(u)=dis(u)\),并枚举它的相邻节点\(v\)更新\(dis(v)\)值,方式为\(dis(v)=min(dis(v),dis(u)+w(u,v))\),直至\(T\)集合为空。

正确性证明

证明的命题为:每个节点只会更新一次,即每次从\(T\)集合取出的\(dis\)值最小的点\(u\),一定有\(D(u)=dis(u)\)

从正面直接攻破貌似很困难,考虑反证法。假设存在路径使得点\(u\)满足\(D(u)<dis(u)\)。那么显然这条更短的路径与当前路径存在有至少一个点不一样。

case1: 路径\(s→...→v2→...→u\)上所有点都在\(S\)集合中

设最后路径\(s→...→v2→...→u\)\(u\)的上一个点为\(x_1\),路径\(s → ... →v1→...→u\)\(u\)的上一个点为\(x_2\),因为\(S\)集合的每个点都更新过其他节点,也就是每个节点的\(D\)值都确定下来了,所以\(x_1\)\(x_2\)都更新过\(u\)的最短路径了,故这种情况不存在。

case2:路径\(s→...→v2→...→u\)上部分点都在\(T\)集合中,且第一个在\(T\)集合中的点为\(v_2\)

因为\(v2\)\(T\)集合,所以\(dis(v2)>dis(u)\),然而最终因为\(v_2\)\(u\)之前且边权非负,所以\(D(v2)<D(u)\),因为在\(S\)集合里的点都更新了\(dis(v2)\),剩下\(T\)集合的一些点\(k\),满足\(dis(u) \leq dis(k)<dis(v2)\)都有可能会将\(dis(v2)\)的值变小,但因为边权非负,所以最终更新完后\(dis(v2)\)的值一定不小于\(dis(u)\),同理其余剩下在集合\(T\)中的点\(x\),满足\(dis(x)>dis(v2)\),经过更新也一定满足\(dis(x)\)一定不小于\(dis(u)\),从而无法在更新使得 \(dis(v2)\)小于\(dis(u)\)。所以最终得到\(D(v2) \ge D(u)\)与上述\(D(v2)<D(u)\)矛盾,故这种情况也不存在。

故得证,每次从\(T\)集合取出\(dis\)值最小的一个点\(u\)一定满足\(D(u)=dis(u)\)

推理

dijkstra第k次从T集合取出的点为到起点第k小(非严格)的节点

证明:

考虑算法流程,每次从未求出最短路径的点集中取出距离起点最近的点,然后以这个点为跳板刷新其他点才符合贪心(或DP)的证明,所以是的。当然,非严格。

小优化

只要搜到终点后就停止搜索,正确性显然。

时间复杂度分析

朴素的\(Dijkstra\)共处理\(n\)个点,每次用\(O(n)\)的复杂度处理暴力枚举出在集合\(T\)的最小值,故总时间复杂度为\(O(n^2)\)

朴素\(Dijkstra\)代码实现

void dijkstra(int s)
{
	memset(dis,0x3f,sizeof(dis));
	dis[s]=0;
	for(int i=1;i<=n;i++)
	{
		int u=0,res=0x3f3f3f3f;
		for(int j=1;j<=n;j++)
			if(!vis[j]&&dis[j]<res)res=dis[j],u=j;
		vis[u]=1;
		for(int j=head[u];j;j=nxt[j])
		{
			int v=to[j];
			if(dis[v]>dis[u]+res)dis[v]=dis[u]+res;
		}
	}
}

我们发现维护最大值可以用优先队列来维护,所以采用\(STL\)\(priority\)来进行编码。此时共计\(O(m)\)次修改,\(O(n)\)次删除堆顶元素,因为不支持删除操作,所以每个点的最短路被更新多次,堆中元素个数为\(m\)个。所以时间复杂度为\(O((n+m)logm)\)但因题目常给的是\(n,m\)同级,所以时间复杂度为\(O(mlogm)\)

\(STL\)优先队列优化\(Dijkstra\)代码实现

struct node{
	int val,pos;
	bool operator>(const node &x)const{
		return val>x.val;
	}
};
priority_queue<node,vector<node>,greater<node> >q;
void dijkstra(int s)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	dis[s]=0;
	q.push(node{dis[s],s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
		vis[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[t.pos]+w[i])
			{
				dis[v]=dis[t.pos]+w[i];
				q.push(node{dis[v],v});
			}
		}
	}
	return;
}

\(Bellman-Ford / SPFA\)

\(Bellman–Ford\) 算法是一种基于松弛(\(relax\))操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。

\(Bellman–Ford\)算法流程

对于边 \((u,v)\),松弛操作对应下面的式子:\(dis(v) = \min(dis(v), dis(u) + w(u, v))\)

这么做的含义是显然的:我们尝试用 \(S \to u \to v\)(其中 \(S \to u\) 的路径取最短路)这条路径去更新 \(v\) 点最短路的长度,如果这条路径更优,就进行更新。

Bellman–Ford 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。

每次循环是 \(O(m)\) 的,那么最多会循环多少次呢?

在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 \(+1\),而最短路的边数最多为 \(n-1\),因此整个算法最多执行 \(n-1\) 轮松弛操作。故总时间复杂度为 \(O(nm)\)

但还有一种情况,如果从 \(S\) 点出发,抵达一个负环时,松弛操作会无休止地进行下去。注意到前面的论证中已经说明了,对于最短路存在的图,松弛操作最多只会执行 \(n-1\) 轮,因此如果第 \(n\) 轮循环时仍然存在能松弛的边,说明从 \(S\) 点出发,能够抵达一个负环。

代码实现

struct edge {
    int v, w;
};        
vector<edge> e[maxn];
int dis[maxn];
const int inf = 0x3f3f3f3f;      
bool bellmanford(int n, int s) {
    memset(dis, 63, sizeof(dis));
    dis[s] = 0;
    bool flag;
    for (int i = 1; i <= n; i++) {
    	flag = false;
        for (int u = 1; u <= n; u++) {
            if (dis[u] == inf) continue;
            for (auto ed : e[u]) {
                int v = ed.v, w = ed.w;
                if (dis[v] > dis[u] + w) {
                  	dis[v] = dis[u] + w;
                  	flag = true;
                }
            }
        }
        if (!flag) break;
    }
    return flag;
}

算法优化

考虑只有每次更新操作更中被新的节点才会进入下一轮松弛,所以建一个队列每次从队首取出一个元素进行更新,再将更新成功的节点放入队尾。这个优化称为\(SPFA\)算法,在随机的图上平均复杂度为\(O(km)\)\(k\)一般取\(2 \sim 3\)的常数,但在网格图中还是会跑满\(O(nm)\)的复杂度,所以平时题目中没说有负权边一般都要用\(Dijkstra\)来求。 可是\(SPFA\)也有自己的优势,比如可以判负环,还可以做\(Dijkstra\)干不到的事情,因为\(Dijkstra\)每个节点只会入队更新一次,所以关于每条路径的最大值和最小值不能正确地求出来。

代码实现

void spfa(int s)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	dis[s]=0;
	queue<int> q;
	q.push(s);
	while(!q.empty())
	{
		int u=q.front();q.pop();
		vis[u]=0;
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[u]+w[i])
			{
				dis[v]=dis[u]+w[i];
				if(vis[v]==0)
				{
					vis[v]=1;
					q.push(v);
				}
			}
		}
	}
}

\(SPFA\)判负环

如果不存在负环,每个节点的最短路径(包括起点和终点)最多\(n\)个点。用利用队列的性质,每次一个节点出队后有进队,那么说明这个节点的最短路径点数一定增加\(1\),如果一个节点进队超过\(n\)次,那么说明存在从起点到达的负环。

也可以用另一种方式理解:\(s\)\(u\)的最短路上的边数一定不多于 \(n−1\) 。否则至少有一个结点被重复经过,这说明存在环,且经过该环能更新该结点的 \(dis\) 值,即存在负环。

代码实现

#include<bits/stdc++.h>
using namespace std;
const int MAXN=2005,MAXM=20005;
int n,m,cnt0;
int dis[MAXN],vis[MAXN],head[MAXN],cnt[MAXN];
struct Edge{
	int nxt,to,dis;
}edge[MAXM];
void add(int u,int v,int w)
{
	edge[++cnt0].nxt=head[u];
	edge[cnt0].to=v;
	edge[cnt0].dis=w;
	head[u]=cnt0;
}
bool spfa()
{
	queue<int> q;
	for(int i=1;i<=n;i++)
	{
		dis[i]=0x3f3f3f3f;
		vis[i]=0;
	}
	q.push(1);
	dis[1]=0;
	vis[1]=1;
	cnt[1]++;
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		vis[u]=0;
		for(int i=head[u];i;i=edge[i].nxt)
		{
			int v=edge[i].to;
			if(dis[v]>dis[u]+edge[i].dis)
			{
				dis[v]=dis[u]+edge[i].dis;
				if(vis[v]==0)
				{
					q.push(v);
					vis[v]=1;
					cnt[v]++;
					if(cnt[v]>n)return 1;
				}
			}
		}
	}
	return 0;
}
void init()
{
	memset(dis,0,sizeof(dis));
	memset(vis,0,sizeof(vis));
	memset(cnt,0,sizeof(cnt));
	memset(edge,0,sizeof(edge));
	memset(head,0,sizeof(head));
	cnt0=0;
}
int main()
{
	int T;
	scanf("%d",&T);
	init();
	for(int a=1;a<=T;a++)
	{
		init();
		scanf("%d %d",&n,&m);
		for(int i=1;i<=m;i++)
		{
			int u,v,w;
			scanf("%d %d %d",&u,&v,&w);
			add(u,v,w);
			if(w>=0)add(v,u,w);
		}
		if(spfa())printf("YES\n");
		else printf("NO\n");
	}
	return 0;
}

\(Floyd\)

算法思想

采用动态规划的思路。设\(dis[i,j,k]\)表示当只途径编号为\(1\)\(k\)的点时(起点和终点不算),\(i\)\(j\)的最短路长度。特别地,\(dis[i,j,0]\)表示初试时\(i\)\(j\)点的边权。

考虑如何进行状态更新。显然从\(i\)\(j\)只经过编号为\(1\)\(k\)的点的路径分为两类:

\(i\)\(j\),且只经过编号为\(1\)\(k-1\)的点的路径。

\(i\)\(k\),再从\(k\)\(j\),且只经过编号为\(1\)\(k-1\)点的路径。

显然对于第一类路径,最短者的长度就是\(dis[i,j,k-1]\)。对于第二类路径,最短者的长度就是\(dis[i,k,k-1]+dis[k,j,k-1]\)。两者当中取最小值,就得到转移方程:

\(dis[i,j,k]=min(dis[i,j,k-1],dis[i,k,k-1]+dis[k,j,k-1])\)

然后只需要依次枚举\(k,i,j\),并进行转移即可。最终结果即为第三维为\(n\)的数组。注意由于在第三维为\(k\)时候需要用到第三维为\(k-1\)的数据,所以在枚举时应当先枚举第三维\(k\),再枚举\(i\)\(j\)

代码非常简洁,如下所示:

for(int k=1;k<=n;k++)
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			dis[i][j][k]=min(dis[i][j][k-1],dis[i][k][k-1]+dis[k][j][k-1]);

时间复杂度\(O(n^3)\),空间复杂度\(O(n^3)\)。在时间上已经难以对该算法优化,但在空间上可以。

显然\(dis[i,k,k-1]\)\(dis[i,k,k]\)\(dis[k,j,k-1]\)\(dis[k,j,k]\)分别相等,因为对于起点或终点为\(k\)的最短路径途径编号为\(1\)\(k-1\)的点就相当于途径编号为\(1\)\(k\)的点,因为最没有环的图中,起点或终点为\(k\)的点到其它点的最短路径途径的点一定不包含\(k\),因为上文对\(dis[i,j,k]\)的定义为当只途径编号为\(1\)\(k\)的点时(起点和终点不算),\(i\)\(j\)的最短路长度,于是发现数组第三维\(k\)是多余的,可以直接用二维数组滚动来解决此题。转移方程变为:

\(dis[i][j]=min(dis[i,j],dis[i,k]+dis[k,j])\)

空间复杂度变为\(O(n^2)\),时间复杂度不变。

代码实现:

for(int k=1;k<=n;k++)
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);

\(BFS\)

你就说它能不能求最短路吧

说白了算法思想还是 \(Dijkstra\) ,只不过运用的场景被局限了。如果考场上没想到,也可以用万能的 \(Dijkstra\),复杂度也就多了个 \(log\)

它是一种由于边权相等的情况下使用,比如题目中常给的边权为\(1\)的情况,当然如果图中只有边的权值只有两种(比如边的权值为\(0\)\(1\)时),那也可以用\(BFS\)来求最短路径。

1).边权相等

本质上是类似与\(dijkstra\)算法,只不过由于边的权值一样,所以就可以只用队列\(queue\)来优化掉\(dijkstra\)算法优先队列\(priority_{}queue\)\(log\)复杂度。

算法流程

每次从队中取出队顶元素,再把更新后的节点放到队尾。这样就可以保证每次从队顶取出的节点一定是\(d\)值最小的。

注:在\(BFS\)求最短路的过程中,任意时刻都满足队列中的元素的\(d\)值是单调(也就是当前遍历到\(u\),那么\(d_u\)一定是队列里面最小的,也就是对于任意的\(v\)在队列中,都满足\(d_v \ge d_u\))的并且这个元素是满足两段性的。 两段性即为遍历这个图时的每个点在\(BFS\)树的层次的极差\(\le 1\)

代码实现:

void bfs(int s)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	queue<int> q;
	dis[s]=0;
	q.push(s);
	while(!q.empty())
	{
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[u]+w[i])
			{
				dis[v]=dis[u]+w[i];
				q.push(v);
			}
		}
	}
}

2).边权为\(0\)\(1\)

算法流程

不说了,直接上模板,大体上就是用个双端队列维护队列中\(d\)值最小的节点是谁(也就是 \(Djikstra\) 的思想)。

代码实现

void bfs(int s)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	deque<int>q;
	dis[s]=0;
	q.push(s);
	while(!q.empty())
	{
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[u]+w[i])
			{
				dis[v]=dis[u]+w[i];
				if(w[i]==0)q.push_front(v);
				else q.push_back(v);
			}
		}
	}
}

时间复杂度

显然一个点只会进队一次,整张图会被遍历一次,故时间复杂度为 \(O(n+m)\) ,所以考场上看到边权相等或为 \(1\) 或为
\(0\)\(1\) 的情况下可以用 \(BFS\) ,复杂度略比 \(Dijkstra\) 优。

题型

常用技巧

  • 求所有点到一个点的最短路,可以用反图的方式解决,脑补一下,所有点到一个点,相当于这个点反向建边倒着这走回去。
  • 二进制分组

二进制分组处理多源多汇最短路例题:

题目传送门

做法

做法\(1\)

考虑直接将\(k\)个标记点当做起点进行\(Dijkstra\)会出现问题(因为把每个标记点的\(dis\)值设为\(0\)就不会在更新了,与题意说的起点和终点均为标记点且为两个不同的点不符。)

那我们看到我们要求\(k\)个标记点的最短路,那么就设起点为\(s\)\(t\),且\(s \neq t\)\(s\)\(t\)均为标记点,\(s\)\(t\)的最短路为\(k\)个标记点的最短路的最小值。

那么最直接的做法是随机讲这\(k\)个点分为两个部分,一个部分的点的\(dis\)值设为\(0\),再进行\(Dijkstra\),最后取另一部分的\(dis\)值的最小值与答案\(ans\)进行比较。这样做的正确性即为起点在\(dis\)值为\(0\)的部分且终点在另一部分,即每次操作有$ \frac{1}{2} \times \frac{1}{2} = \frac{1}{4}$的概率正确。所以多进行几次这样的操作就行了。

做法\(2\)

做法\(2\)为做法\(1\)的优化,且正确性有保障。

在这之前先引入一个概念二进制分组,因为要使\(s\)\(t\)不相同,所以\(s\)\(t\)拆分为二进制数后至少有一位不相同。所以我们直接按照每个二进制位是\(0\)还是\(1\)进行划分即可。

但值得注意的是本题的边为有向边,所以在划分时有可能把终点的\(dis\)值设为\(0\),所以这样就显然会有问题,那就在反过来再跑一次\(Dijkstra\)就行了。

\(Code\)
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
#include <cmath>
#include <vector>
#include <queue>

#define x first
#define y second
#define mp make_pair

using namespace std;
const int N = 1e5 + 10, M = 5e5 + 10;
typedef long long ll;
typedef pair<ll,int> PLI;
const ll INF = 1e15;

struct node{
	ll d;
	int x, st;
	bool operator > (const node& t) const
	{
		return d > t.d;
	}
};


PLI d[N][2];
int v[N][2], head[N], ver[M], Next[M], edge[M], tot, n, m, k, a[N];

void add(int x, int y, int z)
{
	ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}

void dijkstra()
{
	priority_queue <node, vector<node>, greater<node> > q;
	for(int i = 1; i <= n; i++) v[i][0] = v[i][1] = 0, d[i][0] = d[i][1] = mp(INF, 0);
	sort(a + 1, a + k + 1);
	for(int i = 1; i <= k; i++)
	{
		if(a[i] == a[i - 1]) continue;
		int x = a[i];
		d[x][0] = mp(0ll, x);
		q.push(node({0, x, x}));
	}
	while(!q.empty())
	{
		node top = q.top(); q.pop();
		int x = top.x, st = top.st; ll dis = top.d;
		if((v[x][0] && v[x][1]) || (v[x][0] && st == d[x][0].y)) continue;
		if(!v[x][0])
		{
			v[x][0] = 1;
			d[x][0] = mp(dis, st);
		}
		else
		{
			v[x][1] = 1;
			d[x][1] = mp(dis, st);
		}
		for(int i = head[x]; i; i = Next[i])
		{
			int y = ver[i], z = edge[i];
			PLI t = mp(dis + z, st);
			if(t < d[y][0])
			{
				if(t.y != d[y][0].y) d[y][1] = d[y][0];
				d[y][0] = t;
				q.push(node({dis + z, y, st}));
			}
			else if(t < d[y][1] && st != d[y][0].y)
			{
				d[y][1] = t;
				q.push(node({dis + z, y, st}));
			}
		}
	}
}

int read()
{
	int x = 0, t = 1; char ch = getchar();
	while(ch < '0' || ch > '9')
	{
		if(ch == '-') t = -1;
		ch = getchar();
	}
	while(ch >= '0' && ch <= '9')
	{
		x = x * 10 + ch - '0';
		ch = getchar();
	}
	return x * t;
}

int main()
{
	int T = read();
	while(T--)
	{
		tot = 0;
		n = read(), m = read();k = read();
		for(int i = 1; i <= n; i++) head[i] = 0;
		for(int i = 1; i <= m; i++)
		{
			int x = read(), y = read(), z = read();
			add(x, y, z);
		} 
		for(int i = 1; i <= k; i++) a[i] = read();
		dijkstra();
		ll res = INF;
		for(int i = 1; i <= k; i++) res = min(res, d[a[i]][1].x);
		printf("%lld\n", res);
	}
	return 0;
}

判环

判负环

模板题

\(SPFA\),前面也介绍过。

判无向图的最小环

模板题

考虑\(Floyd\)算法的过程。当外层循环到\(k\)刚开始时,\(d[i,j]\)保存着“经过编号不超过\(k-1\)的节点”从\(i\)\(j\)的最短路长度。

于是,\(\min\limits_{1 \le i<j<k}{(d[i,j]+a[j,k]+a[k,i])}\)就是满足以下两个条件的最小环长度。

  • 有编号不超过\(k\)的节点构成。
  • 经过节点\(k\)

上式中相当于枚举了与\(k\)相邻的两个节点\(i\)\(j\),所以正确性显然。

对于所有的整数\(\forall k\in[1,n]\),都对它算出了最小环,答案取最小值即可。

\(Code\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,m,ans;
const int N=150;
int d[N][N],g[N][N];
signed main()
{
	scanf("%lld %lld",&n,&m);
	for(int i=1;i<=140;i++)for(int j=1;j<=140;j++)d[i][j]=g[i][j]=2147483648;
	for(int i=1;i<=n;i++)g[i][i]=0,d[i][i]=0;
	for(int i=1,a,b,w;i<=m;i++)
	{
		scanf("%lld %lld %lld",&a,&b,&w);
		g[a][b]=g[b][a]=min(g[a][b],w);
		d[a][b]=g[a][b],d[b][a]=g[b][a];
	} 
	ans=2147483648;
	for(int k=1;k<=n;k++)
	{
		for(int i=1;i<=n;i++)
		{
			for(int j=1;j<=n;j++)
			{
				if(i!=j&&g[i][k]>0&&g[k][j]>0)
					ans=min(ans,g[i][k]+g[k][j]+d[i][j]);
				d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
			}
		}
	}
	if(ans!=2147483648)printf("%lld\n",ans);
	else printf("No solution.\n");
	return 0;
}

判有向图的最小环

\(dijkstra\)做,对于一个起点\(s\),现将\(dis[s]=0\),先把它相邻的点松弛,再将\(dis[s]\)设为无穷大,等最后\(s\)再入对时,\(dis[s]\)的值即为有向图的最小环的长度。

最短路计数

前置知识

  • 最短路的一个很好的性质:从\(s\)\(t\)的最短路上的一个节点\(k\),都满足\(s\)\(k\)的路径是关于\(s\)单源最短路的最短路

证明:

反证法,假设\(s\)\(k\)的路径不为最短路,但\(s \to k \to t\)为到\(t\)的最短路,那么\(s \to k \to t\)的路径一定不会比\(s\)\(k\)的最短路再加上\(k \to t\)的路径优,所以\(s \to t\)就不为最短路,与上文假设条件矛盾。

故得证。

  • \(s\)\(t\)的最短路径上一定不存在环

证明:

考虑反证法,因为这个图的边权非负,所以环的权值为非负数,所以一定不会比不经过这个环更优。

故得证。

注: 上述结论是在图的边权非负时才成立的,如果图的边权为负数上述结论就不一定成立了


例题[HAOI2012] 道路

直接队每一条边进行枚举肯定会\(TLE\),但如果只计算边的贡献就好像可以优化一点时间复杂度。

\(cnt1_v\)表示从\(s\)到达\(v\)的最短路径条数,\(cnt2_v\)表示在最短路图上以\(v\)作为起点的最短路径条数。

很显然,对于一条边\(u \to v\),它的贡献就为以\(1 \sim n\)每个点作为起点\(cnt1_u \times cnt2_v\)的和。

\(cnt1\)可以在\(dijkstra\)后进行判断哪些边在最短路图上再进行\(topo\)求得,显然\(cnt1_s=1\)

\(cnt2\)可以在拓扑序的逆序上求得,对于一个点\(u\)\(cnt2_u=1+cnt2_v\),其中\(v\)\(u\)的相邻节点。

\(Code\)

#include<bits/stdc++.h>
using namespace std;
const int N=1550,M=5050;
const int mod=1e9+7;
int n,m,head[N],cnt,to[M],nxt[M],w[M],dis[N],fro[M],in[N],cnt1[N],cnt2[N],s[N],ans[M];
bool vis[N],mark[M];
void add(int u,int v,int f)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
	w[cnt]=f;
	fro[cnt]=u;
}
struct node{
	int val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
void dij(int s)
{
	priority_queue<node,vector<node>,greater<node> >q;
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	memset(mark,0,sizeof(mark));
	dis[s]=0;
	q.push(node{dis[s],s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
		vis[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[t.pos]+w[i])
			{
				dis[v]=dis[t.pos]+w[i];
				q.push(node{dis[v],v});
			}
		}
	}
	for(int i=1;i<=m;i++)
		if(dis[to[i]]==dis[fro[i]]+w[i])mark[i]=true;
	return;
}
void topo(int fs)
{
	memset(cnt1,0,sizeof(cnt1));
	memset(cnt2,0,sizeof(cnt2));
	memset(in,0,sizeof(in));
	queue<int>q;
	for(int i=1;i<=m;i++)
	{
		if(mark[i]==false)continue;
		in[to[i]]++;
	}
	q.push(fs);
	cnt1[fs]=1;
	int tag=0;
	while(!q.empty())
	{
		int x=q.front();q.pop();
		s[++tag]=x;
		for(int i=head[x];i;i=nxt[i])
		{
			if(mark[i]==false)continue;
			in[to[i]]--;
			if(in[to[i]]==0)q.push(to[i]);
			cnt1[to[i]]+=cnt1[x];
			cnt1[to[i]]%=mod;
		}
	}
	for(int i=tag;i;i--)
	{
		int x=s[i];
		cnt2[x]++;
		for(int j=head[x];j;j=nxt[j])
		{
			if(mark[j]==false)continue;
			cnt2[x]+=cnt2[to[j]];
			cnt2[x]%=mod;
		}
	}
}
int main()
{
	scanf("%d %d",&n,&m);
	for(int i=1,u,v,w;i<=m;i++)
	{
		scanf("%d %d %d",&u,&v,&w);
		add(u,v,w);
	}
	for(int i=1;i<=n;i++)
	{
		dij(i);topo(i);
		for(int i=1;i<=m;i++)
		{
//		cout<<cnt1[fro[i]]<<" "<<cnt2[to[i]]<<endl;
			if(mark[i])
			{
				ans[i]+=(cnt1[fro[i]]%mod*cnt2[to[i]]%mod)%mod;
				ans[i]%=mod;
			}
		}
	}
	for(int i=1;i<=m;i++)printf("%d\n",ans[i]);
	return 0;
}

时间复杂度\(O(nmlog~m)\)

分层图最短路

概述

分层图最短路,如:有 \(k\) 次零代价通过一条路径,求总的最小花费。对于这种题目,我们可以采用 \(DP\) 相关的思想,设 \(\text{dis}_{i, j}\)表示当前从起点 \(i\) 号结点,使用了 \(j\) 次免费通行权限后的最短路径。显然,\(\text{dis}\) 数组可以这么转移:

\(dis_i,_j = min(min(dis _v , _\text{j-1}if(j \leq k) ),min(dis _v,_\text{j}+w),dis _i,_j)\)

其中\(v\)表是与\(i\)相邻的节点,\(w\)表示经过这条边的边权,特别地,如果\(j > k\),那么\(dis _v,_\text{j}=\infty\)

概念理解:分层图最短路往往是与\(DP\)思想结合的体现,它适用于求最短路性质的问题但加了额外限制,在考场上如果要想作对做这类题,必须先发现这道题隐藏条件和题目意思与最短路有关(不要小看这一步,因为往往最开始就没想到这样做,导致后面的结果是错误的),再思考怎样对题目进行图论建模。

例题

\(1\)

Telephone Lines

做法

简单来说,本题是在无向图上求出一条从\(1\)\(N\)的路径,使路径上第\(K + 1\)大的边权尽量小。

可以仿照前面所说的方法,用\(D[x,p]\)表示从\(1\)号节点到达基站\(x\),途中已经指定了\(p\)条电缆免费时,经过的路径最贵的电缆的话费最小是多少(也就是选择一条从\(1\)\(x\)的路径,使路径上第\(p+1\)大的边权尽量少)。若有一条从\(x\)\(y\)长度为\(z\)的无向边,则应该用\(max(D[x,p],z)\)更新\(D[y,p]\)的最小值,用\(D[x,p]\)更新\(D[y,p+1]\)的最小值。前者表示不在电缆\((x,y,z)\)上使用免费升级服务,后者表示使用。

显然,我们刚才设计的状态转移是有后效性的(因为本题是按照动态规划的思想解决的,动态规划对状态空间的遍历构成一张有向无环图。注意一定是有向无环图!遍历顺序就是该有向无环图的一个拓扑序。 有向无向图中的节点对应问题中的“状态”,图中的边对应状态之间的“转移”,转移的选取就是动态规划中的“决策”。 在本题中,比如有三个点 \(a,b,c\),构成一个环,那么用\(dijkstra\)更新就会发生当前更新的值不是最终的值,那么转移时就会出现问题。)。在有后效性时,一种解决方案是利用迭代思想,借助\(SPFA\)算法进行动态规划, 直至所有状态收敛(不能再更新)。

从最短路径问题的角度去理解,图中的节点也不仅限于“整数编号”,可以扩展到二维,用二元组\((x,p)\)代表一个节点,从\((x,p)\)\((y,p)\)有长度为\(z\)的边,从\((x,p)\)\((y,p+1)\)有长度为\(0\)的边。\(D[x,p]\)表示起点\((1,0)\)到节点\((x,p)\),路径上最长的边最短是多少。这是\(N*K\)个点,\(P*K\)条边的广义最短路径问题。对于非精心构造的数据,\(SPFA\)算法的时间复杂度为\(O(tNP)\),其中\(t\)为常数,实际测试可以\(AC\)。本题也让我们进一步领会了动态规划与最短路的共通性。

\(Code\)
#include<bits/stdc++.h>
using namespace std;
#define int long long
int n,p,k;
const int N=1000000+10,M=10000000+10;
int head[N],to[M],nxt[M],cnt,w[M],dis[N];
struct node{
	int val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
void add(int u,int v,int f){
	to[++cnt]=v;
	w[cnt]=f;
	nxt[cnt]=head[u];
	head[u]=cnt;
	return;
}
priority_queue<node,vector<node>,greater<node> >q;
bool v[N];
void dijkstra(){
	memset(dis,0x3f,sizeof(dis));
	dis[1]=0;
	q.push(node{0,1});
	while(!q.empty()){
		node t=q.top();q.pop();
		if(v[t.pos])continue;
		v[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i]){
			int v=to[i],z=max(dis[t.pos],w[i]);
			if(dis[v]>z){
				dis[v]=z;
				q.push(node{dis[v],v});
			}
		}
	}
	return;
}
signed main(){
	scanf("%lld %lld %lld",&n,&p,&k);
	for(int i=1,x,y,z;i<=p;i++){
		scanf("%lld %lld %lld",&x,&y,&z);
		add(x,y,z);add(y,x,z);
		for(int j=1;j<=k;j++){
			add(x+(j-1)*n,y+j*n,0);
			add(y+(j-1)*n,x+j*n,0);
			add(x+j*n,y+j*n,z);
			add(y+j*n,x+j*n,z);
		}
	}
	dijkstra();
	int ans=1e18;
	for(int i=0;i<=k;i++){
		ans=min(ans,dis[n+i*n]);
	}
	if(ans==1e18)printf("-1\n");
	else printf("%lld\n",ans);
	return 0;
}

\(2\)

飞行路线

做法

由于购买机票不需要花钱,所以肯定不会多次重复乘坐同样的航线或者多次访问到同一个城市。如果\(k=0\)本题就是最基础的最短路问题。但题目中说明了对有限条边设置为免费),可以使用分层图的方式,将图多复制\(k\)次,原编号为\(i\)的节点复制为编号\(i+jn(1 \le j \le k)\)的节点,然后对于原图存在的边,第\(j\)层和第\(j+1\)层的对应节点也需要连上,看起来就是相同的图上下堆叠起来。

\(Code\)
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+50,M=2200050;
int head[N*12],cnt,nxt[M],to[M],w[M],n,m,k,s,t,dis[N*12];
void add(int u,int v,int f)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	w[cnt]=f;
	head[u]=cnt;
} 
struct node
{
	int val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
priority_queue<node,vector<node>,greater<node> >q;
bool b[N*12];
void dij()
{
	memset(dis,0x3f,sizeof(dis));
	dis[s]=0;
	q.push(node{dis[s],s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(b[t.pos])continue;
		b[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[t.pos]+w[i])
			{
				dis[v]=dis[t.pos]+w[i];
				q.push(node{dis[v],v});
			}
		}
	}
	return;
}
int main()
{
	scanf("%d %d %d",&n,&m,&k);
	scanf("%d %d",&s,&t);
	for(int i=1,u,v,w;i<=m;i++)
	{
		scanf("%d %d %d",&u,&v,&w);
		add(u,v,w);add(v,u,w);
		for(int j=1;j<=k;j++)
		{
			add(u+j*n,v+j*n,w);
			add(v+j*n,u+j*n,w);
			add(u+j*n-n,v+j*n,0);
			add(v+j*n-n,u+j*n,0);
		}
	}
	dij();
	int ans=0x3f3f3f3f;
	for(int j=0;j<=k;j++)
		if(dis[t+j*n]<ans)
			ans=dis[t+j*n];
	printf("%d\n",ans);
	return 0;
}

\(3\)

考试真题。

做法

这道题就是一个很显然的二维最短路(特殊的分层图最短路),设 \(dis[i][j]\) 表示到达点 \(i\) 且当前的状态为 \(j\) 的最少代价。其中 \(j=0\) 时表示状态为 \(L\)\(j=1\) 时表示状态为 \(R\)

很显然可以用 \(dijkstra\) 来求解,转移方程为 \(dis[v][v.type] = dis[u][u.type] + w(u.type==v.type)\)

也可以为 \(dis[v][u.type] = dis[u][u.type] + w(v.type==M)\)

还有种情况 \(dis[v][v.type] = dis[u][u.type] + w + x(v.type\ne u.type ~~ and ~~ v.type \ne 2)\)

答案为 \(min(dis[t][0],dis[t][1])\)

\(Code\)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=2e5+50;
int T,cnt;
int n,m,s,t,x;
int head[N];
struct edge{
	int to,nxt,w;
}e[N*4];
void add(int u,int v,int f)
{
	e[++cnt].to=v;
	e[cnt].nxt=head[u];
	head[u]=cnt;
	e[cnt].w=f;
	return;
}
string ss;
bool vis[N][4];
int dis[N][4];
struct node{
	int val,pos,type;
	bool operator >(const node &x)const
	{
		return val>x.val;
	}
};
void dij(int S)
{
	for(int i=1;i<=n;i++){
		for(int j=0;j<=2;j++)
		{
			dis[i][j]=1e18;
			vis[i][j]=0;
		}
	}
	if(ss[S]=='L')dis[S][0]=0;
	else if(ss[S]=='R')dis[S][1]=0;
	else if(ss[S]=='M')dis[S][0]=0,dis[S][1]=0;
	priority_queue<node,vector<node>,greater<node> >q;
	q.push(node{dis[S][0],S,0});q.push(node{dis[S][1],S,1});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos][t.type])continue;
		vis[t.pos][t.type]=1;
		for(int i=head[t.pos];i;i=e[i].nxt)
		{
			int v=e[i].to;
			int op=-1;
			if(ss[v]=='L')op=0;
			else if(ss[v]=='R')op=1;
			else if(ss[v]=='M')op=2;
			if(op==t.type&&dis[v][op]>dis[t.pos][t.type]+e[i].w)
			{
				dis[v][op]=dis[t.pos][t.type]+e[i].w;
				q.push(node{dis[v][op],v,op});
			}
			else if(op==2&&dis[v][t.type]>dis[t.pos][t.type]+e[i].w)
			{
				dis[v][t.type]=dis[t.pos][t.type]+e[i].w;
				q.push(node{dis[v][t.type],v,t.type});
			}
			else if(dis[v][op]>dis[t.pos][t.type]+x+e[i].w)
			{
				dis[v][op]=dis[t.pos][t.type]+e[i].w+x;
				q.push(node{dis[v][op],v,op});
			}
		}
	}
	return;
}
signed main()
{
	scanf("%lld",&T);
	while(T--)
	{
		cnt=0;
		scanf("%lld %lld %lld %lld %lld",&n,&m,&s,&t,&x);
		for(int i=1;i<=n+50;i++)head[i]=0;
		cin>>ss;
		ss=" "+ss;
		for(int i=1,u,v,w;i<=m;i++)
		{
			scanf("%lld %lld %lld",&u,&v,&w);
			add(u,v,w);add(v,u,w);
		}
		dij(s);
		printf("%lld\n",min(dis[t][0],dis[t][1]));
	}
	return 0;
}

补图最短路

是一种很套路的考法,一般做法的主题思想都是不变的。

补图的定义

补图是相对于完全图定义的, 对于一个图\(G\),存在有一个与图\(G, n\)个节点完全相同的完全图\(K_n\),图\(G\)的补图即为将\(K_n\)的边集再减去图\(G\)的边集图\(G'\),则称\(G'\)为图\(G\)的补图。

通俗的理解即为有\(n\)个节点的\(G\),现将它的边都删除掉,再连接剩下的每个点与其他点的边(如果这条边在原图存在,那就不连接)。新的图即为原来的图。

所以补图的有一个性质为点还是原图的点,但边完全不一样。

补图最短路

前置条件:补图中的边权必须相等

补图最短路在补图求最短路,但如果原图的\(n\)很大,\(m\)很小,则相当于原图为一个稀疏图,那么它的补图的边数就很多,导致直接求最短路时间上不能接受。

一种常见的做法为创建两个\(set\),\(S_1\),\(S_2\)\(S_1\)表示补图上能到达且没有更新的点,\(S_2\)表示补图上不能到达且没有更新的点,也就是相邻的节点。

然后在原图跑最短路,遍历到一个点\(u\),它的相邻节点为\(v\),那么如果\(S_1\)存在\(v\),就把它从\(S_1\)中删除,再放到\(S_2\)中。剩下\(S_1\)中的点即为不能直接与\(u\)相通的节点,那么就证明在补图中\(u\)和这些点一定有边连接,所以再在\(S_1\)中更新这些点与\(u\)的最短路,注意这里可以直接删除,因为\(u\)当前是\(dis\)值最小的点,所以更新这些没有更新的点后,这些点的\(dis\)一定定下来了,所以可以直接删去。终止条件即为\(S_2\)为空,即与\(u\)相邻且没有更新的节点没有了,那么就说明\(u\)为最后一个更新的节点,因为\(S_1\)为空且\(S_2\)也为空。

习题\(1\)Sparse Graph

\(Code\)
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+50,M=2e4+50;
bool vis[N];
int dis[N],T,n,m,s,head[N],cnt,nxt[M*2],to[M*2],del[N];
set<int> sx[2];
bool f=0;
void add(int u,int v)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
}
void bfs(int x)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	dis[x]=0;
	queue<int> q;
	q.push(x);
	while(!q.empty())
	{
		int u=q.front();q.pop();
		if(vis[u])continue;
		vis[u]=1;
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(sx[f].find(v)!=sx[f].end())
			{
				sx[f].erase(v);
				sx[!f].insert(v);
			}
		}
		int res=0;
		for(auto v:sx[f])
		{
//			if(dis[v]>dis[u]+1)
//			{
				dis[v]=dis[u]+1;
				q.push(v);
//			}
			del[++res]=v;
		}
		for(int i=1;i<=res;i++)sx[f].erase(del[i]);
		f=!f;
		if(sx[f].size()==0)break;//还没有更新且与u相邻的节点数量为0 
	}
	
}
int main()
{
	scanf("%d",&T);
	while(T--)
	{
		cnt=0;memset(head,0,sizeof(head));f=0;
		scanf("%d %d",&n,&m);
		for(int i=1,u,v;i<=m;i++)
		{
			scanf("%d %d",&u,&v);
			add(u,v);add(v,u);
		}
		for(int i=1;i<=n;i++)sx[f].insert(i);
		scanf("%d",&s);
		sx[f].erase(s);
		bfs(s);
		for(int i=1;i<=n;i++)
		{
			if(i!=s)
			{
				if(dis[i]==0x3f3f3f3f)printf("-1 ");
				else printf("%d ",dis[i]);
			}
		}
		printf("\n");
	}
	return 0;
} 

习题\(2\)神秘力量

算法:二维最短路+补图最短路

和上一题一样,这题做法为在正图上跑最短路,然后再在补图进行更新,很显然,如果上一条边为普通边,那就直接普普通通地进行更新即\(dis_u=min(dis_v+w,dis_u)\),如果上一条边为特殊边,那就将它相邻的点进行更新,即\(dis_u=min(dis_v+w-k,dis_u)\),然后在\(S_1\)里面删去\(u\),将它放入\(S_2\)里面,最后更新完后\(S_1\)里的点即为不与\(v\)相邻的点,再将\(S_1\)的点全部更新最后直接删去。

为什么可以直接删去?,因为边权为\(0\)而且当前点\(v\)的最短路已经定下来了,所以直接更新\(dis\)值再删去同样满足这个点不会再被更新。

具体实现过程为:
\(dis_{u,0}\)表示到\(u\)的上一条边为普通边,\(dis_{u,1}\)表示到\(u\)的上一条边为特殊边。所以对于当前从优先队列中取出并更新的点\(u\)

  • 如果是通过\(dis_{u,0}\)出队的,那么对于\(u\)的相邻节点\(v\),如果连接的边为普通边\(dis_{v,0}=min(dis_{v,0},dis_{u,0}+w)\),而如果为特殊边则有\(dis_{v,1}=min(dis_{v,1},dis_{u,0}+w)\)

  • 如果是通过\(dis_{u,1}\)出队的,那么对于\(u\)的相邻节点\(v\),如果连接的边为普通边\(dis_{v,0}=min(dis_{v,0},dis_{u,1}+w-k)\),而如果为特殊边则有\(dis_{v,1}=min(dis_{v,1},dis_{u,1}+w-k)\),然后在\(S_1\)里面删去\(v\),把它放入\(S_2\)中。最后遍历\(S_1\)中的元素,直接进行更新\(dis_{v,0}=dis_{u,1}\),再删去。

\(Code\)
#include<bits/stdc++.h>
using namespace std;
const int N=2e6+50,M=2e6+50;
int T,n,m,s,k;
int head[N],to[M],nxt[M],cnt,w[M],type[M],D[N];
long long dis[N][2];
bool vis[N][2];
void add(int u,int v,int f,int t)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
	w[cnt]=f;
	type[cnt]=t;
}
struct node{
	int val,type,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
void dij(int ss)
{
	priority_queue<node,vector<node>,greater<node> >q;
	dis[ss][0]=0;
	q.push(node{dis[ss][0],0,ss});
	bool f=0;
	set<int> S[2];
	//	int res=0x3f3f3f3f,pos=0;
	for(int i=1;i<=n;i++)S[f].insert(i);
	S[f].erase(ss);
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos][t.type])continue;
		vis[t.pos][t.type]=1;
		//cout<<t.pos<<" "<<t.type<<endl;
		int type1=t.type,u=t.pos;
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(type1==1&&S[f].find(v)!=S[f].end())
			{
				S[f].erase(v);
				S[!f].insert(v);
			}
			if(type1==1)
			{
				if(type[i]==1)
				{
					if(dis[v][1]>dis[u][type1]+w[i]-k)
					{
						dis[v][1]=dis[u][type1]+w[i]-k;
						q.push(node{dis[v][1],1,v});
					}
				}
				else
				{
					if(dis[v][0]>dis[u][type1]+w[i]-k)
					{
						dis[v][0]=dis[u][type1]+w[i]-k;
						q.push(node{dis[v][0],0,v});
					}
				}
			}
			else
			{
				if(type[i]==1)
				{
					if(dis[v][1]>dis[u][type1]+w[i])
					{
						dis[v][1]=dis[u][type1]+w[i];
						q.push(node{dis[v][1],1,v});
					}
				}
				else
				{
					if(dis[v][0]>dis[u][type1]+w[i])
					{
						dis[v][0]=dis[u][type1]+w[i];
						q.push(node{dis[v][0],0,v});
					}
				}
			}
		}
		if(type1==1)
		{
			int res=0;
			for(auto x:S[f])
			{
				if(dis[x][0]>dis[u][type1])
				{
					dis[x][0]=dis[u][type1];
					q.push(node{dis[x][0],0,x});
				}
				D[++res]=x;
			}
			for(int i=1;i<=res;i++)S[f].erase(D[i]);
			f=!f;
			if(S[f].size()==0)break;
		}
		
	}
	return;	
}
int main()
{
	scanf("%d",&T);
	while(T--)
	{
		scanf("%d %d %d %d",&n,&m,&s,&k);
		cnt=0;
		for(int i=0;i<=n+50;i++)
		{
			head[i]=0;
			dis[i][0]=dis[i][1]=1e18;
			vis[i][0]=vis[i][1]=0;
		}
		for(int i=1,u,v,w,t;i<=m;i++)
		{
			scanf("%d %d %d %d",&u,&v,&w,&t);
			add(u,v,w,t);
		}
		dij(s);
		for(int i=1;i<=n;i++)
		{
			if(dis[i][0]==1e18&&dis[i][1]==1e18)printf("-1 ");
			else printf("%lld ",min(dis[i][0],dis[i][1]));
		}
		printf("\n");
	}
	return 0;
}

杂题

\(1\)(最短路+拓扑排序)

题目传送门

解法

由题可知,一个城市\(u\)保护城市\(v\),所以建一条边\(u \to v\)表示城市\(u\)保护城市\(v\),因为题目说保证有解,所以建的图一定是一个有向无环图\(DAG\) 。再在此基础上求出最短路径。

具体过程为设\(dis_u\)表示实际到达(攻破)\(u\)的最短时间,\(arrive_u\)表示到达\(u\)的时间(注意\(arrive_u \neq dis_u\),而是\(arrive_u \le dis_u\))。然后考虑怎么转移,我们发现\(arrive_u\)只会影响一个值就是\(dis_u\),而\(dis_u\)能影响其他节点的\(arrive\)\(dis\)值。

所以先考虑\(arrive_u\)的转移:

  • \(arrive_u=\min(dis_v,arrive_u)\),其中\(v\)为与\(u\)相邻的节点。显然\(arrive_v\)不能更新\(dis_u\),因为\(arrive_v\)表示只是到达了\(v\)点,但可能还没有进入\(v\)点,所以不能这样更新。

然后再来考虑\(dis_u\)的转移:

  • \(dis_u=\max(arrive_u,dis_v)\)\(v\)表示保护\(u\)的节点编号。如果\(arrive_u \le dis_v\)表明到达\(u\)点可能还没有进入的最早时间比攻破\(v\)点的时间早,那么显然此时\(dis_u\)应该由\(dis_v\)决定。如果\(arrive_u \ge dis_v\)表明到达\(u\)点可能还没有进入的最早时间比攻破\(v\)点的时间晚,而因为此时保护\(u\)点的节点都被攻破了,所以由\(arrive_u\)来决定,即\(dis_u=arrive_u\)

转移方程想出来了,于是现在考虑用怎样的顺序能正确更新\(dis_u\)\(arrive_u\)。我们发现上述过程与\(dijkstra\)算法的过程很相似,如果我们也每次取出\(dis\)值最小的点来进行更新就行。(为什么可以直接取出\(dis\)值最小的点进行更新?因为类似于\(djikstra\)的证明一定不存在另外的路径使得\(dis\)值再变小。)于是我们就可以用\(dijkstra+topo\)来实现啦。

\(Code\)

#include<bits/stdc++.h>
using namespace std;
const int N=3050,M=70050;
long long dis[N],arrive[N],into[N];
int n,m;
int head[N],to[M],w[M],nxt[M],cnt,in[N];
bool vis[N];
vector<int> g[N];
void add(int u,int v,int f)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	w[cnt]=f;
	head[u]=cnt;
}
struct node{
	long long val;int pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
void dij(int s)
{
	for(int i=1;i<=n;i++)dis[i]=arrive[i]=1e18;
	dis[s]=into[s]=arrive[s]=0;
	priority_queue<node,vector<node>,greater<node> >q;
	q.push(node{0,s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
//		cout<<t.pos<<endl;
		vis[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(arrive[v]>dis[t.pos]+w[i])
			{
				arrive[v]=dis[t.pos]+w[i];
				if(!in[v])
				{
					dis[v]=max(into[v],arrive[v]);
					q.push(node{dis[v],v});
//					cout<<v<<endl;
				}
			}
		}
		for(auto v:g[t.pos])
		{
			in[v]--;
			into[v]=max(into[v],dis[t.pos]);
			if(!in[v])
			{
				dis[v]=max(arrive[v],into[v]);
				q.push(node{dis[v],v});
			}
		}
	}
	return;
}
int main()
{
	scanf("%d %d",&n,&m);
	for(int i=1,u,v,w;i<=m;i++)
	{
		scanf("%d %d %d",&u,&v,&w);
		add(u,v,w);
	}
	for(int i=1;i<=n;i++)
	{
		int x,v;
		scanf("%d",&x);
		while(x--)
		{
			scanf("%d",&v);
			g[v].push_back(i);
			in[i]++;
		}
	}
	dij(1);
	printf("%lld\n",dis[n]);
	return 0;
}

\(2\)\(dfs\)判环+简单\(dp\)+单源最短路)

题目传送门

解法

建出反图,再在正图上算出以\(n\)终点的最短路,设\(dis_u\)表示\(u\)\(n\)的最短路径,\(f[u][k]\)表示\(dis(u,n)=dis_u+k\)的方案数。

考虑怎么转移\(f[u][k]\),考虑反图上的一条边\(v \to u\),那么如果在正图上经过这条边到达\(n\)的最短路径为\(dis_u+k-dis_v-w\),所以\(f[u][k]\)就可以从\(f[v][dis_u+k-dis_v-w]\)转移过来。

所以方程为\(f[u][k]=\sum f[v][k+(dis_u-dis_v-w)]\)

考虑到这个方程转移边界会很复杂,可以用记忆化搜索来实现。

初始化\(f[1][0]=1\)

\(Code\)

#include<bits/stdc++.h>
#define re register int
#define fo(i,a,b) for (re i=a;i<=b;i++)
using namespace std;
const int maxn=200000+50;
const int mxn=100000+50;
int T,n,m,k,p,sum1,sum2;
bool flag;
int head[mxn],rev[mxn],dist[mxn],vis[mxn],wd[mxn][51],f[mxn][51],to1[maxn],to2[maxn],w1[maxn],w2[maxn];
int nxt1[maxn],nxt2[maxn];
void add(int x,int y,int z)
{
	to1[++sum1]=y;
	nxt1[sum1]=head[x];
	w1[sum1]=z;
	head[x]=sum1;
}
void addr(int x,int y,int z)
{
	to2[++sum2]=y;
	nxt2[sum2]=rev[x];
	w2[sum2]=z;
	rev[x]=sum2;
}
struct node{
	int val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
void dij()
{
	priority_queue<node,vector<node>,greater<node> > q;
	dist[1]=0;
	q.push(node{dist[1],1});
	while (!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
		vis[t.pos]=1;
		for (int i=head[t.pos];i;i=nxt1[i])
			if (dist[to1[i]]>dist[t.pos]+w1[i]){
				dist[to1[i]]=dist[t.pos]+w1[i];
				q.push(node{dist[to1[i]],to1[i]});	
			}
	}
}
int dfs(int u,int know)
{
	if(know>k||know<0)return 0;
	if(wd[u][know]){
		wd[u][know]=0;
		return -1;
	}
	if(f[u][know]!=-1)return f[u][know];
	wd[u][know]=1;
	long long sum=0;
	for (int i=rev[u];i;i=nxt2[i])
	{
		int tmp=dist[u]+know-w2[i]-dist[to2[i]];
		int val=dfs(to2[i],tmp);
		if(val==-1)
		{
			wd[u][know]=0;
			return -1;
		}
		sum=(sum+val)%p;
	}
	if(u==1&&know==0)sum++;
	wd[u][know]=0;
	return f[u][know]=sum;
}
int main()
{
	scanf("%d",&T);
	while (T--)
	{
		sum1=0;sum2=0;
		flag=false;
		scanf("%d%d%d%d",&n,&m,&k,&p);
		memset(f,-1,sizeof(f));
		for(int i=1;i<=n;i++)
		{
			head[i]=rev[i]=0;
			vis[i]=0;dist[i]=0x3f3f3f3f;
		}
		for(int i=1,x,y,z;i<=m;i++) 
		{
			scanf("%d %d %d",&x,&y,&z);
			add(x,y,z);
			addr(y,x,z);
		}
		dij();
		long long ans=0;
		for(int i=0;i<=k;i++)
		{
			int val=dfs(n,i);
			if(val==-1)
			{
				puts("-1");
				flag=true;
				break;
			}
			ans=(ans+val)%p;
		}
		if (!flag)printf("%lld\n",ans);
	}
	return 0;
}

\(3\)(排序+多次单源最短路)

题目传送门

解法

对所有边进行排序,然后只用取前\(k\)小的边因为不选的话会经过不是边权前\(k\)小的边,就一定不会比不经过不是前\(k\)小的边更优)。

然后对于这\(2\times k\)个点进行最短路,用个堆维护\(dis\)值第\(k\)小的数就行了。

\(Code\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=2e5+50;
int n,m,k,dis[N];
bool vis[N],mark[N];
struct edge{
	int fr,to,w;
}e[400050];
int head[N],cnt,nxt[4050],to[4050],w[4050];
void add(int u,int v,int f)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
	w[cnt]=f;
}
bool cmp(edge a,edge b){return a.w<b.w;}
struct node{
	int val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
priority_queue<int> ans;
void dij(int s)
{
	for(int i=1;i<=n;i++)dis[i]=1e18,vis[i]=0;
	dis[s]=0;
	priority_queue<node,vector<node>,greater<node> >q;
	q.push(node{dis[s],s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
		if(mark[t.pos]&&t.pos>s&&dis[t.pos]!=1e18)
		{
			if(ans.size()<k)ans.push(dis[t.pos]);
			else{
				if(ans.top()>dis[t.pos])
				{
					ans.pop();
					ans.push(dis[t.pos]);				
				} 
			}
		}
		vis[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[t.pos]+w[i])
			{
				dis[v]=dis[t.pos]+w[i];
				q.push(node{dis[v],v});
			}
		}
	}
}
signed main()
{
	scanf("%lld %lld %lld",&n,&m,&k);
	for(int i=1,u,v,w;i<=m;i++)
	{	
		scanf("%lld %lld %lld",&u,&v,&w);
		e[i].fr=u;e[i].to=v;e[i].w=w;
	}
	sort(e+1,e+m+1,cmp);
	for(int i=1;i<=k;i++)
	{
		mark[e[i].fr]=1,mark[e[i].to]=1;
		add(e[i].fr,e[i].to,e[i].w);
		add(e[i].to,e[i].fr,e[i].w);
	}
	for(int i=1;i<=n;i++)
	{
		if(mark[i])
			dij(i);
	}
	printf("%lld\n",ans.top());
	return 0;
}

\(4\) 红灯(图论建模)

题目传送门

解法

按照题意模拟即可,注意要把边看成点进行建模。具体地:看每个点连出去的四条边,设当前点的编号为 \(x\) ,则第一条边的编号为 \(4\times x+0\),第二条边编号为 \(4\times x+1\),第三条边编号为 \(4\times x+2\),第四条边为 \(4\times x+3\)。然后进行再跑最短路即可,注意原来的起点 \(s\) 和终点 \(t\) 要按照新建的图的点的编号重新计算。

\(Code\)

#include<bits/stdc++.h>
using namespace std;
const int N=5e7+50;
int head[N],cnt,nxt[N],to[N],n,a[500050][5],s1,s2,t1,t2,w[N];
bool vis[N];
long long dis[N];
void add(int u,int v,int f)
{
	to[++cnt]=v;
	nxt[cnt]=head[u];
	head[u]=cnt;
	w[cnt]=f;
}
struct node{
	long long val,pos;
	bool operator >(const node &x)const{
		return val>x.val;
	}
};
priority_queue<node,vector<node>,greater<node> >q;
void dij(int s)
{
	for(int i=0;i<=(2000000)*4;i++)dis[i]=5611686018427387904;
	memset(vis,0,sizeof(vis));
	dis[s]=0;
	q.push(node{dis[s],s});
	while(!q.empty())
	{
		node t=q.top();q.pop();
		if(vis[t.pos])continue;
		//		cout<<endl<<t.pos<<endl; 
		vis[t.pos]=1;
		for(int i=head[t.pos];i;i=nxt[i])
		{
			int v=to[i];
			if(dis[v]>dis[t.pos]+w[i])
			{
				dis[v]=dis[t.pos]+w[i];
				q.push(node{dis[v],v});
			}
		}
	}
}
signed main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++)scanf("%d %d %d %d",&a[i][0],&a[i][1],&a[i][2],&a[i][3]);
	for(int i=1;i<=n;i++)
	{
		for(int j=0,u,v;j<=3;j++)
		{
			if(a[i][j]==0)continue;
			for(int x=0;x<=3;x++)
				if(a[a[i][j]][x]==i)u=a[i][j]*4+x;
			//			i->(a[i][(j+1)%4])
			for(int x=0;x<=3;x++)
			{
				if(a[i][(j+1)%4]==a[i][x]&&a[i][(j+1)%4])v=i*4+x,add(u,v,0);
				else if(a[i][x])v=i*4+x,add(u,v,1);
			}
		}
	}
	scanf("%d %d %d %d",&s1,&s2,&t1,&t2);
	//	s=s1*4+s2;t=t1*4+t2;
	int s,t;
	for(int x=0;x<=3;x++)if(a[s1][x]==s2)s=s1*4+x;
	for(int x=0;x<=3;x++)if(a[t1][x]==t2)t=t1*4+x;
	dij(s);
	if(dis[t]!=5611686018427387904)printf("%lld\n",dis[t]);
	else printf("-1\n");
	return 0;
}

总结

只要是是图论,建模一定排在第一位,其次是算法特征与思想,最后才是套路,三者一结合起来那么你在图论这个版块就一定不会很差。

有时候图论题还需要考察你对模型的抽象化,有可能第一眼看上去和图论一点关系没有,结果最后是图论建模后就解决了,这类题型只能靠见多识广,再无其他门路。

一些图论套路题也要多见识,才能在考场上遇见这些题目不慌张。还有平时打图论模板也要一个字一个字的打,切忌不要直接复制,因为我们是 \(OI\) 赛制,考场上没有可以直接复制粘贴的模板。而且重新写一遍对你对该算法的认识又增加一个层次。

最后就是不要小看一些看上去复杂度很高很没有优势的算法,它们在特定的题型中还是会发挥它们自己的作用的。比如 \(Bellman-Ford\) 有时候判断负环时很有优势,在小数据下表现得也不比 \(Dijkstra\) 差,\(SPFA\) 虽然容易卡,但在一些转移需要重复更新的情况下,进行多轮迭代,直至状态全部收敛,这是与它的算法思想是紧密相关的。

\(Floyd\) 也有自己的优势比如判负环、负权回路与差分约束等。\(Dijkstra\) 可以处理有环的图的状态转移,这也与它的算法思想每次从优先队列里选取的数是最后且不会改变的紧密相关。处理不是 \(DAG\) 的时候很方便。

当然有时候一个题目需要运用到多种算法,这就很考验一个选手的综合能力与基本功了,所以我们需要及时复习以前学过的算法,以便临近考试时不慌张。

总之一句话,多见识题型,多训练思维,及时回顾算法思想与解题套路,这样就能在 \(OI\) 生涯中打下坚实的基础。

posted @ 2023-12-17 10:10  CQWYB  阅读(60)  评论(0编辑  收藏  举报  来源