二分图与网络流

这不是 CSP-S 刚考了个网络流嘛,我想着去再刷点题,好家伙这不刷不知道,一刷发现自己网络流的东西忘完了,赶紧写个博客总结一下,就当是我备战 NOIp 复习的开始吧。

概念部分

网络流

基本定义

类比水流,我们定义图论中的流量网络。在一个有向图 \(G=(V,E)\) 中,我们给每条边 \((u,v)\) 定义两个属性 \(c_{u,v},f_{u,v}\) 分别表示该边的容量和通过该边的流量。根据问题需要,我们可能还会指定两个特殊的点 \(S,T\in V\) 分别表示源点和汇点。我们称所有边上流量的集合为网络流。一个合法的可行流应满足以下限制:

  • 容量限制:\(\forall (u,v)\in E\),都应该有 \(0\le f_{u,v}\le c_{u,v}\)
  • 流量守恒:\(\forall u\),都应该有 \(\sum_{v'} f_{u,v'}-\sum_{v''} f_{v'',u}=\begin{cases}|f|&u=T\\-|f|&u=S\\0&\texttt{otherwise}\end{cases}\)。其中 \((u,v')\) 表示 \(u\) 的所有出边,\((v'',u)\) 表示 \(u\) 的所有入边。\(|f|\) 叫做该可行流的流量,是源点的净流出量,也是汇点的净流入量。

注意一个流量网络一定存在 \(|f|=0\) 的可行流。定义最大流为所有可行流中拥有最大流量的可行流。

最大流算法

最大流算法就是在给定的流量网络上找到最大流的流量。

EK 算法

我们定义流量网络上的增广路为一条满足上面所有边 \((u,v)\) 都有剩余流量 \(f=c_{u,v}-f_{u,v}>0\) 的链,换句话说,这条链上还能供流量通过。则最大流算法其实也可以转化为不断找增广路进行增广,直到找不到增广路,此时就找到了最大流(否则还可继续增广)。一个显然的想法就是用 \(\rm bfs\),不断找从 \(S\)\(T\) 的增广路,对于每条增广路,给最终答案加上上面剩余流量最小值 \(f\),并给这条链上面所有边的流量加上 \(f\)。但这个思路是有问题的,每次找不一定找到的是对的,直接贪心下去可能会出问题。所以我们考虑带悔贪心,我们给每条边对应上一个反边,容量等于正边的容量,流量等于正边剩余流量,寻找增广路时也算上这些反边,最终给流量加上 \(f\) 的时候再给反边的流量减去 \(f\) 即可。发现这样反边其实相当于反悔,退流。

这个过程其实就是 \(\rm EK\) 算法的基本思想了,需要注意的是,为了方便,我们在代码中储存的都是剩余流量,而不是当前流量。所以正边的 c 初始时为容量 \(c\),反边初始时为 \(0\)

#include <queue>
#include <cstdio>
#include <cstring>
const int N = 300, M = 5e3 + 10, inf = 2e9; typedef long long ll;
struct edge { int u, v, next, c; }E[M << 1]; int p[N], cnt, s, t, pre[N];
void init() { memset(p, -1, sizeof(p)); cnt = 0; }
void insert(int u, int v, int c) { E[cnt].u = u; E[cnt].v = v; E[cnt].c = c; E[cnt].next = p[u]; p[u] = cnt++; }
void addedge(int u, int v, int c) { insert(u, v, c); insert(v, u, 0); } //建反边
inline void bfs()
{
    std::queue<int> q; q.push(s); memset(pre, -1, sizeof(pre)); int u;
    while (!q.empty())
    {
        u = q.front(); q.pop();
        for (int i = p[u], v; i + 1; i = E[i].next)
        {
            v = E[i].v;
            if (pre[v] == -1 && E[i].c) //如果这条路没有走过且该边仍有剩余流量
            {
                pre[v] = i; //记录路径
                if (v == t) break;
                q.push(v); //继续增广
            }
        }
        if (pre[t] != -1) break; //如果找到汇点就停
    }
}
inline ll EK()
{
    ll ret = 0;
    while (1)
    {
        bfs(); if (pre[t] == -1) break; //如果找不到增广路,就停
        int minx = inf;
        for (int u = t; u != s; u = E[pre[u]].u)
            minx = std::min(minx, E[pre[u]].c); //在增广路上找最小剩余流量
        for (int u = t; u != s; u = E[pre[u]].u)
			E[pre[u]].c -= minx, E[pre[u] ^ 1].c += minx; 
			//更新,因为反边和正边的标号是相邻的,所以 ^ 1 就是反边
        ret += minx;
    }
    return ret;
}
int main() 
{
	init(); int n, m; scanf("%d%d%d%d", &n, &m, &s, &t);
    for (int i = 1, x, y, z; i <= m; ++i)
        scanf("%d%d%d", &x, &y, &z), addedge(x, y, z);
    printf("%lld\n", EK()); return 0;
}

该算法的时间复杂度为 \(\mathcal{O}(nm^2)\)。但实际中很难跑到上界,大概能处理 \(10^3\sim 10^4\) 规模的网络。

Dinic 算法

发现 \(\rm EK\) 算法的瓶颈在于一次只能找到一条增广路进行更新,所以我们来想办法优化掉。我们定义流量网络中由所有结点和剩余流量大于 \(0\) 的边组成的图为残量网络。显然,残量网络中任意一条链均为增广路。我们考虑在残量网络上跑 \(\rm bfs\),把原图分为若干个层级,即变为分层图。接着用 \(\rm dfs\) 从低层到高层不断维护当前链上的流量最小值直到到汇点,即找到了一条链,也就是增广路,并在回溯的时候在增广路上处理流量的变化。这里分层的目的是为了防止图中有环而反复更新。这样就可以做到一次 \(\rm bfs\) 和一次 \(\rm dfs\) 找到并更新多条增广路了,效率自然大大优化。

#include <queue>
#include <cstdio>
#include <cstring>
const int N = 2e5 + 10, inf = 2e9; typedef long long ll;
struct edge{ int v, next, c; }E[N << 1]; int p[N], d[N], cur[N], cnt, s, t;
inline void init() { memset(p, -1, sizeof (p)); cnt = 0; }
inline void insert(int u, int v, int c) { E[cnt].v = v; E[cnt].c = c; E[cnt].next = p[u]; p[u] = cnt++; }
inline void addedge(int u, int v, int c) { insert(u, v, c); insert(v, u, 0); }
inline bool bfs()
{
	memset(d, -1, sizeof (d)); d[s] = 0; cur[s] = p[s];
	std::queue<int> q; q.push(s); int u;
	while (!q.empty())
	{
		u = q.front(); q.pop();
		for (int i = p[u], v; i + 1; i = E[i].next)
		{
			v = E[i].v; cur[v] = p[v];
			if (d[v] == -1 && E[i].c) { d[v] = d[u] + 1; q.push(v); }
		}
	}
	return d[t] != -1;
}
ll dfs(int u, int flow)
{
    //flow 记录链上最小剩余流量,到汇点后返回
	if (u == t) return flow; ll ans = 0, ret;
	for (int i = cur[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v; cur[u] = i; //1)
		if (E[i].c && d[v] == d[u] + 1)
		{
			ret = dfs(v, std::min(flow, E[i].c)); //更新记录的最小剩余流量
			E[i].c -= ret; E[i ^ 1].c += ret; //回溯时更新流量
			flow -= ret; ans += ret; if (!flow) break; //2)
		}
	}
	if (!ans) d[u] = -1; //3)
	return ans;
}
//不断 bfs 找增广路并 dfs 更新
inline ll dinic() { ll ans = 0; while (bfs()) ans += dfs(s, inf); return ans; }
int main()
{
	init(); int n, m; scanf("%d%d%d%d", &n, &m, &s, &t);
    for (int i = 1, x, y, z; i <= m; ++i)
        scanf("%d%d%d", &x, &y, &z), addedge(x, y, z);
    printf("%lld\n", dinic()); return 0;
}

该算法的时间复杂度为 \(\mathcal{O}(n^2m)\),在洛谷上跑板子题比 \(\rm EK\) 快了近十倍。这个复杂度上界也很难跑到,能处理大概 \(10^4\sim 10^5\) 规模的网络。

这里有必要专门提一下代码中标记的一些优化点,没了它们 \(\rm Dinic\) 算法的时间复杂度是不能保证的。首先是 1) 位置上的 cur。这个被称作当前弧优化,我们发现一条增广路一旦被遍历过就不能再被增广了,而每次再重新搜索这些链是很费时间的,所以我们用个 cur 记录一下当前遍历到哪条边了,就不会重复遍历了。然后是 2) 位置上的 break;,这个就比较显然了,当前已经没有流量可以继续增广了,跳出就好。最后是 3) 位置上的 d[u] = -1;,这个表示这条链没有可以增广的地方,把它封死,以后不再进来了。只有这些剪枝和优化都加上了,\(\rm Dinic\) 的时间复杂度才能保证。

最小割

对于流量网络 \(G=(V,E)\),我们称 \(E'\subseteq E\) 为原图的一个割,当且仅当把 \(E'\) 中的边全部删掉后原图不再连通。割会将原图分为两个部分:\(V_1,V_2\)。我们可以用 \((V_1,V_2)\) 来表示一个割。特别地,如果 源点 \(S\in V_1\),汇点 \(T\in V_2\),则称割 \((V_1,V_2)\) 为 S-T 割,下文中的割如无特殊说明均指 S-T 割。

对于一个割 \(E'\),我们称 \(\sum_{(u,v)\in E'}c_{u,v}\) 为割的容量。在所有割中有最小容量的割被称为最小割。

引理1.1 最小割等于最大流。

证明1.1 不会。但感觉可以感性理解,实在想知道的话可以百度。

有了最小割最大流定理后,我们可以把一些问题抽象为最小割模型,并用最大流问题解决,一些常见模型将会在下文给出。

费用流

在原流量网络的定义上,我们给每条边 \((u,v)\) 再加入一个 \(w_{u,v}\) 的边权属性。而一条边 \((u,v)\) 产生的费用为边权乘流量,即 \(f_{u,v}\times w_{u,v}\)。最大/小费用最大流问题就是询问在最大流的前提下,所有边产生的费用之和最大/小是多少。

考虑在 \(\rm Dinic\) 算法的基础上修改一下。注意到原来我们采用的是分层图 \(\rm bfs\),如果改成 \(\rm spfa\) 找边权最短/长路径,就可以在边权最短/长的增广路上进行更新了。但注意这样会失去分层图的作用,可能会在环上反复更新,要开一个 vis 记录一下当前正在遍历的路径,不能重复访问。还要注意,反边的作用是反悔,所以边权应该是正边的相反数。

#include <queue>
#include <cstdio>
#include <cstring>
const int N = 4e5 + 10, M = 9e6 + 100; typedef long long ll;
const ll inf = 0x3f3f3f3f3f3f3f3f; ll mincost, d[N]; int vis[N];
struct edge { int v, next; ll w, c; }E[M]; int p[N], cur[N], cnt, s, t; 
inline void init() { memset(p, -1, sizeof(p)); cnt = 0; }
inline void insert(int u, int v, ll c, ll w) { E[cnt].v = v; E[cnt].c = c; E[cnt].w = w; E[cnt].next = p[u]; p[u] = cnt++; }
inline void addedge(int u, int v, ll c, ll w) { insert(u, v, c, w); insert(v, u, 0, -w); }
bool bfs()
{
	memset(d, 0x3f, sizeof (d)); d[s] = 0; cur[s] = p[s];
    std::queue<int> q; q.push(s); vis[s] = 1; int u;
	while (!q.empty())
	{
		u = q.front(); q.pop(); vis[u] = 0;
		for (int i = p[u], v; i + 1; i = E[i].next)
		{
			v = E[i].v; cur[v] = p[v];
			if (E[i].c && d[v] > d[u] + E[i].w )
			{
				d[v] = d[u] + E[i].w;
				if (!vis[v]) vis[v] = 1, q.push(v); 
			}
		}
	}
	return d[t] != inf;
}
ll dfs(int u, ll flow)
{
	if (u == t) return flow; ll ans = 0, ret; vis[u] = 1;
	for (int i = cur[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v; cur[u] = i;
		if (E[i].c && d[v] == d[u] + E[i].w && !vis[v])
		{
			ll ret = dfs(v, std::min(E[i].c, flow));
			E[i].c -= ret; E[i ^ 1].c += ret; mincost += ret * E[i].w;
			flow -= ret; ans += ret; if (!flow) break;
		}
	}
	if (ans == 0) d[u] = inf; vis[u] = 0;
	return ans;
}
inline ll dinic() { ll ans = 0; while (bfs()) ans += dfs(s, inf); return ans; }
int main()
{
	init(); int n, m; scanf("%d%d%d%d", &n, &m, &s, &t);
	for (int i = 1, u, v, w, c; i <= m; i++)
	    scanf("%d%d%d%d", &u, &v, &w, &c), addedge(u, v, w, c);
	ll ans = dinic(); printf("%lld %lld\n", ans, mincost);
	return 0;
}

该算法的时间复杂度为 \(\mathcal{O}(nmf)\),其中 \(f\) 为最大流。

因为费用流有双重限制,既要求最大流,也要求费用最大/小,所以有很多问题都可以归结为费用流,既要求题目条件得到满足,也要求花费的费用最小,把题目要求用流量表示,费用用权值表示即可。下文会给出一些经典模型。

最大权闭合子图

对于一张有向图 \(G=(V,E)\),我们定义它的子图 \(G'=(V'\subseteq V,E'\subseteq E)\) 为它的闭合子图,当且仅当 \(\forall (u,v)\in E'\),都有 \(u,v\in V'\),即子图内所有边对应的顶点都在该子图内。对于点有点权的图,我们定义它的最大权闭合子图为所有闭合子图中点权和最大的那个。

求解这个问题是最小割模型的经典应用。对于原图的一条有向边 \(u\rightarrow v\),对应到流量网络上是一条从 \(u\)\(v\) 容量为 \(\infty\) 的边对于所有正权点,从源点 \(S\) 向它连一条容量为其点权的边,对于所有负权点,从它向汇点连一条容量为其点权相反数的边。最终最大权闭合子图的权值和为所有正权点对应点权的和减去最小割。证明可以考虑最小割的定义,因为是最小,所以不可能切到容量为 \(\infty\) 的边,就只能去切那些容量是点的权值对应的边。而我们的基本思路是先假设正权点全部选上,再考虑根据闭合子图性质加入负权点或去掉正权点。一个负权点连向汇点的边被割就表示它被选上了,源点连向一个正权点被割就表示它被去掉了。所以最终统计答案是所有正权点对应点权的和减去最小割。

考虑为什么这样割是对的。一条有向边 \(u\rightarrow v\) 表示如果 \(u\) 选了,\(v\) 也要选,反之亦然。而这条有向边在流量网络上对应的边是不能被割掉的,所以如果想保证 \(S\rightarrow T\) 不连通,我们只能从 \(u,v\)\(S,T\) 的连边割。如果 \(u,v\) 的边权均为正,则它们都与 \(S\) 相连,此时 \(u\rightarrow v\) 不对 \(S\rightarrow T\) 的连通性做贡献,可以不用管,对应到原问题就是已经全选上了,不用管。类似地,如果均为负边权,则都与 \(T\) 相连,此时也不做贡献,不用管,对应到原问题就是全都没选,不用管。而如果一个正一个负(不妨令 \(u\) 为正权点),则需要在 \(S\rightarrow u\)\(v\rightarrow T\) 的边中选一条割掉才能保证 \(S\rightarrow T\) 不连通。对应到原问题就是现在 \(u,v\) 一个选了一个没选,要不不选 \(u\),要不选 \(v\)

上下界网络流

暂时不会。

二分图

基本定义

对于一个无向图 \(G=(V,E)\),如果能把结点集合 \(V\) 分为两个不相交的非空集合 \(V_1,V_2\),满足不存在一条边 \((u,v)\in E\) 使得 \(u,v\in V_1\)\(u,v\in V_2\),则称该图为二分图,称 \(V_1\) 为左部点,\(V_2\) 为右部点。

引理2.1 一张图是二分图,当且仅当其不含有奇环。

证明2.1 发现如果成环只能是在左右部点之间来回跳形成的,显然长度为偶数,必要性得证。如果原图没有环,则显然可以分为二分图,如果出现偶环,可以把相邻点分到不同的集合内(注意到奇环做不到这样),所以可以构造出一个二分图,充分性得证。

判断二分图可以通过 \(\rm dfs\) 染色找奇环的方式 \(\mathcal{O}(n)\) 实现。

匹配相关

对于一张无向图 \(G=(V,E)\),我们称 \(E'\subseteq E\) 为原图的匹配,当且仅当 \(E'\) 中任意两条边均没有公共顶点,\(E'\) 也被叫做边独立集。如果 \(E'\) 中再加任意一条边都不是匹配,则称 \(E'\) 为最大匹配,\(|E'|\) 被称为原图的匹配数或边独立数。对于一般图,求最大匹配是 NP 问题,但对二分图,我们有较为高效的算法。对于二分图 \(G=(V_1,V_2,E)\),不放令 \(|V_1|\le |V_2|\),如果 \(S\)\(G\) 的一个匹配且 \(|S|=|V_1|\),则称 \(S\)\(V_1\)\(V_2\) 的完备匹配,如果 \(|V_1|=|V_2|\),则称 \(S\) 为完美匹配。注意最大匹配不一定完备,而完备匹配一定最大。

考虑图 \(G=(V,E)\) 的一个匹配 \(S\)。我们定义 \(e\in S\) 为匹配边,反之为非匹配边。与匹配边相关联的点称为饱和点,反之为非饱和点。若 \(V\) 中所有点都为饱和点,则称 \(S\) 为完美匹配。\(G\) 中由匹配边和非匹配边交错构成的路径称为交错路径,起点终点均为非饱和点的路径称为增广路径。

引理2.2\(S\)\(G\) 的一个匹配,则 \(S\)\(G\) 的最大匹配,当且仅当不存在关于 \(S\) 的增广路径。

证明2.2 考虑反证,如果 \(S\) 是最大匹配且存在一条增广路径,则对于这条增广路径,如果我们把匹配边和非匹配边的状态交换一下(即匹配->非匹配,非匹配->匹配),则显然 \(|S|\) 又会加一,与最大匹配的前提矛盾,得证。

引理2.3 Hall定理:设二分图 \(G=(V_1,V_2,E)\),不妨令 \(|V_1|\le |V_2|\),则 \(G\) 中存在 \(V_1\)\(V_2\) 的完备匹配,当且仅当 \(V_1\) 中任意 \(k\in[1,|V_1|]\) 个顶点至少与 \(V_2\) 中的 \(k\) 个顶点相邻。

证明2.3 证明显然不会。

匈牙利算法

根据上文所说的增广路定理,我们找二分图最大匹配其实相当于不断寻找增广路进行更新直到找不到增广路就找到了最大匹配。匈牙利算法就是基于这个思想的,它一开始把最大匹配置空,不断寻找增广路,并用上文所说的交换状态更新最大匹配,直到找不到增广路算法结束。

#include <cstdio>
#include <cstring>
const int N = 1e3 + 10, M = 5e4 + 10;
struct edge{ int v, next; }E[M]; int p[N], cnt;
inline void init() { memset(p, -1, sizeof p); cnt = 0; }
inline void insert(int u, int v) { E[cnt].v = v; E[cnt].next = p[u]; p[u] = cnt++; }
int n, m, e, match[N], vistime[N];
bool dfs(int u, int tag)
{
	if (vistime[u] == tag) return false; vistime[u] = tag;
	for (int i = p[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v;
		if (match[v] == 0 || dfs(match[v], tag)) { match[v] = u; return true; }
	}
	return false;
}
int main()
{
	init(); scanf("%d%d%d", &n, &m, &e);
	for (int i = 1, u, v; i <= e; i++) scanf("%d%d", &u, &v), insert(u, v);
	int ans = 0;
	for (int i = 1; i <= n; i++) if (dfs(i, i)) ans++;
	printf("%d\n", ans); return 0;
}

该算法时间复杂度为 \(\mathcal{O}(nm)\)

最大流

二分图匹配的问题可以用最大流模型来解决。考虑从源点 \(S\) 向所有左部点连一条容量为 \(1\) 的边,从所有右部点向汇点 \(T\) 连一条容量为 \(1\) 的边,从所有左部点向与它有边相连的右部点连一条容量为 \(1\) 的边。则这张流量网络的最大流即为原二分图最大匹配。源点向左部点连的边和右部点向汇点限制了每个点只能选一次,左部点 右部点之间的边限制了每个边只能选一次,自然最大匹配可以转化为最大流。虽然直接跑 \(\rm Dinic\) 的时间复杂度劣于匈牙利算法,但因为 \(\rm Dinic\) 跑不满,所以通常会比匈牙利算法快。

#include <queue>
#include <cstdio>
#include <cstring>
const int N = 1e3 + 10, M = 1e5 + 10, inf = 2e9;
struct edge{ int v, next, c; }E[M << 1]; int p[N], d[N], cur[N], cnt, s, t;
inline void init() { memset(p, -1, sizeof (p)); cnt = 0; }
inline void insert(int u, int v, int c) { E[cnt].v = v; E[cnt].c = c; E[cnt].next = p[u]; p[u] = cnt++; }
inline void addedge(int u, int v, int c) { insert(u, v, c); insert(v, u, 0); }
inline bool bfs()
{
	memset(d, -1, sizeof (d)); d[s] = 0; cur[s] = p[s];
	std::queue<int> q; q.push(s); int u;
	while (!q.empty())
	{
		u = q.front(); q.pop();
		for (int i = p[u], v; i + 1; i = E[i].next)
		{
			v = E[i].v; cur[v] = p[v];
			if (d[v] == -1 && E[i].c) { d[v] = d[u] + 1; q.push(v); }
		}
	}
	return d[t] != -1;
}
int dfs(int u, int flow)
{
	if (u == t) return flow; int ans = 0, ret;
	for (int i = cur[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v; cur[u] = i; 
		if (E[i].c && d[v] == d[u] + 1)
		{
			ret = dfs(v, std::min(flow, E[i].c));
			E[i].c -= ret; E[i ^ 1].c += ret;
			flow -= ret; ans += ret; if (!flow) break;
		}
	}
	if (!ans) d[u] = -1;
	return ans;
}
inline int dinic() { int ans = 0; while (bfs()) ans += dfs(s, inf); return ans; }
int main()
{
	init(); int n, m, e; scanf("%d%d%d", &n, &m, &e); s = 0; t = n + m + 1;
	for (int i = 1, x, y; i <= e; ++i) scanf("%d%d", &x, &y), addedge(x, y + n, 1);
	for (int i = 1; i <= n; ++i) addedge(s, i, 1);
	for (int i = 1; i <= m; ++i) addedge(i + n, t, 1);
	printf("%d\n", dinic()); return 0;
}
最大权匹配

在一张边有边权的二分图上,最大权匹配定义为在所有最大匹配中匹配边权值和最大的匹配。解决这个问题有个专门的算法叫 \(\rm KM\) 算法,时间复杂度为 \(\mathcal{O}(n^3)\),但是它太过于繁琐了,本文不再介绍。注意到这个限制非常像刚刚所说的费用流既要最大流也要最大费用的限制,所以我们考虑费用流建模。还是延续刚刚二分图匹配的建图方法,只不过加上边权,源汇点与其他点之间的边边权为 \(0\),左右部点之间的边边权为其对应二分图上原边的边权,跑最大费用最大流即可。

#include <queue>
#include <cstdio>
#include <cstring>
const int N = 1e3 + 10, M = N * N + 1e6; typedef long long ll;
const ll inf = -0x8080808080808080; ll maxcost, d[N]; int vis[N];
struct edge { int v, next; ll w, c; }E[M << 1]; int p[N], cur[N], cnt, s, t; 
inline void init() { memset(p, -1, sizeof(p)); cnt = 0; }
inline void insert(int u, int v, ll c, ll w) { E[cnt].v = v; E[cnt].c = c; E[cnt].w = w; E[cnt].next = p[u]; p[u] = cnt++; }
inline void addedge(int u, int v, ll c, ll w) { insert(u, v, c, w); insert(v, u, 0, -w); }
bool bfs()
{
	memset(d, 0x80, sizeof (d)); d[s] = 0; cur[s] = p[s];
    std::queue<int> q; q.push(s); vis[s] = 1; int u;
	while (!q.empty())
	{
		u = q.front(); q.pop(); vis[u] = 0;
		for (int i = p[u], v; i + 1; i = E[i].next)
		{
			v = E[i].v; cur[v] = p[v];
			if (E[i].c && d[v] < d[u] + E[i].w )
			{
				d[v] = d[u] + E[i].w;
				if (!vis[v]) vis[v] = 1, q.push(v); 
			}
		}
	}
	return d[t] != -inf;
}
ll dfs(int u, ll flow)
{
	if (u == t) return flow; ll ans = 0, ret; vis[u] = 1;
	for (int i = cur[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v; cur[u] = i;
		if (E[i].c && d[v] == d[u] + E[i].w && !vis[v])
		{
			ll ret = dfs(v, std::min(E[i].c, flow));
			E[i].c -= ret; E[i ^ 1].c += ret; maxcost += ret * E[i].w;
			flow -= ret; ans += ret; if (!flow) break;
		}
	}
	if (ans == 0) d[u] = inf; vis[u] = 0;
	return ans;
}
inline ll dinic() { ll ans = 0; while (bfs()) ans += dfs(s, inf); return ans; }
int main()
{
	init(); int n, m; scanf("%d%d", &n, &m); s = 0; t = n + n + 1;
	for (int i = 1, x, y, z; i <= m; ++i)
		scanf("%d%d%d", &x, &y, &z), addedge(x, y + n, 1, z);
	for (int i = 1; i <= n; ++i) addedge(s, i, 1, 0), addedge(i + n, t, 1, 0);
	dinic(); printf("%lld\n", maxcost);
	for (int u = n + 1; u <= n + n; ++u)
		for (int i = p[u]; i + 1; i = E[i].next)
			if (E[i].c && E[i].v <= n) { printf("%d ", E[i].v); break; }
	printf("\n"); return 0;z
}

二分图最小点覆盖

对于一张无向图 \(G=(V,E)\),我们称 \(V'\subseteq V\) 为原图的一个点覆盖,当且仅当 \(\forall (u,v)\in E\),都有 \(u,v\in V'\)。二分图的点覆盖中最小的点覆盖称为该二分图的最小点覆盖。注意到二分图的最大匹配和最小点覆盖很相似,事实上最大匹配中的边数等于最小点覆盖的点数。

二分图最大独立集

对于一张无向图 \(G=(V,E)\),我们称 \(V'\subseteq V\) 为原图的独立集,当且仅当 \(\forall u,v\in V'\),都有 \((u,v)\notin E\),包含点数最多的独立集为最大独立集。

对于一张无向图 \(G=(V,E)\),我们称 \(V'\subseteq V\) 为原图的团,当且仅当 \(\forall u,v\in V'\),都有 \((u,v)\in E\),包含点数最多的团为最大团。

无向图的最大团,等于补图 \(G'\) 的最大独立集,对于一般无向图,最大团和最大点独立集都是 NP 问题。

对于二分图,最大独立集大小等于顶点总数减去最小点覆盖集的大小等于顶点总数减去最大匹配的大小。

DAG 的最小路径覆盖

给定一张 DAG,用尽量少的不相交路径覆盖所有顶点,这样的路径集合称为最小路径覆盖。考虑二分图建模,把原图中所有点拆成 \(u\)\(u'\),从源点 \(S\) 向所有 \(u\) 连一条容量为 \(1\) 的边,从所有 \(u'\) 向汇点 \(T\) 连一条容量为 \(1\) 的边,对于每一条有向边 \(u\rightarrow v\),我们从 \(u\)\(v'\) 连一条 容量为 \(1\) 的边。最终答案即为顶点总数减去该二分图的最大匹配数。一条边被选上了,当且仅当其对应的左部点右部点之间的连边满流。

经典建模

目前更新的基本只有网络流 24 题里面的一些建模,之后我做到一些新的建模会随时补上的。注意这里用到的基本全都是 \(\rm Dinic\) 求最大流或费用流的板子,就略去了。

拆点

拆点有多种作用,比如把点的流量限制转化到边上,标记一个点的不同功能等等,这里给出一些例题具体说明。

P1251 餐巾计划问题

一个餐厅在连续的 \(n\) 天内每天需要用 \(r_i\) 块餐巾,每天可以花 \(p\) 分每个购买餐巾,可以把用过的旧餐巾送到快洗部花 \(f\) 分每个洗 \(m\) 天后洗完,可以把旧餐巾送到慢洗部花 \(s\) 分每个洗 \(n\) 天后洗完。每天早上餐巾来源可以是新买,之前送去洗的,晚上可以把餐巾保存延期送洗,或者直接送去洗。问 \(n\) 天每天都满足要求的最小花费。

首先发现既要满足要求,也要花费最小,可以想到费用流模型。注意到每天早晚的功能是不一样的,早上是要接收餐巾,晚上要送洗或保存餐巾。所以我们把每天 \(u\) 拆成早上 \(X_u\) 和晚上 \(Y_u\)。接着根据题意一点点连边。首先要满足餐巾足够,这点应该通过流量来反映。我们从源点 \(S\) 向每个 \(Y_u\) 连一条容量为 \(r_u\),费用为 \(0\) 的边,表示每晚会获得 \(r_u\) 个旧餐巾,从每个 \(X_u\) 向汇点连一条容量为 \(r_u\),费用为 \(0\) 的边,表示每天早上要提供 \(r_u\) 个新餐巾,最大流即要求这些都满流,也就保证要求满足。接下来考虑餐巾的转移问题,这就涉及到花费了。从每个 \(Y_u\)\(Y_{u+1}\) 连一条容量为 \(\infty\),费用为 \(0\) 的边,表示延期送洗。从每个 \(Y_u\)\(X_{u+m}\) 连一条容量为 \(\infty\),费用为 \(f\) 的边,表示快洗。从每个 \(Y_u\)\(X_{u+n}\) 连一条容量为 \(\infty\),费用为 \(s\) 的边,表示慢洗。最终从源点向每个 \(X_u\) 连一条容量为 \(\infty\),费用为 \(p\) 的边,表示直接买。这样就把题目所有的信息涵盖进来了,直接跑费用流即可。

int main()
{
	init(); N = read(); int x; S = 0, T = 2 * N + 1;
	for (int i = 1; i <= N; i++)
	    x = read(), addedge(S, i + N, x, 0), addedge(i, T, x, 0);
	int p = read(), m = read(), f = read(), n = read(), s = read();
	for (int i = 1; i <= N; i++)
	{
		addedge(S, i, inf, p);
		if (i != N) addedge(i + N, i + 1 + N, inf, 0);
		if (i + m <= N) addedge(i + N, i + m, inf, f);
		if (i + n <= N) addedge(i + N, i + n, inf, s);
	}
	dinic(); printf("%lld", mincost); return 0;
}

P2754 [CTSC1999]家园 / 星际转移问题

\(k\) 个人想从起点 \(0\) 转移到终点 \(n+1\)。其中有 \(1\sim n\) 个中转节点,有 \(m\) 个飞船在 \(0\sim n+1\) 个结点中的一部分周期性停靠,每个可容纳 \(h_i\) 人。问经过多少天所有人能转移成功,或报告无解。

首先看无解,把所有可能停靠的地方连边,看看 \(0\)\(n+1\) 在不在同一集合即可,可以用并查集轻松实现。对于找天数,我们发现每个点除了它的编号需要记录,还需要记录它的当前对应的时间。所以我们可以考虑枚举答案,对于当前枚举到的值 \(t\),我们把所有点分成 \(t\) 个点表示不同时刻的点。并根据飞船的周期性停靠在不同时刻之间连容量为 \(h_i\) 的边即可。并将源点 \(S\) 连向 \(0\) 时刻的 \(0\) 结点,容量为 \(\infty\)。汇点需要处理一下,可以连向 \(0\) 时刻的 \(n+1\) 结点,并把所有时刻的 \(n+1\) 结点都像 \(0\) 时刻的连一条容量为 \(\infty\) 的边。当枚举到的值第一次能使对应的图能使得最大流大于等于 \(k\) 时,这个值即为答案。

inline int dinic(int s) 
{ 
	int ans = 0, d; 
    //这里稍微有点不一样,为了效率我们是每次都在残量网络上跑的,所以最大流是每次累加的 
	while (bfs()) 
	{
		for (int i = 0; i <= s; ++i) cur[i] = p[i];
		ans += dfs(S, inf); 
	}
	return maxflow += ans; 
}
int main()
{
	init(); scanf("%d%d%d", &n, &m, &k); S = 0; T = n + 1;
	for (int i = 1; i <= n + 1; ++i) f[i] = i;
	for (int i = 1; i <= m; ++i)
	{
		scanf("%d%d", &lim[i], &l[i][0]);
		for (int j = 1; j <= l[i][0]; ++j)
		{
			scanf("%d", &l[i][j]);
			l[i][j] = l[i][j] == -1 ? n + 1 : l[i][j];
			if (j > 1) merge(l[i][j], l[i][j - 1]);
		}
	}
	if (getf(0) != getf(n + 1)) { printf("0\n"); return 0; }
	for (int ans = 1; ; ++ans)
	{
		for (int i = 0; i <= n; ++i)
			addedge(i + (ans - 1) * (n + 2), i + ans * (n + 2), inf);
		addedge(n + 1 + ans * (n + 2), n + 1 + (ans - 1) * (n + 2), inf);
		for (int i = 1, las, now; i <= m; ++i)
		{
			las = (ans - 1) % l[i][0] + 1;
			now = ans % l[i][0] + 1;
			addedge(l[i][las] + (ans - 1) * (n + 2), l[i][now] + ans * (n + 2), lim[i]);
		}
		if (dinic((ans + 1) * (n + 2)) >= k) { printf("%d\n", ans); break; }
	}
	return 0;
}

P2766 最长不下降子序列问题

给出一个长为 \(n\) 的正整数序列 \(x\),共有三问:

  1. 计算其最长不下降子序列的长度 \(s\)
  2. 每个元素只允许使用一次的情况下,从原序列中能取出多少长为 \(s\) 的子序列。
  3. 如果允许多次使用 \(x_1,x_n\),则从原序列中能取出多少长为 \(s\) 的子序列。

首先对于第一问,我们 \(\rm dp\) 随便求一下就好。求完之后我们会得到一个 \(\rm dp\) 数组 \(f_i\),表示前 \(i\) 个数能形成的最长不下降子序列,我们用这个数组来建图。注意到一个点只能使用一次,但网络流中只有对边的流量限制没有对点的流量限制。我们可以把一个点拆为 \(X_i\)\(Y_i\),在它们之间连一条容量为 \(1\) 的边,这样就限制通过这个点的流量了。而对于 \(f_i=1\) 的点,这是子序列的开头,我们让源点 \(S\)\(X_i\) 连一条容量为 \(1\) 的边,如果 \(f_i=s\),则是子序列的结尾,直接让这个点向汇点 \(T\) 连一条容量为 \(1\) 的边即可。而如果 \(f_j+1=f_i,a_j\le a_i\),则这两个点在子序列上相邻,从 \(Y_j\)\(X_i\) 连一条容量为 \(1\) 的边即可。直接跑最大流我们就做出来了第二问。而对于第三问,我们只需要把跟 \(x_1,x_n\) 有关的边容量改改就好,具体来讲,\((X_1,Y_1),(X_n,Y_n),(S,X_1),(Y_n,T)\)(后面两条边如果存在就改)这四条边的容量改为 \(\infty\) 就好。注意,如果 \(s=1\),则需要特判,否则第三问的求法会给出 \(\infty\)

inline void add(int d)
{
	init(); s = 0; t = 2 * n + 1;
	for (int i = 2; i < n; ++i) addedge(i, i + n, 1);
	addedge(1, 1 + n, d ? inf : 1); addedge(n, n + n, (d && f[n] == ans) ? inf : 1);
	if (f[1] == 1) addedge(s, 1, d ? inf : 1);
	if (f[n] == ans) addedge(n + n, t, d ? inf : 1);
	for (int i = 2; i < n; ++i)	
		if (f[i] == 1) addedge(s, i, 1);
		else if (f[i] == ans) addedge(i + n, t, 1);
	for (int i = 1; i <= n; ++i)
		for (int j = 1; j < i; ++j)
			if (a[j] <= a[i] && f[j] + 1 == f[i]) addedge(j + n, i, 1);
}
int main()
{
	scanf("%d", &n); if (n == 1) { printf("1\n1\n1\n"); return 0; }
	for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	for (int i = 1; i <= n; ++i)
	{
		f[i] = 1;
		for (int j = 1; j < i; ++j)
			if (a[j] <= a[i] && f[j] + 1 > f[i]) f[i] = f[j] + 1;
	}
	for (int i = 1; i <= n; ++i) ans = std::max(ans, f[i]); printf("%d\n", ans);
	if (ans == 1) { printf("%d\n%d\n", n, n); return 0; }
	add(0); printf("%d\n", dinic()); add(1); printf("%d\n", dinic());
	return 0;
}

P4016 负载平衡问题

给出 \(n\) 个沿环形排列的仓库,每个仓库储存了 \(a_i\) 个货物。可以在相邻的仓库之间搬运货物,问最少需要搬运多少货物能使得所有仓库存储的货物相同。

因为既要满足仓库货物相同,又要满足搬运次数少,所以考虑费用流。首先显然有最终每个仓库应该有 \(\overline{a_i}\),即所有货物的平均值,个货物。这样我们就能明白哪个应该接收,哪个应该发送了。注意到一个点有两个功能,它既可以接收货物,也可以作为货物的中转站,所以我们考虑拆点。我们把每个仓库拆成 \(X_i,Y_i\)。对于所有应该接收的仓库,我们把 \(Y_i\) 向汇点 \(T\) 连容量为 \(\overline{a_i}-a_i\),费用为 \(0\) 的边。对于所有应该发送的仓库 ,我们把源点 \(S\)\(X_i\) 连容量为 \(a_i-\overline{a_i}\),费用为 \(0\) 的边。这样最大流的时候就满足货物相同了。搬运就简单了,一个点当中转站时,就是由相邻的点向它的 \(X_i\) 连一条容量为 \(\infty\),费用为 \(1\) 的边,当接收站时,就是由相邻的点向它的 \(Y_i\) 连一条容量为 \(\infty\),费用为 \(1\) 的边。跑费用流即可。

int main()
{
	init(); int n, sum = 0; scanf("%d", &n);
	for (int i = 1; i <= n; ++i) scanf("%d", &a[i]), sum += a[i];
	sum /= n; s = 0; t = n * 2 + 1;
	for (int i = 1; i <= n; ++i)
		if (a[i] > sum) addedge(s, i, a[i] - sum, 0);
		else if (a[i] < sum) addedge(i + n, t, sum - a[i], 0);
	for (int i = 1; i < n; ++i) addedge(i, i + 1, inf, 1), addedge(i, i + 1 + n, inf, 1);
	addedge(n, 1, inf, 1); addedge(n, 1 + n, inf, 1);
	for (int i = n; i > 1; --i) addedge(i, i - 1, inf, 1), addedge(i, i - 1 + n, inf, 1);
	addedge(1, n, inf, 1); addedge(1, n + n, inf, 1);
	dinic(); printf("%d\n", mincost); return 0;
}

P2765 魔术球问题/P2764 最小路径覆盖问题

这俩题差不多,我只放非板子题的题面了。有 \(n\) 个柱子,依次在上面放入编号为 \(1,2,\cdot\cdot\cdot\) 的小球,满足每次只能在最上面放且相邻球的编号和为平方数。问 \(n\) 个柱子上最多能放多少球。

先把非板子题变成板子。还是依次枚举答案(其实跟上面的家园一样,都满足单调性可以二分,但因为我们要每次加入点边,不方便回撤,所以就只能枚举了),对于枚举到的 \(t\),我们建出 \(1\sim t\) 的点。一条边 \(u\rightarrow v\) 存在,当且仅当 \(u+v\) 为平方数且 \(u<v\)。这样问题就变为了求这个 DAG 的最小路径覆盖。然后可以参照上面的介绍拆点跑匹配,输出方案我会在代码内说明。

inline bool check(int a)
{
	int d = ceil(sqrt(a));
	return d * d == a;
}
void dfs(int u)
{
	st[++st[0]] = u; vis[u] = 1;
	for (int i = G2.p[u], v; i + 1; i = G2.E[i].next)
	{
		v = G2.E[i].v; if (G2.E[i].vis || vis[v]) continue;
		dfs(v); //vis 表示这个点或边走过了或不能走
	}
}	
int main()
{
	G1.init(); G2.init(); int n, d = 0, kn, ans; 
	scanf("%d", &n); s = 0; t = 1e6; kn = 1e5;
	for (ans = 1; ; ++ans)
	{
		G1.addedge(s, ans, 1); G1.addedge(ans + kn, t, 1);
		for (int i = 1; i < ans; ++i) 
			if (check(i + ans)) 
			{
				G2.insert(i, ans, 0), G1.addedge(i, ans + kn, 1);
				id[++tp].first = G1.cnt - 1; id[tp].second = G2.cnt - 1;
			}
				
		d += dinic(); if (ans - d > n) break;
	}
	printf("%d\n", ans - 1); vis[ans] = 1; //ans 已经不合法了,不能走
	for (int i = 1; i <= tp; ++i)
		if (!G1.E[id[i].first].c) G2.E[id[i].second].vis = 1; //没匹配上,不能走
	for (int i = 1; i < ans; ++i)
		if (!vis[i]) 
		{
			st[0] = 0; dfs(i); //对于能走的点依次 dfs 找路径即可
			for (int j = 1; j <= st[0]; ++j) printf("%d ", st[j]);
			printf("\n");
		}
	return 0;
}

最小割模型

最小割模型包括许多部分,会在每道例题具体说明。

P2762 太空飞行计划问题

现在能做 \(m\) 个实验,实验会用到 \(n\) 个器材。配置器材要花费 \(c_i\) 的费用,进行每个实验会获得 \(p_i\) 的收入。一个实验能进行当且仅当其需要的所有器材都被配置,求能获得的最大收入。

这个可以转化为最大权独立集理解,不过我个人更喜欢直接用二分图表示,感觉也可以单独叫依赖问题。考虑把实验看成左部点,器材看成右部点,源点 \(S\) 向每个实验连容量为 \(p_i\) 的边,每个器材向汇点 \(T\) 连一条容量为 \(c_i\) 的边。每个实验向它需要的器材连一条容量为 \(\infty\) 的边。最终答案即为所有实验的收入减去最小割,其实就是最大权独立集的想法,所以证明不在赘述了。

int main()
{
	init(); int n, m, sum = 0; scanf("%d%d", &m, &n); s = 0; t = n + m + 1;
	for (int i = 1, val, ulen, tool; i <= m; ++i)
	{
		scanf("%d", &val); sum += val; ulen = 0; addedge(s, i, val);
		memset(tools, 0, sizeof (tools)); std::cin.getline(tools, 10000);
		while (sscanf(tools + ulen, "%d", &tool) == 1)
		{
			addedge(i, tool + m, inf); 
			if (tool == 0) ++ulen;
			else while (tool) tool /= 10, ++ulen;
			++ulen;
		}
	}
	for (int i = 1, w; i <= n; ++i) scanf("%d", &w), addedge(i + m, t, w);
	int ans = sum - dinic();
	for (int i = 1; i <= m; ++i) if (d[i] != -1) printf("%d ", i); printf("\n");
	for (int i = 1; i <= n; ++i) if (d[i + m] != -1) printf("%d ", i); printf("\n");
	printf("%d\n", ans); return 0;
}

P2774 方格取数问题/P3355 骑士共存问题

又是差不多的两道题,题意放两个题的共同抽象题意吧。方格上每个点都有点权,但有些点对(点对不能被同时选的必要不充分条件是横纵坐标和奇偶性不同)不能同时取到,问能取的最大点权为多少。

这是典型的二者选其一模型,可以转化为最小割来求解。考虑先全部选,然后去掉尽量少的边权。把 \(S\) 向所有横纵坐标和为偶数的点连一条容量为点权的边,把所有横纵坐标为奇数的点向 \(T\) 连一条容量为点权的边。不能同时选的点之间连一条容量为 \(\infty\) 的边。这样割的时候不会割掉不能同时选的点之间的边,而两边显然必须割一个表示不选。最终答案就是全部边权和减去最小割。代码给的是方格取数。

int main()
{
	init(); int n, m, sum = 0; scanf("%d%d", &n, &m); s = 0; t = n * m + 1;
	for (int i = 1, a; i <= n; ++i)
		for (int j = 1; j <= m; ++j) 
		{
			scanf("%d", &a), sum += a;
			if ((i + j) & 1) addedge(id(i, j), t, a);
			else
			{
				addedge(s, id(i, j), a);
				for (int k = 0; k < 4; ++k)
				{
					int tx = i + nxt[k][0], ty = j + nxt[k][1];
					if (tx < 1 || tx > n || ty < 1 || ty > m) continue;
					addedge(id(i, j), id(tx, ty), inf);
				}
			}
		}
	printf("%d\n", sum - dinic()); return 0;
}

费用流模型

像上文说的那样,费用流用来求解既要满足某一条件,又要使某种花费最小/大的问题。

P4009 汽车加油行驶问题

有初始装满油的汽车在网格图上从起点行驶到终点,这个汽车充满油后能行驶 \(k\) 个网格边,网格图上除了起点终点有若干个加油站。如果汽车经过网格边时横纵坐标减小则需要付 \(b\) 元,反之不需要。如果遇到加油站则必须加满油并付 \(a\) 元,如果需要可以增设加油站,付 \(c\) 元。(不包括加油费用)问从起点到终点的最小费用。

因为既要满足到达终点,也要满足花费最小,所以考虑费用流。这个油量比较烦人,考虑分层图套路,从 \(1\sim k\) 给每个图编号表示当前油量。加油站点可以将所有 \(1\sim k-1\) 层的点向 \(k\) 层点连一条容量为 \(1\),费用为 \(a\) 的边,并在 \(k\) 层向 \(k-1\) 层上相邻的点连容量为 \(1\) 的边。(注意反向走的费用为 \(b\))对于普通点的第 \(2\sim k\) 层,我们向油量少一的层数上相邻的点连容量为 \(1\) 的边,对于第 \(1\) 层只能增设加油站,向第 \(k\) 层连容量为 \(1\),边权为 \(a+c\) 的边。源点向第 \(k\) 层的起点连容量为 \(1\),费用为 \(0\) 的边,第 \(1\sim k\) 的终点向汇点连容量为 \(1\),费用为 \(0\) 的边。跑费用流即可。

inline void add(int x, int y, int k)
{
	for (int l = 0; l < 2; ++l)
	{
		int tx = x + nxt[l][0], ty = y + nxt[l][1];
		if (tx < 1 || ty < 1 || tx > n || ty > n) continue;
		addedge(id(x, y, k), id(tx, ty, k - 1), inf, 0);
	}
	for (int l = 2; l < 4; ++l)
	{
		int tx = x + nxt[l][0], ty = y + nxt[l][1];
		if (tx < 1 || ty < 1 || tx > n || ty > n) continue;
		addedge(id(x, y, k), id(tx, ty, k - 1), inf, b);
	}
}
int main()
{
	init(); int K, a, c; scanf("%d%d%d%d%d", &n, &K, &a, &b, &c);
	s = 0; t = id(n, n, K) + 1;
	for (int i = 1, x; i <= n; ++i)
		for (int j = 1; j <= n; ++j)
		{
			scanf("%d", &x);
			if (x) for (int k = 0; k < K; ++k) addedge(id(i, j, k), id(i, j, K), 1, a);
			for (int k = 1; k <= K; ++k) if ((x && k == K) || !x) add(i, j, k);
			addedge(id(i, j, 0), id(i, j, K), 1, a + c);
		}
	addedge(s, id(1, 1, K), 1, 0); 
	for (int k = 0; k <= K; ++k) addedge(id(n, n, k), t, 1, 0);
	dinic(); printf("%d\n", mincost); return 0;
}

P4012 深海机器人问题/火星探险问题

这俩差不多。在一个网格图上有一些边上有边权(或点有点权),也有一些有障碍,但重复经过只能计算一次。从起点 \((1,1)\) 出发有若干个小车,只能向下或向右移动,一个小车经过的所有边权(或点权)被计算当且仅当其到达了终点 \((n,n)\)。问在到达终点小车最多的前提下能得到的最大边权(或点权)和。

既要到达终点,也要边权和最大,显然考虑费用流。不过问题在于重复经过只计算一次,因为普通的费用流多次经过是算多次的。考虑连两条边,一条容量为 \(1\),费用为边权,另一条容量为 \(\infty\),费用为 \(0\)。这样既可以多次经过,也只算一次边权了(点权类似,拆点即可)。别的就类似上面的网格图连边了,向相邻的连容量 \(\infty\),费用为 \(0\) 的边,不连有障碍的即可。最终源点向 \((1,1)\) 连容量为小车个数,费用为 \(0\) 的边,\((n,n)\) 向汇点连容量为 \(\infty\),费用为 \(0\) 的边,跑费用流即可。这里给火星探险问题的代码,找路径的部分有我写代码的时候写的注释,凑合看吧。

void getPath(int x, int d)
{
	if (x == n * m) return ; int u = x + n * m, v;
	if (x % m > 0) //not at the rightmost
	{
		v = x + 1; int i; //go right 
		for (i = p[u]; i + 1; i = E[i].next)
			if (E[i].v == v) break;
		if (E[i ^ 1].c) //there was a car
		{
			--E[i ^ 1].c; printf("%d 1\n", d);
			getPath(v, d); return ;
		}
	}
	if (x <= n * m - m) //not at the bottom
	{
		v = x + m; int i; //go down
		for (i = p[u]; i + 1; i = E[i].next)
			if (E[i].v == v) break;
		if (E[i ^ 1].c)
		{
			--E[i ^ 1].c; printf("%d 0\n", d);
			getPath(v, d); return ;
		}
	}
}
int main()
{
	init(); int k; scanf("%d%d%d", &k, &m, &n); s = 0; t = 2 * n * m + 1;
	for (int i = 1; i <= n; ++i)
		for (int j = 1; j <= m; ++j) scanf("%d", &mp[i][j]);
	for (int i = 1; i <= n; ++i)
		for (int j = 1; j <= m; ++j)
		{
			if (mp[i][j] == 1) continue;
			if (mp[i][j] == 2) addedge(id(i, j), id(i, j) + n * m, 1, 1);
			addedge(id(i, j), id(i, j) + n * m, inf, 0); 
			if (i < n && mp[i + 1][j] != 1) addedge(id(i, j) + n * m, id(i + 1, j), inf, 0);
			if (j < m && mp[i][j + 1] != 1) addedge(id(i, j) + n * m, id(i, j + 1), inf, 0);
		}
	addedge(s, id(1, 1), k, 0); addedge(id(n, m) + n * m, t, inf, 0);
	int d = dinic(); for (int i = 1; i <= d; ++i) getPath(1, i); return 0;
}

P4013 数字梯形问题

有一个数字梯形,第一行有 \(m\) 个数字,之后每一行比上一行多一个数字,共 \(n\) 行。从第一行的 \(m\) 个数字出发形成的 \(m\) 条路径(一个点只能朝它的左下角或右下角行进)中经过的数字之和(多次经过算多次)是该方案的权值。问分别遵守以下三种规则得到的最大权值方案的权值。

  1. \(m\) 条路径互不相交。
  2. \(m\) 条路径只允许在数字结点处相交。
  3. \(m\) 条路径允许在任意位置相交。

既要到达底部,又要权值最大,考虑费用流。对于边上的限制,我们把点转移时走的边容量设为 \(1\),费用设为 \(0\) 即可。对于点权计算和点上的限制,考虑拆点,\(X_i\)\(Y_i\) 连容量为 \(1\),费用为点权的边。最后源点向所有第一层的 \(X_i\) 连容量为 \(1\),费用为 \(0\) 的边,所有最后一层的 \(Y_i\) 向汇点连容量为 \(1\) 的边即可。考虑规则 2 放开的限制,我们需要把 \(X_i\)\(Y_i\) 连的边容量改为 \(\infty\),而因为这样最后一层的每个点可能称为多个路径的即为,所以最后一层的 \(Y_i\) 向汇点连的边容量也要改为 \(\infty\)。而规则 3 放开的限制只需要把点转移时走的边容量也改为 \(\infty\) 即可。最后跑三遍费用流。

int main()
{
	int m, n; scanf("%d%d", &m, &n); int d = id(n, n + m - 1);
	s = 0, t = d * 2 + 1;
	for (int i = 1, len = m; i <= n; ++i, ++len)
		for (int j = 1; j <= len; ++j) scanf("%d", &a[i][j]);
	init(); for (int i = 1; i <= m; ++i) addedge(s, id(1, i), 1, 0);
	for (int i = 1; i <= n + m - 1; ++i) addedge(id(n, i) + d, t, inf, 0);
	for (int i = 1, len = m; i <= n; ++i, ++len)
		for (int j = 1; j <= len; ++j) addedge(id(i, j), id(i, j) + d, 1, a[i][j]);
	for (int i = 1, len = m; i < n; ++i, ++len)
		for (int j = 1; j <= len; ++j) 
			addedge(id(i, j) + d, id(i + 1, j), 1, 0), 
			addedge(id(i, j) + d, id(i + 1, j + 1), 1, 0);
	dinic(); printf("%d\n", maxcost); maxcost = 0;
	init(); for (int i = 1; i <= m; ++i) addedge(s, id(1, i), 1, 0);
	for (int i = 1; i <= n + m - 1; ++i) addedge(id(n, i) + d, t, inf, 0);
	for (int i = 1, len = m; i <= n; ++i, ++len)
		for (int j = 1; j <= len; ++j) addedge(id(i, j), id(i, j) + d, inf, a[i][j]);
	for (int i = 1, len = m; i < n; ++i, ++len)
		for (int j = 1; j <= len; ++j) 
			addedge(id(i, j) + d, id(i + 1, j), 1, 0), 
			addedge(id(i, j) + d, id(i + 1, j + 1), 1, 0);
	dinic(); printf("%d\n", maxcost); maxcost = 0;
	init(); for (int i = 1; i <= m; ++i) addedge(s, id(1, i), 1, 0);
	for (int i = 1; i <= n + m - 1; ++i) addedge(id(n, i) + d, t, inf, 0);
	for (int i = 1, len = m; i <= n; ++i, ++len)
		for (int j = 1; j <= len; ++j) addedge(id(i, j), id(i, j) + d, inf, a[i][j]);
	for (int i = 1, len = m; i < n; ++i, ++len)
		for (int j = 1; j <= len; ++j) 
			addedge(id(i, j) + d, id(i + 1, j), inf, 0), 
			addedge(id(i, j) + d, id(i + 1, j + 1), inf, 0);
	dinic(); printf("%d\n", maxcost); return 0;
}

P4015 运输问题

\(m\) 个仓库和 \(n\) 个零售店,每个仓库有 \(a_i\) 个货物,每个零售店需要 \(b_i\) 个货物。保证 \(\sum_{i=1}^m a_i=\sum_{i=1}^n b_i\)。从第 \(i\) 个仓库向第 \(j\) 个零售店运货物的价格是 \(c_{ij}\)。问把所有货物从仓库运到零售店的最小和最大价格。

...我不想说那句话了,反正显然考虑费用流。这个比较板子了,从源点向仓库连容量为 \(a_i\) ,费用为 \(0\) 的边,从零售店向汇点连容量为 \(b_i\),费用为 \(0\) 的边。从每个仓库向每个零售店连容量为 \(\infty\),费用为 \(c_{i,j}\) 的边。跑两遍费用流即可。

int main()
{
	int m, n; scanf("%d%d", &m, &n); s = 0, t = m + n + 1;
	for (int i = 1; i <= m; i++) scanf("%d", &a[i]);
	for (int i = 1; i <= n; i++) scanf("%d", &b[i]);
	for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++)
		scanf("%d", &c[i][j]);
	init();
	for (int i = 1; i <= m; i++) addedge(s, i, a[i], 0);
	for (int i = 1; i <= n; i++) addedge(i + m, t, b[i], 0);
	for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++)
		addedge(i, j + m, inf, c[i][j]);
	dinic(1); printf("%d\n", mincost);
	init();
	for (int i = 1; i <= m; i++) addedge(s, i, a[i], 0);
	for (int i = 1; i <= n; i++) addedge(i + m, t, b[i], 0);
	for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++)
		addedge(i, j + m, inf, c[i][j]);
	dinic(2); printf("%d\n", maxcost);
	return 0;
} 

P2770 航空路线问题

给定一张航空图,边表示两个城市之间的直通航道,不存在两个城市在同一个经线上。现在要求找出满足以下两个条件的一条经过城市最多的路径或报告无解:

  1. 从最西端城市出发,单向从西到东经过若干城市到达最东端城市,然后再单向从东向西途经若干城市飞回起点。
  2. 除起点外,每个城市都只能经过一次。

既满足绕回来,也要经过城市最多,费用流。这说是找个回路,其实我们也可以理解为找两条从起点到终点的不相交的路径。由于点的流量限制,考虑拆点,除了起点终点外每个点只能经过一次,\(X_i\rightarrow Y_i\) 边的容量为 \(1\),费用为 \(1\) 表示经过一个城市,对于起点终点可以经过两次,容量为 \(2\),费用为 \(1\),但这样会多算两个城市,最后要减去。源点向起点的 \(X_s\) 连容量为 \(2\),费用为 \(0\) 的边,终点的 \(Y_t\) 向汇点连容量为 \(2\),费用为 \(0\) 的边。每条边 \((u,v)\) 对应着 \(Y_u\rightarrow X_v\) 的一条容量为 \(1\),花费为 \(0\) 的边。直接跑费用流即可,跑完之后 \(\rm dfs\) 找路径。

std::map<std::string, int> mp; std::string c1, c2, c[N]; int n, vs[N];
void findPath(int u, int d)
{
    //第一次正序输出
	if (!d) std::cout << c[u - n] << std::endl, vs[u] = 1;
	for (int i = p[u], v; i + 1; i = E[i].next)
	{
		v = E[i].v; //break; 意思是第一次只找一条路径
		if (v <= n && !E[i].c && (!d ? 1 : !vs[v + n])) { findPath(v + n, d); if (!d) break; }
	}
    //第二次倒序输出且不输出终点
	if (d) std::cout << c[u - n] << std::endl;
}
int main()
{
	std::ios::sync_with_stdio(0); std::cin.tie(0); std::cout.tie(0);
	init(); int m; std::cin >> n >> m; s = 0; t = n * 2 + 1; int flag = 0;
	for (int i = 1; i <= n; ++i) 
		std::cin >> c[i], mp[c[i]] = i, addedge(i, i + n, i == 1 || i == n ? 2 : 1, 1);
	for (int i = 1, x, y; i <= m; ++i)
	{
		std::cin >> c1 >> c2; x = mp[c1]; y = mp[c2]; 
		if (x > y) std::swap(x, y); 
		addedge(x + n, y, 1, 0); flag |= (x == 1 && y == n);
	}
	addedge(s, 1, 2, 0); addedge(n + n, t, 2, 0); int d = dinic(); 
	if (d == 2) std::cout << maxcost - 2 << std::endl;
    //特判只有起点和终点之间有边的情况
	else if (d == 1 && flag) { std::cout << 2 << '\n' << c[1] << '\n' << c[n] << '\n' << c[1] << std::endl; return 0; }
	else { std::cout << "No Solution!" << std::endl; return 0; }
	findPath(1 + n, 0); findPath(1 + n, 1); return 0;
}

二分图匹配模型

二分图匹配就是解决匹配的问题(废话),可以是物体和特性的匹配之类的。

P2756 飞行员配对方案问题

\(m\) 个外籍飞行员和 \(n-m\) 个英国飞行员。共有若干个关系 \(u,v\) 表示外籍飞行员 \(u\) 可以和英国飞行员 \(v\) 组成队伍。问最多能组成多少队伍。

二分图匹配板子题。具体建边看上面的介绍吧,这题主要重点是最后找路径,看代码吧。

int main()
{
	init(); int m, n, u, v; scanf("%d%d%d%d", &m, &n, &u, &v);
	while (u != -1 && v != -1)
	    addedge(u, v, 1), scanf("%d%d", &u, &v);
	s = 0, t = n + 1;
	for (int i = 1; i <= m; i++) addedge(s, i, 1);
	for (int i = m + 1; i <= n; i++) addedge(i, t, 1);
	printf("%d\n", dinic());
    //一条边满流了就说明两边的点能组成队伍
	for (int i = 0; i < cnt; i += 2)
		if (E[i].c == 0 && E[i].v > m && E[i].v <= n)
			printf("%d %d\n", E[i ^ 1].v, E[i].v);
	return 0;
}

P2763 试题库问题

\(n\) 道试题,每道试题有对应的 tag 集合,现在想给共 \(m\) 个 tag 选出 \(p_i\) 道题,求出一个可能的方案或报告无解。

不那么板子的二分图匹配板子。看到物品和它特性的匹配可以想到二分图匹配。从源点向每道试题连一条容量为 \(1\) 的边,从每个 tag 向汇点连一条容量为 \(p_i\) 的边,每道试题向它对应的 tag 连一条容量为 \(1\) 的边。跑个最大流找方案即可。

int main()
{
	init(); int k, n, m = 0; scanf("%d%d", &k, &n); S = 0, T = n + k + 1;
	for (int i = 1; i <= n; i++) addedge(S, i, 1); 
    for (int i = 1, x; i <= k; i++) scanf("%d", &x), addedge(i + n, T, x), m += x;
	for (int i = 1, q; i <= n; i++) 
    { 
        scanf("%d", &q); 
        for (int j = 1, x; j <= q; j++) 
            scanf("%d", &x), addedge(i, n + x, 1); 
    }
	if (dinic() != m) printf("No Solution!"); //如果不能满流显然无解
	else  //对于每个 tag 找对应选的试题
        for (int u = n + 1; u <= n + k; u++) 
        { 
            printf("%d: ", u - n); 
            for (int i = p[u]; i + 1; i = E[i].next) //满流表示选了
                if (E[i ^ 1].c == 0 && E[i].v != T) printf("%d ", E[i].v); printf("\n"); 
        }	
	return 0;
}

P3254 圆桌问题

有来自 \(m\) 个不同单位的代表参加一次会议,每个单位派出了 \(r_i\) 人。会议共有 \(n\) 张餐桌,第 \(i\) 张可供 \(c_i\) 个代表就餐。现在想设计一种方案使得相同单位的代表不在同一餐桌就餐,输出方案或报告无解。

比上面的题稍微不板子一点的二分图匹配板子题。代表和餐桌明显是匹配关系,考虑二分图匹配。从源点向所有单位连一条容量为 \(r_i\) 的边,从所有餐桌向汇点连一条容量为 \(c_i\) 的边,每个单位向所有餐桌连一条容量为 \(1\) 的边,表示每个单位最多只有 \(1\) 个人去那个餐桌。跑最大流即可,输出方案方法类似上一题。

int main()
{
	init(); int m, n; scanf("%d%d", &m, &n); s = 0, t = m + n + 1;
	for (int i = 1, r; i <= m; i++) scanf("%d", &r), addedge(s, i, r), sum += r;
	for (int i = 1, c; i <= n; i++) scanf("%d", &c), addedge(i + m, t, c);
	for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++)
		addedge(i, j + m, 1);
	if (dinic() == sum) printf("1\n");
	else { printf("0\n"); return 0; }
	for (int u = 1; u <= m; u++)
	{
		for (int i = p[u]; i + 1; i = E[i].next)
			if (E[i].f == 0) printf("%d ", E[i].v - m);
		printf("\n");
	}
	return 0;
}

区间覆盖模型

大概就是选择一个类似线段的东西会覆盖一段区间,对区间内的点被覆盖次数有要求。大概都是把整个区间范围连成一条链,在这条链上来回连边做操作。

P3358 最长k可重区间集问题

在一条数轴上有 \(n\) 个开区间 \([l_i,r_i)\)。现想从中选取若干个区间使得数轴上的所有点被覆盖的次数不超过 \(k\),并使得选取的区间长度之和最大。

显然区间选的越多越好,在此限制下我们还要长度之和最大,考虑费用流。这种类型的题我们可以考虑取 \(k\) 次,每次都取尽量都的不相交区间,这样如果两条区间要分两次取,则一定会有相交部分,而只取 \(k\) 次就限制了被覆盖的次数不超过 \(k\)。这样就有网络流的雏形出来了。先把坐标离散化,然后从源点向 \(1\) 连一条容量为 \(k\),花费为 \(0\) 的边,从 \(i\)\(i+1\) 连一条容量为 \(\infty\),花费为 \(0\) 的边,从 \(d\) 向汇点连一条容量为 \(\infty\) 的边。(\(d\) 表示离散化后的坐标最大值)对于每条区间,我们让 \(l\)\(r\) 连一条容量为 \(1\),花费为区间长度的边。这样可以保证一次取边(可以看做增广一次)取到的开区间都不相交,且跑最大流可以取到长度之和最长的区间。

int main()
{
	init(); int n, k; scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++i)
		scanf("%d%d", &l[i], &r[i]), tmp[++tp] = l[i], tmp[++tp] = r[i];
	std::sort(tmp + 1, tmp + tp + 1);
	tp = std::unique(tmp + 1, tmp + tp + 1) - tmp - 1;
	s = 0; t = tp + 1; addedge(s, 1, k, 0); addedge(tp, t, inf, 0);
	for (int i = 1; i < tp; ++i) addedge(i, i + 1, inf, 0);
	for (int i = 1; i <= n; ++i)
		addedge(getid(l[i]), getid(r[i]), 1, r[i] - l[i]); 
        //getid 是二分找离散化后对应的值的函数
	dinic(); printf("%d\n", maxcost); return 0;
}

P3357 最长k可重线段集问题

在一个平面直角坐标系上有 \(n\) 个线段 \(((x_1,y_1),(x_2,y_2))\)。现想从中选取若干个线段使得对于所有实数 \(p\),直线 \(x=p\) 被覆盖的次数不超过 \(k\),并使得选取的线段长度之和最大。

乍一看跟上一题差不多,反正都是只跟横坐标有关,拍扁到 x 轴上就好了。但不同于上题,这里会允许自环的存在,即 \(x_1=x_2\)。而这会破坏一次选到的线段都不相交的性质。(考虑 \(((1,2),(1,3))\)\(((1,2),(2,3))\),这两个显然相交,但画图可以发现一次可以全部取到)所以我们考虑扩域来避免自环,把每个点的横坐标都乘二,如果 \(x_1=x_2\) 则给 \(x_2\) 加上 \(1\),反之给 \(x_1\) 加上 \(1\)。这样既破坏了自环,也保持了相交的性质不变。依然向上题一样连边跑费用流即可。

int main()
{
	init(); int n, k; scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++i)
	{
		scanf("%lld%lld%lld%lld", &sg[i].x1, &sg[i].y1, &sg[i].x2, &sg[i].y2);
		sg[i].len = calc(sg[i]); sg[i].x1 <<= 1; sg[i].x2 <<= 1; 
		if (sg[i].x1 == sg[i].x2) ++sg[i].x2; else ++sg[i].x1;
		tmp[++tp] = sg[i].x1; tmp[++tp] = sg[i].x2;
	}
	std::sort(tmp + 1, tmp + tp + 1);
	tp = std::unique(tmp + 1, tmp + tp + 1) - tmp - 1;
	s = 0; t = tp + 1; addedge(s, 1, k, 0); addedge(tp, t, inf, 0);
	for (int i = 1; i < tp; ++i) addedge(i, i + 1, inf, 0);
	for (int i = 1, x, y; i <= n; ++i) 
	{
		x = getid(sg[i].x1), y = getid(sg[i].x2); //getid 函数意义同上
		if (x > y) std::swap(x, y); addedge(x, y, 1, sg[i].len);
	}
	dinic(); printf("%lld\n", maxcost); return 0;
}

P3980 [NOI2008] 志愿者招募

网络流 24 题有数道不是网络流解法的题没被我加进来,所以补一道吧。

在连续的 \(n\) 天内,每天需要 \(a_i\) 人。现在共有 \(m\) 种志愿者可以招募,每种志愿者可以从 \(s_i\) 工作到 \(t_i\),招募费用为每人 \(c_i\) 元。问最少花费多少钱能使得每天的需求得到满足。

既要需求得到满足,也要花费最少,显然费用流。发现这道题类似区间覆盖,所以我们先连起来一条 \(S\rightarrow 1\rightarrow 2\rightarrow\cdot\cdot\cdot\rightarrow n+1\rightarrow T\) 的链(为啥到 \(n+1\) 稍后解释),费用均为 \(0\),容量稍后分析。然后对于每种志愿者,从 \(s_i\)\(t_i+1\)(为啥是 \(t_i+1\) 稍后解释)连一条费用为 \(c_i\) 的边,容量稍后分析。首先考虑最简单的想法,容量可能都为 \(\infty\) 吗?显然不可能,这样 \(a_i\) 甚至没用上。对于一种志愿者代表的边,如果流量流过这个边且源点的流量充足,则其对应的下方所有边都会被流过,这样就表示所有天数都被覆盖了。我们用 \(u\rightarrow u+1\) 表示第 \(u\) 天,则想要 \([s_i,t_i]\) 所有边都被流过,应该连到 \(t_i+1\)。而加上每天招募人数的限制,我们给 \(u\rightarrow u+1\) 这条边安排的容量应该为一个很奇怪的值:\(-a_i\)。这样才能保证最多流过 \(a_i\) 个人,然后其余的边容量均为 \(0\)。这样一个最大流量为 \(0\) 的最大流对应的最小费用即为答案。考虑到流量网络容量非负,给每条边的容量平移一个极大值即可。

int main()
{
	init(); int n, m; scanf("%d%d", &n, &m); s = 0; t = n + 2;
	for (int i = 1, x; i <= n; ++i) scanf("%d", &x), addedge(i, i + 1, inf - x, 0);
	for (int i = 1, s, t, c; i <= m; ++i)
	{
		scanf("%d%d%d", &s, &t, &c);
		addedge(s, t + 1, inf, c);
	}
	addedge(s, 1, inf, 0); addedge(n + 1, t, inf, 0);
	dinic(); printf("%lld\n", mincost); return 0;
}
posted @ 2022-03-16 21:30  zhiyangfan  阅读(168)  评论(0编辑  收藏  举报