BIPARTITE MATCHING · FROM ONE EXAMPLE
二分图匹配
三位候选人竞争三个岗位。最基本的枚举能够得到答案,但方案数量增长过快。 问题的特殊结构将搜索缩减为一次次增广。
01 · 基本方案
枚举所有录取方案
候选人逐个选择岗位或不录取;岗位不能重复使用;记录最多录取人数。
暂不引入图、匹配或增广路。
招聘组有三位候选人和三个岗位。一次录取必须满足技能要求,每个人和每个岗位最多使用一次。
- 阿青 → 前端
- 小北 → 无可用岗位
- 晨晨 → 后端
- 阿青 → 后端
- 小北 → 前端
- 晨晨 → 测试
递归枚举每位候选人的所有选择,遇到岗位重复或技能不符就丢弃该分支,最终取最大值。 这个方法能够正确得到 3,因此小数据下问题已经解决。
n 位候选人各自可能选择 m 个岗位或“不录取”,粗略上界达到 (m+1)n。 当 n=m=20 时,需要面对 2120 个原始分支。困难不在检查某一个方案,而在方案数量随人数指数增长。
02 · 关系表达
关系表与图模型
用二维表预存谁能胜任谁,再把对象和两两关系表示成点与边。
图在此只负责表达关系,还没有降低方案数量。
枚举的每个分支都会重新询问“候选人能否胜任岗位”。技能关系并不会随分支改变, 因此可以先保存为二维表。重复检查被消除,但录取组合仍需继续枚举。
二维表与下面的图表达同一份数据:对象变成点,表中的一个 ✓ 变成连接两个对象的边。 图模型解决的是关系表达,并不会自动把指数搜索变快。
此时还没有决定录取谁。
六个点和五条合法边不再改变;后续只讨论从这些边中怎样选择。
03 · 特殊结构
二分图
两组顶点、组内无边、二染色判定、奇环反例,以及结构对后续搜索的作用。
二分图上的着色、覆盖、独立集、加权匹配与博弈。
左边所有点都是候选人,右边所有点都是岗位。边只描述“候选人能否胜任岗位”, 所以候选人之间没有边,岗位之间也没有边;每一条线都有明确的左端点和右端点。
这不是为了套算法而强行分组,而是关系本身就有两种角色。把两组记为 L、R,边集记为 E,便得到G = (L ∪ R, E),并且 E ⊆ L × R。
阿青、小北、晨晨
前端、后端、测试


组内无边使搜索方向稳定交替。后续“尝试新关系—找到旧搭档”的路径因此可以按两种边交替组织。
扩展:二分结构还使哪些问题变得更容易?
二分图没有三角形,团最多包含两个顶点;一般图中的团搜索在这里退化为边或孤立点。
左右两侧直接使用两种颜色;只要图中存在边,最少需要两色。
二分图的边染色数等于最大度数 Δ,每一种颜色对应一组互不抢端点的匹配边。
一对一选择变成互不共享端点的边,可通过增广路在 O(|L|·|E|) 内求解。
没有孤立点时,可由最大匹配补边得到,数量为 |V|−最大匹配数。
Kőnig 定理保证二分图中最小点覆盖数等于最大匹配数。
独立集与点覆盖互为补集,因此二分图中可由最大匹配间接得到。
边带收益后变成指派问题,可使用加权匹配算法或费用流。
部分删点移动博弈可利用最大匹配中的关键点判断胜负。
04 · 一对一选择
匹配与贪心方案
匹配边、非匹配边、匹配点与自由点;贪心为什么可能停在非最优答案。
最大匹配与完美匹配留到增广过程完成后再定义。
贪心先到先得
比完整枚举更直接的方案是逐人选择第一个尚未占用的岗位。先让阿青去前端、晨晨去后端后, 小北已经找不到可用岗位。蓝色边表示已经选中,灰色虚线依然合法,只是当前没有选择。
小北与测试岗位仍是自由点。
一组被选边如果互不共享端点,就叫匹配。这里的两条蓝边没有抢同一个人, 也没有抢同一个岗位,所以它们构成一份大小为 2 的匹配。
匹配词汇
蓝色边,属于当前方案 M。
灰色虚线,合法但当前没有选择。
接触一条匹配边的点,如阿青、前端。
没有接触匹配边的自由点,如小北、测试。
问题不在合法性检查,而在早期选择占用了后来更稀缺的岗位。只寻找空岗位无法修正旧决定。
05 · 调整旧选择
交替路
非匹配边与匹配边为何必须交替,以及路径中每一步对应的实际操作。
一般交替路不要求端点自由;能够增加匹配数的特殊路径留到下一章。
小北只能去前端,而前端已经属于阿青。沿着前端的蓝边找到阿青,再检查阿青能否改去后端; 后端又属于晨晨,因此继续检查晨晨能否改去测试。测试恰好空闲。
走一条尚未选择的边。
前端已占用,沿匹配边找到原搭档。
继续重复“尝试新边—找到旧搭档”。
抵达右侧自由点,整条替换链成立。
按步骤观察非匹配边与匹配边怎样交替出现。
点击“下一步”,从左侧自由点小北出发。
相邻两条边必须一条属于当前匹配 M、另一条不属于 M,不能连续经过两条匹配边或两条非匹配边。定义本身不要求端点自由。
- 本身是一条交替路。
- 两个端点都是未匹配点。
- 因此前后两端必然是非匹配边。
- 所以非匹配边恰好比匹配边多一条。
“非匹配边数量多一条”是前三个条件共同产生的结果,不能脱离交替性和自由端点单独判定。
更复杂的例子:八节点交替链
上面的替换链只有一条出路。当候选人拥有多个可选岗位时,交替搜索会产生分支。 看一个八个节点、十条边的例子:阿强同时能胜任前端、后端、测试、运维四个岗位, 但贪心先把前端、测试、后端分别给了阿强、小美、阿力。
小可与运维岗位仍是自由点;灰色虚线是合法但未选择的关系(如阿力—运维)。
现在小可只剩“前端、后端”两个念头,而它们都已被占用;运维岗位空着, 但小可自己并没有直接通向运维的边——连向运维的阿力已经被后端占用, 只有阿强还握着这条自由出路。于是交替链变成:小可沿非匹配边到前端, 再沿匹配边回到阿强;阿强面前有四条出路,前三条都被占用,第四条运维是自由点。
走一条尚未选择的边,发现前端已被占用。
沿当前匹配边找到原搭档阿强。
阿强依次尝试四条出路,前三条被占用,第四条通向自由点。
阿强有四条出路:前端、后端、测试都已被占用,第四条运维通向自由点。
点击“下一步”,从左侧自由点小可出发。
没有任何端点被重复占用,匹配数从 3 变成 4。
增广路每一步只取一条交替边:尝试被占用的岗位就沿匹配边回到原搭档,直到某个终点是自由点。阿强的前三条出路只是“先被尝试、再被排除”,路径本身始终只有一条,不需要记录整棵搜索树。
DFS 搜索:一条不同的增广路
真实搜索不会总是一帆风顺。假设阿强的邻接顺序是“前端、测试、后端、运维”, 那么从小可出发的 DFS 会先钻进一条死路:阿强先试测试,测试被小美占用, 而小美除了测试只剩前端(已访问),递归失败。DFS 于是回退到阿强, 继续尝试下一条出路:后端被阿力占用,再请阿力另找,阿力只剩运维且空闲, 整条链才成功。
非匹配边,前端被阿强占用。
匹配边,请阿强让出前端。
阿强按顺序先试测试,发现被小美占用。
匹配边,递归调用 tryMatch(小美),请小美另找位置。
小美只能去前端或测试:前端已访问、测试是来路,递归返回失败。
测试分支整体失败,DFS 回溯到阿强,继续尝试下一条出路。
阿强接着试后端,发现被阿力占用。
匹配边,递归调用 tryMatch(阿力),请阿力另找位置。
阿力只剩运维一条新出路,运维空闲,递归返回成功。
测试分支失败后回退到阿强,再走后端分支才成功。
点击“下一步”,从左侧自由点小可出发。
代码里的 if (matchRight[v] == -1 || tryMatch(matchRight[v])) 表示: 岗位空闲直接占用;否则递归请当前占用者另找。小美的递归返回 false 时, 阿强的“测试”分支被放弃,代码回到 for 循环尝试下一条边——这就是回溯。 直到某个分支递归成功,整条链让位,匹配数 +1。
06 · 增加一对
增广路与翻转
两个自由端点、非匹配边多一条、对称差翻转和匹配数增加 1。
Berge 定理用于判断当前匹配是否已经最大。
翻转操作
不能只把小北—前端加进去,那会让前端同时属于两个人。正确操作是把整条增广路上的边同时翻转: 原匹配边删除,原非匹配边加入。记作 M′ = M △ E(P)。
没有任何端点被重复占用,匹配数从 2 变成 3。
阿青—前端、晨晨—后端
3 条新边加入
小北—前端、阿青—后端、晨晨—测试
Berge 定理:何时停止?
因此算法不需要猜最终答案:找到增广路就加 1;再也找不到时,当前匹配就是最大匹配。
07 · 目标定义
最大匹配与完美匹配
最大匹配、完美匹配及二者关系。
Hall 定理描述覆盖左侧全部点的充要条件。
最大匹配
在所有匹配中,边数最多的一份。这个例子最多为 3。
完美匹配
每一个顶点都恰好被匹配一次。翻转后的 3 对同时也是完美匹配。
“最大”只表示不能获得更多配对,不保证覆盖全部点。例如 4 位候选人竞争 3 个岗位时, 最大匹配最多为 3,但永远无法覆盖所有候选人。完美匹配一定是最大匹配,反过来不一定。
Hall 定理:什么时候能够覆盖左侧全部点?
对左侧任意点集 S,把它们能够连接的右侧点合起来记作 N(S)。存在覆盖左侧全部点的匹配, 当且仅当每个 S 都满足 |N(S)| ≥ |S|。直觉是:任意一群候选人可选择的岗位总数, 至少要和这群候选人一样多。
08 · 搜索结构
匈牙利树
偶数层左点、奇数层右点,以及非匹配边和匹配边的层间移动规则。
搜索树只是原图的遍历记录,并不是新的业务关系图。
从小北开始的一连串“这个岗位有人吗—原搭档还能去哪”,就是一次交替搜索。 当一个人有多个可选岗位时,搜索会产生分支;记录每个点从谁而来,便展开成一棵交替搜索树, 也常称匈牙利树。
交替树的层次规则
沿尚未匹配的边尝试岗位。
若已占用,沿唯一匹配边找原搭档。
继续尝试他的其他岗位。
原图仍是那 6 个点和 5 条边;树只是记录搜索次序与父节点。到达自由岗位后,根到叶的路径就是增广路。
09 · 算法实现
DFS 增广
matchRight、visited、递归改配和外层逐点增广。
更大稀疏图可使用 Hopcroft–Karp;容量大于 1 时改用网络流。
对每一个左侧点尝试一次:遇到空闲右点就直接匹配;遇到已占用右点,就递归请它的原搭档另找位置。 递归成功后,当前点才接管这个右点。这对应“小北占用前端、阿青改去后端、晨晨改去测试”的替换链。
核心递归
vector<vector<int>> graph;
vector<int> matchRight; // 右侧岗位当前与谁匹配
vector<bool> visited;
bool tryMatch(int u) {
for (int v : graph[u]) {
if (visited[v]) continue;
visited[v] = true;
// 岗位空闲,或者原搭档能够换到别处
if (matchRight[v] == -1 || tryMatch(matchRight[v])) {
matchRight[v] = u;
return true;
}
}
return false;
}
int maximumMatching(int leftCount, int rightCount) {
matchRight.assign(rightCount, -1);
int answer = 0;
for (int u = 0; u < leftCount; ++u) {
visited.assign(rightCount, false);
if (tryMatch(u)) ++answer;
}
return answer;
}右侧点 v 当前的搭档,冲突时沿它找到原搭档。
一次寻找中不重复询问同一右点,防止递归绕圈。
找到一条增广路,递归回程会完成整条路的翻转。
每个左点都尝试让当前匹配增加 1。
中文竞赛资料常把这套 DFS 增广法叫“匈牙利算法”,也常称 Kuhn algorithm。 Kuhn 1955 年论文中的 Hungarian Method 处理的是带权指派问题,并不是上面这段 DFS。
复杂度对比
重新尝试大量完整方案。
每轮只搜索一条局部替换路径。
10 · 进阶加速
BFS 增广与 Hopcroft–Karp
BFS 分层、分层图上 DFS、每轮增广多条;复杂度 O(|E|·√|V|)。
与网络流 Edmonds–Karp / Dinic 的“广度分层 + 深度找路”是同一对思想。
BFS 分层
在交替路里,搜索方向被结构锁死:非匹配边一定从左到右、匹配边一定从右到左。 BFS 利用这一点,把所有自由左点同时放进队列,按 “左点 → 非匹配边 → 右点 → 匹配边 → 左点”逐层扩散。 第一次到达某个未匹配右点的层数,就是当前最短增广路的长度。
全部自由左点同时入队,距离 0。
未匹配即找到增广路;已占用则沿匹配边去下一层。
距离 +1,继续尝试他的其他岗位。
def bfs(): q = deque() for u in left: if match_l[u] == -1: dist[u] = 0; q.append(u) else: dist[u] = -1 found = False while q: u = q.popleft() for v in graph[u]: pu = match_r[v] if pu == -1: found = True elif dist[pu] == -1: dist[pu] = dist[u] + 1 q.append(pu) return foundmatch_l、match_r 全部初始化为 -1,A、B、C 都是自由左点。BFS 的初始化循环把它们全部放入队列:dist[A] = dist[B] = dist[C] = 0。
分层图:BFS 增量构建
BFS 的产物就是一张分层图:自由左点在第 0 层, 沿“非匹配边 → 右点 → 匹配边 → 左点”每推进一轮,左点层数 +1。 右侧的空闲右点就是终点。下面的演示把这张图一棵树一样逐步长出来, 每一步只新增一个节点或一条边。
DFS 搜索树:增量展开
拿到分层图后,DFS 只沿着“层数恰好 +1”的方向走,把尝试过程展开成一棵搜索树: 左点先选一条非匹配边到右点,右点被占用就递归到原搭档,原搭档再分出多条候选路, 失败的分支标 ✗,成功走到空闲右点标 ✓,整条路径最后一起翻转。
一轮的执行:BFS 分层 + DFS 沿层增广
Hopcroft–Karp 把一轮分成两半:BFS 只负责算距离(dist[u]), DFS 只沿着“距离恰好 +1”的方向走,因此每条找到的路都是最短增广路, 并且一轮内可以找出多条互不相交的最短增广路,一起翻转。
const int INF = 1e9;
vector<int> g[MAXN]; // g[u]:左点 u 能匹配的右点列表(邻接表)
int matchL[MAXN]; // matchL[u]:左点 u 当前匹配到的右点,0 表示空闲
int matchR[MAXN]; // matchR[v]:右点 v 当前匹配到的左点,0 表示空闲
int distL[MAXN]; // distL[u]:BFS 分层中左点 u 的层数,INF 表示未访问
bool bfs() {
queue<int> q;
for (int u = 1; u <= nL; ++u) {
if (matchL[u] == 0) distL[u] = 0, q.push(u); // 所有自由左点作为第 0 层
else distL[u] = INF;
}
distL[0] = INF; // 0 号是虚拟空闲右点:所有空闲右点都指向它
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u])
// 右点 v 空闲(matchR[v] == 0)时走到虚拟点;否则沿匹配边走回左点 matchR[v]
if (distL[matchR[v]] == INF) {
distL[matchR[v]] = distL[u] + 1;
q.push(matchR[v]);
}
}
return distL[0] != INF; // 能到达虚拟空闲右点 -> 存在增广路
}
bool dfs(int u) {
if (u == 0) return true; // 走到虚拟空闲右点,增广路终点已找到
for (int v : g[u])
if (distL[matchR[v]] == distL[u] + 1 && dfs(matchR[v])) {
matchR[v] = u; // 右点 v 改配左点 u
matchL[u] = v; // 左点 u 匹配右点 v
return true;
}
distL[u] = INF; // 剪枝:u 已无路可走
return false;
}
int hopcroftKarp() {
int ans = 0;
while (bfs())
for (int u = 1; u <= nL; ++u)
if (matchL[u] == 0 && dfs(u)) ++ans; // 只从自由左点出发增广
return ans;
}DFS 与 BFS 的对比
| 维度 | DFS(Kuhn 匈牙利) | BFS(Hopcroft–Karp) |
|---|---|---|
| 搜索策略 | 一条路钻到底,失败回退 | 所有自由左点逐层扩散 |
| 找到的路径 | 任意一条增广路 | 最短增广路 |
| 每轮增广数 | 1 条 | 多条互不相交 |
| 复杂度 | O(|L|·|E|) | O(|E|·√|V|) |
| 适用场景 | 小规模、模板题 | 大规模稀疏图 |
每完成一轮,最短增广路长度严格增加;路径长度最多到 O(√|V|) 就结束了, 所以总轮数有界,复杂度降到 O(|E|√|V|)。这与网络流里 Edmonds–Karp (BFS 找单条最短路)和 Dinic(BFS 分层 + DFS 阻塞流)是同一对思想。
11 · 模型迁移
四类二分匹配建模
两类资源、合法关系、每个资源至多使用一次,以及最大/完美匹配目标。
不同题面只改变点和边的含义,不改变匹配算法。
一次选择是否恰好占用左侧一个资源和右侧一个资源,并且每个资源最多使用一次?
男女配对
左右是男孩与女孩,彼此喜欢就连边。最多组成多少对是最大匹配;能否让所有人配对是完美匹配。
候选人与岗位
本文贯穿例子。候选人与岗位各最多使用一次,一次录取就是一条匹配边。
有墙棋盘放棋子
墙把行列切成横向段与纵向段。一个空格连接它所在的横段和纵段;在格子放棋子就是选择这条边。
多米诺覆盖
棋盘黑白染色,相邻格异色。一块骨牌连接一个黑格与一个白格;覆盖全部格子就是完美匹配。


12 · 判断边界
匹配问题的识别顺序
谁是点?什么条件产生边?
每条边是否只跨组,不留在组内?
两条选择是否不能共享端点?
交替经过非匹配边与匹配边。
每找到一条增广路,答案增加 1。
每个点容量大于 1考虑 b-matching 或最大流。
边有收益或代价考虑带权匹配、指派算法或费用流。
图无法二染色考虑一般图匹配,不能直接套本文 DFS。
13 · 参考