在软件开发领域,任务指派和数据关联是一种常见业务需求,比如买卖订单的匹配,共享出行的人车匹配,及自动驾驶领域中目标追踪。
这都牵扯到一种技术,那就是数据关联,而匈牙利算法就是解决此类问题最典型的算法,也是今天本文的主题。
我们感性的认为目标之间的匹配好像一目了然的样子,但是计算机可不这样认为。计算机是理性的,如果要处理问题,一般我们会用数据结构来表示数据,栈、队列、树、图都很常用。
上面的形式,其实也可以用图 表示,所以它等同于下面的形式。
不过,这是一种特殊的图,叫做二分图。
二分图图最大的特点就是,图中的顶点可以分别划分到两个 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 数组就保存了匹配关系,当改变其中的值顶点的匹配关系就变了,并不需要用一个真正的链表来表示增广路。
转载:https://blog.csdn.net/briblue/article/details/102475401