在軟件開發領域,任務指派和數據關聯是一種常見業務需求,比如買賣訂單的匹配,共享出行的人車匹配,及自動駕駛領域中目標追蹤。
這都牽扯到一種技術,那就是數據關聯,而匈牙利算法就是解決此類問題最典型的算法,也是今天本文的主題。
我們感性的認爲目標之間的匹配好像一目瞭然的樣子,但是計算機可不這樣認爲。計算機是理性的,如果要處理問題,一般我們會用數據結構來表示數據,棧、隊列、樹、圖都很常用。
上面的形式,其實也可以用圖 表示,所以它等同於下面的形式。
不過,這是一種特殊的圖,叫做二分圖。
二分圖圖最大的特點就是,圖中的頂點可以分別劃分到兩個 Set 當中,每個 Set 中的頂點,相互之間沒有邊連接。
我們假設一個集合 X,它的元素可以有 {A,B,C,D}。
假設一個集合 Y,它的元素可以有{E,F,G,H,I}。
恰好這個圖中的所有頂點都可以分配到這兩個集合當中,每個集合中的頂點互相沒有連接。
這樣我們不難理解,匹配問題就可以轉換成圖的形式,用圖論中的算法來解決問題,匈牙利算法就是求這樣的二分圖的匹配。
匹配
匹配是專業名詞,它是指一組沒有公共端點的邊的集合。
按照定義,匹配裏面的元素是邊。
上圖 a 中的紅線可以是一個集合,而 b 不是。
最大匹配
按照定義,我們很容易知道,一個圖中可以有許多匹配,而包含邊數最多的那個匹配,我們稱之爲最大匹配。
完美匹配
如果一個匹配,它包含了圖中所有的頂點,那麼它就是完美匹配。
完備匹配
完備匹配的條件沒有完美匹配那麼嚴苛,它要求一個匹配中包含二分圖中某個集合的全部頂點。比如,X 集合中的頂點都有匹配邊。
匈牙利算法就是要找出一個圖的最大匹配。
算法思想
其實匈牙利算法如果要感性理解起來是很容易的,核心就在於
衝突和協調
我們看看下圖:
黑線表示可以匹配,
紅線表示當前匹配,
藍色的虛線表示取消匹配
那麼匈牙利算法是怎麼一個過程呢?
第 1 步
從頂點 A 開始,E 點可以匹配,那麼就直接匹配。並將邊 AE 加入到匹配中。
第 2 步
從 B 點開始,F 點可以匹配,所以將 BF 邊也加入到匹配中。
第 3 步
當 C 點開始尋求匹配時,可以發現 CE 和 AE 起衝突了。
前面講到了衝突和協調兩個關鍵詞。
匈牙利算法是讓CE 先匹配, AE 取消匹配,然後讓 A 嘗試重新匹配。
第 4 步
A 在再次尋找匹配的道路上,發現 AF 和 BF 又衝突了,所以需要遞歸協調下去。
這時,讓 AF 匹配,BF 斷開連接,讓 B 點去重新尋找匹配。
第 5 步
B 點尋求匹配的過程,沒有再遇到衝突,所以,BH 直接匹配。
需要注意的是,這條路徑是從頂點 C 出發的,發生了 2 次衝突,協調了兩次,最終協調成功。
如果協調不成功的話,CE 這條路就不成功,它需要走另外的邊,但是上圖中 C 只和 E 有可能匹配,所以,如果協調不成功,C 將找不到匹配。
第 6 步
從 D 點開始尋求匹配時,直接可以和 DG 匹配。
最終匈牙利算法就結束了,找到了最大匹配。
最終的結果就是:
A <--> F
B <--> H
C <--> E
D <--> G
相信到這裏後,大家都弄懂了算法的原理,但是這並不代表你能寫好代碼。
弄懂了道理,不代表你能寫好代碼,不信,你先不看下面的內容自己試一試怎麼寫出來。
匈牙利算法的思想就是:
1.如果沒有衝突,按照正常的連接
2.有衝突的話,就衝突的頂點協調,遞歸下去。
所以,遞歸很重要,我們就可以寫出如下的代碼,本文示例用 Python 編寫,其它編程語言其實相差也不大。
# G 是臨接表的方式表達圖形
G={}
G[0] = {0,1}
G[1] = {1,3}
G[2] = {0}
G[3] = {2,4}
match_list = [-1,-1,-1,-1,-1]
label_x = ['A','B','C','D']
label_y = ['E','F','G','H','I']
# v 代表當前的 x 集合中的頂點
# current 代表 y 集合中起衝突的頂點,如果爲 -1 則代表沒有衝突
def match(v,current):
for i in G[v]:
if i == current:continue
# 如果可以直接匹配,或者是協調一下就可以匹配,那麼就匹配成功,並做標記
if match_list[i] == -1 or match(match_list[i],i):
match_list[i] = v
return True
return False
def hungarian():
# 訪問 X 集合的頂點
for i in range(G.__len__()):
# 對集合中的頂點逐個匹配
match(i,-1)
for i in range(match_list.__len__()):
if match_list[i] == -1:continue
print("%s <--match--> %s:" %(label_x[match_list[i]],label_y[i]))
if __name__ == "__main__":
hungarian()
代碼很簡單,詳情見註釋,打印結果如下:
C <--match--> E:
A <--match--> F:
D <--match--> G:
B <--match--> H:
到這裏,就可以了,匈牙利算法的思想和代碼編寫我們都掌握了。
但是,文章最後,我還是想用更學術的方式去描述和編寫這個算法。
用學術的目的是讓我們顯得更科班,不能總是野路子憑靈感去弄是吧?
學術化的目的是爲了讓我們的思維更嚴謹,夯實我們的技術基礎,這樣遇到新的問題時,我們能夠泛化解決它。
交替路與增廣路
我們現在站在結果處回溯。
上面求得匈牙利的匹配結果如下圖:
我們可以稍作變化。
我們可以觀察到,匈牙利算法要求得的匹配結果,可以用上圖形式表示。
紅實線表示兩頂點匹配,灰色的虛線表示未匹配。
上面的路徑中匹配的邊和未匹配的邊交替出現。
所以,我們的目標是去構建這樣一條路徑。
這涉及到兩個概念:交替路和增廣路。
什麼是交替路?
從未匹配的頂點出發,依次經過未匹配的邊,匹配的邊,未匹配的邊,這樣的路徑就稱爲交替路。
重點是未匹配的點和未匹配的邊開始
什麼是增廣路?
從未匹配的頂點出發,按照交替路的標準尋找頂點,如果途經了另外一個未匹配的點,那麼這條路徑就是增廣路。
增廣路有一些重要的特徵:
- 增廣路中爲匹配的邊的數量要比匹配的邊的數量多 1 條。
- 增廣路取反(匹配的邊變成未匹配,未匹配的邊變成匹配)後,匹配的邊的數量會加 1,從而達到匹配增廣的目的。
而匈牙利算法其實就是就可以看做是一個不斷尋找增廣路的過程,直到找不到更多的增廣路
。
下面圖例說明:
上面是二分圖。
首先,從頂點 A 開始,如果要尋求增廣路,它應該先走未匹配的邊,可以選擇 AE,因爲 E 點也未匹配,所以 A --> E 符合增廣路的定義,它就是一條增廣路。
再從 B 點找增廣路,因爲路徑 BF 沒有匹配,所以 B --> F 也是一條增廣路。
再從 C 點尋找增廣路,它走未匹配的邊,CE,然後走 EA,再走 AF,再走 FB,最後 BH,因爲 H 是未匹配的點,所以 C–>E–>A–>F–>B–>H 就是一條增廣路,如上圖。
我們知道對增廣路取反,匹配的邊數量會加 1,那麼我們果斷這樣做。
也就是
我們再對 D 頂點尋找增廣路。
很容易就找到了,自此匈牙利算法就此結束,途中的匹配就是最大匹配。
我們看到,以增廣路的方式描述算法其實就包括了文章前面提到的衝突與協調這種感性的思想,因爲它顯得更加科學和條理清楚。
下面是以增廣路的方式實現的代碼。
import numpy as np
# G 是臨接表的方式表達圖形
G={}
G[0] = {0,1}
G[1] = {1,3}
G[2] = {0}
G[3] = {2,4}
match_list = [-1,-1,-1,-1,-1]
label_x = ['A','B','C','D']
label_y = ['E','F','G','H','I']
y_in_path = [False,False,False,False,False]
def find_agument_path(v):
for i in G[v]:
# 已經在交替路中出現的點就不需要匹配,避免死循環,例如 C 點需求增廣路過程中,A 點不應該和E點再匹配
if not y_in_path[i]:
y_in_path[i] = True
if (match_list[i] == -1 or find_agument_path(match_list[i])):
match_list[i] = v
return True
return False
def hungarian():
for i in range(G.__len__()):
global y_in_path
# 清空交替路
y_in_path = [False,False,False,False,False]
find_agument_path(i)
for i in range(match_list.__len__()):
if match_list[i] == -1:continue
print("%s <--match--> %s:" %(label_x[match_list[i]],label_y[i]))
if __name__ == "__main__":
hungarian()
打印結果如下:
C <--match--> E:
A <--match--> F:
D <--match--> G:
B <--match--> H:
也許有同學會問,怎麼沒有看見對增廣路取反?
其實,match_list 數組就保存了匹配關係,當改變其中的值頂點的匹配關係就變了,並不需要用一個真正的鏈表來表示增廣路。