[学习笔记]二分图最大权完美匹配-KM算法

壹、模板测试链接

模板测试链接

贰、说明与概念

我们现在要解决的问题:在一个二分图中 \(G=\lang V_1,V_2,E\rang\) 中,\(V_1,V_2\) 是两个点集且 \(|V_1|=|V_2|\),现在所有的 \(e_i\in E\) 都有一个边权 \(w_i\in R\),求在 \(G\) 中的一种完美匹配方案,使得所有匹配边的权和最大,记做最大权完美匹配。

特殊地,如果 \(|V_1|\neq |V_2|\),那么我们需要将两个集合中点数比较少的补点,使得两边点数相同,再将不存在的边权重设为 \(0\).

这个问题对于网络流来说是一种费用流情况,用其思想时间复杂度不是十分优秀,我们考虑使用一种新的算法 —— \(\tt KM\) 算法。

\(\tt KM\) 算法使用的一些概念需要进行定义。

2.1.可行顶标

对于原图中的任意一个结点,给定一个函数 \(L(node)\) 求出结点的顶标值。我们用数组 \(L_1\) 记录集合 \(V_1\) 中的结点顶标值,用数组 \(L_2\) 记录集合 \(V_2\) 中的结点顶标值。顶标值并非没有任何要求,在图 \(G\) 中,若有 \(e=\lang u,v,w\rang\in E\),那么一定有

\[L_1(u)+L_2(v)\ge w \]

2.2.相等子图

相等子图的定义是基于可行顶标的,相等子图是 \(G\) 的一个子图。

定义可行边满足 \(e=\lang u,v,w\rang\in E\)\(L_1(u)+L_2(v)=w\). 那么相等子图即为所有可行边构成的图.

叁、一些定理

说明算法流程前,首先引出一些定理。

3.1.定理一

定理内容:

如果原图的一个相等子图中包含完备匹配(完美匹配针对原图而言),那么这个匹配就是原图的最大权完美匹配。

证明:

由于算法中一直保持顶标的可行性,所以任意一个匹配的权值之和肯定小于等于所有结点的顶标之和,则相等子图中的完美匹配(完美匹配针对原图而言)肯定是最大权完美匹配。

3.2.定理二

对于原图 \(G\) 的任何一个相等子图 \(G'=\lang V_1',V_2',E'\rang\),一定满足

\[\sum_{u\in V_1' \\v\in V_2'}L_1(u)+L_2(v)\ge \sum_{e\in E'}w_e \]

并且,如果 \(G'\) 中存在一种完美匹配 \(M\)(完美匹配针对原图而言)有

\[\forall e=\lang u,v,w\rang\in M,L_1(u)+L_2(v)=w \]

那么匹配 \(M\) 就是 \(G\) 的最大权完美匹配。

肆、算法流程

根据定理一与定理二,\(\tt EK\) 算法其实就是不断调整 \(L_1,L_2\) 使得相等子图有完备匹配。我们的目的就是如何实现调整的过程。


现在假设点数为 \(n\)\(w(i,j)\) 表示由 \(i\in V_1\) 连向 \(j\in V_2\) 的边的边权,并且如果 \(\lang i,j\rang \notin E\),那么 \(w(i,j)=-\infty\),首先进行初始化:

\[L_1(i)=\max_{1\le j\le n}\{w(i,j)\},L_2(i)=0 \]

不难发现,初始化之后每条边 \(e=\lang u,v,w\rang\in E\) 都满足 \(L_1(u)+L_2(v)\ge w\).


初始化之后,考虑对 \(V_1\)尚未匹配的点在相等子图中进行增广,如果找到了增广路就增广,如果找不到,我们就会得到一个交错树。

交错树 :增广路径形成的树。

假设 \(S,T\) 表示 \(V_1,V_2\) 中在交错树中的点的点集,\(S',T'\) 表示 \(V_1,V_2\) 中不在在交错树中的点的点集,我们现在取一个 \(d\),并对于所有 \(i\in S\),让 \(L_1(i)\) 减去 \(d\),并让所有 \(j\in T\) 的点的 \(L_2(j)\) 加上 \(d\),对于不同的边会出现什么情况:

  1. \(S-T\)\(S'-T'\),没有影响;
  2. \(S-T'\),这种边 \(L_1(u)+L_2(v)-w(u,v)\) 值会变小,如果这个值变成 \(0\) 就被加进相等子图中去了,并且可以证明所有相等子图中的边都不可能是这种边,不然交错树会变大;
  3. \(S'-T\),这种边 \(L_1(u)+L_2(v)-w(u,v)\) 的值会变大,如果它原来是相等子图中的边,那么它之后就不是了,如果它原来就不是,那它在修改之后就更不可能是了;

现在考虑我们选择的 \(d\) 是什么,由于我们目的是让至少一条边加入到相等子图中去,并且始终满足 \(\sum_{u\in V_1' \\v\in V_2'}L_1(u)+L_2(v)\ge \sum_{e\in E'}w_e\),那么我们选择的 \(d\) 一定来源于第 \(2\) 种情况,因为只有它能让某条边加入到相等子图中去,并且这个 \(d\) 一定是

\[d=\min_{u\in S \\v\in T'}\{L_1(u)+L_2(v)-w(u,v)\} \]

之后我们重复这个过程,直到每个点都完成一次增广,这个时候就找到了最大权完美匹配。

时间复杂度:每个点都会被交错树中一次,每拉进去一次就会执行一次尝试增广,所以这里的复杂度是 \(\mathcal O(n(n+m))\),但是每个点都要求完成一次增广,所以总复杂度是 \(\mathcal O(n\times n(n+m))\),当 \(m=n^2\) 时,总体复杂度是 \(\mathcal O(n^4)\) 的。

伍、代码(优化前)

using namespace Elaina;

const int maxn=500;
const ll inf=(1<<30)-1;

int e[maxn+5][maxn+5];

int n,m;

inline void input(){
    n=readin(1),m=readin(1);
    int u,v;ll w;
    rep(i,1,m){
        u=readin(1),v=readin(1),w=readin(1ll);
        e[u][v]=w;
    }
}

int vall[maxn+5],valr[maxn+5];
int visl[maxn+5],visr[maxn+5];
int matchl[maxn+5],matchr[maxn+5];
// record the value of expression : vall[u]+valr[v]-weight_edge
int down[maxn+5];
int findAugRoad(const int u){
    visl[u]=1;
    for(int v=1;v<=n;++v)if(e[u][v] && !visr[v]){
        if(vall[u]+valr[v]==e[u][v]){
            if(!matchr[v] || findAugRoad(matchr[v])){
                matchl[u]=v,matchr[v]=u;
                return 1;
            }
        }
        else down[v]=Min(down[v],vall[u]+valr[v]-e[u][v]);
    }
    return 0;
}
inline void KM(){
    // initialize value array
    memset(valr+1,0,n<<2);
    for(int u=1;u<=n;++u){
        vall[u]=-inf;
        for(int v=1;v<=n;++v)vall[u]=Max(vall[u],e[u][v]);
    }
    // enumerate each node as start
    for(int s=1;s<=n;++s){
        while(1){
            memset(visl+1,0,n<<2);
            memset(visr+1,0,n<<2);
            memset(down,0x3f,n<<2);
            // if an augmented road has existed in the equal graph
            // the current node has finished augmenting.
            if(findAugRoad(s))break;
            int delta=inf;
            for(int i=1;i<=n;++i)if(!visr[i])
                delta=Min(delta,down[i]);
            for(int i=1;i<=n;++i){
                if(visl[i])vall[i]-=delta;
                if(visr[i])valr[i]+=delta;
            }
        }
    }
    ll ans=0;
    rep(i,1,n)ans+=e[matchr[i]][i];
    writc(ans,'\n');
    rep(i,1,n)writc(matchr[i],' ');Endl;
}

signed main(){
    input();
    KM();
    return 0;
}

陆、优化

这个算法是 \(\mathcal O(n^4)\) 的,不是十分理想,能不能进行一些优化?

原来的算法中,每次将顶标修改之后,需要使用一次匈牙利进行拓展或者找到当前最小的 \(\min_{u\in S \\v\in T'}\{L_1(u)+L_2(v)-w(u,v)\}\),匈牙利给我们带来了无穷无尽的复杂度,考虑能否简化这个过程?

对于 \(V_2\) 中的点,我们定义一个数组 \(\tt def[i]\) 表示最少需要花费 \(def[i]\) 的修改将其拉进交错树中,即

\[def[i]=\min_{1\le j\le n}\{L_1(j)+L_2(i)-w(i,j)\}\quad (i\in T',j\in S) \]

定义松弛操作为将 \(V_1\) 中的某个点去取出,遍历其连接的所有的 \(V_2\) 中的点,执行 \(def[i]=\min_{1\le j\le n}\{L_1(j)+L_2(i)-w(i,j)\}\quad (i\in T',j\in S)\).

最开始,\(S\) 中只有起点,然后将起点拿去松弛,然后在所有的 \(def\) 中找到一个最小的,这个值就是 \(d\),然后进行修改操作,在修改完之后一定会有一个 \(V_2\) 中的点被加入到 \(T\),如果这个点还没有被匹配,那么我们就找到了一条增广路,只需要跑一遍匈牙利增广这条路即可,如果这个点 \(v\) 在之前被匹配了,即连向 \(v\) 的点想要 \(v\),但是可惜它已经名花有主,这个时候我们想办法让它的 “主” 找另外的点,这样我们就将它的主人拿去松弛,松弛完又去找 \(d\),然后重复这个过程...直到某个时刻找到的 \(v\) 没有主人,那么我们就成功找到了一条增广路,然后跑一边匈牙利增广就可以了.

由于我们松弛的过程不修改匹配,所以每个匹配的主人最多松弛一次,松弛复杂度是 \(\mathcal O(n)\) 的,每个点作为起点,找到增广路就执行一次匈牙利并到下一个点,所以整体复杂度是 \(\mathcal O(n((m+n)+n^2))=\mathcal O(nm+n^3)\) 的。

柒、真正的代码

using namespace Elaina;

const int maxn=500;
const int inf=(1<<30)-1;

struct edge{int to,nxt,w;}e[maxn*maxn+5];
int tail[maxn+5],ecnt;
inline void add_edge(const int u,const int v,const int w){
    e[++ecnt]=edge{v,tail[u],w};tail[u]=ecnt;
}

int n,m;

inline void input(){
    n=readin(1),m=readin(1);
    int u,v,w;
    rep(i,1,m){
        u=readin(1),v=readin(1),w=readin(1);
        add_edge(u,v,w);
    }
}

int def[maxn+5];
int l1[maxn+5],l2[maxn+5];
/** @brief record the match of the node set V_2 */
int match[maxn+5];
/** @brief whether we have used this pair of match */
int used[maxn+5];
inline void slack(const int u){
    for(int i=tail[u],v;i;i=e[i].nxt){
        v=e[i].to;
        def[v]=Min(def[v],l1[u]+l2[v]-e[i].w);
    }
}
int hungary(const int u){
    for(int i=tail[u],v;i;i=e[i].nxt)if(!used[v=e[i].to]){
        if(l1[u]+l2[v]==e[i].w){
            used[v]=1;
            if(!match[v] || hungary(match[v])){
                match[v]=u;
                return 1;
            }
        }
    }
    return 0;
}
inline void KM(){
    /* initial */
    rep(u,1,n){
        l1[u]=-inf;
        for(int i=tail[u];i;i=e[i].nxt)
            l1[u]=Max(l1[u],e[i].w);
    }
    /* numerate the start point */ ;
    rep(s,1,n){
        /* initial */ ;
        rep(i,1,n)used[i]=0,def[i]=inf;
        /* first you should slack point @p s */ ;
        slack(s);
        while(true){
            /* find the point with the least def */ ;
            int mini=inf,p;
            rep(i,1,n)if(!used[i] && def[i]<mini)
                mini=def[i],p=i;
            // printf("mini == %d, p == %d\n",mini,p);
            if(mini==0){
                /* we've found the augmented road which meets our demand */ ;
                if(!match[p]){
                    /* initial the array used as the vis array of hungary */ ;
                    memset(used+1,0,n<<2);
                    hungary(s);
                    break;
                }
                else{
                    /* or we're going to try to extend the staggered tree */ ;
                    /* mark this pair of match */ ;
                    used[p]=1;
                    slack(match[p]);
                }
            }
            else{
                /* if there's no such point whose def equals to 0 */ ;
                /* we're going to adapt l1 and l2 */ ;
                rep(i,1,n){
                    /* a node has been used, which means it has already matched, so we need to update l1 and l2 */ ;
                    if(used[i])l2[i]+=mini,l1[match[i]]-=mini;
                    /* pay attention to this if, if you do not want to use this if, please set INF a larger number*/
                    else if(def[i]!=inf)def[i]-=mini;
                }
                /* pay attention !! point s is simple, but it exists in the staggered tree */ ;
                l1[s]-=mini;
            }
        }
    }
    ll ans=0;
    rep(i,1,n)ans+=l1[i]+l2[i];
    writc(ans,'\n');
    rep(i,1,n)writc(match[i],' ');Endl;
}

signed main(){
    input();
    KM();
    return 0;
}

捌、个人理解

以上的算法都是在干一件事:

在相等子图中找到一条增广路,这条增广路的开头由我们指定,结尾是一个无主的节点。如果在当前的相等子图中没有这样的增广路,那么我们就会得到一个交错树,然后我们需要干一些更改可行顶标的事,让我们的交错树变大到在其之内有一条我们想要的增广路,然后就增广.

时间复杂度不同是出现在我们如何更改可行顶标,如果每次都暴力用匈牙利改,那么时间复杂度就是 \(\mathcal O(n^2m)\),如果使用优化之后的思路,复杂度就是 \(\mathcal O(nm+n^3)\).

其实,优化之后的思路更像是用 \(\tt bfs\) 实现匈牙利,难道不是吗?

posted @ 2021-02-04 19:21  Arextre  阅读(340)  评论(0编辑  收藏  举报