1. 程式人生 > >二分匹配——匈牙利演算法和KM演算法

二分匹配——匈牙利演算法和KM演算法

一、二分圖
定義:若把簡單圖G的頂點集分成兩個不相交的非空集合V1和V2,使得圖中每一條邊都連線V1中的一個頂點和V2中的一個頂點(邊的兩個端點一個在V1中,另一個在V2中),則圖G稱為二分圖,此時稱(V1,V2)為G的頂點集的一個二部劃分。

什麼是簡單圖?
定義:圖G的每條邊都連線兩個不同的頂點,且沒有兩條相同的邊連線同一對頂點,則圖 G 稱為簡單圖。(沒有多重邊,沒有一個頂點自身形成一個環)

二、判斷一個圖是否是二分圖
定理:一個簡單圖是二分 圖,當且僅當能夠對圖中任意相鄰的兩點賦予兩種不同的顏色,使得沒有一對相鄰的頂點被賦予相同的顏色。(一個頂點只可能是兩種顏色中的一種)

三、什麼是匹配
一個匹配即是二分圖中一個包含若干條邊的集合,且其中任意兩條邊沒有公共端點。所以一個二分圖的匹配可以有多個。

四、最大匹配和完美匹配
1、顧名思義,在一個二分圖的所有匹配中,包含邊數最多的一個匹配就是最大匹配。
說到最大匹配,就順便說一下最小點覆蓋。因為最大匹配數 = 最小點覆蓋
點覆蓋的定義:圖G=(V,E)中的一個點覆蓋是一個集合S⊆V使得每一條邊至少有一個端點在S中。
最小點覆蓋顧名思義就是含最少元素的 S。

下面給出 最大匹配數 = 最小點覆蓋 的證明(參考了其他博主的想法再結合自己的想法)

①最小點覆蓋 <= 最大匹配數
若最大匹配數為 n ,則存在n條不相鄰的邊。取每一條邊的一個端點,組成一個點集S,則最小點覆蓋至少需要包含 |S|個點。

②最大匹配數 >= 最小點覆蓋
對於最小點覆蓋集S中的任一點,總有一條邊連線該點與集合外的一點。若不存在這樣的邊,則所有以該點為端點的邊同時有連線了S中的其他點,這樣的話即使把該點從S中刪去,得到原圖的一個更小的點覆蓋,這與S是最小點覆蓋矛盾。所以對S中任一點v,存在一條連線v和不屬於S的點w的邊。對S中每一個v,都取這樣一條邊,構成一個邊集E。下面證明E中每一條邊都不相鄰。若存在相鄰的兩條邊,則存在v1、v2屬於S且與S外同一點w相鄰。由E的構成方式可知,在不屬於S的點中,只有w與v1相鄰,也只有w與v2相鄰。有二分圖的性質可知,在所有點中只有w與v1相鄰,只有w與v2相鄰,所以將S中的v1,v2刪去,將w加入到S中,我們可以得到一個對於原圖來說更小的點覆蓋,這與S是最小點覆蓋矛盾,所以E中每一條邊都不相鄰。所以E構成的了原圖的一個匹配,所以最大匹配數 >= 最小點覆蓋。

綜上,最小點覆蓋 == 最大匹配數。

2、若二分圖的一個匹配為M,其劃分為(V1,V2),若V1中每個頂點都是M中邊的端點,即 |M| = |V1| (M中的邊數等於V1的頂點數),則該匹配稱為完美匹配。
所以,完美匹配一定是最大匹配,最大匹配不一定是完美匹配;且一個二分圖肯定有最大匹配,但不一定有完美匹配。

五、最優匹配
最優匹配又稱為帶權最大匹配,是指在帶有權值邊的二分圖中,求一個匹配使得匹配邊上的權值和最大。一般求最優匹配時,所求二分圖的劃分(V1,V2)的頂點數相同,使得每一個頂點都需要被匹配,這樣也就等同求出了完美匹配。如果V1和V2的頂點數不同,可以通過補點加權值0邊實現轉化,然後用KM演算法解決。

說了那麼多概念,接下來開始說演算法了!!!

一、匈牙利演算法——求二分圖最大匹配

演算法步驟:從一個未匹配的頂點出發,依次經過非匹配邊、匹配邊、非匹配邊…形成一條路徑,按此路徑走找到第一個不同於起點的未匹配點V,則不再走下去。把起點和V的路徑上的未匹配邊都換成匹配邊,已匹配邊都換成未匹配邊。重複這樣,直到找不到這樣的路徑。

為什麼這樣就能得到最大匹配?
因為我們尋找的路徑的起點和終點都是未匹配的點,且路徑上的邊是交替的,即是一條已經匹配的邊和一條還未匹配的邊交替存在。這樣的話,這條路徑上的未匹配邊肯定比已經匹配的邊多一(不清楚的話可以手動畫一下)。將未匹配的邊換成已經匹配的邊,已經匹配的邊換成未匹配的邊,這樣的話就形成一個比原匹配多一條邊的匹配。當找不到這樣一條路徑時,我們就已經找到最大匹配了。
上述的路徑稱為增廣路徑。

下面是匈牙利演算法的DFS實現:
其中圖的表示是用鄰接表表示;我把演算法封裝成一個結構體


#include<vector>
using namespace std;
const int M = 1e5+5;
vector<int> adj[M]; //構建一張圖
struct Hungary{
    vector<int> vis,mat;//vis 記錄頂點是否被訪問,mat記錄哪兩個頂點互相匹配
    int num; //頂點數
    //由於頂點編號從1開始,所以陣列的大小宣告為 n+1
    Hungary(int n):vis(n+1,0),mat(n+1,0){num=n;}
    //尋找增廣路,u為還未匹配的點
    int path(int u)
    {
        //搜尋與 u 相鄰的頂點
        for(int i=0;i<adj[u].size();++i)
        {
            int v = adj[u][i];
            if(!vis[v])
            {
                vis[v] = 1;
                //分兩種情況討論:
                //1、找到可以和u匹配的未匹配的點v,因為u和v都未匹配,所以把連線u和v的邊加入匹配中,可以得到更大的匹配。
                //2、如果與u相連的點v已經匹配好了,那麼從v的匹配點開始搜尋。
                //因為我們要尋找一條交替的路徑,由於u為匹配,所以任何以u為端
                //點的邊都是未匹配的邊,從u開始尋找路徑,找的第一條邊就是未匹
                //配的,如果該邊的另一個端點已經匹配,就繼續從它所匹配的點開始
                //尋找路徑,這樣的話我們的路徑上的第二條邊就是v到它所匹配的點
                //的邊,該邊是已經匹配的邊,這就是匈牙利演算法的思想了。
                if(!mat[v]||path(mat[v]))
                {
                    //標記對應的匹配
                    mat[v] = u;
                    mat[u] = v;
                    //成功找到增廣路
                    return 1;
                }
            }
        }
        //無增廣路,已經達到最大匹配
        return 0;
    }

    int Match()
    {
        //最小點覆蓋數
        int min_point_cover = 0;
        依次遍歷所有的點,若已知二分圖的劃分(V1,V2),則遍歷其中一個點集的點即可
        for(int i=1;i<=num;++i)
        {
            //每一次尋找增廣路時頂點不能重複訪問
            vis.assign(n+1,0);
            //以未匹配點為起點,若有增廣路則最小點覆蓋加一,因為匹配每一個都是多一條邊
            if(!mat[i]&&path(i))
                min_point_cover ++;
        }
        return min_point_cover;
    }

};

二、KM演算法——求二分圖的最佳匹配(運用匈牙利演算法輔助求解)

我的理解是:
先忽略所有的邊,把原圖看成一張新的圖,其中的頂點不變,然後往新圖中新增邊,保證每一次新增邊後新圖的邊的總權值在滿足匹配的條件下是最大的,不斷加邊,直到得到最佳匹配。
首先明確二分圖的劃分(左,右),將左邊第一個點的權值最大的邊加入新圖,然後搜尋左邊點集的第二個點,把和它相連的權值最大的邊(w)加入新圖,如果 w 的另一個端點已經匹配,那麼我們要為新圖重新分配邊,為了保證重新分配的邊的總權值和把 w 加入新圖得到的總權值的差最小(保證了最佳匹配的取得),我們依次把已經在新圖裡的邊,跟左邊的已經匹配好的點的其他未匹配的鄰邊比較,讓權值相差最小的那兩條邊交換,即若A和B兩條邊權值相差最小,A在新圖中而B不在,那麼用B代替A,這樣我們把新圖的邊重新分配,使得可以滿足匹配的條件的同時所有邊的總權值最大。(如果把上述 的w新增進新圖,那形成的就不是匹配啦)。
依據這種思路,最終得到最佳匹配。這種思路很像匈牙利演算法尋找增廣路的思路,其實我覺得同理,我們要為左邊還未匹配的點尋找適合的匹配,這個匹配要滿足的是,新增這個匹配邊後已形成的匹配的總權值要最大。所以只是在匈牙利演算法尋找增廣路上加一些限制。

看程式碼前需要了解:
KM演算法引入了一種表示邊的方式:頂標(用陣列表示,T[i]=5表示 i 點的頂標為5)
一開始把左邊的點的頂標初始化為跟該點相連的邊的最大權值,右邊的點的頂標初始化為0。 T[左] + T[右] = G[左][右](G為鄰接矩陣) 表示邊 G[左][右]可以選擇新增進新圖。

此處借用上述部落格中的一張圖說明:
此處借用上述部落格中的一張圖說明:
初始化後,A的標杆為15,B的標杆為14,C的標杆為13;而D、E、F的標杆都為0。

演算法流程:
1、初始化可行頂標的值
2、用匈牙利演算法判斷是否有符合條件的增廣路,
3、若未找到符合的增廣路則修改可行頂標的值(換邊)
4、重複(2)(3)直到找到最佳匹配為止

程式碼如下:
此程式碼是題解程式碼,原題為HDU2255
其中一些針對於題目的表示我已經註釋明白,可以當成演算法模板看。
需要注意的是這種實現方式不是最優化的KM演算法,等以後學習了我再整理成部落格。

#include<cstdio>
#include<vector>
#include<algorithm>
#define INF 1e4+5
using namespace std;

int n,g[500][500]; // 用鄰接矩陣構建一張圖, n 為左右點集的點數(左==右) 
// p表示左邊的點集的點對應的標杆, h表示右邊的點集的點對應的標杆;
//vp用來標記左邊的點集哪一個點已經訪問過,vh標記右邊的點集,mat記錄哪兩個點互相匹配 
vector<int> p,h,vp,vh,mat;

//尋找總權值更大的增廣路 
int path(int u)
{
    vp[u]=1;
    for(int i=1;i<=n;++i)
    //當  p[u]+h[i]==g[u][i] 時 u 和 i之間存在一條邊,且該邊是當前 u 可以匹配的權值最大的邊 
        if(!vh[i]&&(p[u]+h[i]==g[u][i]))
        {
            vh[i]=1;
            //此處同匈牙利演算法 
            if(!mat[i]||path(mat[i]))
            {
                //只需知道從右邊到左邊的匹配就好,因為我們已經劃分好左右點集,每一次都取左邊點集中未匹配的點尋找增廣路徑 
                mat[i] = u;
                return 1;
            }
        }
    return 0;
}
int KM()
{
    p.assign(n+1,0);
    h.assign(n+1,0);
    // mat[i] = 0,表示 i 還未匹配。 
    mat.assign(n+1,0);
    //初始化頂標 
    for(int i=1;i<=n;++i)
        for(int j=1;j<=n;++j)
            p[i] = max(p[i],g[i][j]);
    //遍歷左邊的點 
    for(int i=1;i<=n;++i)
        while(1)
        {
            //將標記陣列置零,同匈牙利演算法 
            vp.assign(n+1,0);
            vh.assign(n+1,0);
            //因為下面要把 d 進行比較取最小,所以初始化為一個比較大的數 
            int d = INF;
            // 如果有增廣路則不用修改頂標
            //沒有增廣路的話,則重新分配新圖的邊,然後繼續尋找增廣路 
            if(path(i))break;
            //修改頂標 
            for(int i=1;i<=n;++i)
            //左邊的點中已經訪問過的點,即已經匹配過的點可能需要重新匹配以得到更大的總權值,
            //所以修改頂標,往子圖中新增一條邊,重新尋找增廣路看能不能增廣 
            //取與左邊的點相鄰的未匹配邊中跟當前存在子圖中的以該點為端點的邊相差最小的兩條邊 
            //這樣才能保持總權值最大 
                if(vp[i])
                for(int j=1;j<=n;++j)
                    if(!vh[j])
                        d=min(d,p[i]+h[j]-g[i][j]);
            //修改頂標,交換兩條邊 
            for(int i=1;i<=n;++i)
            {
                if(vp[i])p[i]-=d;
                if(vh[i])h[i]+=d;
            }
        }
    // ans 為 最大總權值 
    int ans=0;
    for(int i=1;i<=n;++i)
        ans+=p[mat[i]]+h[i];
    return ans;
}
int main()
{
    while(scanf("%d",&n)!=EOF)
    {
        for(int i=1;i<=n;++i)
            for(int j=1;j<=n;++j)
                scanf("%d",&g[i][j]);
        printf("%d\n",KM());
    }
}

沒想到這篇部落格寫了三個多小時。。。
若有不足的地方等發現後再修改,夜已深,先入睡。

感嘆一點,在寫部落格的時候不僅對學過的知識回顧複習,而且還不時有新的理解。真是溫故而知新!