图论-二分图专题(二分图匹配、匈牙利算法、KM算法)
😊 | Powered By HeartFireY | BG |
📕 | 需要的前导知识:图论基础 |
文章目录
一、二分图-定义与基本性质
1.二分图-定义
二分图(Bipartite graph),也称作”二部图“。简单的来说,二分图由两个点集和他们之间的映射(连边)构成,点集内部的点相互独立,不存在连边。
换而言之,我们可以对一个图采取某种方案,得到一张所有节点被划分为两个集合,且两个集合内部没有边的图。
如上图-(示例一)所示,就是一张构造好的二分图, S E T 1 SET_1 SET1和 S E T 2 SET_2 SET2集合之间存在连边关系,两个集合的内部不存在任何边关系,集合内点相互独立。
2.二分图-性质
根据二分图的定义,我们可以发现对于任意节点一定满足:
图中任意一条边一定满足起点是从 S E T 1 SET_1 SET1出发,然后到达 S E T 2 SET_2 SET2,也就是说,任意一条边的起止点一定属于不同的集合。
格局上面的性质,我们可以进一步推广:二分图不存在长度为奇数的环。
因为每一条边都是从一个集合走到另一个集合,只有走偶数次才可能回到同一个集合。
3.二分图-判定
显然对于给定的图,枚举答案集合是过于朴素的做法,应该考虑更高效的做法。
我们可以直接 d f s dfs dfs或者 b f s bfs bfs来遍历整张图,遍历过程中不断的查环的长度,如果查到了奇环,那么说明一定不是二分图。
二、二分图-匹配、最大匹配、完美匹配和最优匹配
1.匹配
在图论中,一个"匹配"就是一个边集,这个边集中的任意两条边都没有公共顶点
匹配点: 位于匹配(边集)中某条连接的点,也就是已经匹配的点;
匹配边: 位于匹配(边集)中的某条边,也就是已经匹配的边;
未匹配点、未匹配边: 顾名思义,尚未匹配的点和边
如下图所示就是上文二分图示例一的一个匹配。
显然,红色的边是匹配边,红边连接的点为匹配点…
2.最大匹配
一个图所有匹配中,所含匹配边数最多的匹配,称为这个图的最大匹配。
如上图所示,两个图片都为示例一的匹配,其中Graph_2
为示例一的最大匹配。
3.完美匹配
如果一个图的某个匹配中,所有的顶点都是匹配点,那么它就是一个完美匹配。图 4 是一个完美匹配。显然,完美匹配一定是最大匹配(完美匹配的任何一个点都已经匹配,添加一条新的匹配边一定会与已有的匹配边冲突)。但并非每个图都存在完美匹配。
显然,Graph_2
并不是一个完美匹配。
我们通过一些示例继续说明完美匹配和最大匹配的区别:
如P_1
所示,假设女生和男生之间的连线表示彼此之间存在好感。现在要求求最多多少对男女匹配,使得每对匹配的男生女生相互喜欢。那么这就是一个求最大匹配的问题。
那么我们根据最大匹配的定义可以知道:P_2
不是最大匹配,因为并不满足”最多“;而P_3
是满足题意的一个匹配。
我们换一种问问题的方式:是否可能让所有男生和女生两两配对,使得每对男生女生之间都相互喜欢?那么这就是一个求完美匹配的问题。
通过上面的示例,我们不难发现:完美匹配一定是最大匹配,而最大匹配不一定就是完美匹配。
4.最优匹配
最优匹配又称为带权最大匹配,是指在带有权值边的二分图中,求一个匹配使得匹配边上的权值和最大。
三、二分图-增广路、交错路
这两个概念将为求解二分图最大匹配的匈牙利算法做前置知识铺垫。
1.交错路
交错路又称交替路,从一个未匹配点出发,依次经过非匹配边、匹配边、非匹配边…形成的路径。
2.增广路
从一个未匹配点出发,走交替路,如果途径另一个未匹配点(不能是出发的点),则这条交替路称为增广路(Agumenting Path)。
容易发现:增广路有一个重要特点:非匹配边比匹配边多一条。因此,研究增广路的意义是改进匹配。只要把增广路中的匹配边和非匹配边的状态交换即可。由于中间的匹配节点不存在其他相连的匹配边,所以这样做不会破坏匹配的性质。交换后,图中的匹配边数目比原来多了 1 条。
四、二分图-最小覆盖、最大独立集
1.最小覆盖
二分图的最小覆盖分为最小顶点覆盖和最小路径覆盖:
-
最小顶点覆盖:指最少的顶点数使得二分图G中的每条边都至少与其中一个点相关联,二分图的最小顶点覆盖数=二分图的最大匹配数;
-
最小路径覆盖:也称为最小边覆盖,是指用尽量少的不相交简单路径覆盖二分图中的所有顶点。
-
二 分 图 的 最 小 路 径 覆 盖 数 = ∣ V ∣ − 二 分 图 的 最 大 匹 配 数 二分图的最小路径覆盖数=|V|-二分图的最大匹配数 二分图的最小路径覆盖数=∣V∣−二分图的最大匹配数;
2.最大独立集
最大独立集是指寻找一个点集,使得其中任意两点在图中无对应边。对于一般图来说,最大独立集是一个NP完全问题,对于二分图来说:最大独立集=|V|-二分图的最大匹配数。
五、二分图-匈牙利算法
1.简介
匈牙利算法的主要用于解决二分图的最大匹配、最小覆盖问题。
我们不难发现,对于求解最大匹配问题,实际上就是在求解在二分图中最多能找到多少条没有公共端点的边。在下面的样例中,我们会详细的分析这个过程。
OI-WIKI上对二分图的最大匹配算法中提到了"增广路算法"。从实现原理上来看,个人觉得跟匈牙利算法就是同一个算法。
2.过程分析
我们首先继续引用男女配对这个经典的例子:如图L_1
所示,图中的各个男生和女生之间存在着相互的好感。我们首先从第一个女生开始配对(如图L_2
所示),按照边的顺序我们会选择第一个男生,此时没有发生任何冲突,因此我们继续:
如图L_3
所示,我们给第二个女生进行配对,此时按顺序首先选择第一个男生,此时我们可以清楚的发现:产生冲突了。那么我们该怎样找到一种通用的方法解决这个冲突呢?我们使用匈牙利算法的思路继续分析:寻找增广路。
如上图L_5
所示,按照增广路的定义,我们从第二个女生(未匹配点)出发走到第一个男生(已匹配点),再经过第一个女生(未匹配点)到达第三个男生(未匹配),这样一条路径便是一条交错路,而到达了第一个为匹配点时的路便是增广路。找到这样一条增广路后,我们对增广路进行一个"取反"操作,也就是让匹配边变为未匹配边,未匹配边变为匹配边,如图L_6
所示。经此变化后,我们不难发现匹配的问题已经得到了解决。
这个过程很好的利用了增广路的性质:非匹配边比匹配边多一条,中间的匹配节点不存在其他相连的匹配边。因此我们进行取反操作不会影响匹配的性质,同时通过交换我们使得匹配边和非匹配边的数量互换,也就是匹配边比原来多出一条。匹配性质不变+匹配边多一条,一次来继续匹配的过程,便是匈牙利算法的核心思想。
我们可以继续根据以上思路匹配,直至所有的点都被匹配。当然,有些图可能并非所有的点都能够得到匹配。
如图L_11
便是一个 最大匹配
&&完美匹配
。
3.实现
const int MAXN = GRAPH_SIZE_L, MAXM = GRAPH_SIZE_W;
int M, N; //M, N分别表示左、右侧集合的元素数量
int g[MAXM][MAXN]; //邻接矩阵存图
int p[MAXN]; //记录当前右侧元素所对应的左侧元素
bool vis[MAXN]; //记录右侧元素是否已被访问过
bool dfs(int i){
for (int j = 1; j <= N; ++j)
if (g[i][j] && !vis[j]){ //有边且未访问
vis[j] = true; //记录状态为访问过
if (p[j] == 0 || dfs(p[j])){//如果暂无匹配,或者原来匹配的左侧元素可以找到新的匹配
p[j] = i; //当前左侧元素成为当前右侧元素的新匹配
return true; //返回匹配成功
}
}
return false; //循环结束,仍未找到匹配,返回匹配失败
}
int Hungarian(){
int cnt = 0;
for (int i = 1; i <= M; ++i){
memset(vis, 0, sizeof(vis)); //vis标记清零
if (dfs(i)) cnt++;
}
return cnt;
}
注:
参考匈牙利算法详解实现
六、二分图-KM算法
在使用匈牙利算法解决二分图最大匹配问题的时候,我们认为:图中男女生之间只要存现相互喜欢的关系,那么喜欢的程度就是相同的。也就是对于双方而言,只要符合关系的匹配都可以在相同程度上接受。但现在我们的问题发生了变化:男女生之间喜欢的程度是不同的,换而言之,女生对不同的男生可能存在相互喜欢的关系,但喜欢的程度时不同的。换到图论里的说法就是,图中的边是具有边权的。
1.简介
KM算法用于解决"带权最大匹配问题",也就是带有权值边的二分图中,求一个匹配使得匹配边上的权值和最大。
可以认为KM算法是基于匈牙利算法的一种拓展延深算法,其核心思想之一仍是不断地寻找增广路、取反,直至实现最大匹配。
y在本部分,我们依然通过匈牙利算法中男女生匹配的例子进行说明,不同于匈牙利算法中的样例,我们需要给图加边权便于说明。
2.详解-过程
如图K_1
所示是一个男女配对例子的延申,我们对男女之间喜欢的程度进行了定义和标注:我们首先对图中的点赋一个点权,由于我们以左侧的女生为基准进行匹配,于是我们对左侧点集中的每个点赋值为其所连接的最大边权,右侧的点集中的每个点都赋初始值为
0
0
0,如图L_2
所示。
然后我们开始进行匹配,我们匹配的原则是:只与权重相同的边匹配,若是找不到边匹配,对此条路径的所有左边顶点
−
1
-1
−1,右边顶点
+
1
+1
+1,再进行匹配,若还是匹配不到,重复
+
1
+1
+1和
−
1
-1
−1操作。如上图K_3
所示,我们首先给
a
a
a进行配对,匹配到男生
D
D
D。没有发生什么冲突。然后我们继续给
b
b
b执行匹配。如图K_4
所示,我们继续给
b
b
b分配对象,按照权重,我们给她分男生
C
C
C,仍然没有产生冲突。
于是我们继续给
c
c
c匹配对象,按照最大权值应该给他分配
C
C
C,如图K_5
所示,显然此时产生了冲突。
如何解决冲突?我们首先需要来考虑寻找最优匹配的过程,也就是尽可能让每个女生都找到她们各自喜欢程度最高的男生,但显然这样是会产生冲突的,就像这里情形一样, b b b和 c c c心中最心仪的对象都是 C C C,这时候我们只能退而求其次,让其中一个女生选择另外的对象。这样的操作肯定会降低整体的喜欢程度累加和(图的边权和),但为了整体的喜欢程度累加和最大,我们需要选择一个合适的策略,使得这个累加和降低的幅度最小,换而言之,对于二分图中产生冲突的点,我们需要考虑如何更换匹配使边权和降低的更少一些。
KM算法的核心之一便是基于这个思想:如图K_6
所示,我们让产生冲突的左侧点权
−
1
-1
−1,让产生冲突的右侧点权
+
1
+1
+1,现在我们再继续进行分析:
若是左侧集合有 n n n个顶点参与运算,则右侧就有 n − 1 n-1 n−1个顶点参与运算,整体累加和下降了 1 × ( n − ( n − 1 ) ) = 1 1 \times (n - (n - 1)) = 1 1×(n−(n−1))=1,而对于 b b b来说, b C bC bC本来为可匹配的边 ( 3 + 0 = 3 ) (3 + 0 = 3) (3+0=3),现在仍为可匹配边 ( 2 + 1 = 3 ) (2 + 1 = 3) (2+1=3),对于 c c c来说, c C cC cC本来为可匹配的边 ( 6 + 0 = 0 ) (6 + 0 = 0) (6+0=0),现在仍为可匹配的边 ( 5 + 1 = 6 ) (5 + 1 = 6) (5+1=6),我们通过上述操作,为 b b b增加了一条可匹配的边 b A bA bA,为 c c c增加了一条可匹配的边 c B cB cB。
于是我们给
c
c
c更换匹配边,换到
c
B
cB
cB上,如图K_7
所示,此时的
c
c
c的匹配问题就得到了解决。
我们继续给
d
d
d匹配,但是发现选择最大边权不满足匹配规则
(
4
+
1
≠
4
)
(4 + 1 \neq 4)
(4+1=4),因此将
d
d
d的点权值
−
1
-1
−1变为
3
3
3(图K_8
所示),此时再与
C
C
C匹配,仍然冲突。那么此时我们需要采取匈牙利算法的策略:找交错路,走增广路至为匹配点如图K_9
所示,然后取反,可完成匹配。
值得一提的是,如果 b b b无法匹配(假设顶点 A A A被占用),那么我们应该继续换到 a a a上匹配,如果 a a a没有符合条件的匹配点则再执行 − 1 -1 −1操作…直至匹配完成。这便是KM算法的完全步骤。
3.详解-原理
我们需要引入一些概念来解释算法的原理,这里引用OI-WIKI的解释。
原文地址:二分图最大权匹配 - OI Wiki
(1).可行顶标
给每个节点 i i i 分配一个权值 l ( i ) l(i) l(i),对于所有边 ( u , v ) (u,v) (u,v) 满足 w ( u , v ) ≤ l ( u ) + l ( v ) w(u,v) \leq l(u) + l(v) w(u,v)≤l(u)+l(v)。
(2).相等子图
在一组可行顶标下原图的生成子图,包含所有点但只包含满足 w ( u , v ) = l ( u ) + l ( v ) w(u,v) = l(u) + l(v) w(u,v)=l(u)+l(v) 的边 ( u , v ) (u,v) (u,v)。
(3).定理与推广
对于某组可行顶标,如果其相等子图存在完美匹配,那么,该匹配就是原二分图的最大权完美匹配。"
这里引用OI-WIKI的证明:
考虑原二分图任意一组完美匹配 M M M,其边权和为
v a l ( M ) = ∑ ( u , v ) ∈ M w ( u , v ) ≤ ∑ ( u , v ) ∈ M l ( u ) + l ( v ) ≤ ∑ i = 1 n l ( i ) val(M) = \sum_{(u,v)\in M} {w(u,v)} \leq \sum_{(u,v)\in M} {l(u) + l(v)} \leq \sum_{i=1}^{n} l(i) val(M)=∑(u,v)∈Mw(u,v)≤∑(u,v)∈Ml(u)+l(v)≤∑i=1nl(i)
任意一组可行顶标的相等子图的完美匹配 M ′ M' M′ 的边权和
v a l ( M ′ ) = ∑ ( u , v ) ∈ M l ( u ) + l ( v ) = ∑ i = 1 n l ( i ) val(M') = \sum_{(u,v)\in M} {l(u) + l(v)} = \sum_{i=1}^{n} l(i) val(M′)=∑(u,v)∈Ml(u)+l(v)=∑i=1nl(i)
即任意一组完美匹配的边权和都不会大于 v a l ( M ′ ) val(M') val(M′),那个 M ′ M' M′ 就是最大权匹配。
有了这个定理,我们的目标就是透过不断的调整可行顶标,使得相等子图是完美匹配。
因为两边点数相等,假设点数为 n n n, l x ( i ) lx(i) lx(i) 表示左边第 i i i 个点的顶标, l y ( i ) ly(i) ly(i) 表示右边第 i i i 个点的顶标, w ( u , v ) w(u,v) w(u,v) 表示左边第 u u u 个点和右边第 v v v 个点之间的权重。
首先初始化一组可行顶标,例如 l x ( i ) = max 1 ≤ j ≤ n { w ( i , j ) } , l y ( i ) = 0 lx(i) = \max_{1\leq j\leq n} \{ w(i, j)\},\, ly(i) = 0 lx(i)=max1≤j≤n{w(i,j)},ly(i)=0
然后选一个未匹配点,如同最大匹配一样求增广路。找到增广路就增广,否则,会得到一个交错树。
令 S S S, T T T 表示二分图左边右边在交错树中的点, S ′ S' S′, T ′ T' T′ 表示不在交错树中的点。
在相等子图中:
- S − T ′ S-T' S−T′ 的边不存在,否则交错树会增长。
- S ′ − T S'-T S′−T 一定是非匹配边,否则他就属于 S S S。
假设给 S S S 中的顶标 − a -a −a,给 T T T 中的顶标 + a +a +a,可以发现
- S − T S-T S−T 边依然存在相等子图中。
- S ′ − T ′ S'-T' S′−T′ 没变化。
- S − T ′ S-T' S−T′ 中的 l x + l y lx + ly lx+ly 有所减少,可能加入相等子图。
- S ′ − T S'-T S′−T 中的 l x + l y lx + ly lx+ly 会增加,所以不可能加入相等子图。
所以这个 a a a 值的选择,显然得是 S − T ′ S-T' S−T′ 当中最小的边权,
a = min { l x ( u ) + l y ( v ) − w ( u , v ) ∣ u ∈ S , v ∈ T ′ } a = \min \{ lx(u) + ly(v) - w(u,v) | u\in{S} , v\in{T'} \} a=min{lx(u)+ly(v)−w(u,v)∣u∈S,v∈T′}。
当一条新的边 ( u , v ) (u,v) (u,v) 加入相等子图后有两种情况
- v v v 是未匹配点,则找到增广路
- v v v 和 S ′ S' S′ 中的点已经匹配
这样至多修改 n n n 次顶标后,就可以找到增广路。
每次修改顶标的时候,交错树中的边不会离开相等子图,那么我们直接维护这棵树。
我们对 T T T 中的每个点 v v v 维护
s l a c k ( v ) = min { l x ( u ) + l y ( v ) − w ( u , v ) ∣ u ∈ S } slack(v) = \min \{ lx(u) + ly(v) - w(u,v) | u\in{S} \} slack(v)=min{lx(u)+ly(v)−w(u,v)∣u∈S}。
所以可以在 O ( n ) O(n) O(n) 算出顶标修改值 a a a
a = min { s l a c k ( v ) ∣ v ∈ T ′ } a = \min \{ slack(v) | v\in{T'} \} a=min{slack(v)∣v∈T′}
交错树新增一个点进入 S S S 的时候需要 O ( n ) O(n) O(n) 更新 s l a c k ( v ) slack(v) slack(v)。修改顶标需要 O ( n ) O(n) O(n) 给每个 s l a c k ( v ) slack(v) slack(v) 减去 a a a。只要交错树找到一个未匹配点,就找到增广路。
一开始枚举 n n n 个点找增广路,为了找增广路需要延伸 n n n 次交错树,每次延伸需要 n n n 次维护,共 O ( n 3 ) O(n^3) O(n3)。
4.实现
同样借用一下OI-WIKI的模板~,后面会更新一下自己写的。
template <typename T>
struct hungarian { // km
int n;
vector<int> matchx; // 左集合对应的匹配点
vector<int> matchy; // 右集合对应的匹配点
vector<int> pre; // 连接右集合的左点
vector<bool> visx; // 拜访数组 左
vector<bool> visy; // 拜访数组 右
vector<T> lx;
vector<T> ly;
vector<vector<T> > g;
vector<T> slack;
T inf;
T res;
queue<int> q;
int org_n;
int org_m;
hungarian(int _n, int _m) {
org_n = _n;
org_m = _m;
n = max(_n, _m);
inf = numeric_limits<T>::max();
res = 0;
g = vector<vector<T> >(n, vector<T>(n));
matchx = vector<int>(n, -1);
matchy = vector<int>(n, -1);
pre = vector<int>(n);
visx = vector<bool>(n);
visy = vector<bool>(n);
lx = vector<T>(n, -inf);
ly = vector<T>(n);
slack = vector<T>(n);
}
void addEdge(int u, int v, int w) {
g[u][v] = max(w, 0); // 负值还不如不匹配 因此设为0不影响
}
bool check(int v) {
visy[v] = true;
if (matchy[v] != -1) {
q.push(matchy[v]);
visx[matchy[v]] = true; // in S
return false;
}
// 找到新的未匹配点 更新匹配点 pre 数组记录着"非匹配边"上与之相连的点
while (v != -1) {
matchy[v] = pre[v];
swap(v, matchx[pre[v]]);
}
return true;
}
void bfs(int i) {
while (!q.empty()) {
q.pop();
}
q.push(i);
visx[i] = true;
while (true) {
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v = 0; v < n; v++) {
if (!visy[v]) {
T delta = lx[u] + ly[v] - g[u][v];
if (slack[v] >= delta) {
pre[v] = u;
if (delta) {
slack[v] = delta;
} else if (check(v)) { // delta=0 代表有机会加入相等子图 找增广路
// 找到就return 重建交错树
return;
}
}
}
}
}
// 没有增广路 修改顶标
T a = inf;
for (int j = 0; j < n; j++) {
if (!visy[j]) {
a = min(a, slack[j]);
}
}
for (int j = 0; j < n; j++) {
if (visx[j]) { // S
lx[j] -= a;
}
if (visy[j]) { // T
ly[j] += a;
} else { // T'
slack[j] -= a;
}
}
for (int j = 0; j < n; j++) {
if (!visy[j] && slack[j] == 0 && check(j)) {
return;
}
}
}
}
void solve() {
// 初始顶标
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
lx[i] = max(lx[i], g[i][j]);
}
}
for (int i = 0; i < n; i++) {
fill(slack.begin(), slack.end(), inf);
fill(visx.begin(), visx.end(), false);
fill(visy.begin(), visy.end(), false);
bfs(i);
}
// custom
for (int i = 0; i < n; i++) {
if (g[i][matchx[i]] > 0) {
res += g[i][matchx[i]];
} else {
matchx[i] = -1;
}
}
cout << res << "\n";
for (int i = 0; i < org_n; i++) {
cout << matchx[i] + 1 << " ";
}
cout << "\n";
}
};