【学习笔记】二分图
当遇到「匹配题」或者「矩阵中有行列限制」「黑白染色」类题,常常使用二分图算法。
二分图最大匹配 / 最小点覆盖
所谓最大匹配,就是选最多的边,使得任意两条边不具有公共端点。
所谓最小点覆盖,就是选最少的点,使得每条边至少有一个端点被选。
对于所有二分图,都有:
匈牙利算法
思路
算法核心:找“增广路”
遍历所有左侧点,每次进行以下流程:
- 尝试去寻找一个右侧点来匹配;
- 若该右侧点还没有匹配的左侧点,则找到了,回溯。否则进入该右侧点的匹配左侧点,回到1。
由于每次的 dfs 找匹配点是
代码
点击查看代码
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; }
[HNOI2013]消毒
题意
有一个
你可以使用一种消毒剂,花费
求将整个立方体消毒干净的最小代价。
思路
Easy version
首先从简单的情况考虑,想想二维怎么做。
假设我们选择了一个长为
问题便转化成了在一个矩形上刷一行或一列,覆盖所有点的最小刷的次数。
即「选择最少的行、列,包含所有要选的点」,一眼最小点覆盖。
对每个行、列建点。若该格子需要消毒,则链接行点、列点。
最后跑一遍匈牙利即可。时间复杂度
Hard version
在立方体上,利用刚刚的结论,我们可以刷掉一些层。
然后我们可以把剩下的层拿出来,拍扁成一个二维矩形,就变成了 Easy version。
至于刷掉哪些层,暴力枚举即可。
注意要选用最短的棱长枚举。不妨设
时间复杂度
瓶颈是
代码
点击查看代码
#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设为特殊值,详见代码)。若当前点没有匹配点,则必然不在最大匹配内(易得);否则若匹配点还能找到另一个除了本轮点以外的点匹配(可能有点绕,就是说本轮点的匹配点不一定是本轮点),则不必须在最大匹配内。否则必须在最大匹配内。
一开始要对棋盘每个点跑匈牙利,然后每走一步跑一次匈牙利,故时间复杂度
代码
思路很巧,代码也很好写。
注:由于本轮是否必胜看的是当前状态,而不是走完后的状态,故将输入放在循环最后(见代码)。
点击查看代码
#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; }
霍尔定理
霍尔定理:设
霍尔定理还有一条重要的推论:二分图的最大匹配为
这条结论看似没什么用处,实则常常能把一些问题中「判断是否存在完美匹配」「动态最大匹配」等的问题大大简化。
[ARC076F] Exhausted?
思路
原问题显然在求一个最大匹配。于是我们考虑使用霍尔定理。
但我们不能枚举子集,所以考虑枚举邻域。
假设当前前缀为
于是可以枚举
时间复杂度
代码
使用霍尔定理的前提是 ,所以要特判 的情况,此时答案至少为 !
点击查看代码
#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; if (n > m) ans = n - m; // Important!! 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; }
二分图最小边覆盖 / 最大独立集
所谓最小边覆盖,就是选择最少的边,使得覆盖到所有的点。
所谓最大独立集,就是选择最多的点,使得它们两两间没有边直接相连。
对于所有二分图,都有:(挨揍ing)
二分图最大权匹配
二分图的最大权匹配是指二分图中边权和最大的匹配。
KM算法
学习此算法前,请确保您已经掌握「匈牙利算法」。
参考资料:Singercoder 的博客
KM 算法可以求出最大权完美匹配(即边权和最大的完美匹配),其本质也是找增广路。
实现方式有
另外,如果单纯要求最大权匹配的话,可以通过建立虚边虚点来解决,后面会讲到。
DFS version
想要理解效率较高的
KM 算法的精髓是「顶标」。
我们先规定一些变量:
:匹配点。 :访问标记。 :顶标。 :对于所有边 , 的值。
那么找到一组完美匹配等价于,对于每个左侧点
其具体实现过程如下:(以下过程可能较难理解,看代码会好很多)
- 先给每个点赋上权值为
的顶标。然后枚举左侧点 作为增广路起点。 - 跑寻找增广路的
, 。 - 若
,则 ;否则说明 ,此时找到了可配对的一对点,尝试匹配两点。若 已有匹配点,重复3;否则匹配成功。 - 修改顶标,枚举左侧点
,若只有 被遍历(而没有/不是其匹配点),说明 应当 ,否则不用变(而下面的代码会把 减回去,是一样的效果)。 - 若
匹配成功,则继续枚举下一个左侧点 ,执行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 有可能遍历所有边,所以单次的复杂度是
接下来,有请——
BFS version
首先,规定一些新的变量:
:对于每个右侧点 , 。 :寻找增广路时,右侧点 准备匹配的左侧点。
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 照样跑即可。但是注意根据题目输出要求进行适当判断。
此外,还有些题目可能无法保证有完美匹配,会让你判断无解。这种情况下还是一样的套路,把虚边权赋成
本文作者:Aquizahv
本文链接:https://www.cnblogs.com/aquizahv/p/18435290
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步