1. 程式人生 > >Shapley演算法解決舞伴問題過程詳解(C++實現)

Shapley演算法解決舞伴問題過程詳解(C++實現)

舞伴問題是這樣的:有 n 個男孩與 n 個女孩參加舞會,每個男孩和女孩均交給主持一個名單,寫上他(她)中意的舞伴名字。無論男孩還是女孩,提交給主持人的名單都是按照偏愛程度排序的,排在前面的都是他們最中意的舞伴。試問主持人在收到名單後,是否可以將他們分成 n 對,使每個人都能和他們中意的舞伴結對跳舞?為了避免舞會上出現不和諧的情況,要求這些舞伴的關係是穩定的。

假如有兩對分好的舞伴:(男孩 A,女孩 B)和(男孩 B,女孩 A),但是男孩A更偏愛女孩 A,女孩 A 也更偏愛男孩 A,同樣,女孩 B 更偏愛男孩 B,而男孩 B 也更偏愛女愛 B。在這種情況下,這兩對舞伴就傾向於分開,然後重新組合,這就是不穩定因素。很顯然,這個問題需要的是一個穩定匹配的結果,適合使用 Gale-Shapley 演算法

演算法實現

首先定義舞伴的資料結構,根據題意,一個舞伴至少要包含兩個屬性,就是每個人的偏愛舞伴列表和他(她)們當前選擇的舞伴。根據 Gale-Shapley 演算法的規則,還需要有一個屬性表示下一次要向哪個偏愛舞伴提出跳舞要求。當然,這個屬性並不是男生和女生同時需要的,當使用“男士優先”策略時,男生需要這個屬性,當使用“女士優先”策略時,女生需要這個屬性。為了使程式輸出更有趣味,需要為每個角色提供一個名字。

綜上所述,舞伴的資料結構定義如下:
typedef struct tagPartner {
    char *name; //名宇
    int next; //下一個邀請物件
    int current; //當前舞伴,-1表示還沒有舞伴
    int pcount; //偏愛列表中舞伴個數
    int perfect[UNIT_C0UNT]; //偏愛列表
}PARTNER;
UNIT_COUNT 是男孩或女孩的數量(穩定匹配問題總是假設男孩和女孩的數雖相等),pcount 是偏愛列表中的舞伴個數。根據標準的“穩定婚姻問題”的要求,pcount 的值應該是和 UNIT_COUNT —致的,但是某些情況下(比如一些演算法比賽題目的特殊要求)也會要求夥伴們提供的偏愛列表可長可短,因此我們增加了這個屬性。

這裡給出的實現演算法使用陣列來儲存參加舞會的男孩和女孩列表,因此這個資料結構中的 next、current 和 perfect 列表中存放的都是陣列索引,瞭解這一點有助於理解演算法的實現程式碼。

Gale-Shapley 演算法的實現非常簡單,完整的演算法程式碼如下:
bool Gale_Shapley(PARTNER *boys, PARTNER *girls, int count)
{
    int bid = FindFreePartner(boys, count);
    while(bid >= 0)
    {
        int gid = boys[bid].perfect[boys[bid].next];
        if(girls[gid].current == -1)
        {
            boys[bid].current = gid;
            girls[gid].current = bid;
        }
        else
        {
            int bpid = girls[gid].current;
            //女孩喜歡bid勝過其當前舞伴bpid
            if(GetPerfectPosition(&girls[gid],bpid) > GetPerfectPosition(&girls[gid], bid)) {
                boys [bpid].current = -1; //當前舞伴恢復自由身
                boys[bid].current = gid; //結交新舞伴
                girls[gid].current = bid;
            }
        }
        boys[bid] .next++; //無論是否配對成功,對同一個女孩只邀請一次 bid = FindFreePartner(boys, count);
    }
    return IsAllPantnerMatch(boys> count);
}
FindFreePartner() 函式負責從男孩列表中找一個還沒有舞伴、並且偏好列表中還有沒有邀請過的女孩的男孩,返回男孩在列表(陣列)中的索引。如果返回值等於 -1,表示沒有符合條件的男孩了,於是主迴圈停止,演算法就結束了。

GetPerfectPosition() 函式用於判斷女孩喜歡一個舞伴的程度,通過返回舞伴在自己的偏愛列表中的位罝來判斷,位罝越靠前,也就是 GetPerfectPosition() 函式的返回值越小,說明女孩越喜歡這個舞伴。

GetPerfectPosition() 函式的實說程式碼如下:
int GetPerfectPosition(PARTNER *partner, int id)
{
    for(int i = 0; i < partner->pCount; i++)
    {
        if(partner->perfect[i] == id)
        {
            return i;
        }
    }
    //返回一個非常大的值,意味著根本排不上對
    return 0X7FFFFFFF;
}
按照“穩定婚姻問題”的要求,這個函式應該總是能夠得到 ID 指定的異性舞伴在 partner 的偏愛列表中的位置,因為每個 partner 的偏愛列表包含所有異性舞伴。但是當題目有特殊需求時,partner 的偏愛列表可能只有部分異性舞伴。比如 partner 非常恨一個人,他們絕對不能成為舞伴,那麼 partner 的偏愛列表肯定不會包含這個人。

考慮到演算法的通用性,GetPerfectPosition() 函式預設返回一個非常大的數,返回這個數這意味著 ID 指定的異性舞伴在 partner 的偏愛列表中根本沒有位罝(非常恨),根據演算法的規則,partner 最不喜歡的異性舞伴的位置都比id指定的異性舞伴位置靠前。這也是演算法一致性處理的一個技巧,GetPerfectPosition() 函式當然可以設計成返回 -1 表示 ID 指定的異性舞伴不在 partner 的偏愛列表中,但是大家想一想,演算法中是不是要對這個返回值做特殊處理?

原來程式碼中判斷位置關係的一行程式碼處理:
if(GetPerfectPosition(&girls[gid], bpid) > GetPerfectPosition(&girls[gid], bid))
就會變得非常繁瑣,讓我們看看會是什麼情況:
if((GetPerfectPosition(&girls[gid], bpid) == -1)&& (GetPerfectPosition(&girls[gid], bid) == -1))
{
    //當前舞伴bpid和bid都不在女孩的喜歡列表中,太糟糕了
    ...
}
else if(GetPerfectPosition(&girls[gid], bpid) == -1)
{
    //當前舞伴bpid不在女孩的喜歡列表中,bid有機會
    ...
}
else if(GetPerfectPosition(&girls[gid], bid) == -1)
{
    //bid不在女孩的喜歡列表中,當前舞伴bPid維持原狀
    ...
}
else if(GetPerfectPosition(&girls[gid], bpid) >GetPerfectPosition(&girls[gid], bid))
{
    //女孩喜歡bid勝過其當前舞伴bpid\
    ...
}
else
{
    //女孩喜歡當前舞伴bpid勝過bid
    ...
}
這是我最不喜歡的程式碼邏輯,真的,太糟糕了。可見,這個小小的技巧為程式碼的邏輯處理帶來了極大的好處。類似的技巧被廣泛應用,在排序演算法中經常使用“哨兵”位,避免每次都要判斷是否比較完全部元素。面向物件技術中常用的“DuramyObject”技術,也是類似的思想。

Gale-Shapley 演算法原來如此簡單,你是不是為沙普利能獲得諾貝爾獎憤憤不平?其實不然,演算法原理的簡單並不等於其解決的問題也簡單,小演算法解決大問題。

改進優化:空間換時間

Gale_Shapley() 函式給出的演算法還有點問題,主要是 GetPerfectPosition() 函式的策略,這個函式每次都要遍歷 partner 的偏愛舞伴列表才能確定 bid 的位罝,很可能導致理論上時間複雜度為的演算法在實際實現時變成的時間複雜度。為了避免演算法在多輪選擇過程中頻繁遍歷每個 partner 的偏愛舞伴列表,需要對 partner 到底更偏愛哪個舞伴的判斷策略進行改進。

改進的原則就是“以空間換時間”。簡單來講,以空間換時間的方法就是用一張事先初始化好的表儲存這些位罝關係,在使用個過程中,以 O(1) 時間複雜度的方式直接查表確定偏愛舞伴的關係。這樣的表可以是線性表,也可以是雜湊表這樣的對映表。

對於這個問題,我們選擇使用二維表來儲存這些位置關係。假設存在二維表 priority[n][n],我們用 priority[w][m] 表示在 w 的偏愛列表中的位置,這個值越小,表示 m 在 w 的偏愛列表中的位置越靠前。在演算法開始之前,首先初始化這個關係表:
for(int w = 0; w < unit_count; w++)
{
    //初始化成最大值,原理同上
    for(int j = 0; j < UNIT_COUNT; j++)
    {
        priority= 0X7FFFFFFF;
    }
    //給偏愛舞伴指定位置關係
    int pos = 0;
    for(int m = 0; m < girls[w].pCount; m++)
    {
        priority[w][girls[w].perfect[m]] = pos++;
    }
}
最後,將對 GetPerfectPosition() 函式的呼叫替換成查表:
if(priority[gid][bpid] > priority[gid][bid])
對於一些在演算法執行過程中不會發生變化的靜態資料,如果演算法執行過程中需要反覆讀取這些資料,並且讀取操作存在一定時間開銷的場合,比較適合使用這種“以空間換時間”的策略。用合理的方式組織這些資料,使得資料能夠在 O(1) 時間複雜度內實現是這種策略的關鍵。

對本問題應用“以空間換時間”的策略,需要在演算法開始的準備階段初始化好 priority 二維表,這需要一些額外的開銷,但是相對於 n2 次查詢節省的時間來說,這點開銷是能夠容忍的。