圖論專題-學習筆記:匈牙利演算法
1. 前言
本篇博文將會專門講述匈牙利演算法的具體思路,實現過程以及正確性證明。
匈牙利演算法是在 \(O(n \times e+m)\) 內的時間內尋找二分圖的最大匹配的一種演算法,其中 \(n\) 為左部點個數,\(m\) 為右部點個數。
在學習匈牙利演算法之前,請先確保掌握以下名詞:
- 二分圖
- 匹配與最大匹配
- 增廣路
如果對上述部分名詞沒有掌握,請先掌握後再來學習。
懶得百度?傳送門:演算法學習筆記:二分圖#1——定義+性質+判定
2. 例題
由於自然語言描述匈牙利演算法難懂且難表述,直接採用圖示方法講解。
(繪圖工具: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\)
最後,\(A_4\) 誰都沒選到。
因此結果為 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_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. 總結
匈牙利演算法的本質就是不斷尋找增廣路來擴大匹配。