图论 III

本文主要讲解:网络流,二分图染色,2-sat,差分约束

Change Log

  • 2025.7.23 新增 2-SAT 部分。
  • 2025.7.24 新增差分约束。
  • 2025.7.25 新增网络最大流部分。
  • 2025.7.26 新增无负环费用流部分。
  • 2025.7.27 新增上下界网络流。

定义与记号

基本定义

  • 图:一张图 \(G\) 由若干个点和连接这些点的边构成。点的集合称为 点集 \(V\),边的集合称为 边集 \(E\),记 \(G = (V, E)\)
  • 阶:图 \(G\) 的点数 \(|V|\) 称为 ,记作 \(|G|\)
  • 无向图:若 \(e\in E\) 没有方向,则 \(G\) 称为 无向图。无向图的边记作 \(e = (u, v)\)\(u, v\) 之间无序。
  • 有向图:若 \(e\in E\) 有方向,则 \(G\) 称为 有向图。有向图的边记作 \(e = u\to v\)\(e = (u, v)\)\(u, v\) 之间有序。无向边 \((u, v)\) 可视为两条有向边 \(u\to v\)\(v\to u\)
  • 重边:端点和方向(有向图)相同的边称为 重边,当且仅当对于两条边 \(e_1=(u,x),e_2=(u,y)\)\(x=y\)
  • 自环:连接相同点的边称为 自环,当且仅当 \((u,v)\)\(u=v\)

相邻

  • 相邻:在无向图中,称 \(u, v\) 相邻 当且仅当存在 \(e = (u, v)\)
  • 邻域:在无向图中,点 \(u\)邻域 为所有与之相邻的点的集合,记作 \(N(u)\)
  • 邻边:在无向图中,与 \(u\) 相连的边 \((u, v)\) 称为 \(u\)邻边
  • 出边 / 入边:在有向图中,从 \(u\) 出发的边 \(u\to v\) 称为 \(u\)出边,到达 \(u\) 的边 \(v\to u\) 称为 \(u\)入边
  • 度数:一个点的 度数 为与之关联的边的数量,记作 \(d(u)\)\(d(u) = \sum_{e\in E} ([u = e_u] + [u = e_v])\)。点的自环对其度数产生 \(2\) 的贡献。
  • 出度 / 入度:在有向图中,从 \(u\) 出发的边数称为 \(u\)出度,记作 \(d ^ +(u)\);到达 \(u\) 的边数称为 \(u\)入度,记作 \(d ^ -(u)\)

路径

  • 途径:连接一串相邻结点的序列称为 途径,用点序列 \(v_{0..k}\) 和边序列 \(e_{1..k}\) 描述,其中 \(e_i = (v_{i - 1}, v_i)\)。常写为 \(v_0\to v_1\to \cdots \to v_k\)
  • 迹:不经过重复边的途径称为
  • 回路:\(v_0 = v_k\) 的迹称为 回路
  • 路径:不经过重复点的迹称为 路径,也称 简单路径。不经过重复点比不经过重复边强,所以不经过重复点的途径也是路径。注意题目中的简单路径可能指迹。
  • 环:除 \(v_0 = v_k\) 外所有点互不相同的途径称为 ,也称 简单环

连通性

  • 连通:对于无向图的两点 \(u, v\),若存在途径使得 \(v_0 = u\)\(v_k = v\),则称 \(u, v\) 连通
  • 弱连通:对于有向图的两点 \(u, v\),若将有向边改为无向边后 \(u, v\) 连通,则称 \(u, v\) 弱连通
  • 连通图:任意两点连通的无向图称为 连通图
  • 弱连通图:任意两点弱连通的有向图称为 弱连通图
  • 可达:对于有向图的两点 \(u, v\),若存在途径使得 \(v_0 = u\)\(v_k = v\),则称 \(u\) 可达 \(v\),记作 \(u \rightsquigarrow v\)
  • 关于点双连通 / 边双连通 / 强连通,见对应章节。

特殊图

  • 简单图:不含重边和自环的图称为 简单图
  • 基图:将有向图的有向边替换为无向边得到的图称为该有向图的 基图
  • 有向无环图:不含环的有向图称为 有向无环图,简称 DAG(Directed Acyclic Graph)。
  • 完全图:任意不同的两点之间恰有一条边的无向简单图称为 完全图\(n\) 阶完全图记作 \(K_n\)
  • 树:不含环的无向连通图称为 ,树上度为 \(1\) 的点称为 叶子。树是简单图,满足 \(|V| = |E| + 1\)。若干棵(包括一棵)树组成的连通块称为 森林。相关知识点见 “树论”。
  • 稀疏图 / 稠密图: \(|E|\) 远小于 \(|V| ^ 2\) 的图称为 稀疏图\(|E|\) 接近 \(|V| ^ 2\) 的图称为 稠密图。用于讨论时间复杂度为 \(\mathcal{O}(|E|)\)\(\mathcal{O}(|V| ^ 2)\) 的算法。

子图

  • 子图:满足 \(V'\subseteq V\)\(E'\subseteq E\) 的图 \(G' = (V', E')\) 称为 \(G = (V, E)\)子图,记作 \(G'\subseteq G\)。要求 \(E'\) 所有边的两端均在 \(V'\) 中。
  • 导出子图:选择若干个点以及两端都在该点集的所有边构成的子图称为该图的 导出子图。导出子图的形态仅由选择的点集 \(V'\) 决定,记作 \(G[V']\)
  • 生成子图:\(|V'| = |V|\) 的子图称为 生成子图
  • 极大子图(分量):在子图满足某性质的前提下,子图 \(G'\) 称为 极大 的,当且仅当不存在同样满足该性质的子图 \(G''\)\(G'\subsetneq G''\subseteq G\)\(G'\) 称为满足该性质的 分量。例如,极大的连通的子图称为原图的连通分量,也就是我们熟知的连通块。

约定

  • \(n\) 表示点集大小 \(|V|\)\(m\) 表示边集大小 \(|E|\)

1. 2-SAT

前置:强联通分量(scc)、拓扑序、缩点

1.1 定义

给出 \(n\) 个变量,每个变量有一个取值集合,每个集合有两个元素 \({a_i,b_i}\)。已知若干个条件,表示 \(c_i\)\(c_j\) 矛盾(即集合 \(i\) 选择 \(c_i\) 则集合 \(j\) 不能选择 \(c_j\))。
我们希望给每个变量赋值,判断是否存在一组解使得没有产生冲突。

1.2 解的存在性

我们通常建图后使用强联通分量解决 2-SAT 问题。
具体的,假设我们要处理条件 \([a_i,a_j]\),即集合 \(i\) 选择 \(a_i\) 则集合 \(j\) 不能选择 \(a_j\)
我们建两条边:\((a_i,b_j)\)\((a_j,b_i)\),含义为前者满足后者就必须满足。
其他条件用类似的方法处理建图。
之后我们求 scc。如果有一个集合的 \(a_i,b_i\) 在用一个 scc 里,其含义为如果选了 \(a_i\) 就必须选 \(b_i\),这肯定是无解的。否则就存在合法解。

1.3 构造合法解

我们按照图中拓扑序定向,如果 \(a_i\)\(b_i\) 前面,那么取 \(a_i\),否则为 \(b_i\)。然而并不需要求一遍拓扑序,在 Tarjan 算法中我们维护的栈就是一个天然的拓扑序,所以求得的 scc 编号即为反拓扑序。

1.4 例题

1.4.1 P5782 [POI 2001] 和平委员会

题意

\(n(n\le8000)\) 个党派,每个党派有两个代表。有 \(m(m\le20000)\) 个代表之间的双向厌恶关系。
现在要成立一个委员会,要求每个党派恰好有一个代表在会中,且没有两个代表是互相厌恶的。构造合法解。

这里的厌恶关系就是我们给出的定义中的条件,按照上述的做法建图跑强联通分量即可。
具体的,对于一个厌恶关系 \([a_i,a_j]\),连边 \((a_i,b_j)\)\((a_j,b_i)\),其他厌恶关系是类似的。
时间复杂度 \(\mathcal{O}(n+m)\)

P5782
#include<bits/stdc++.h>
#define endl '\n'
#define pi pair<int,int>
#define partner(x) (x&1?(x+1):(x-1))

using namespace std;
const int N=1.6e4+10;
int n,m,dfn[N],low[N],cnt;
int scc[N],sccnt;
vector<int> t[N];
stack<int> st;

inline void tarjan(int u,int fa){
    low[u] = dfn[u] = ++cnt;
    st.push(u);
    for(int i = 0; i < t[u].size(); i++){
        int v = t[u][i];
        if(!dfn[v]){
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
        }else if(!scc[v]) low[u] = min(low[u], dfn[v]);
    }
    if(low[u] == dfn[u]){
        scc[u] = ++sccnt;
        while(st.top() != u){
            scc[st.top()] = sccnt;
            st.pop();
        }
        st.pop();
    }
    return ;
}

inline int read(){
	int x;
	cin >> x;
	return x;
}

signed main(){
	cin.tie(nullptr)->sync_with_stdio(false);
    n=read(),m=read();
    for(int i=1;i<=m;i++) {
        int u=read(),v=read();
        t[u].push_back(partner(v));
        t[v].push_back(partner(u));
    }
    for(int i = 1; i <= 2*n; i++)
        if(!dfn[i])
            tarjan(i, -1);
    for(int i = 1; i <= 2*n; i += 2)
        if(scc[i] == scc[i + 1])
            return cout << "NIE" << endl, 0;
    for(int i = 1; i <= 2*n;i += 2)
        if(scc[i] < scc[i + 1]) cout << i << endl;
        else cout << i + 1 << endl;
    return 0;
}

1.4.2 P4171 [JSOI2010] 满汉全席

题意

\(n(n\le100)\) 道菜,每到菜有两种做法。有 \(m(m\le100)\) 个评委,每个评委有两个要求。为每到菜确定一个做法,使得每个评委的要求至少满足一个。

假设某个要求为 \([a_i,a_j]\),可以转化为我们在定义中给出的条件 \([b_i,b_j]\)
于是建边 \((b_i,a_j)\)\((b_j,a_i)\) 即可,其他的条件的处理也是类似的。
建图后跑缩点,按照前面讲的方法判断解的存在性以及构造合法解即可。
复杂度 \(\mathcal{O}(n+m)\)

P4171
#include<bits/stdc++.h>
#define endl '\n'
#define pi pair<int,int>

using namespace std;
const int N=410;

inline int read(){
	int x;
	cin>>x;
	return x;
}

int t,n,m;

int pre[N],tot;
struct node{
	int v,nxt;
}E[4010];

int low[N],dfn[N],cnt,judge;
int col[N],ins[N],colnum;
stack<int> st;
char s1[5],s2[5];

inline void add(int u,int v){
    E[++tot].nxt=pre[u];
    E[tot].v=v;
    pre[u]=tot;
    return ;
}
 
void tarjan(int u){
    low[u]=dfn[u]=++cnt;
    st.push(u);
	ins[u]=1;
    for(int i=pre[u];i;i=E[i].nxt){
        int v=E[i].v;
        if(!dfn[v]){
			tarjan(v);
			low[u]=min(low[u],low[v]);
		}
        else if(ins[v])
        low[u]=min(low[u],dfn[v]);
    }
    if(low[u] == dfn[u]){
        int v;
		colnum++;
        do{
            v = st.top();
            st.pop();
			ins[v] = 0;
            col[v] = colnum;
        }while(v != u);
    }
    return ;
}
 
inline void init(){
    judge = tot = cnt = colnum = 0;
    memset(pre, 0, sizeof pre);
    memset(col, 0, sizeof col);
    memset(low, 0, sizeof low);
    memset(dfn, 0, sizeof dfn);
    return ;
}

signed main(){
	cin.tie(nullptr)->sync_with_stdio(false);
    t=read();
    while(t--){
        init();
        n = read(), m = read();
        for(int i = 1; i <= m; i++){
            string s1,s2;
            cin >> s1 >> s2;
            int u = 0, v = 0, k = 1;
			while(isdigit(s1[k])) u = u * 10 + s1[k++] - '0';
            k = 1;
			while(isdigit(s2[k])) v = v * 10 + s2[k++] - '0';
            if(s1[0] == 'm'){
                if (s2[0] == 'h') add(u + n, v + n), add(v, u);
                else if (s2[0] == 'm') add(u + n, v), add(v + n, u);
            }else if (s1[0] == 'h'){
                if (s2[0] == 'h') add(u, v + n), add(v, u + n);
                else if (s2[0] == 'm') add(u, v), add(v + n, u + n);
            }
        }
        for(int i = 1; i <= (n<<1); i++)
			if(!dfn[i])
				tarjan(i);
		bool f = false;
        for(int i = 1; i <= n; i++)
        	if(col[i] == col[i + n]){
				f = true;
				break;
			}
        if(f) cout << "BAD" << endl;
        else cout << "GOOD" << endl;
    }
    return 0;
}

1.4.3 P6378 [PA 2010] Riddle

题意

给出一张 \(n(n\le10^6)\) 个点 \(m(m\le10^6)\) 条边的无向图。这个图被划分为 \(k\) 部分,给出这 \(k(k\le10^6)\) 个点集。
选出一些关键点,使得每个部分恰好有一个关键点,并且每条边至少有一个端点是关键点,构造合法解。

我们把点看做变量,每个变量的取值集合为: \(\{a_i=\) 是关键点,\(b_i=\) 不是关键点 \(\}\),转化为 2-SAT 问题。
每条边的条件很容易用建边来描述,但是每个部分的条件如果暴力建边是 \(\mathcal{O}(n^2)\) 的,我们尝试优化边的数量。
我们把每个部分也看做变量,取值集合为:\(\{c_x=\) 有关键点,\(d_x=\) 没有关键点 \(\}\)
那么每个部分的点向这个不分连边:\((a_i,c_x)\)\((d_x,b_i)\) 即可。
建边复杂度降到 \(\mathcal{O}(n)\),可以通过本题。

P6378
#include<bits/stdc++.h>

using namespace std;
const int N=2*1e6+10,M=2*1e7;
int dfn[2*N],low[2*N],fa[2*N],vis[2*N],st[2*N],head[2*N],a[N];
int to[2*M],Next[2*M],pre[N][2];
int cnt,p,cntk;

void add(int x,int y){
	to[cnt]=y;
	Next[cnt]=head[x];
	head[x]=cnt++;
}

void tar(int x){
	dfn[x]=low[x]=++cntk;
	vis[x]=1;
	st[++p]=x;
	for(int i=head[x];i!=-1;i=Next[i]){
		if(!dfn[to[i]]){
			tar(to[i]);
			low[x]=min(low[x],low[to[i]]);
		}
		else if(vis[to[i]])low[x]=min(low[x],dfn[to[i]]);
	}
	int cur;
	if(low[x]==dfn[x]){
		do{
			cur=st[p];
			p--;
			vis[cur]=0;
			fa[cur]=x;
		}while(cur!=x);
	}
}

void init(){
	memset(head,-1,sizeof(head));
	cnt=0;
	cntk=0;
	p=0;
}

int main(){
	init();
	int n,m,k,x,y,t;
	scanf("%d%d%d",&n,&m,&k);
	for(int i=1;i<=m;i++){
		scanf("%d%d",&x,&y);
		add((x-1)*2+1,(y-1)*2);
		add((y-1)*2+1,(x-1)*2);
	}
	int cntt=2*n;
	for(int j=1;j<=k;j++){
		scanf("%d",&t);
		for(int i=1;i<=t;i++){
			scanf("%d",&a[i]);
			pre[a[i]][0]=++cntt;
			pre[a[i]][1]=++cntt;
			add((a[i]-1)*2,pre[a[i]][0]);
			add(pre[a[i]][1],(a[i]-1)*2+1);
		}
		for(int i=2;i<=t;i++){
			int d1=a[i-1],d2=a[i];
			add(pre[d1][0],pre[d2][0]);
			add(pre[d2][1],pre[d1][1]);
			add(pre[d1][0],(d2-1)*2+1);
			add((d2-1)*2,pre[d1][1]);
		}
	}
	cntk=0;
	for(int i=0;i<=cntt;i++)
	if(!dfn[i])tar(i);
	bool flag=1;
	for(int i=1;i<=n&&flag;i++)
	if(fa[(i-1)*2]==fa[(i-1)*2+1])flag=0;
	if(flag) printf("TAK");
	else printf("NIE");
	return 0;
}

1.4.4 CF568C New Language

题意

\(a\)\(a+l−1\)\(l(l\le26)\) 个字符,这些字符被划分成 \(V,C\) 两个集合。
构造一个字典序最小的字符串,满足长度为 \(n(1\le n\le200)\),字符集为给定的字符,满足 \(m(m\le4n(n-1))\) 个限制,且字典序大于等于一个给定长度为 \(n\) 的字符串。
每个限制为如果第 \(p_{i,1}\) 个字符属于 \(t_{i,1}\),则第 \(p_{i,2}\) 个字符属于 \(t_{i,2}\)

首先特判 \(V\)\(C\) 为空的情况。
之后建出来 \(n\) 个变量,每个变量的取值集合为:\(\{a_i = [s_i\in V],b_i = [s_i\in C]\}\)
2-SAT 建图判断解的存在性是容易的,难点在于满足字典序的要求。
字典序问题我们通常考虑贪心做法,即按顺序贪心填最小的字符。
因为本题数据范围较小,所以可以直接暴力验证当前贪心填的串的合法性。
复杂度为 \(\mathcal{O}(n\times l(n + m))\)

CF568C

1.5 小结

2-SAT 问题是一类很经典的问题,当我们看到题目中有“两个集合”、“不能同时选择”、“构造解”等关键词时,我们就可以尝试用 2-SAT 建模。
和连通性问题一样,2-SAT 问题也可以和其他算法搭配着出题,比如优化建图的相关算法。
需要注意的是,\(k\)-SAT 问题(即每个变量的取值集合大小为 \(k\))和求 2-SAT 的合法解数量都是没有多项式复杂度解法的问题,如果你把原问题转化成了这样的问题,那你不妨检查一下是不是转化错了,或者看看图有没有特殊性质。

2. 差分约束

2.1 定义

差分约束系统是一种特殊的 \(n\) 元一次不等式组。
它包含 \(n\) 个变量 \(x_1,x_2,\dots,x_n\) 以及 \(m\) 个约束条件,每个约束条件是由两个其中的变量做差构成的,形如 \(x_i−x_j\le c_k\),其中 \(1\le i,j\le n,i\ne j\),并且 \(c_k\) 是常数(可以不是正数)。
我们要解决的问题是:求一组解 \(x_1=a_1,x_2=a_2,\dots,x_n=a_n\),使得所有的约束条件得到满足,或者判断出无解。

2.2 与最短路的联系

观察到每个约束条件 \(x_i−x_j\le c_k\) 都可以变形成 \(x_i\le x_j+c_k\),这与单源最短路中的三角形不等式 \(dist_y\le dist_x+z\) 非常相似。
因此,我们可以把每个变量 \(x_i\) 看做图中的一个节点,对于每个约束条件 \(x_i−x_j\le c_k\),从节点 \(j\) 向节点 \(i\) 连一条长度为 \(c_k\) 的有向边。

2.3 解法

建出上述的边后,我们设置 \(dist_0=0\),让 \(0\) 作为超级源点向每个点连一条边权为 \(0\) 的边,跑单源最短路。
这相当于补充 \(x_i\le0\) 的约束,不影响原差分约束系统解的存在性。
若图中存在负环,则给定的差分约束系统无解;否则,\(x_i=dist_i\) 为该差分约束系统的一组解。
我们可以将这组解中的每个变量的取值加上同一个常数,从而扩展到新的解,因为这样作差后常数会被消掉。

使用 Bellman-Ford 或队列优化的 Bellman-Ford (即 SPFA) 可以判断是否存在负环:若某个点入队超过 \(n\) 次,则出现负环。
因此,求解差分约束系统的时间复杂度为 \(\mathcal{O}(n\times m)\)
注意,并不是只能跑最短路,在很多情况跑最短路和最长路都是可行的,在部分最优化问题中需要我们必须跑最短路或最长路。

2.4 例题

2.4.1 P2294 [HNOI2005] 狡猾的商人

P2294

记第 \(i\) 个月的收入为 \(a_i\),已知某些时间段的总收入,即已知 \(m\) 个信息为 \(\displaystyle\sum_{i=l_k}^{r_k}{a_i}=c_k\),求一组解或判断无解。
\(n\le100,m\le1000\)

将信息转化为对前缀和的约束,即 \(s_{r_k}-s_{l_k−1}=c_k\),其中 \(s_i=\displaystyle\sum_{j=1}^{i}{a_j}\)
注意到这里的约束是等号,我们可以拆解为 \(s_{r_k}−s_{l_k−1}\le c_k\)\(s_{r_k}−s_{l_k−1}\ge c_k\),再建图跑最短路即可。

P2294

2.4.2 P3275 [SCOI2011] 糖果

题目

给出五类要求,形如 \(c_a=c_b,c_a<c_b,c_a\ge c_b,c_a>c_b,c_a\le c_b\)
判断是否有合法解,如果有则求出每个变量都的前提下最小的变量取值之和。\(n\le 10^5,m\le 10^5\)

这里需要我们求最小的变量取值之和,我们只能使用最长路。
证明考虑每个从点 \(i\) 到超级汇点的路径可以描述为一个约束 \(c_i\ge\displaystyle\sum_{i=1}^{n}{w_e}\)
那么我们求最长路,即为在满足所有这些不等式的前提下求出了 \(c_i\) 的最小值。
本题数据范围较大,所以不能直接建图跑最长路。
容易发现图中的边权非负,所以缩点后,如果强连通分量中存在正数边权的边,那么就存在了自环,就说明无解。
否则缩点后即为求 DAG 上的最长路,可以利用拓扑排序解决。

P3275


/*+ Nimbunny +*/

#include <bits/stdc++.h>
#define endl '\n'
#define pi pair<int, int>
#define int long long
// #pragma GCC optimize(2)

using namespace std;
const int INF = INT_MAX;
const int mod = 1e9 + 7;
const int N = 1e5 + 10;

struct relationship {
    int x, a, b;
} r[N];

struct edge {
    int to, next;
    bool same;
} e[N << 1], e2[N];

int n, m, num, num2, cnt, pre[N], pre2[N], ltk[N], dfn[N], low[N];
int sta[N], top, size[N], que[N], du[N], h, tail, candy[N];
bool vis[N];
int ans;

inline void add(int u, int v, bool k) {
    e[++num] = {v, pre[u], k};
    pre[u] = num;
    return;
}

inline void add2(int u, int v, bool k) {
    du[v]++;
    e2[++num2] = {v, pre2[u], k};
    pre2[u] = num2;
    return;
}

void dfs(int x) {
    dfn[x] = low[x] = ++cnt;
    sta[++top] = x;
    for (int i = pre[x]; i; i = e[i].next) {
        if (!dfn[e[i].to]) {
            dfs(e[i].to);
            low[x] = min(low[x], low[e[i].to]);
        } else if (!ltk[e[i].to])
            low[x] = min(low[x], dfn[e[i].to]);
    }
    if (dfn[x] == low[x]) {
        ltk[0]++;
        while (top) {
            ltk[sta[top]] = ltk[0];
            size[ltk[0]]++;
            if (sta[top--] == x) break;
        }
    }
    return;
}

inline void Rebuild() {
    for (int i = 1; i <= n; i++)
        for (int j = pre[i]; j; j = e[j].next)
            if (ltk[i] != ltk[e[j].to])
                add2(ltk[i], ltk[e[j].to], true);
    for (int i = 1; i <= m; i++)
        if (r[i].x == 2) {
            if (ltk[r[i].a] == ltk[r[i].b]) {
                printf("-1\n");
                exit(0);
            } else
                add2(ltk[r[i].a], ltk[r[i].b], false);
        } else if (r[i].x == 4) {
            if (ltk[r[i].a] == ltk[r[i].b]) {
                printf("-1\n");
                exit(0);
            } else
                add2(ltk[r[i].b], ltk[r[i].a], false);
        }
    return;
}

inline void Topsort() {
    for (int i = 1; i <= ltk[0]; i++)
        if (!du[i]) {
            que[++tail] = i;
            vis[i] = true;
            candy[i] = 1;
        }
    while (h < tail) {
        int x = que[++h];
        for (int i = pre2[x]; i; i = e2[i].next) {
            if (!--du[e2[i].to]) {
                que[++tail] = e2[i].to;
                vis[e2[i].to] = true;
            }
            if (!e2[i].same)
                candy[e2[i].to] = max(candy[e2[i].to], candy[x] + 1);
            else
                candy[e2[i].to] = max(candy[e2[i].to], candy[x]);
        }
    }
    for (int i = 1; i <= ltk[0]; i++)
        if (!vis[i]) {
            printf("-1\n");
            exit(0);
        }
    return;
}

inline int read() {
    int x;
    cin >> x;
    return x;
}

signed main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    n = read(), m = read();
    for (int i = 1; i <= m; i++) {
        r[i].x = read(), r[i].a = read(), r[i].b = read();
        if (r[i].x == 1) {
            add(r[i].a, r[i].b, true), add(r[i].b, r[i].a, true);
        } else if (r[i].x == 3)
            add(r[i].b, r[i].a, true);
        else if (r[i].x == 5)
            add(r[i].a, r[i].b, true);
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) dfs(i);
    Rebuild();
    Topsort();
    for (int i = 1; i <= ltk[0]; i++) ans += (candy[i] * size[i]);
    cout << ans << endl;
    return 0;
}

2.4.3 P2474 [SCOI2008] 天平

题意

\(n(n\le50)\) 个砝码,已知每个砝码的重量取值范围为 \({1,2,3}\),以及一些砝码之间的重量关系,保证没有矛盾。
现在考虑选出了 \(A\)\(B\) 两个砝码 \((A \ne B)\),需要在选剩下的砝码中再两个,分别求出:

  1. 一定比 \(w_A+w_B\) 重的方案数。
  2. 一定比 \(w_A+w_B\) 轻的方案数。
  3. 一定和 \(w_A+w_B\) 一样重的方案数。

\(mn_{i,j}\) 表示 \(i,j\) 两个砝码的质量差 \(w_i−w_j\) 最少是多少(可以为负数)。
分类讨论建边:

  • \(i=j\)\(a_{i,j}=\) = \(,mn_{i,j}=0\)
  • \(a_{i,j}=\) +\(mn_{i,j}=1\)
  • \(a_{i,j}=\) -\(mn_{i,j}=-2\)
  • \(a_{i,j}=\) ?\(mn_{i,j}=-2\)
    建图后跑最长路即可。

对称地设 \(mx_{i,j}\) 表示 \(i,j\) 两个砝码的质量差 \(w_i−w_j\) 最多是多少,建图后跑最短路即可。

因为数据范围很小,所以我们可以暴力枚举另外的两个砝码。三类情况类似,这里只考虑 $w_A+w_B>w_i+w_j。
约束转化为 \(w_A−w_i>w_j−w_B\)\(w_A−w_j>w_i−w_B\)
这是就利用上前面预处理的变量:\(mn_{A,i}>mx_{j,B}\)\(mn_{A,j}>mx_{i,B}\)
时间复杂度为 \(\mathcal{O}(n^3)\),为了方便最短/长路部分可以直接使用 Floyd。

P2474

/*+ Cloudybunny +*/

#include<bits/stdc++.h>
#define endl '\n'
#define pi pair<int,int>

using namespace std;
const int INF = INT_MAX;
const int mod = 1e9 + 7;
const int N = 55;
int n,A,B;
int dmx[N][N], dmi[N][N];
char s[N][N];

inline int read(){
	int x;
	cin >> x;
	return x;
}

signed main(){
	cin.tie(nullptr)->sync_with_stdio(false);
	n = read(), A = read(), B = read();
	for(int i=1;i<=n;i++)
		cin>>s[i]+1;
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			if(s[i][j]=='=') dmx[i][j]=dmi[i][j]=0;
			else if(s[i][j]=='+') dmx[i][j]=2,dmi[i][j]=1;
			else if(s[i][j]=='-') dmx[i][j]=-1,dmi[i][j]=-2;
			else dmx[i][j]=2,dmi[i][j]=-2;
	for(int i=1;i<=n;i++)
		dmx[i][i]=dmi[i][i]=0;
	for(int k=1;k<=n;k++)
		for(int i=1;i<=n;i++)
			for(int j=1;j<=n;j++)
				dmx[i][j]=min(dmx[i][k]+dmx[k][j],dmx[i][j]),
				dmi[i][j]=max(dmi[i][k]+dmi[k][j],dmi[i][j]);
	int c1=0,c2=0,c3=0;
	for(int i=1;i<=n;i++){
		for(int j=i+1;j<=n;j++){
			if(i==A||i==B||j==A||j==B) continue;
			if(dmi[A][i]+dmi[B][j]>0||dmi[A][j]+dmi[B][i]>0) ++c1;
			if(dmx[A][i]+dmx[B][j]<0||dmx[A][j]+dmx[B][i]<0) ++c3;
			if(dmi[A][i]==dmx[A][i]&&dmi[j][B]==dmx[j][B]&&dmi[A][i]+dmi[B][j]==0) ++c2;
			else if(dmi[A][j]==dmx[A][j]&&dmi[i][B]==dmx[i][B]&&dmi[A][j]+dmi[B][i]==0) ++c2;
		}
	}
	cout << c1 << " " << c2 << " " << c3 << endl;
	return 0;
}

2.5 小结

差分约束系统的求解核心思想在于与最短路算法的结合,利用最短路算法的三角形不等式完成不等关系的递推。
差分约束系统有很强的扩展性,本节课中我们已经讲解了求最小解、与缩点配合等应用场景,在之后的学习中我们会遇到更多的拓展内容。

3. 网络流

网络流的重点是 建图,建图是精髓。

网络流的建图方法一定程度上刻画了贪心问题的内在性质,从而简便地支持了 反悔,不需要我们为每道贪心问题都寻找反悔策略。

3.1 基本定义

一个网络是一张 有向图 \(G=(V,E)\),对于有条有向边 \((u,v)\in E\) 存在 容量限制 \(c(u,v)\)。特别的,若 \((u,v)\notin E\),则 \(c(u,v)=0\)

网络的可行流分为有源汇(通常用 \(S\) 表示源点,\(T\) 表示汇点)和无源汇两种,但无论哪一种,其对应的 流函数 \(f\) 均具有以下三个性质:

  • 首先给出定义,流函数 \(f:(u,v)\to \red{\ R}\) 是从二元 有序对 \((u,v)\) 向实数集,\(\red{\ R}\) 的映射,其中 \(u,v\in V\)\(f(u,v)\) 称为边 \((u,v)\)流量
  • \(f\) 满足 流量限制\(f(u,v)\le c(u,v)\)。每条边的流量不能超过容量。若 \(f(u,v)=c(u,v)\),则称边 \((u,v)\) 满流
  • \(f\) 具有 斜对称 性质:\(f(u,v)=-f(v,u)\)\(u\to v\)\(1\) 的流量,也可称 \(v\to u\)\(-1\) 的流量。
  • \(f\) 具有 流量守恒 性质:除源汇点外(无源汇网络流则不存在源汇点),从每个节点流入和流出的流量相等,即 \(\forall i\ne S,T,\sum{f(u,i)}=\sum{f(i,v)}\)。每个节点 不储存流量,流进多少就流出多少。

以下是一些网络流相关定义。

  • 对于 有源汇 网络,根据斜对称和容量守恒性质,可以得到 \(\sum{f(S,i)}=\sum{f(i,T)}\),此时这个相等的和称为当前流 \(f\)流量
  • 定义流 \(f\) 在网络 \(G\) 上的 残量网络 \(G_f=(V,E_f)\) 为容量函数等于 \(c_f=c-f\) 的网络。根据容量限制,我们有 \(c_f(u,v)\ge 0\)。若 \(c_f(u,v)=0\),则视 \((u,v)\) 在残量网络上不存在,\((u,v)\notin E_f\)。换句话说,将每条边的容量减去流量后,删去满流边即可得到残量网络。
  • 定义 增广路 \(P\) 是残量网络 \(G_f\) 上从 源点 \(S\)汇点 \(T\) 的一条路径。无源汇网络流不讨论增广路。
  • \(V\) 分成 互不相交 的两个点集 \(A,B\),其中 \(S\in A,T\in B\),这种点的划分方式叫做 。定义割的 容量\(\displaystyle\sum_{u\in A}\displaystyle\sum_{u\in B}{f(u,v)}\)。若 \(u,v\) 所属点集不同,则称有向边为 割边

3.2 网络最大流

最大流,就是给定网络 \(G=(V,E)\) 和源汇,求最大流量(Maximum flow)。

3.2.1 增广

接下来要介绍的两个算法均使用了 不断寻找增广路 和 “能流满就流满” 的贪心思想。

具体地,找到残量网络 \(G_f\) 上的一条增广路 \(P\),并为 \(P\) 上的每一条边增加 \(c_f(P)=\displaystyle\min_{(u,v)\in P}{c_f(u,v)}\) 的流量。如果增加的流量大于该值,一些边将不满足容量限制,而根据“能流满就流满”的思想,增加的流量也不应小于该值。

这条路径上的每一条边的剩余流量都 \(-1\),不断重复这个过程知道不存在增广路。

可惜,这个做法是的。

引入反向边,流量初始为 \(0\),剩余流量 \(-1\) 时反向边的剩余流量 \(+1\)。这样,如果一条增广路中包含反向边,相当于给这条边“退流”。

我们在增广的过程中尽量流满一条增广路,同时每条边的流量在增广过程中不会减少。

不断操作可以求出最大流。

Tips:同割边,一个求反边的技巧:\(cnt\)\(1\) 开始,若一条边编号为 \(k\),则其反边编号为 \(k\operatorname{xor}1\)。因为正反边编号为 \(k,k+1\)

3.2.2 Edmonds-Karp

3.2.2.1 算法简介

Edmonds-Karp 算法的核心是使用 bfs 寻找 长度最短 的增广路。

为此,我们记录流向每个点的边的编号,然后从汇点 \(T\) 不断反推到源点 \(S\)。时间复杂度 \(\mathcal{O}(nm^2)\)

注意,任意选择增广路增广,复杂度将会退化成和流量相关(朴素的 FF 算法),因为 EK 的复杂度证明需要用到增广路长度最短的性质。

3.2.2.2 时间复杂度证明
  1. 引理:最短距离单调性
    设某次增广前的残差网络为 \(G_f\),增广后的为 \(G_f'\)。对任意节点 \(u\),有:
    \(d_f'(s, u)\ge d_f(s, u)\)

    • 证明:若存在 \(u\) 使得 \(d_f'(s,u)<d_f(s, u)\),则增广前的 \(G_f\) 中本应存在更短的 \(s\to u\) 路径,矛盾。
  2. 关键边的重新出现条件
    \((u, v)\) 在增广路径上被饱和后,只有当 \((v, u)\) 出现在后续增广路径中时,\((u, v)\) 才可能重新出现。此时:
    \(d_f'(s, u) = d_f'(s, v) + 1\ge d_f(s, v) + 1 = d_f(s, u) + 2\)
    (因为 \((v, u)\) 是反向边,增广前需满足 \(d_f(s, u) = d_f(s, v) + 1\)

  3. 每条边的关键次数限制

    • 每次 \((u, v)\) 成为关键边时,\(d_f(s, u)\) 至少增加 \(2\)
    • 由于 \(d_f(s, u)\le\mid n\mid\)(最短路径不超过 \(\mid n\mid-1\)),每条边最多成为关键边 \(\mathcal{O}(n)\) 次。
  4. 总时间复杂度

    • 每次增广:bfs 耗时 \(\mathcal{O}(m)\)
    • 总增广次数:\(\mathcal{O}(nm)\)
    • 最终时间复杂度:\(\mathcal{O}(nm^2)\)

3.2.3 dinic

3.2.3.1 算法简介

dinic 算法的核心在于使用 分层图(Level Graph)阻塞流(Blocking Flow) 的概念:

  1. 分层图:通过 bfs 从源点 \(S\) 出发,按照距离给每个节点分配一个层级。
  2. 阻塞流:用 dfs 在分层图中找不到从 \(S\)\(T\) 的增广路径时的流。

其中,bfs 给图分层,分层后用 dfs 多路增广,维护当前节点和剩余流量,向下一层节点继续流。

给图分层的目的是将网络视作 DAG,规范增广路的形态,防止流成一个环。

dinic 算法有重要的 当前弧优化。增广时,容量等于流量的边无用,可直接跳过,不需要每次搜索到同一个点时都从邻接表头开始遍历。为此,记录从每个点出发第一条没有流满的边,称为 当前弧。每次搜索到一个节点就从其当前弧开始增广。

注意,每次多路增广前每个点的当前弧应初始化为邻接表头,因为并非一旦流量等于容量,这条边就永远无用。反向边流量的增加会让它重新出现在残量网络中。

当前加上弧优化,dinic 时间复杂度为 \(\mathcal{O}(n^2m)\),否则时间复杂度退化为 \(\mathcal{O}(nm^2)\)

3.2.3.2 时间复杂度证明

引理1:dinic 算法中分层图的构建次数不超过 \(n-1\) 次,其中 \(n\) 是图中顶点数。

证明:每次分层图构建后,至少有一条边被饱和(即流量达到容量),导致 \(S\)\(T\) 的最短路径长度严格增加。由于最短路径长度最多为 \(n-1\),故分层图最多构建 \(n-1\) 次。

引理2:在分层图中寻找阻塞流的时间复杂度为 \(\mathcal{O}(mn)\),其中 \(m\) 是边数。

证明:使用 dfs 寻找阻塞流时,每个顶点最多被访问一次(因为一旦发现无法到达t就回溯),每次 dfs 的时间为 \(\mathcal{O}(n)\)。对于每条边,最多被考虑一次(饱和后被移除),因此总时间为 \(\mathcal{O}(mn)\)

结合上述两个引理:

  • 最多构建 \(n-1\) 次分层图。
  • 每次构建分层图后,寻找阻塞流的时间为 \(\mathcal{O}(mn)\)

因此,总时间复杂度为:\(\mathcal{O}(n)\times\mathcal{O}(mn)=O(mn^2)\)

3.3 无负环的费用流

费用流一般指 费用最大流(Minimum cost maximum flow,简称 MCMF)

相较于一般的网络最大流,在原有网络 \(G\) 的基础上,每条边多了一个属性:权值 \(w(x,y)\)。最小费用最大流在要求我们在 保证最大流 的前提下,求出 \(\displaystyle\sum_{(x,y)\in E}{f(x,y)\times w(x,y)}\) 的最小值。

简单地说,\(w\) 就是每条边流 \(1\) 单位流量的费用。我们需要最小化这一费用,因此被称为费用流。

3.3.1 SSP

3.3.1.1 算法简介

连续最短路算法 Successive Shortest Path,简称 SSP。这一算法的核心思想是每次找到 长度最短的增广路 进行增广,且仅在网络 初始无负环 时能得到正确答案。

SSP 算法有两种实现,一种基于 EK(Edmonds-Karp) 算法,另一种基于 dinic 算法。这两种实现均要求将 bfs 换成 SPFA(每条边的长度即 \(w\)),且 dinic 的 dfs 多路增广仅在 \(dis_x+w(x,y)=dis_y\) 之间的边进行。

\(x\to y\) 在退流流过 \(y\to x\) 时费用也要退掉,所以对于原网络的每条边 \((x,y)\),其反边的权值 \(w(y,x)\) 应设为 \(-w(x-y)\)

时间复杂度 \(\mathcal{O}(nmf)\),其中 \(f\) 为最大流流量。实际应用中此上界非常松,因为不仅增广次数远远达不到 \(f\),,同时 SPFA 的复杂度也远远达不到 \(nm\),可以放心大胆使用。

OI 界一般以 dinic 作为网络最大流的标准算法,以基于 EK 的 SSP 作为费用流的标准算法。「最大流不卡 dinic,费用流不卡 EK」是业界公约。

P3381 【模板】最小费用最大流

// Problem: P3381 【模板】最小费用最大流
// Contest: Luogu
// URL: https://www.luogu.com.cn/problem/P3381
// Memory Limit: 128 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)

/*+ Nimbunny +*/

#include <bits/stdc++.h>

using namespace std;
const int N = 5e3 + 5, M = 5e4 + 5;

struct flow {
    int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1],
        cst[M << 1];
    void add(int u, int v, int w, int c) {
        nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w,
        cst[cnt] = c;
        nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0,
        cst[cnt] = -c;
    }
    int fr[N], fl[N], in[N], dis[N];
    pair<int, int> mincost(int s, int t) {
        int flow = 0, cost = 0;
        while (1) {
            queue<int> q;
            memset(dis, 0x3f, sizeof(dis));
            memset(in, 0, sizeof(in));
            fl[s] = 1e9, dis[s] = 0, q.push(s);
            while (!q.empty()) {
                int t = q.front();
                q.pop(), in[t] = 0;
                for (int i = hd[t]; i; i = nxt[i]) {
                    int it = to[i], d = dis[t] + cst[i];
                    if (limit[i] && d < dis[it]) {
                        fl[it] = min(limit[i], fl[t]), fr[it] = i,
                        dis[it] = d;
                        if (!in[it]) in[it] = 1, q.push(it);
                    }
                }
            }
            if (dis[t] > 1e9) return make_pair(flow, cost);
            flow += fl[t], cost += dis[t] * fl[t];
            for (int u = t; u != s; u = to[fr[u] ^ 1])
                limit[fr[u]] -= fl[t], limit[fr[u] ^ 1] += fl[t];
        }
    }
} g;

int n, m, s, t;

int main() {
    cin >> n >> m >> s >> t;
    for (int i = 1; i <= m; i++) {
		int u, v, w, c;
		cin >> u >> v >> w >> c;
		g.add(u, v, w, c);
    }
    pair<int, int> ans = g.mincost(s, t);
    cout << ans.first << " " << ans.second << endl;
    return 0;
}
3.3.1.2 正确性证明

这个也是技术活呀。

  1. 基本概念和术语

    • 残差网络:对于流量 \(f\),残差网络 \(G_f\) 包含所有可以增加或减少流量的边。
    • 势函数:一个节点势函数 \(\varphi: V\to\R\),用于保持边费用非负。
    • 约简费用\(c_\varphi(u,v) = c(u,v) + \varphi(u) - \varphi(v)\)
  2. 关键引理

    引理1:如果 \(f\) 是最小费用流,当且仅当残差网络 \(G_f\) 中没有负费用循环。

    引理2:在 SSP 算法中,每次迭代后,残差网络中从源到汇的最短路径长度(按约简费用)非减。

  3. 归纳证明

    基例:初始流量为 \(0\),残差网络与原网络相同,第一次找到的最短路径确实是最小费用路径。

    归纳假设:假设前 \(k\) 次迭代后,当前流量 \(f_k\) 是所有满足 \(\mid f\mid=F_k\) 的流中费用最小的。

    归纳步骤:考虑第 \(k+1\) 次迭代:
    i. 我们沿着 \(G_f\) 中的最短路径 \(P\) 推送流量。
    ii. 设新流量为 \(f_{k+1} = f_k + f_P\)
    iii. 需要证明 \(f_{k+1}\) 是所有满足 \(\mid f\mid=F_{k+1}\) 的流中费用最小的。

    证明

    • 假设存在更优的流量 \(f'_{k+1}\),则 \(f'_{k+1}-f_k\) 是一个流差,可以分解为循环和从源到汇的路径。
    • 由于 \(f_k\) 是最优的,任何改进必须来自从源到汇的路径。
    • 但我们选择的是最短路径 \(P\),因此不可能存在更优的路径。
  4. 势函数的作用

    势函数 \(\varphi\) 通过约简费用保持所有边费用非负:

    • 初始化 \(\varphi=0\)
    • 每次最短路径计算后,更新 \(\varphi(v)=\varphi(v)+d(v)\),其中 \(d(v)\) 是从源到 \(v\) 的最短距离。
    • 这样保证了 \(c_\varphi(u,v)=c(u,v)+\varphi(u)-\varphi(v)\ge0\)
  5. 终止条件

    算法终止时:

    • 要么达到了所需的流量值,此时根据归纳证明它是最小费用的。
    • 要么残差网络中不再存在源到汇的路径,此时问题不可行。
  6. 复杂度说明

    每次迭代:
    i. 使用 dijkstra 算法计算最短路径 \(\mathcal{O}(m+n\log n)\)
    ii. 最多需要 \(F\) 次迭代(每次至少推送 \(1\) 单位流量)。
    因此总复杂度为 \(\mathcal{O}(F\cdot(m+n\log n))\)

3.4 上下界网络流

上下界网络流相较于原始网络 \(G\) ,每条边多了一个属性:流量下界 \(b(u,v)\) ,它使可行的流函数需满足的流量限制更加严格:\(b(u,v)\le f(u,v)\le c(u,v)\)

3.4.1 无源汇可行流

3.4.1.1 问题描述

无源汇有上下界可行流问题是指:给定一个有向图,每条边都有一个流量下界和上界,要求判断是否存在一种流量分配方式,满足:

  1. 每条边的流量在其上下界之间
  2. 对于每个顶点(除了源点和汇点),流入的流量等于流出的流量(流量守恒)

注意:这个问题中没有明确的源点和汇点。

3.4.1.2 解决思路

我们可以通过构造一个附加网络并将其转化为普通的最大流问题来解决。主要步骤如下:

  1. 构造附加网络

    • 添加新的超级源点 \(S\) 和超级汇点 \(T\)
    • 对原图中每个顶点 \(u\),计算其“必要流量差”:\(D(u) = \sum\) (进入 \(u\) 的边下界) - \(\sum\) (离开 \(u\) 的边下界)。
    • 如果 \(D(u)>0\),表示 \(u\) 需要额外流入 \(D(u)\) 流量,添加边 \(S\to u\),容量为 \(D(u)\)
    • 如果 \(D(u)<0\),表示 \(u\) 需要额外流出 \(-D(u)\) 流量,添加边 \(u\to T\),容量为 \(-D(u)\)
  2. 处理原图中的边

    • 将原图中每条边 \(u\to v\) 的容量设为上界减去下界(即允许的额外流量)。
  3. 求解最大流

    • 在附加网络上从 \(S\)\(T\) 求最大流。
    • 如果从 \(S\) 出发的所有边都满流,则原问题有可行解。
  4. 构造可行流

    • 原图中每条边的可行流 = 下界 + 附加网络中该边的流量。
3.4.1.3 代码

无源汇有上下界可行流

// Problem: #115. 无源汇有上下界可行流
// Contest: LibreOJ
// URL: https://loj.ac/p/115
// Memory Limit: 256 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)

/*+ Nimbunny +*/

#include <bits/stdc++.h>
#define endl '\n'
#define pi pair<int, int>
// #define int long long

using namespace std;
const int INF = INT_MAX;
const int mod = 1e9 + 7;
const int N = 202 + 5, M = 1.02e4 + N;
int n, m;

struct graph {
    int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1];
    void add(int u, int v, int w) {
        nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w;
        nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0;
        return;
    }
    int cur[N], dis[N], T;
    int dfs(int id, int res) {
        if (id == T) return res;
        int flow = 0;
        for (int i = cur[id]; i && res; i = nxt[i]) {
            cur[id] = i;
            int c = min(res, limit[i]), it = to[i];
            if (dis[id] + 1 == dis[it] && c) {
                int k = dfs(it, c);
                flow += k;
                res -= k;
                limit[i] -= k;
                limit[i ^ 1] += k;
            }
        }
        if (!flow) dis[id] = -1;
        return flow;
    }
    int maxflow(int s, int t) {
        T = t;
        int flow = 0;
        while (true) {
            queue<int> q;
            memcpy(cur, hd, sizeof hd);
            memset(dis, -1, sizeof dis);
            q.push(s);
            dis[s] = 0;
            while (!q.empty()) {
                int t = q.front();
                q.pop();
                for (int i = hd[t]; i; i = nxt[i])
                    if (dis[to[i]] == -1 && limit[i])
                        dis[to[i]] = dis[t] + 1, q.push(to[i]);
            }
            if (dis[t] == -1) return flow;
            flow += dfs(s, 1e9);
        }
    }
};
struct network_flow {
    int cnt, u[M], v[M], limit[M], low[M];
    void add(int _u, int _v, int w, int b) {
        u[++cnt] = _u, v[cnt] = _v, limit[cnt] = w, low[cnt] = b;
    }
    graph g;
    int maxflow(int n, int ss, int tt) {
        static int w[N];
        memset(w, 0, sizeof(w));
        for (int i = 1; i <= cnt; i++) {
            w[u[i]] -= low[i], w[v[i]] += low[i];
            g.add(u[i], v[i], limit[i] - low[i]);
        }
        int tot = 0;
        for (int i = 1; i <= n; i++)
            if (w[i] > 0)
                g.add(ss, i, w[i]), tot += w[i];
            else if (w[i] < 0)
                g.add(i, tt, -w[i]);
        return g.maxflow(ss, tt) == tot;
    }
} f;

inline int read() {
    int x;
    cin >> x;
    return x;
}

signed main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u = read(), v = read(), b = read(), w = read();
        f.add(u, v, w, b);
    }
    if (!f.maxflow(n, 0, n + 1))
        cout << "NO" << endl;
    else {
        cout << "YES" << endl;
        for (int i = 1; i <= m; i++)
            cout << f.low[i] + f.g.limit[(i * 2) ^ 1] << endl;
    }
    return 0;
}
3.4.1.4 代码说明
  1. Dinic算法:实现了 dinic 最大流算法,用于求解附加网络的最大流。

  2. BoundedFlow类:封装了无源汇有上下界可行流的解决方案:

    • solve():构造附加网络并求解。
    • getFlow():获取可行流的实际流量分配。
  3. 主要步骤

    • 计算每个顶点的必要流量差 \(D(u)\)
    • 构造附加网络,添加超级源点和汇点。
    • 求解最大流并判断是否满足条件。
    • 如果满足,则构造可行流。
3.4.1.5 复杂度分析
  • 时间复杂度:主要由 dinic 算法决定,为 \(\mathcal{O}(n^2m)\)
  • 空间复杂度:\(O(n^2)\) 用于存储图的邻接矩阵。

这个解决方案可以有效地判断无源汇有上下界的网络中是否存在可行流,并在存在时给出一个可行的流量分配方案。

3.4.2 有源汇可行流

\(T\to S\) 连容量为 \(+\infty\) 的边,转化为 无源汇 上下界可行流。注意连边是 源汇 之间而非 超源超汇

3.4.3 有源汇最大流

3.4.3.1 问题描述

给定一个包含 \(n\) 个点 \(m\) 条边的有向图,每条边有一个流量下界和流量上界。给定源点 \(S\) 和汇点 T,求源点到汇点的最大流。

3.4.3.2 算法思路

有源汇有上下界的最大流问题可以通过以下步骤解决:

  1. 转化为无源汇有上下界可行流问题:添加从汇点 \(T\) 到源点 \(S\) 的无限容量边,形成循环流。
  2. 构建附加网络:建立超级源点 \(SS\) 和超级汇点 \(TT\),计算每个点的流量差。
  3. 求解可行流:在附加网络上求最大流,判断是否存在可行流。
  4. 求解最大流:在可行流的基础上,在原始网络上求从 \(S\)\(T\) 的最大流。
3.4.3.3 代码
// Problem: #116. 有源汇有上下界最大流
// Contest: LibreOJ
// URL: https://loj.ac/p/116
// Memory Limit: 256 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)

/*+
        两个缩进,向 zhoukangyang 靠拢!
        author: Nimbunny
        powered by c++14
+*/

#include <bits/stdc++.h>
#define endl '\n'
#define pi pair<int, int>
// #define int long long
// #pragma GCC optimize(2)

using namespace std;
const int INF = INT_MAX;
const int mod = 1e9 + 7;
const int N = 202 + 10, M = 1e4 + N;

struct graph {
    int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1];
    inline void add(int u, int v, int w) {
        nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w;
        nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0;
        return;
    }
    int cur[N], dis[N], T;
    inline int dfs(int id, int res) {
        if (id == T) return res;
        int flow = 0;
        for (int i = cur[id]; i && res; i = nxt[i]) {
            cur[id] = i;
            int c = min(res, limit[i]), it = to[i];
            if (dis[id] + 1 == dis[it] && c) {
                int k = dfs(it, c);
                flow += k, res -= k, limit[i] -= k, limit[i ^ 1] += k;
            }
        }
        if (!flow) dis[id] = -1;
        return flow;
    }
    inline int maxflow(int s, int t) {
        T = t;
        int flow = 0;
        while (1) {
            queue<int> q;
            memcpy(cur, hd, sizeof hd);
            memset(dis, -1, sizeof dis);
            q.push(s), dis[s] = 0;
            while (!q.empty()) {
                int t = q.front();
                q.pop();
                for (int i = hd[t]; i; i = nxt[i])
                    if (dis[to[i]] == -1 && limit[i])
                        dis[to[i]] = dis[t] + 1, q.push(to[i]);
            }
            if (dis[t] == -1) return flow;
            flow += dfs(s, 1e9);
        }
    }
};

struct network_flow {
    int e, u[M], v[M], limit[M], low[M];
    inline void add(int _u, int _v, int w, int b) {
        u[++e] = _u, v[e] = _v, limit[e] = w, low[e] = b;
    }
    graph g;
    inline int maxflow(int n, int s, int t, int ss, int tt) {
        static int w[N];
        memset(w, 0, sizeof w);
        for (int i = 1; i <= e; i++) {
            w[u[i]] -= low[i], w[v[i]] += low[i];
            g.add(u[i], v[i], limit[i] - low[i]);
        }
        int tot = 0;
        for (int i = 1; i <= n; i++)
            if (w[i] > 0)
                g.add(ss, i, w[i]), tot += w[i];
            else if (w[i] < 0)
                g.add(i, tt, -w[i]);
        g.add(t, s, 1e9);
        if (g.maxflow(ss, tt) != tot) return -1;
        int flow = g.limit[g.hd[s]];
        g.hd[s] = g.nxt[g.hd[s]], g.hd[t] = g.nxt[g.hd[t]];
        return flow + g.maxflow(s, t);
    }
} f;

int n, m, s, t;

inline int read() {
    int x;
    cin >> x;
    return x;
}

signed main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n >> m >> s >> t;
    for (int i = 1; i <= m; i++) {
        int u = read(), v = read(), b = read(), w = read();
        f.add(u, v, w, b);
    }
    int res = f.maxflow(n, s, t, 0, n + 1);
    if (res == -1)
        cout << "please go home to sleep" << endl;
    else
        cout << res << endl;
    return 0;
}
3.4.3.4 复杂度分析
  • 时间复杂度:主要取决于使用的最大流算法,SAP 算法的复杂度为 \(\mathcal{O}(n^2m)\)
  • 空间复杂度:\(\mathcal{O}(n+m)\),存储图的结构。
3.4.3.5 注意事项
  1. 需要正确处理边的索引,特别是在删除边时。
  2. 可行流不存在时需要输出特定信息。
  3. 残余网络的处理要小心,确保不影响最终的最大流计算。

3.4.4 有源汇最小流

根据 \(S\to T\) 的最小流等于 \(T\to S\) 的最大流的相反数这一结论,用可行流流量减掉 \(G'\)\(T\to S\) 的最大流。

4. 二分图

4.1 定义,判定

定义:

设无向图 \(G=(V,E)\) 若能够将 \(V\) 分成两个点集 \(V_1,V_2\),满足 \(V_1\cap V_2=\varnothing\)\(V_1\cup V_2=V\),且 \(\forall(u,v)\in E\) 均有 \(u\in V_1\wedge v\in V_2\)\(u\in V_2\wedge v\in V_1\),则称 \(G\) 为一张二分图,\(V_1,V_2\) 分别为其左部点和右部点。

简单地说,二分图就是可以将原图点集分成两部分,满足两个点集内部没有边的图。这也是它的名字的由来。

判定

我们发现,从一个点开始,每走一条边就会切换一次所在集合。这说明从任意一个点出发,必须经过偶数条边才能回到这个点,即图上不存在奇环。反过来,若一张图不存在奇环,对其进行黑白染色就可以得到一组划分 \(V_1,V_2\) 的方案。

综上,我们得到判定二分图的充要条件:不存在奇环

  • 什么是黑白染色?我们希望给每个点染上白色或黑色,使得任意一条边两端的颜色不同。

    从某个点开始深搜,初始点的颜色任意。遍历当前点 \(u\) 的所有邻居 \(v\)。如果 \(v\) 未被访问,则将 \(v\) 的颜色设为与 \(u\) 相反的颜色并向 \(v\) 深搜。否则检查 \(u\) 的颜色是否与 \(v\) 的颜色不同 —— 若是,说明满足限制;否则说明图上存在奇环,黑白染色无解。

黑白染色的时间复杂度为 \(\mathcal{O}(n+m)\)

4.2 二分图的匹配

给定二分图 \(G=(V,E)\),若边集 \(M\subseteq E\) 满足 \(M\) 中任意两条边不交于同一端点,则称 \(M\)\(G\) 的一组 匹配,匹配的大小为 \(\mid M\mid\)

特别的,若 \(\mid V_1\mid=\mid V_2\mid\) 且匹配 \(M\) 包含 \(\mid V_1\mid\) 条边,则称 \(M\)完美匹配

下文称节点 \(u\) 被匹配当且仅当 \(M\) 存在一条边以 \(u\) 为端点。

4.2.1 最大匹配

4.2.1.1 dinic

给定二分图 \(G=(u,v)\)

posted @ 2025-07-21 21:39  酱云兔  阅读(34)  评论(0)    收藏  举报