1. 程式人生 > 其它 >圖論專題-學習筆記:匈牙利演算法

圖論專題-學習筆記:匈牙利演算法

目錄

1. 前言

本篇博文將會專門講述匈牙利演算法的具體思路,實現過程以及正確性證明。

匈牙利演算法是在 \(O(n \times e+m)\) 內的時間內尋找二分圖的最大匹配的一種演算法,其中 \(n\) 為左部點個數,\(m\) 為右部點個數。

在學習匈牙利演算法之前,請先確保掌握以下名詞:

  1. 二分圖
  2. 匹配與最大匹配
  3. 增廣路

如果對上述部分名詞沒有掌握,請先掌握後再來學習。

懶得百度?傳送門:演算法學習筆記:二分圖#1——定義+性質+判定

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. 總結

匈牙利演算法的本質就是不斷尋找增廣路來擴大匹配。