网络流5:割与流的转化

网络流5:割与流的转化

这个坑挖了很久了,顺手填一下吧。至于网络流 3 和网络流 4,只能说祝它们好运。

或许也可以称之为模拟最大流/模拟最小割?

隔壁还有模拟费用流呢,自然也可以有模拟最大流。

这算一类小 trick 了吧。

当我们遇到一道流/割问题时,不一定非得用流/割的思路来做。我们可以观察图的性质,比如一些局部最优解(贪心)、可行解结构(贪心或 dp),并使用其他算法来进行模拟。

转化方式因题而异,让我们结合题目来试试吧。

例题I AT_arc125_e 模拟最大流

题面

有 \(N\) 种编号为 \(1\) 到 \(N\) 的糖果。第 \(i\) 种糖果有 \(A_i\) 个。

有 \(M\) 个编号为 \(1\) 到 \(M\) 的孩子。现在要给这些孩子分糖果。分配时需满足以下所有条件:

  • 孩子 \(i\) 对于任意一种糖果,最多只能拿 \(B_i\) 个。
  • 孩子 \(i\) 拿到的糖果总数不超过 \(C_i\)。

在满足上述条件的前提下,求能分给孩子们的糖果总数的最大值。

Solution

本题不难想到最大流建图。具体的:

  • 建立源点 \(S\) 和汇点 \(T\)。
  • 建立糖果点 \([1,n]\),由源点向每个糖果点连容量为 \(A_i\) 的边,约束糖果总数。这类边称之为第一类边;
  • 建立孩子点 \([n+1,n+m]\),每个糖果点向孩子点 $n+i $ 连 \(B_i\) 的边约束孩子拿到每类最多的糖果数量。这类边称之为第二类边;
  • 每个人向汇点连边,容量 \(C_i\),约束人能选的总量。这类边称之为第三类边。

然后你的复杂度就炸啦。

就在这时,注意本章名称,不难发现我们可以尝试将最大流转化。

仔细观察性质,发现很多边是冗余的,大量的边容量相同,大量的点是对称的。我们从这个方向考虑。

什么东西会破坏点与边之间的等价呢?答案是边的流量。但是边的流量会随着方案改变而改变,看起来是个很 Hard 的问题,不太像能轻易维护的样子。有什么方法可以做到边不与流量、方案有关,而只与边本身有关呢?

最小割!

从最小割考虑。我们发现一个割的方案是:一些糖被割到 \(T\) 侧,他们的第一类边被割掉;一些孩子被分到 \(S\) 侧,他们的第三类边被割掉;此外,还有一些中间横跨 \(S\) 侧和 \(T\) 侧的第二类边被割掉。

注意到第二类边与只孩子有关,那我们贪心的选糖。若有 \(i\) 个糖连向 \(T\) 侧,贪心地选择 \(i\) 个最小的 \(A_i\) 即可;对于每个小孩 \(j\),如果连 \(S\) 侧代价为 \(C_j\),如果连 \(T\) 侧代价为 $ (n-i) B_j $。

接下来枚举 \(i\),并按 $ C_j/B_j $ 贪心取小孩即可。

代码

void mian()
{
	read(n);read(m);
	LL res=1ll*INF*INF,sa=0,sb=0,sc=0;
	For(i,1,n) read(a[i]);
	For(i,1,m) read(p[i].first);
	For(i,1,m) read(p[i].second),sc+=p[i].second;
	sort(a+1,a+1+n);
	sort(p+1,p+1+m,[](const auto &p1,const auto &p2){return p1.second*p2.first>p1.first*p2.second;});
	int r=1;
	For(i,0,n)
	{
		sa+=a[i];
		while(p[r].second>=(n-i)*(p[r].first)&&r<=m) sb+=p[r].first,sc-=p[r].second,r++;
		tomin(res,sa+sb*(n-i)+sc);
	}
	printf("%lld\n",res);
}

例题II [CSP-S 2021] 交通规划 模拟最小割

好题确实是好题,就是有点超纲。

题面

太长了,略。

可以直接点题目链接。

part 1

不难想到最小割建图:

超级源点向所有白点连代价为正无穷的边;超级汇点向所有黑点连代价为正无穷的边,那么题目就是让我们求一个最小割。

直接做可以获得高额的 60 分。

part 2

注意到有一个 \(k \le 2\) 的性质。

首先,如果 \(k=0\),那直接 puts("0") 跑路。

对于 \(k=2\),我们发现原图是平面图,而平面图最小割等于对偶图的最短路。如果我们将附加边看成无限长的射线,将原本的一个无限面划分成了多个无限面,那么答案就是两个无限面之间的最短路。

每次跑 Dijkstra 即可,复杂度 $ O(Tnm \log(nm)) $

到这一步为止,其实已经与最小割没关系了。

part 3

(接下来复杂度分析省略多测)

对于 $k\ge 2 $ 的情况,由此启发,我们想到试着刻画最小割的形态,推广对偶图最短路。最基本的想法就是不同颜色的点两两配对,随后配对的点之间连最短路。

我们可以直接对每个起始点跑一个单源最短路,就能 \(O(1)\) 计算配对点之间的最短路了。预处理复杂度 $ O(k nm\log (nm)) $。

单这一点是不够的,直接枚举配对方案需要指数复杂度,我们还需要更多性质。通过简单的手玩发现,似乎这些最短路是没有交叉的。人类智慧地思考,如果有交叉,交换配对点会获得不劣的方案。

详细证明的话,OI不需要证明,限于篇幅原因,在这里就不讲证明了。这是一份别的题解的证明,题解原文为参考资料 5。

也就是说,将点顺时针排序后,配对点不交。观察到这一点后,配对方案就变成了一个平凡的环上区间 dp,这部分复杂度 \(O(k^3)\)。

合计复杂度 $ O(knm\log(nm) + k^3) $

代码

感觉本题更难的部分是代码细节。可以参考注释。

代码注释部分由 AI(deepseek)添加,人工润色了一下。

// 链式前向星存图
struct Edge{
	int v,nx,w;
}e[M];
int hd[N],ecnt;
void add(int u,int v,int w){e[++ecnt]={v,hd[u],w};hd[u]=ecnt;}
void __add(int u,int v,int w){add(u,v,w);add(v,u,w);}

// 我们直接建对偶图
// 外围环节点编号 1 ~ N (N = 2*(n+m)),内部小方格节点从 N+1 开始
// 注意:这里采用“边编号 = 节点编号”的自然方式:
//   先加边 (1, N),然后加 (1,2), (2,3), ..., (N-1, N)
//   这样外围边 i 恰好穿过节点 i,因此附加点 id 可以直接当作节点 id 使用
int init()
{
	int Q;
	read(n),read(m);read(Q);
	int N=n+m<<1;  // 外围环的节点数
	// 建外围环:边 1 连接 (1, N),边 2 连接 (1,2),边 3 连接 (2,3)...
	__add(1,2*(n+m),0);
	For(i,1,N-1) __add(i,i+1,0);
	// 内部节点编号函数:第 i 行第 j 列的小方格对应节点 id
	auto f=[](int x,int y){return (x-1)*(m-1)+y+(n+m)*2;};
	// 读入水平方向的边(横向边),连接内部小方格或外围环
	For(i,1,n-1) For(j,1,m){
		int w;read(w);
		int id=f(i,j);
		if(j==1) __add(N-i,id,w);          // 最左侧,连接到外围环节点 N-i
		else if(j==m) __add(m+i,id-1,w);   // 最右侧,连接到外围环节点 m+i
		else __add(id-1,id,w);             // 内部小方格之间
	}
	// 读入垂直方向的边(纵向边)
	For(i,1,n) For(j,1,m-1){
		int w;read(w);
		int id=f(i,j),id2=f(i-1,j);
		if(i==1) __add(j,id,w);            // 最上方,连接到外围环节点 j
		else if(i==n) __add(m+n+m-j,id2,w);// 最下方,连接到外围环节点 m+n+m-j
		else __add(id,id2,w);              // 内部小方格之间
	}
	return Q;
}

// 附加点信息
struct Node{
	int w,id,col;  // w: 附加点代价,id: 所在外围边编号,col: 颜色(0/1)
}a[N];

int sc,ec;          // sc: 起点数量,ec: 终点数量
int st[N],ed[N],rk[N]; // st/ed 存起点/终点的外围边编号(1~2*N),rk 存每个关键点在环上的顺序编号(1~sc+ec)

// 扫描环上相邻点对,识别颜色变化,确定起点和终点
// 利用 a[1] 作为参考色,统一颜色变化方向,避免首尾特殊处理
void check(int x,int y)
{
	// 若 a[x] 与参考色相同,a[y] 不同,则 a[x] 为起点
	if(a[x].col==a[1].col&&a[y].col!=a[1].col) st[++sc]=a[x].id,rk[a[x].id]=sc+ec;
	// 若 a[x] 与参考色不同,a[y] 相同,则 a[x] 为终点
	else if(a[x].col!=a[1].col&&a[y].col==a[1].col) ed[++ec]=a[x].id,rk[a[x].id]=sc+ec;
}

// 单源最短路(Dijkstra)
LL dis[N];bool vis[N];
priority_queue <pair <LL,int> > pq;
void dij(int st)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	while(!pq.empty()) pq.pop();
	dis[st]=0;pq.push({0,st});
	while(!pq.empty()){
		int x=pq.top().second;pq.pop();
		if(vis[x]) continue;vis[x]=1;
		for(int i=hd[x];i;i=e[i].nx){
			int v=e[i].v,w=e[i].w;
			if(dis[v]>dis[x]+w){
				dis[v]=dis[x]+w;
				pq.push({-dis[v],v});  // 取负实现小根堆
			}
		}
	}
}

// 区间DP:环上起点与终点一一配对,求最小总代价
// 传入参数 k 为总关键点数(即 sc+ec,也等于 2*sc)
LL cost[K][K];  // cost[i][j] 表示环上第 i 个关键点到第 j 个关键点的最短路代价
LL F[K][K];     // F[l][r] 表示区间 [l, r] 内所有点完成匹配的最小代价
LL dp(int k)
{
	int n=k<<1;  // 将环拆成二倍链,长度为 2*k
	// 将二倍链下标映射回原始环上的 1~k
	auto f=[&](const int x){return (x-1)%k+1;};
	// 初始化长度为 2 的区间
	For(i,1,n-1) F[i][i+1]=cost[f(i)][f(i+1)];
	// 区间 DP,长度从 4 开始,每次增加 2
	for(int len=4;len<=k;len+=2){
		For(l,1,n){
			int r=l+len-1;if(r>n) break;
			// 情况1:l 和 r 直接配对,内部区间 [l+1, r-1] 已完成匹配
			F[l][r]=F[l+1][r-1]+cost[f(l)][f(r)];
			// 情况2:枚举分割点 mid,将区间分成两部分
			for(int mid=l+1;mid<=r;mid+=2){
				tomin(F[l][r],F[l][mid]+F[mid+1][r]);
			}
		}
	}
	// 在二倍链上找一个长度为 k 的区间,取最小值
	LL res=INF;
	For(i,1,k) tomin(res,F[i][i+k-1]);
	return res;
}

void solve()
{
	int N=n+m<<1;
	// 多测不清空,爆零两行泪
	For(i,1,N<<1) e[i].w=0;
	sc=ec=0;
	memset(cost,0x3f,sizeof(cost));
	memset(F,0x3f,sizeof(F));

	read(k);
	// 附加点,并直接修改对应外围边的权值
	// 外围边 id 对应有向边 (id<<1)-1 和 id<<1
	For(i,1,k)
		read(a[i].w),read(a[i].id),read(a[i].col),
		e[(a[i].id<<1)-1].w=e[a[i].id<<1].w=a[i].w;
	// 按外围边编号升序排序,保证扫描顺序为环上的顺时针顺序
	sort(a+1,a+1+k,[](const Node &x,const Node &y){
		return x.id<y.id;
	});

	// 扫描环上相邻点对,识别起点和终点
	check(k,1);  // 检查最后一个和第一个(环的闭合处)
	For(i,1,k-1) check(i,i+1);

	if(sc==0) return puts("0"),void();  // 没有颜色变化,无需割边

	// 对每个起点跑一次最短路,得到到所有终点的距离
	For(i,1,sc){
		dij(st[i]);
		For(j,1,ec){
			// cost 数组下标使用 rk 值(环上顺序编号)
			cost[rk[st[i]]][rk[ed[j]]]=cost[rk[ed[j]]][rk[st[i]]]=dis[ed[j]];
		}
	}
	// 区间 DP 求最小匹配代价
	LL res=dp(sc+ec);
	printf("%lld\n",res);
}
bool Med;
signed main()
{
	int Test=init();
	while(Test--) solve();
	return 0;
}

总结

其实一般模拟最大流更多是转化为最小割来做,毕竟流量太难维护了。

而模拟最小割则是通过割本身的性质,比如说最优解形式,或是对偶图最短路来做。

好像就是重复了一遍开头的介绍。

参考资料/其他推荐

1 其他模拟最大流题目。
2 AT_arc125_e 题解
3 AT_arc125_e 题解
4 交通规划题解
5 交通规划题解
6 交通规划题解

posted @ 2026-10-09 09:32  FarrisL  阅读(4)  评论(0)    收藏  举报