【好题选讲】图论中分层图技巧的应用

前言

数据结构学腻了来补补图论。

0. 背景故事

怎么还有故事
第一次听说分层图是在 2023 年 CSP-J 考完出来的时候,当时连任何一个最短路算法都不会,(好像会 BFS 但是只用来在网格图里走迷宫过),于是 T4 打了一个暴力 BFS,当时我还天真的认为能骗不少分,结果只得了输出 -1\(5\) 分,早知道去打 T3 大模拟了(不然也不会只得了 \(50\) 分)。想要了解更多可以去看我去年的游记
出考场了以后去吃了馄饨,看到讨论区里一车人被 T4 卡了,然后有人说 T4 是个阴间 BFS,那我就放心了至少不会有很多人都做出来。然后又看到有人说 T4 用分层图秒了啊,我寻思分层图是个啥遂去网上搜结果发现当时的我根本看不懂,那我就更放心了啊
出成绩后令我意外的是 SD 竟无人 AC T4。
于是今天来学学分层图顺便看看是不是能够切掉去年 T4。

1. 原理

分层图是一种特殊的建图方式,旨在处理题目中某些对边的奇奇怪怪的限制,包括但不限于:

  • 使用一张卡片,这样,我们通过这一条道路的时间 就可以减少到原先的一半。
  • 免费为此市连接 \(k\) 对电话线杆,对于此外的那些电话线,需要为它们付费。
  • 到达和离开景区的时间都必须是 \(k\) 的非负整数倍。
  • 对于每条道路均设置了一个 “开放时间” \(a_i\),游客只有不早于 \(a_i\) 时刻才能通过这条道路。
  • 航空公司对他们这次旅行也推出优惠,他们可以免费在最多 \(k\) 种航线上搭乘飞机。

这种题只要建好图普遍可以用最短路算法解决。

那么怎么建图呢?类似于扩展域并查集,我们把原来的图复制成多层,每一层代表一个不同的状态,再用某些特殊的边将各层之间两两相连,选定起点和终点,跑最短路算法即可解决。

你肯定发现了,如果想用分层图解决问题,需要 "状态" 的类别比较少,这样我们的时间和空间才够,而这种特性让我们可以从题目中的数据范围内找到一些线索从而成为我们建图的有力提示。当然如果范围太大了用来骗分也是一个不错的选择。

下面来一道例题吧:

2. 例题

JLOI2011 飞行路线

好像所有讲分层图的文章都是用的这道题作为例题,因为它太经典了。但是不要着急,我们下面还有 \(3\) 倍经验。

题意简述

\(n\) 个点,\(m\) 条无向边,每条边有边权,你要从 \(0\) 号节点到 \(n-1\) 号节点去,花费是你经过的路径的边权之和,但是你有 \(k\) 次机会把图中某些边的边权变为 \(0\),求你的最小花费。

既然是例题了,肯定要用分层图解决,但是怎么建图呢?看看数据范围:

对于 \(100\%\) 的数据,\(2 \le n \le 10^4\)\(1 \le m \le 5\times 10^4\)\(0 \le \textit{\textbf{k}} \le 10\)\(0\le s,t,a,b < n\)\(a\ne b\)\(0\le c\le 10^3\)

\(k\) 最多竟然只有 \(10\)!于是我们尝试以 \(k\) 代表的 “\(k\) 次机会” 来建分层图,定义第 \(i\) 层图上的所有点一定是在使用了正好 \(i\) 次 "边权变为 \(0\)" 操作来到的,很明显我们要建 \(k+1\) 层图,每一层图上的所有点连边的情况可原始图是一样的,问题是怎么两两连接这些图呢?

显然,如果在第 \(i\) 层图上 \(u\) 点可以到达 \(v\) 点,那么 \(u\) 就有可能使用一次机会把边权变为 \(0\),那么为了符合我们对这个分层图的定义,只需要从第 \(i\) 层的 \(u\) 点向第 \(i+1\) 层的 \(v\) 点连一条有向边就好了。同理,由于是无向边,我们也要从第 \(i\) 层的 \(v\) 点向第 \(i+1\) 层的 \(u\) 点连一条有向边。

但是要注意,即使原图中是无向边,我们在连接各不同层图的时候也不应用无向边,因为从 \(i+1\) 层图使用机会返回 \(i\) 层图违反了我们对分层图的定义。

我们的起点显然是第 \(0\) 层图中的 \(0\) 号节点,但答案可能出现在这 \(k+1\) 层图中的任何一个 \(n-1\) 号节点,枚举一遍找个最小值就可以了。

问题又来了,我们如何在代码中表示这 \(k+1\) 层图呢?

仿照扩展域并查集,我们可以发现,设总点数为 \(n\),第 \(i\) 层图上的第 \(u\) 个节点可以表示为 \(u+i\times n\)。于是我们可以用下标和数学公式方便的表示出任何一层上的任何一个点。
当然也有 dalao 使用 DP 的风格定义数组,但是我可不想让 DP 污染了图论

来看代码吧~

#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll read(){
	ll x=0,f=1;
	char c=getchar();
	while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
	while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
	return x*f;
}
const int N=1e6+5;
int n,m,k;
int s,t;
struct e{
	int v;
	int w;
};
vector<e> edge[N];
struct work{
	int v;
	int w;
	bool operator<(const work &x)const{
		return x.w<w;
	}
};
priority_queue<work> q;
int dis[N];
bool vis[N];
void dij(int s){
	memset(dis,0x3f,sizeof dis);
	q.push(work{s,0});
	while(q.size()){
		work dx=q.top();
		q.pop();
		int u=dx.v;
		if(vis[u])continue;
		vis[u]=1;
		dis[u]=dx.w;
		for(int i=0;i<edge[u].size();i++){
			int v=edge[u][i].v;
			int w=edge[u][i].w;
			if(dis[v]>dx.w+w){
				dis[v]=dx.w+w;
				q.push(work{v,dx.w+w});
			}
		}
	}
}
int main(){

	cin>>n>>m>>k;
	cin>>s>>t;
	for(int i=1;i<=m;i++){
		int u,v,c;
		cin>>u>>v>>c;
		for(int j=0;j<=k;j++){
			edge[u+(n*j)].push_back(e{v+(n*j),c});
			edge[v+(n*j)].push_back(e{u+(n*j),c});
			if(j<k){
				edge[u+(n*j)].push_back(e{v+(n*(j+1)),0});	
				edge[v+(n*j)].push_back(e{u+(n*(j+1)),0});	
			}
		}
	}
	dij(s);
	int ans=0x3f3f3f3f;
	for(int j=0;j<=k;j++){
		ans=min(ans,dis[t+(n*j)]);
	}
	cout<<ans;
	

	return 0;
}

CSP-J 2023 旅游巴士

以下是我思考的全过程记录。

题意简述

\(n\) 个点,\(m\) 条有向边,有一个单位时间 \(k\),你到达 \(1\) 号点和从 \(n\) 号点离开的时间都必须是 \(k\) 的非负整数倍,你最早只能在 \(0\) 时刻来到 \(1\) 号点。通过每一条边消耗的时间都是 \(1\),但是每条边有一个开放时间 \(a_i\),只有在不小于 \(a_i\) 时刻才能通过这条边,问你最小的可能从 \(n\) 号点离开的时间是多少?

不错,确实很像一个分层图题目,但是有 \(k\) 的倍数和开放时间两重限制,用谁建图比较好呢?
来看看数据范围:

对于所有测试数据有:\(2 \leq n \leq 10 ^ 4\)\(1 \leq m \leq 2 \times 10 ^ 4\)\(1 \leq \textit{\textbf{k}} \leq 100\)\(1 \leq u _ i, v _ i \leq n\)\(0 \leq a _ i \leq 10 ^ 6\)

显然出题人让我从 \(k\) 上入手,但是 \(k\) 是一个类似于倍数的关系,如何转化呢?
我想到了余数分类的技巧,即建出 \(0\sim k-1\) 层图,第 \(i\) 层代表 到达这一层上的任意点所耗费的时间 \(\bmod\ k=i\)

而每条边的边权正好是 \(1\),这不是更方便我建图了吗?看来出题人就引导着我继续往下想。

答案如何求解呢?很明显我要从第 \(0\) 层的 \(1\) 号点开始跑最短路,答案一定在第 \(0\) 层的 \(n\) 号点。

但是还有一个限制:通过边需要在 \(a_i\) 时刻及以后,这个该如何处理?总不能枚举我到来的时间吧?

我想到一个办法,先不管我们能否通过这条边,把从起始点到这个点的最短路径,也就是耗费的最短时间记录下来,设这个值为 \(w\),如果要通过一条时间限制为 \(a\) 的边,那么我仅需要在 \(a-w\) 时刻来到公园起点,就可以通过这条边了,下面设这个时刻为 \(t\)。当然,如果 \(a<w\) 就可以直接通过了。

此时我通过这条边到达另一个点花费的时间可以表示为 \(w+1+t\),当然,在路径中我们要记录的是 \(t\) 的最大值,原因很显然吧。

那么在我们重载运算符的时候就把这个公式丢给 dij 算法,让它自己跑,我们只需要在第 \(0\) 层的 \(n\) 号点取答案就好了。

于是我们可以写出第一版代码:

#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll read(){
	ll x=0,f=1;
	char c=getchar();
	while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
	while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
	return x*f;
}
const int N=1e6+5;
struct e{
	int v;
	int w;
	int a;
};
vector<e> edge[N];
int n,m,k;
struct work{
	int v;
	int w;//从起始点到这个点的最短时间
	int timeh;//来到公园起点的最大时间
	bool operator<(const work &x)const{
		return x.w+x.timeh<w+timeh;//公式
	}
};
priority_queue<work> q;
bool vis[N];
int dis[N];
bool dij(){
	memset(dis,0x3f,sizeof dis);
	q.push(work{1,0,3});
	while(q.size()){
		work dx=q.top();
		q.pop();
		int u=dx.v;
		if(vis[u])continue;
		vis[u]=1;
		dis[u]=dx.timeh+dx.w;
		if(u==n){
			return 1;
		}
		for(int i=0;i<edge[u].size();i++){
			int v=edge[u][i].v;
			int ctime=max(edge[u][i].a-dx.w,dx.timeh);//要取最大值
			if(dis[v]>dx.w+1+ctime){
				dis[v]=dx.w+1+ctime;
				q.push(work{v,dx.w+1,ctime});
			}
		}
	}
	return 0;
}
int main(){
	
	cin>>n>>m>>k;
	for(int i=1;i<=m;i++){
		int u,v,a;
		cin>>u>>v>>a;
		for(int j=0;j<k;j++){//建图部分应该很容易理解
			if(j==k-1){
				edge[u+j*n].push_back(e{v,1,a});
			}
			else edge[u+j*n].push_back(e{v+(j+1)*n,1,a});
		}
	}
	if(!dij()){//没有解
		cout<<-1;
	}
	else{
		cout<<dis[n];//有解直接取答案
	}
		
	return 0;
}

测测样例,诶,怎么少了一个单位时间?

简单 Debug 我们发现,此时程序记录的到达起点的最晚时间并不是 \(k\) 的倍数,这不符合题目的要求。

解决方法也很简单,我们在取最大值的时候要让答案向上取到 \(k\) 的某个倍数。

修改完成后,我们没看一眼题解,成功切掉此题!

#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll read(){
	ll x=0,f=1;
	char c=getchar();
	while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
	while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
	return x*f;
}
const int N=1e6+5;
struct e{
	int v;
	ll w;
	ll a;
};
vector<e> edge[N];
ll n,m,k;
struct work{
	int v;
	ll w;
	ll timeh;
	bool operator<(const work &x)const{
		return x.w+x.timeh<w+timeh;
	}
};
priority_queue<work> q;
bool vis[N];
ll dis[N];
bool dij(){
	fill(dis,dis+1+n*(k+1),99999999999999);//小开了一波long long
	q.push(work{1,0,0});
	while(q.size()){
		work dx=q.top();
		q.pop();
		int u=dx.v;
		if(vis[u])continue;
		vis[u]=1;
		dis[u]=dx.timeh+dx.w;
		if(u==n){
			return 1;
		}
		for(int i=0;i<edge[u].size();i++){
			int v=edge[u][i].v;
			ll ctime=max(((edge[u][i].a-dx.w+k-1)/k)*k,dx.timeh);//改成向上取整
			if(dis[v]>dx.w+1+ctime){
				dis[v]=dx.w+1+ctime;
				q.push(work{v,dx.w+1,ctime});
			}
		}
	}
	return 0;
}
int main(){
	
	cin>>n>>m>>k;
	for(int i=1;i<=m;i++){
		int u,v,a;
		cin>>u>>v>>a;
		for(int j=0;j<k;j++){
			if(j==k-1){
				edge[u+j*n].push_back(e{v,1,a});
			}
			else edge[u+j*n].push_back(e{v+(j+1)*n,1,a});
		}
	}
	if(!dij()){
		cout<<-1;
	}
	else{
		cout<<dis[n];
	}
		
	return 0;
}

更多题目:

BJWC2012 冻结

USACO08JAN Telephone Lines S

ABC395E - Flip Edge

posted @ 2025-02-21 20:42  hm2ns  阅读(31)  评论(0)    收藏  举报