【学习笔记】二分图
当遇到「匹配题」或者「二维平面 / grid 中的行列限制 / 黑白染色」类题,常常使用二分图算法。
二分图最大匹配 / 最小点覆盖 / 最大独立集 / 最小边覆盖
所谓最大匹配,就是选最多的边,使得任意两条边不具有公共端点。
所谓最小点覆盖,就是选最少的点,使得每条边至少有一个端点被选。
所谓最大独立集,就是选择最多的点,使得它们两两间没有边直接相连。
所谓最小边覆盖,就是选择最少的边,使得覆盖到所有的点。
对于所有二分图,都有:\(|最大匹配|=|最小点覆盖|=点数-|最大独立集|=点数-|最小边覆盖|\)。证明在后面。
匈牙利算法
思路
算法核心:找“增广路”
遍历所有左侧点,每次进行以下流程:
- 尝试去寻找一个右侧点来匹配;
- 若该右侧点还没有匹配的左侧点,则找到了,回溯。否则进入该右侧点的匹配左侧点,回到1。
由于每次的 dfs 找匹配点是 $ \mathcal{O}(n+m)$ 的,因此匈牙利算法的时间复杂度为 $ \mathcal{O}(n(n+m))$。
代码
点击查看代码
const int N = 505;
int n, m, tag;
vector<int> g[N];
int match[N], vis[N];
int ans;
bool dfs(int u)
{
vis[u] = tag;
for (auto &&v : g[u])
if (!match[v] || vis[match[v]] != tag && dfs(match[v])) // 要么v没有匹配点,要么v成功找到其他匹配点
{
match[v] = u;
return true;
}
return false;
}
int main()
{
cin >> n >> x >> m;
int u, v;
for (int i = 1; i <= m; i++)
scanf("%d%d", &u, &v), g[u].push_back(v);
for (int i = 1; i <= n; i++)
{
++tag; // 每轮的tag不一样,vis与本轮的tag相同就访问过,这样免掉了每轮vis清0
ans += dfs(i); // 为true表示成功匹配,否则失败
}
cout << ans << endl;
return 0;
}
网络流
前置知识:网络流。
对每个左侧点 \(u\) 连 \(s \xrightarrow{1} u\),右侧点 \(v\) 连 \(v \xrightarrow{1} t\),对于左侧点到右侧点的连边连 \(u\xrightarrow{\infty}v\),跑最大流即可。
流量为 \(1\) 的 \(\infty\) 边就是选择的匹配。
根据时间复杂度分析,这个网络可以看成各容量为 1 的网络(inf 边可以改成 1),因此可以使用 \(O(m\min\{m^{\frac{1}{2}},n^{\frac{2}{3}}\})\)。
但是!我们注意到还有一个 \(O(\sqrt{\sum \min\{in_u,out_u\}}\sum w_i)\),因此可以分析成 \(O(m\sqrt{n})\)!这是比匈牙利快的。
构造
最大匹配 / 最小边覆盖
匈牙利算法的构造就是 match 数组。
网络流,流量为 \(1\) 的 \(\infty\) 边就是选择的匹配。
当确定了一个最大匹配,注意到选择一个匹配能覆盖 2 个点,于是贪心地先选择所有匹配,然后再挨个覆盖其他点,恰好能取到下界 \(n-|最大匹配|\)。
最大独立集 / 最小点覆盖
考虑最小割的定义。
其中 \(x_i\) 是布尔变量,表示在 \(S\) 连通块还是 \(T\)。
而求解最大独立集可以看成最小化:
其中 \(x_i\) 表示选没选。
对于一般图,难以转化成最小割,毕竟一般图最大匹配是 NP 问题。但是二分图的边都是左连向右,因此可以规定右边的点的 \(x\) 取反。转化成:
恰好转为了最小割,\(x_s=1,x_t=0\)。
于是建图 \(s\xrightarrow{1}u,u\xrightarrow{\infty}v,v\xrightarrow{1}t\)(同最大匹配),方案为最小割中 \(s\) 内的左部点和 \(t\) 内的右部点。
妙哉!
最小点覆盖就是最大独立集的补集。最大独立集中每个边至多有一个端点被选,所以补集中每个边至少有一个端点被选,就是最小点覆盖。
证明
证明:\(|最大匹配|=|最小点覆盖|=n-|最大独立集|=n-|最小边覆盖|\)。
首先我们证明 \(|最大匹配|=|最小点覆盖|,|最大独立集|=|最小边覆盖|\)。
发现对于最小点覆盖,多个匹配不可能被同一个点覆盖,因此 \(|最大匹配|\le |最小点覆盖|\),而根据上面的构造,下界可以取到,因此 \(|最大匹配|=|最小点覆盖|\)。
对于最小边覆盖,多个独立集不可能被同一条边覆盖,因此 \(|最大独立集|\le |最小边覆盖|\),而根据上面的构造,下界可以取到,因此 \(|最大独立集|=|最小边覆盖|\)。
然后证明 \(|最大匹配|=n-|最大独立集|\)。
对于建图 \(s\xrightarrow{1}u,u\xrightarrow{\infty}v,v\xrightarrow{1}t\),最大匹配大小就是最大流。
而最大独立集大小是 \(\sum -x_u +\sum_{E} \infty x_u x_v\) 的最小值的相反数。
也就是 \(\sum_{u\in L}1(1-x_u)+\sum_{v\in R}x_v(1-0)+\sum_{E} \infty x_u(1-x_v)-n\) 的相反数的最大值。
也就是 \(n-(\sum_{u\in L}1(1-x_u)+\sum_{v\in R}x_v(1-0)+\sum_{E} \infty x_u(1-x_v))\) 的最大值。
也就是 \(n-最小割\)。也就是 \(n-最大流\)。也就是 \(n-|最大匹配|\)。Q.E.D.
题:[HNOI2013]消毒
有一个 \(a\times b\times c\) 的立方体,其中有一些格子需要消毒。
你可以使用一种消毒剂,花费 \(\min\lbrace x,y,z \rbrace\) 代价,给一个 \(x \times y \times z\) 的立方体消毒。
求将整个立方体消毒干净的最小代价。
Easy version
首先从简单的情况考虑,想想二维怎么做。
假设我们选择了一个长为 \(x\),宽为 \(y\) 的矩形(\(x>y\)),那么无论 \(x\) 如何变化,代价不变。因此 \(x\) 取最大值一定最优。
问题便转化成了在一个矩形上刷一行或一列,覆盖所有点的最小刷的次数。
即「选择最少的行、列,包含所有要选的点」,一眼最小点覆盖。
对每个行、列建点。若该格子需要消毒,则链接行点、列点。
最后跑一遍匈牙利即可。时间复杂度 \(\mathcal{O}((a+b)\times[(a+b)+ab])\)。
Hard version
在立方体上,利用刚刚的结论,我们可以刷掉一些层。
然后我们可以把剩下的层拿出来,拍扁成一个二维矩形,就变成了 Easy version。
至于刷掉哪些层,暴力枚举即可。
注意要选用最短的棱长枚举。不妨设 \(a\leq b\leq c\),因为 \(a\times b\times c \leq 5\times 10^3\),所以可以用反证法证明 \(a<=17\)。
时间复杂度 \(\mathcal{O}(2^a\times (b+c)\times[(b+c)+bc])\)。(\(a\leq b\leq c\))
瓶颈是 \(a=b=c=17\),数量级约为 \(1.3 \times 10^9\),但是卡不满匈牙利。可以通过。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5e3 + 5;
int a, b, c, mark[N][N], ans;
vector<pair<int, int> > pos[N];
int vis[N], match[N], tag;
vector<int> g[N];
bool Hungary(int u)
{
vis[u] = tag;
for (auto v : g[u])
if (!match[v] || vis[match[v]] != tag && Hungary(match[v]))
{
match[v] = u;
return true;
}
return false;
}
int cal()
{
for (int i = 1; i <= b; i++)
{
vis[i] = 0;
g[i].clear();
for (int j = 1; j <= c; j++)
if (mark[i][j])
g[i].push_back(j);
}
for (int i = 1; i <= c; i++)
match[i] = 0;
int res = 0;
for (int i = 1; i <= b; i++)
{
tag = i;
if (Hungary(i))
res++;
}
return res;
}
void dfs(int dep, int cnt) // 暴搜应该刷掉哪些层
{
if (dep > a)
{
ans = min(ans, cal() + cnt);
return;
}
dfs(dep + 1, cnt + 1);
for (auto i : pos[dep])
mark[i.first][i.second]++; // 记录哪些格子需要消毒
dfs(dep + 1, cnt);
for (auto i : pos[dep])
mark[i.first][i.second]--;
}
void solve()
{
int x;
pair<int, pair<int, int> > t[5];
scanf("%d%d%d", &a, &b, &c);
for (int i = 1; i <= 20; i++)
pos[i].clear();
for (int i = 1; i <= a; i++)
for (int j = 1; j <= b; j++)
for (int k = 1; k <= c; k++)
{
scanf("%d", &x);
if (!x)
continue;
t[1] = {a, {1, i}}, t[2] = {b, {2, j}}, t[3] = {c, {3, k}}; //pair套pair是为了防止棱长相同时,由于下标不同导致的交换
sort(t + 1, t + 4); // 小的棱长对应小的下标
pos[t[1].second.second].push_back({t[2].second.second, t[3].second.second});
}
a = t[1].first, b = t[2].first, c = t[3].first; // 最后将a、b、c从小到大排序
ans = 0x3f3f3f3f;
dfs(1, 0);
printf("%d\n", ans);
}
int main()
{
int T;
cin >> T;
while (T--)
solve();
return 0;
}
题:[NOI2011] 兔兔与蛋蛋游戏
一张棋盘上有黑、白两种颜色的棋子和一个空位。
兔兔要选一个与空位相邻的白棋子并移到空位,而蛋蛋要选黑的。如果某轮有一方不能移动了他就输了。
现在给你一个蛋蛋赢的棋局,你需要求出哪些步兔兔走错了。
很有趣的一道题。难点在于把「判断哪方必胜」转化成二分图问题。
因为把棋子移到空格内比较难理解,所以不妨想象成移动空格。
首先找一找性质。经过手玩样例,不难发现空格的路径不能形成环,也就是说不能走到以前走过的位置。
然后作为一道博弈题,我们考虑如何判断必胜方。
看到黑白棋子可以试试变成二分图,这时思路就基本出来了:
若两格子相邻且颜色不同,则连边。
若本轮玩家的起点必须在最大匹配里,则其必胜,否则必输。
证明很简单,因为可以走到它的匹配点上,而下一步要么走进另一个有匹配的点上,要么输。如图:(S为起点,由于一开始要走向白色,不妨将S设为黑色)

若下一步走到了一个没有匹配的点上,则可以将匹配方案沿路径前移一格,如图:

此时起点不必须在最大匹配内,与条件不符。故命题成立。
这样一来,每到下一个点,就把上一个点以及其匹配点抹去(可以将vis设为特殊值,详见代码)。若当前点没有匹配点,则必然不在最大匹配内(易得);否则若匹配点还能找到另一个除了本轮点以外的点匹配(可能有点绕,就是说本轮点的匹配点不一定是本轮点),则不必须在最大匹配内。否则必须在最大匹配内。
一开始要对棋盘每个点跑匈牙利,然后每走一步跑一次匈牙利,故时间复杂度 \(\mathcal{O}((nm)^2 + knm)\)。
思路很巧,代码也很好写。
注:由于本轮是否必胜看的是当前状态,而不是走完后的状态,故将输入放在循环最后(见代码)。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int L = 45, N = 2005;
int a, b, m, win[N];
char s[L][L];
bool c[N];
int n, st, tag, vis[N], match[N];
vector<int> g[N], ans;
int trans(int x, int y) // 将坐标转为点编号
{
return (x - 1) * b + y;
}
bool dfs(int u)
{
vis[u] = tag;
for (auto v : g[u])
if (vis[v] != -1 && (!match[v] || vis[match[v]] != tag && vis[match[v]] != -1 && dfs(match[v])))
{
match[v] = u;
match[u] = v;
return true;
}
return false;
}
int main()
{
cin >> a >> b;
n = a * b;
for (int i = 1; i <= a; i++)
{
scanf("%s", s[i] + 1);
for (int j = 1; j <= b; j++)
{
int u = trans(i, j), v;
if (s[i][j] == '.')
st = u;
c[u] = s[i][j] != 'O';
v = trans(i - 1, j);
if (i > 1 && c[u] != c[v])
g[u].push_back(v), g[v].push_back(u);
v = trans(i, j - 1);
if (j > 1 && c[u] != c[v])
g[u].push_back(v), g[v].push_back(u);
}
}
for (int i = 1; i <= n; i++) // 对所有点跑匈牙利
if (c[i])
{
tag++;
dfs(i);
}
cin >> m;
m *= 2; // 每人m轮,共2m轮
for (int k = 1; k <= m; k++)
{
vis[st] = -1;
if (!match[st])
win[k] = false;
else
{
match[match[st]] = 0;
tag++;
win[k] = !dfs(match[st]); // 是否还有别的点能和匹配点匹配,没有说明必胜
}
if (!(k & 1) && win[k] == win[k - 1]) // 正常来说两人应该轮流胜负,如果连续一样的结果说明出错了
ans.push_back(k / 2);
int x, y;
scanf("%d%d", &x, &y); // 到下一个状态
st = trans(x, y);
}
cout << ans.size() << endl;
for (auto i : ans)
printf("%d\n", i);
return 0;
}
霍尔定理
霍尔定理:设 \(G=〈V_1,V_2,E〉\) 为二分图,\(|V_1|\le|V_2|\),则 \(G\) 中存在 \(V_1\) 到 \(V_2\) 的完美匹配当且仅当对于任意的 \(S\subset V_1\),均有 \(|S|\le |N(S)|\),其中 \(N(S)\) 是 \(S\) 的邻域。
霍尔定理还有一条重要的推论:二分图的最大匹配为 \(|V_1| - \max_{S \subset V_1} \{|S|-|N(S)|\}\)。
这条结论看似没什么用处,实则常常能把一些问题中「判断是否存在完美匹配」「动态最大匹配」等的问题大大简化。
题:[ARC076F] Exhausted?
原问题显然在求一个最大匹配。于是我们考虑使用霍尔定理。
但我们不能枚举子集,所以考虑枚举邻域。
假设当前前缀为 \(L\),后缀为\(R\),因为 要求 \(\max_{S \subset V_1} \{|S|-|N(S)|\}\),所以统计前后缀都被包含的人的个数 \(num\),答案就为
(注意 \(L\) 不一定要 \(\le R\))
于是可以枚举 \(L\),那么对于每个 \(L\),答案为
注意到当 \(R<L\) 时,左边不会大于右边;另外,右边是单调的。所以等于
\(L\) 从0开始往右扫,同时线段树上维护 \(\sum_{l_i \le L} [r_i\ge R] +R\) 以及区间 \(\max\) 即可。
时间复杂度 \(\mathcal{O}(m\log m+n)\)。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define lson u + u
#define rson u + u + 1
const int N = 2e5 + 5, ND = N << 2;
struct segtree
{
int tag[ND], mx[ND];
void pushup(int u)
{
mx[u] = max(mx[lson], mx[rson]);
}
void add(int u, int x)
{
tag[u] += x;
mx[u] += x;
}
void pushdown(int u)
{
if (!tag[u])
return;
add(lson, tag[u]);
add(rson, tag[u]);
tag[u] = 0;
}
void build(int u, int l, int r)
{
if (l == r)
{
mx[u] = l; // 初值为下标
return;
}
int mid = (l + r) >> 1;
build(lson, l, mid);
build(rson, mid + 1, r);
pushup(u);
}
void update(int u, int l, int r, int L, int R)
{
if (L <= l && r <= R)
{
add(u, 1);
return;
}
if (R < l || r < L)
return;
pushdown(u);
int mid = (l + r) >> 1;
update(lson, l, mid, L, R);
update(rson, mid + 1, r, L, R);
pushup(u);
}
int query()
{
return mx[1];
}
} t;
int n, m, ans;
pair<int, int> a[N];
int main()
{
cin >> n >> m;
ans = n - m;
for (int i = 1; i <= n; i++)
scanf("%d%d", &a[i].first, &a[i].second);
sort(a + 1, a + n + 1);
int pos = 0;
t.build(1, 1, m + 1);
for (int i = 0; i <= m; i++)
{
while (pos < n && a[pos + 1].first == i)
t.update(1, 1, m + 1, 1, a[++pos].second); // 做一个前缀区间加即可维护所有>=R的r[i]个数
ans = max(ans, t.query() - m - i - 1); // 如上式
}
cout << ans << endl;
return 0;
}
二分图最大权匹配
二分图的最大权匹配是指二分图中边权和最大的匹配。
KM算法
学习此算法前,请确保您已经掌握「匈牙利算法」。
参考资料:Singercoder 的博客
KM 算法可以求出最大权完美匹配(即边权和最大的完美匹配),其本质也是找增广路。
实现方式有 \(\mathcal{O}(n^4)\) 的 dfs 和 \(\mathcal{O}(n^3)\) 的 bfs。
另外,如果单纯要求最大权匹配的话,可以通过建立虚边虚点来解决,后面会讲到。
DFS version
想要理解效率较高的 \(bfs\) 版本,首先要理解基本的 dfs 版本。
KM 算法的精髓是「顶标」。
我们先规定一些变量:
- \(match\):匹配点。
- \(vis\):访问标记。
- \(val\):顶标。
- \(d\):对于所有边 \(〈u,v,w〉\),\(\min\lbrace val_u+val_v-w\rbrace\) 的值。
那么找到一组完美匹配等价于,对于每个左侧点 \(u\),都有对应的 \(v\) 使得 \(min\lbrace val_u+val_v-w\rbrace=0\)。
其具体实现过程如下:(以下过程可能较难理解,看代码会好很多)
- 先给每个点赋上权值为 \(inf\) 的顶标。然后枚举左侧点 \(i\) 作为增广路起点。
- 跑寻找增广路的 \(dfs\),\(inf\rightarrow d\)。
- 若 \(val_u+val_v>w\),则 \(d=\min(d,val_u+val_v-w)\);否则说明 \(val_u+val_v=w\),此时找到了可配对的一对点,尝试匹配两点。若 \(v\) 已有匹配点,重复3;否则匹配成功。
- 修改顶标,枚举左侧点 \(u\),若只有 \(u\) 被遍历(而没有/不是其匹配点),说明 \(val_u\) 应当 \(-=slack\),否则不用变(而下面的代码会把 \(val_v\) 减回去,是一样的效果)。
- 若 \(i\) 匹配成功,则继续枚举下一个左侧点 \(i\),执行2;否则,回到2继续匹配。
是不是和匈牙利很像?代码也很好写,如下:
点击查看代码
#define ll long long
#define inf 0x3f3f3f3f3f3f3f3f
const int N = 1005;
int n, m;
int match[N];
bool vis[N];
ll g[N][N], val[N], d, ans;
bool dfs(int u)
{
vis[u] = true;
for (int v = n + 1; v <= n + n; v++)
{
ll w = g[u][v];
if (vis[v])
continue;
if (val[u] + val[v] > w)
d = min(d, val[u] + val[v] - w);
else
{
vis[v] = true;
if (!match[v] || dfs(match[v]))
{
match[u] = v;
match[v] = u;
return true;
}
}
}
return false;
}
int main()
{
cin >> n >> m;
int u, v;
ll w;
for (int i = 1; i <= n + n; i++)
fill(g[i] + 1, g[i] + n + n + 1, -1e10);
for (int i = 1; i <= m; i++)
scanf("%d%d%lld", &u, &v, &w), g[u][v + n] = w;
fill(val + 1, val + n + n + 1, 1e7);
for (int i = 1; i <= n; i++)
while (true)
{
memset(vis, 0, sizeof(vis));
d = inf;
if (dfs(i))
break;
for (int u = 1; u <= n; u++)
if (vis[u])
val[u] -= d;
for (int u = n + 1; u <= n + n; u++)
if (vis[u])
val[u] += d;
}
for (int i = 1; i <= n; i++)
ans += val[i] + val[match[i]];
cout << ans << endl;
for (int i = n + 1; i <= n + n; i++)
printf("%d%c", match[i], " \n"[i == n + n]);
return 0;
}
先别急着交啊,这份代码会T
交上去之后会发现,正确性可以保证,但是会TLE。我们分析一下时间复杂度:因为 dfs 有可能遍历所有边,所以单次的复杂度是 \(\mathcal{O}(n^2)\) 的,因此这种写法的时间复杂度是 \(\mathcal{O}(n^4)\) 的。
接下来,有请——
BFS version
首先,规定一些新的变量:
- \(slack\):对于每个右侧点 \(v\),\(\min \lbrace val_u+val_v-w\rbrace\)。
- \(pre\):寻找增广路时,右侧点 \(v\) 准备匹配的左侧点。
bfs 的优点在于,它可以把在一次寻找增广路时中断的位置 push 到 queue 里,这样下次就可以直接将这个点作为起点,省掉了 dfs 做法每次重新找增广路的过程。
代码量虽然偏大,但是比较好写,不过细节颇多。
我的建议是,理解当然好(毕竟这个不难理解),但是最好背一下,毕竟即使理解透彻了也很难在考场一字不差地写好。
其实这种比较死的模版不如直接背
点击查看代码
#define ll long long
#define inf 0x3f3f3f3f3f3f3f3f
const int N = 1005, M = N * N;
int n, m;
bool vis[N];
int match[N], pre[N];
ll g[N][N], val[N], slack[N], ans;
void dfs_match(int v)
{
int u = pre[v];
int nxt = match[u];
match[v] = u;
match[u] = v;
if (nxt)
dfs_match(nxt);
}
void bfs(int st)
{
memset(vis, 0, sizeof(vis));
memset(slack, 0x3f, sizeof(slack));
queue<int> q;
q.push(st);
while (true)
{
while (!q.empty())
{
int u = q.front(); q.pop();
vis[u] = true;
for (int v = n + 1; v <= n + n; v++)
{
ll w = g[u][v];
if (vis[v])
continue;
if (val[u] + val[v] - w < slack[v])
{
slack[v] = val[u] + val[v] - w;
pre[v] = u;
if (slack[v] == 0)
{
vis[v] = true;
if (!match[v])
{
dfs_match(v);
return;
}
else
q.push(match[v]);
}
}
}
}
ll d = inf;
for (int i = n + 1; i <= n + n; i++)
if (!vis[i])
d = min(d, slack[i]);
for (int i = 1; i <= n; i++)
if (vis[i])
val[i] -= d;
for (int i = n + 1; i <= n + n; i++)
{
if (vis[i])
val[i] += d;
else
slack[i] -= d;
}
for (int v = n + 1; v <= n + n; v++)
if (!vis[v] && slack[v] == 0)
{
vis[v] = true;
if (!match[v])
{
dfs_match(v);
return;
}
else
q.push(match[v]);
}
}
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= n + n; i++)
fill(g[i] + 1, g[i] + n + n + 1, -1e10);
int u, v;
for (int i = 1; i <= m; i++)
scanf("%d%d", &u, &v), scanf("%lld", &g[u][n + v]);
fill(val + 1, val + n + n + 1, 1e7);
for (int i = 1; i <= n; i++)
bfs(i);
for (int i = 1; i <= n; i++)
ans += val[i] + val[match[i]];
cout << ans << endl;
for (int i = n + 1; i <= n + n; i++)
printf("%d%c", match[i], " \n"[i == n + n]);
return 0;
}
好了,现在你已经掌握了最大权完美匹配,那么最大权匹配肯定也难不倒你。
显然只需建立虚边虚点,虚边赋权为0,KM 照样跑即可。但是注意根据题目输出要求进行适当判断。
此外,还有些题目可能无法保证有完美匹配,会让你判断无解。这种情况下还是一样的套路,把虚边权赋成 \(-inf\) 即可,最后如果方案里选了非法边就说明无解。

浙公网安备 33010602011771号