1. 前言
本篇博文将会专门讲述匈牙利算法的具体思路,实现过程以及正确性证明。
匈牙利算法是在 O ( n × e + m ) O(n \times e+m) O(n×e+m) 内的时间内寻找二分图的最大匹配的一种算法,其中 n n n 为左部点个数, m m m 为右部点个数。
在学习匈牙利算法之前,请先确保掌握以下名词:
- 二分图
- 匹配与最大匹配
- 增广路
如果对上述部分名词没有掌握,请先掌握后再来学习。
懒得百度?传送门:算法学习笔记:二分图#1——定义+性质+判定
2. 例题
由于自然语言描述匈牙利算法难懂且难表述,直接采用图示方法讲解。
(绘图工具:Windows 画图软件)
为了理解方便,我们假设左边的 A 1 − A 4 A_1-A_4 A1−A4 表示 4 个人,这 4 个人去吃饭, B 1 − B 4 B_1-B_4 B1−B4 表示 4 道菜,每个人都有自己喜欢的菜,而连线就表示喜欢的菜。
没错 B2 因为太难吃了以至于没人喜欢
每个人至多只能选择一道菜。
先给 A 1 A_1 A1 分菜。 A 1 A_1 A1 喜欢吃 B 1 B_1 B1,那么就将 B 1 B_1 B1 分配给 A 1 A_1 A1。
那么分配如下所示(红色边就是匹配):
再给 A 2 A_2 A2 分菜。 A 2 A_2 A2 也喜欢吃 B 1 B_1 B1,于是就有了这样一段对话:
A 2 A_2 A2:“我说 A 1 A_1 A1 呀,我也想吃 B 1 B_1 B1,您就不能换一道菜吗?”
A 1 A_1 A1:“啊这?好吧, B 1 B_1 B1 给你,我吃 B 3 B_3 B3。”
于是当前分配如下:
现在考虑 A 3 A_3 A3。糟糕, A 3 A_3 A3 也想吃 B 1 B_1 B1。
A 3 A_3 A3 :“ A 2 A_2 A2 在吗?您可不可以换一道菜啊,我也想吃 B 1 B_1 B1。”
A 2 A_2 A2 :“啊这?但是我只喜欢这一道菜,根据先到先得原则,我不能让给您。”
于是 A 3 A_3 A3 没有吃到 B 1 B_1 B1,但是 A 3 A_3 A3 还喜欢 B 4 B_4 B4 啊!于是 A 3 A_3 A3 选择了 B 4 B_4 B4 这道菜。
那么当前分配如下:
最后是 A 4 A_4 A4。 A 4 A_4 A4 也想吃 B 1 B_1 B1,而且也只喜欢 B 1 B_1 B1。很遗憾的是, B 1 B_1 B1 已经被 A 2 A_2 A2 选走了,且 A 2 A_2 A2 不愿意换。
最后, A 4 A_4 A4 谁都没选到。
因此结果为 3。
你会发现,实际上上面的所有过程就是寻找最大匹配的过程。
简单总结一下:
- 如果新来的人想选择一道菜且这道菜没有被选,那么就选上。
- 如果想选的菜冲突了,以前的换一道菜。
- 但是如果以前的菜不能换,那么新来的人只能换一道菜。
- 如果新来的人想选的菜都选不了,那么就别吃了。
有关该算法的系统语言描述以及正确性证明见代码后面。
代码(推荐先将后面理论看完再看代码):
/*
========= Plozia =========
Author:Plozia
Problem:P3386 【模板】二分图最大匹配
Date:2021/3/14
========= Plozia =========
*/
#include <bits/stdc++.h>
using std::vector;
typedef long long LL;
const int MAXN = 500 + 10;
int n, m, e, Match[MAXN], ans;
vector <int> Next[MAXN];
bool book[MAXN];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return (fh == 1) ? sum : -sum;
}
bool dfs(int k)
{
for (int i = 0; i < Next[k].size(); ++i)
{
int u = Next[k][i];
if (book[u]) continue;
book[u] = 1;
if (!Match[u] || dfs(Match[u])) {Match[u] = k; return 1;}//寻找增广路
}
return 0;
}
void Hungary()
{
memset(Match, 0, sizeof(Match));
for (int i = 1; i <= n; ++i)
{
memset(book, 0, sizeof(book));//注意重置
if (dfs(i)) ++ans;
}
}
int main()
{
n = read(), m = read(), e = read();
for (int i = 1; i <= e; ++i)
{
int u = read(), v = read();
Next[u].push_back(v);
}
Hungary();
printf("%d\n", ans);
return 0;
}
那么上面算法的本质是什么呢?
考虑一下 A 1 A_1 A1 和 A 2 A_2 A2 的对话。
我们发现,在 A 2 A_2 A2 还没有匹配之前, A 2 A_2 A2 为 非匹配点。
( A 2 , B 1 ) (A_2,B_1) (A2,B1) 为一条 非匹配边。
而 ( A 1 , B 1 ) (A_1,B_1) (A1,B1) 为一条 匹配边。
( A 1 , B 3 ) (A_1,B_3) (A1,B3) 为一条 非匹配边, B 3 B_3 B3 为 非匹配点。
于是:
路径 A 2 − > B 1 − > A 1 − > B 3 A_2->B_1->A_1->B_3 A2−>B1−>A1−>B3 为一条 增广路!
增广路!
在二分图#1(link)中作者提到过增广路有一条重要性质:
- 当增广路上非匹配边比匹配边数量大一,那么将非匹配边改为匹配边,匹配边改为非匹配边,那么该路径依然是增广路而且匹配数加一。
于是我们再结合上面的图示来理解,正确性就显然了。
根据这个性质,在保证路径不变的情况下我们能够尽可能的增加匹配数,而最大匹配一定在若干条增广路上,且增广路上匹配数达到最大。
于是正确性证完了。
因此,匈牙利算法的本质就是不断寻找增广路来扩大匹配。
而代码中的 M a t c h Match Match 数组就是表示匹配, M a t c h i Match_i Matchi 表示 ( i , M a t c h i ) (i,Match_i) (i,Matchi) 是一条匹配边。
3. 总结
匈牙利算法的本质就是不断寻找增广路来扩大匹配。