KM 算法学习笔记
AI 使用声明:文章由作者自行完成。使用 AI 查找并更正了一些错误。
前言
个人的感觉是 KM 算法模板题的题解区中,少有可以给我讲透这个算法的题解,因此写了这一篇。
因为原题已经无法提交题解了,所以交算法理论。
回顾增广路理论
让我们回顾二分图的最大匹配这个题。
在不带权的最大匹配中,你面对着一个二分图,其中已经有一些边的两端被匹配。
你希望调整匹配,使得匹配数增加
一种平凡的情况是找一个两端都没有被匹配的边,然后匹配它的两个端点。
但是有时候不一定存在这样的边。这时你注意到这样的情况。
左1 - 右1
左2 - 右1(匹配边)
左2 - 右2
你发现这个时候,如果把 左2 和 右1 解绑,就可以分别匹配 左1 - 右1 和 左2 - 右2。
同理地,情况可能是这样。
左1 - 右1
左2 - 右1(匹配边)
左2 - 右2
左3 - 右2(匹配边)
左3 - 右3
这类情况的特点是,存在一个简单路径,依次经过非匹配边
出现这种路径,你可以反转每条边的匹配状态,得到一个新的匹配,而且新匹配的匹配数比原匹配大
这就是增广路理论。
定理 1 对于二分图的一个匹配,其不存在增广路当且仅当其是最大匹配。
证明 充分性:最大匹配如果有增广路,则反转增广路中边的状态就会得到更大的匹配,与当前匹配是最大匹配矛盾。
必要性:假如存在一个没有增广路且非最大的匹配
考虑一个新的二分图
因为在一个匹配中,每个点的度数不超过
这意味着它的每个连通块都是链或者环。读者可以试着手玩一下,任何非链非环的连通块一定会出现度数大于
对于两条
考虑环,因为图是二分图,所以每个环都有偶数条边,显然
(你可能在想
对于链,
因为
考虑原图上的这条链!链上的点中,起点和终点必然不是
证毕。
回顾匈牙利算法
匈牙利算法的思路是寻找增广路。
放一个匈牙利算法的代码,可能不少人已经背下来了。
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,m,e;
vector<int> E[1000005];
int match[1000005];
int vis[1000005];
int dfs(int u){
for (int v:E[u]){
if (vis[v])continue;
vis[v]=1;
if (!match[v]||dfs(match[v])){
match[v]=u;return 1;
}
}
return 0;
}
signed main(){
cin>>n>>m>>e;
for (int i=1;i<=e;i++){
int u,v;cin>>u>>v;
E[u].push_back(v);
}
int ans=0;
for (int i=1;i<=n;i++){
for (int j=1;j<=m;j++)vis[j]=0;
if (dfs(i))ans++;
}
cout<<ans;
}
算法流程是这样的:
- 从每个左部点出发找一次增广路。
- 寻找增广路的方式:
- 枚举所有与当前左部点
u 相邻的右部点v 。 - 如果她未被匹配,
(u,v) 是一条增广路。作为对这条增广路的处理,匹配(u,v) 。 - 否则,考虑与
v 匹配的点u' 。 - 如果从
u' 出发存在一条不经过v 的增广路,那么在这条增广路的前面插入u 和v 就得到了一条从u' 出发的增广路。由于u' 寻找增广路成功了,现在u' 有了新的右部点作为匹配对象。于是v 被放了出来,匹配(u,v) 。
- 枚举所有与当前左部点
每次找到一条增广路并处理,会新增被匹配的左部点和右部点各一个。已经匹配过的左部点或右部点不会变得未匹配。因此按照某个顺序枚举左部点各一次就可以了。
Q:从
A:是的。考虑它连接的所有点都被
Q:运行完这个算法之后,就再也没有新的增广路了吗?
A:是的。考虑增广路必须从未匹配的点出发,而根据前一个问题,这些点出发都没有增广路。
Q:为什么有 vis 数组?
A:增广路必须是简单路径。必须避免找增广路时多次访问某个节点。此外,对于这份代码的实现,多次访问同一个节点会导致死循环。
带权二分图匹配问题
为了简化问题,我们在处理带权的二分图匹配的时候,一般给所有原图中没有的边连上边权为
如果求出的最大权完美匹配包含边权为
Q:我求权值和最大增广路,好吗?
A:不。
这本质是费用流算法。实现出来是
顶标理论
考虑另一个问题。
对于一个完全二分图,你需要给每个点赋一个整数作为权值,称为顶标。
你需要设置顶标,使得对于二分图中的任何一条边,其两个端点的顶标之和不小于边的权值。这样的一组顶标称为可行顶标。
你需要最小化可行顶标的所有顶标之和。
为了方便阐述,令满足权值等于其两个端点的顶标之和的边称为相等边。
定理 2 若相等边构成的子图有完美匹配,那么此时的顶标之和等于原图的最大权完美匹配。
证明 考虑原图的任何一个完美匹配。它有
考虑相等边构成的子图的一个完美匹配,它的权值显然是所有顶标之和,得证。
根据定理 2,我们想要求出原图的最大权完美匹配,产生了一个新的方向,就是构造一个满足条件的可行顶标。
KM 算法的核心思路
先令左部点的顶标为它连接的权值最大边,右部点的顶标为
这时不一定有完美匹配。我们需要在失配时尝试调整顶标,使得匹配数增加。
接下来的描述中,相等边构成的子图称为相等子图。
考虑我们进行匈牙利算法,dfs 在当前相等子图寻找从
令这次遍历遍历到的左部点集合为
观察
我们想要扩展
此外,我们不能影响
你发现:
- 将
S 中的所有点顶标-d ,T 中的点顶标+d ,既不会影响S 和T 之间的边,也不会影响S' 和T' 之间的边。 -
Q:
A:不会,后面的点如果想用掉这些被踢出的边,还会用同样的操作把它补回来。
选取一个恰当的
这样的
解释:
- 大于
d_0 的d 不合法:会导致式子取到最小值的边的两端顶标之和小于权值。 - 小于
d_0 的d 不合法:会导致相等子图无法扩展。
选择这个
Q:得出的
A:不会。如果会,这条卡住算法的边其实是相等边,与
Q:如何维护这个
A:对于每个右部点
在给
于是你成功得到了一个求二分图最大权匹配的算法!
让我们尝试分析它的复杂度。
- 给
n 个点找增广路,复杂度乘上O(n) 。 - 给一个点找增广路,最多需要扩展
O(n) 次T ,每次扩展的开销是O(m) = O(n^2) 。
乘起来,是
::::info[代码]
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,m,a[505],b[505],f[505][505];
int match[505];
int s[505],t[505];
bool dfs(int u){
s[u]=1;
for (int v=1;v<=n;v++){
if (t[v])continue;
if (a[u]+b[v]==f[u][v]){
t[v]=1;
if (!match[v]||dfs(match[v])){
match[v]=u;
return 1;
}
}
}
return 0;
}
signed main(){
cin>>n>>m;
for (int i=1;i<=n;i++){
for (int j=1;j<=n;j++)f[i][j]=-1e18;
a[i]=-1e18,b[i]=0;
}
for (int i=1;i<=m;i++){
int u,v;cin>>u>>v;
cin>>f[u][v];
a[u]=max(a[u],f[u][v]);
}
for (int i=1;i<=n;i++){
for (int j=1;j<=n;j++)s[j]=t[j]=0;
while (!dfs(i)){
int d=1e18;
for (int j=1;j<=n;j++){
if (!s[j])continue;
for (int k=1;k<=n;k++){
if (t[k])continue;
d=min(d,a[j]+b[k]-f[j][k]);
}
}
for (int j=1;j<=n;j++){
if (s[j])a[j]-=d;
if (t[j])b[j]+=d;
}
for (int j=1;j<=n;j++)s[j]=t[j]=0;
}
}
int ans=0;
for (int i=1;i<=n;i++){
ans+=a[i]+b[i];
}
cout<<ans<<"\n";
for (int i=1;i<=n;i++){
cout<<match[i]<<" ";
}
return 0;
}
::::
优化
你觉得这太劣了。每更新一次相等子图,就要把
你心想,只要对新加入
思路是对的,但是我们不能利用回溯来维护当前的增广路了。
既然不能利用回溯,那么 dfs 的优势就消失了,我们改为使用 bfs。
两个问题:
- 我们需要把增广路径的信息显式地存储下来,以便更新匹配边。
- 我们不能通过重新 dfs 来更新
slack 数组了,我们需要找到更高效的办法更新slack 数组。
先解决第一个问题。
维护
维护
定理 3 在增广路
证明 对于
对于
第一种是搜索的时候搜到了
第二种是更新顶标之后发现
根据定理 3,当我们找到
像更新链表一样,在回溯时计算新的
然后解决第二个问题。第二个问题其实就是,将
答案很简单,对于
证明方式还是考虑
理由是,
所有问题都解决了。于是算法成立。
这时,对于找
总复杂度是
代码实现
如果对实现还是没有头绪,可以对照上面给出的算法过程观看代码。
本代码可以通过二分图最大权完美匹配模板题。
::::info[代码]
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,m,a[505],b[505],f[505][505];
int matchx[505],matchy[505],slack[505];
int s[505],t[505];
int pre[505];
void aug(int u){
while (u){
int t=matchx[pre[u]];
matchx[pre[u]]=u;
matchy[u]=pre[u];
u=t;
}
}
void bfs(int U){
for (int i=1;i<=n;i++)s[i]=t[i]=0,slack[i]=1e18;
queue<int> q;
q.push(U);
while (1){
while (!q.empty()){
int u=q.front();q.pop();
s[u]=1;
for (int v=1;v<=n;v++){
if (a[u]+b[v]-f[u][v]<slack[v]){
slack[v]=a[u]+b[v]-f[u][v];
pre[v]=u;
}
if (t[v])continue;
if (a[u]+b[v]==f[u][v]){
pre[v]=u;
t[v]=1;
if (!matchy[v]){
aug(v);
return;
}
else q.push(matchy[v]);
}
}
}
int d=1e18;
for (int i=1;i<=n;i++){
if (!t[i])d=min(d,slack[i]);
}
for (int i=1;i<=n;i++){
if (s[i])a[i]-=d;
if (t[i])b[i]+=d;
if (!t[i])slack[i]-=d;
}
for (int i=1;i<=n;i++){
if (!t[i]){
if (slack[i]==0){
t[i]=1;
if (!matchy[i]){
aug(i);
return;
}
else q.push(matchy[i]);
}
}
}
}
}
signed main(){
cin>>n>>m;
for (int i=1;i<=n;i++){
for (int j=1;j<=n;j++)f[i][j]=-1e18;
a[i]=-1e18,b[i]=0;
}
for (int i=1;i<=m;i++){
int u,v;cin>>u>>v;
cin>>f[u][v];
a[u]=max(a[u],f[u][v]);
}
for (int i=1;i<=n;i++){
bfs(i);
}
int ans=0;
for (int i=1;i<=n;i++){
ans+=a[i]+b[i];
}
cout<<ans<<"\n";
for (int i=1;i<=n;i++){
cout<<matchy[i]<<" ";
}
return 0;
}
::::