图论专题-学习笔记:匈牙利算法

1. 前言

本篇博文将会专门讲述匈牙利算法的具体思路,实现过程以及正确性证明。

匈牙利算法是在 \(O(n \times e+m)\) 内的时间内寻找二分图的最大匹配的一种算法,其中 \(n\) 为左部点个数,\(m\) 为右部点个数。

在学习匈牙利算法之前,请先确保掌握以下名词:

  1. 二分图
  2. 匹配与最大匹配
  3. 增广路

如果对上述部分名词没有掌握,请先掌握后再来学习。

懒得百度?传送门:图论专题-学习笔记:二分图基础

2. 例题

模板题:P3386 【模板】二分图最大匹配

由于自然语言描述匈牙利算法难懂且难表述,直接采用图示方法讲解。

在这里插入图片描述

(绘图工具:Windows 画图软件)

为了理解方便,我们假设左边的 \(A_1-A_4\) 表示 4 个人,这 4 个人去吃饭,\(B_1-B_4\) 表示 4 道菜,每个人都有自己喜欢的菜,而连线就表示喜欢的菜。

没错 B2 因为太难吃了以至于没人喜欢

每个人至多只能选择一道菜。

先给 \(A_1\) 分菜。\(A_1\) 喜欢吃 \(B_1\),那么就将 \(B_1\) 分配给 \(A_1\)

那么分配如下所示(红色边就是匹配):

在这里插入图片描述

再给 \(A_2\) 分菜。\(A_2\) 也喜欢吃 \(B_1\),于是就有了这样一段对话:

\(A_2\):“我说 \(A_1\) 呀,我也想吃 \(B_1\),您就不能换一道菜吗?”

\(A_1\):“啊这?好吧,\(B_1\) 给你,我吃 \(B_3\)。”

于是当前分配如下:

在这里插入图片描述

现在考虑 \(A_3\)。糟糕,\(A_3\) 也想吃 \(B_1\)

\(A_3\) :“\(A_2\) 在吗?您可不可以换一道菜啊,我也想吃 \(B_1\)。”

\(A_2\) :“啊这?但是我只喜欢这一道菜,根据先到先得原则,我不能让给您。”

于是 \(A_3\) 没有吃到 \(B_1\),但是 \(A_3\) 还喜欢 \(B_4\) 啊!于是 \(A_3\) 选择了 \(B_4\) 这道菜。

那么当前分配如下:

在这里插入图片描述

最后是 \(A_4\)\(A_4\) 也想吃 \(B_1\),而且也只喜欢 \(B_1\)。很遗憾的是,\(B_1\) 已经被 \(A_2\) 选走了,且 \(A_2\) 不愿意换。

最后,\(A_4\) 谁都没选到。

因此结果为 3。

你会发现,实际上上面的所有过程就是寻找最大匹配的过程。

简单总结一下:

  1. 如果新来的人想选择一道菜且这道菜没有被选,那么就选上。
  2. 如果想选的菜冲突了,以前的换一道菜。
  3. 但是如果以前的菜不能换,那么新来的人只能换一道菜。
  4. 如果新来的人想选的菜都选不了,那么就别吃了。

有关该算法的系统语言描述以及正确性证明见代码后面。

代码(推荐先将后面理论看完再看代码):

/*
========= 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_2\) 的对话。

我们发现,在 \(A_2\) 还没有匹配之前,\(A_2\)非匹配点

\((A_2,B_1)\) 为一条 非匹配边

\((A_1,B_1)\) 为一条 匹配边

\((A_1,B_3)\) 为一条 非匹配边\(B_3\)非匹配点

于是:

路径 \(A_2->B_1->A_1->B_3\) 为一条 增广路

增广路!

在二分图#1(link)中作者提到过增广路有一条重要性质:

  • 当增广路上非匹配边比匹配边数量大一,那么将非匹配边改为匹配边,匹配边改为非匹配边,那么该路径依然是增广路而且匹配数加一。

于是我们再结合上面的图示来理解,正确性就显然了。

根据这个性质,在保证路径不变的情况下我们能够尽可能的增加匹配数,而最大匹配一定在若干条增广路上,且增广路上匹配数达到最大。

于是正确性证完了。

因此,匈牙利算法的本质就是不断寻找增广路来扩大匹配。

而代码中的 \(Match\) 数组就是表示匹配,\(Match_i\) 表示 \((i,Match_i)\) 是一条匹配边。

3. 总结

匈牙利算法的本质就是不断寻找增广路来扩大匹配。

posted @ 2022-04-17 14:41  Plozia  阅读(1005)  评论(0)    收藏  举报