一、 引言
窮舉是解決問題的一種常用思路,當對一個問題無從下手的時候,可以考慮在問題域允許的範圍內將所有可能的結果窮舉出來,然後根據正確結果的判斷規則對這些結果逐個驗證,從而找出正確的結果。採用窮舉的方法求解問題的答案比較適合計算機做,對這種體力活它們沒有怨言,本文就以常見的兩個猜結果的題目爲例,介紹一下如何通過計算機程序解決此類問題,順便介紹一下窮舉法常見的算法結構和實現方式。
二、 猜結果遊戲的分析過程
先來看一個問題,有五個運動員(甲、乙、丙、丁、戊)參加運動會,分別獲得了一百米、二百米、跳高、跳遠和鉛球冠軍,現在有另外四個人(A、B、C、D)對比賽的結果進行了描述,分別是:
A說:“乙獲得鉛球冠軍,丁獲得跳高冠軍”
B說:“甲獲得一百米冠軍,戊獲得跳遠冠軍”
C說:“丙獲得跳遠冠軍,丁獲得二百米冠軍”
D說:“乙獲得跳高冠軍,戊獲得鉛球冠軍”
A、B、C和D四個人每個人的描述都對一句,錯一句,現在根據這四個人的描述猜一下五名運動員各獲得了什麼項目的冠軍?
現在來分析這個問題,五個運動員獲得5個冠軍,正確的結果需要5個描述即可,現在題目給出了四個人的8個描述,其中一些是相互矛盾的錯誤描述,因此需要對這些描述的正確性進行假設,假設的過程其實就是窮舉的過程。每個人有兩個描述,分別對其進行正確性假設可以得到兩個假設結果,四個人就有24=16種假設結果的組合,下表就是這16種假設結果組合的全部描述:
描述 結果 |
A |
B |
C |
D |
1 |
對,錯 |
對,錯 |
對,錯 |
對,錯 |
2 |
對,錯 |
對,錯 |
對,錯 |
錯,對 |
3 |
對,錯 |
對,錯 |
錯,對 |
對,錯 |
4 |
對,錯 |
對,錯 |
錯,對 |
錯,對 |
5 |
對,錯 |
錯,對 |
對,錯 |
對,錯 |
6 |
對,錯 |
錯,對 |
對,錯 |
錯,對 |
7 |
對,錯 |
錯,對 |
錯,對 |
對,錯 |
8 |
對,錯 |
錯,對 |
錯,對 |
錯,對 |
9 |
錯,對 |
對,錯 |
對,錯 |
對,錯 |
10 |
錯,對 |
對,錯 |
對,錯 |
錯,對 |
11 |
錯,對 |
對,錯 |
錯,對 |
對,錯 |
12 |
錯,對 |
對,錯 |
錯,對 |
錯,對 |
13 |
錯,對 |
錯,對 |
對,錯 |
對,錯 |
14 |
錯,對 |
錯,對 |
對,錯 |
錯,對 |
15 |
錯,對 |
錯,對 |
錯,對 |
對,錯 |
16 |
錯,對 |
錯,對 |
錯,對 |
錯,對 |
尋找正確答案的過程就是對這16組假設結果逐個判斷的過程。先來看看第1個結果,A說“乙獲得鉛球冠軍”被假定是正確的,但是D說“乙獲得跳高冠軍”也被假定是正確的,因爲一個運動員只能獲得一個冠軍,因此第1個結果就互相矛盾,不能得到正確的答案。再來看看第2個結果,A說“乙獲得鉛球冠軍”被假定是正確的,但是D說“戊獲得鉛球冠軍”也被假定是正確結果,這又是另一種矛盾,因爲鉛球冠軍不能被兩個人同時獲得。如果某個結果的所有假定沒有互相矛盾的地方,就可以得到一個正確的結果,比如第10個結果,其正確的描述分別是A的第二句描述、B和C的第一句描述以及D的第二句描述,這幾個描述沒有矛盾,可以得到一個正確答案,就是:
丁獲得跳高冠軍 (A描述的第二句)
甲獲得一百米冠軍(B描述的第一句)
丙獲得跳遠冠軍 (C描述的第一句)
戊獲得鉛球冠軍 (D描述的第二句)
乙獲得二百米冠軍 (根據前面的結果做不矛盾推解)
三、 用程序求解猜結果遊戲
3.1 建立數學模型
《算法系列》的文章中多次提到,爲計算機程序建立的數學模型必須能夠解決三大問題,其一是要能夠方便地把自然語言描述的問題轉化成計算機能夠理解的數據結構,其二是轉換後的數據結構要適用與問題的求解模式並能夠推演出正確的結果,其三是能夠方便地把求解的結果轉化成自然語言方式的輸出。針對不同的問題建立的數學模型沒有統一的模式,也沒有標準答案,能夠解決問題就是正確的數學模型,但是隻有簡潔且能夠配合算法高效地求解問題的數學模型纔是好的數學模型。
首先需要數學模型化的是四個人的描述,考察這些自然語言的描述,發現每句描述其實只包含了兩個信息:一個是運動員是誰?另一個是他得了什麼冠軍?如果我們給每個運動員編一個序號,同時給每種運動的冠軍也編一個序號,則每個自然語言的描述就可以量化爲兩個數字。爲運動員或冠軍編制序號有多種方法,本文的算法採用的是C/C++語言,因此用枚舉類型來定義它們:
13 typedef enum tagAthleteNumber 14 { 15 athleteJia = 0, 16 athleteYi, 17 athleteBing, 18 athleteDing, 19 athleteWu 20 }AthleteNumber; 21 22 typedef enum tagChampionResult 23 { 24 ShotChampion = 0, 25 HighJumpChampion, 26 LongJumpChampion, 27 OneHMChampion, 28 TwoHMChampion 29 }ChampionResult; |
描述的數學模型就可以這樣定義:
32 typedef struct tagDescription 33 { 34 AthleteNumber athlete; 35 ChampionResult result; 36 }Description; |
最終自然語言描述的問題就可以量化爲如下二維數組:
44 Description peopleDesc[peopleCount][descPerPeople] = 45 { 46 { {athleteYi, ShotChampion}, {athleteDing, HighJumpChampion} }, 47 { {athleteJia, OneHMChampion}, {athleteWu, LongJumpChampion} }, 48 { {athleteBing, LongJumpChampion}, {athleteDing, TwoHMChampion} }, 49 { {athleteYi, HighJumpChampion}, {athleteWu, ShotChampion} } 50 }; |
問題的求解過程中,還有一個重要信息需要記錄,那就是每一個描述者提供的兩個描述中總是一真一假。窮舉的過程就是真和假的不斷假設過程,根據第二節的分析,在這個過程中可以得到16組真和假的配對結果,這也需要一個數學模型承載這些配對結果。本來描述是四個人給出的,每個人給出兩個描述,但是結果判斷不需要知道某個描述是哪個人給出的,只需要知道描述的內容以及結果認定是真還是假就可以了,因此定義每組配對結果爲一維數組,從而簡化算法:
std::vector<DescDecision> decisions;
DescDecision定義包含兩部分,一部分是描述Description,另一部分是描述真假標誌,具體定義如下:
38 typedef struct tagDescDecision 39 { 40 Description desc; 41 bool decision; 42 }DescDecision; |
最後是結果輸出,通過對問題的分析可以得知,結果其實就是5個描述,只不過這5個描述都是正確的,因此結果也可以定義爲Description的數組:
std::vector<Description> result;
3.2 正確結果判斷方法
窮舉可以得到問題域內的所有可能的結果,但是如何判斷哪個是正確的結果?根據本題的題意,正確的結果就是所有描述都沒有互相矛盾的結果。再根據第二節的分析,這個題目只有兩種可能的矛盾:一種是兩個運動員得到同一個冠軍,另一種是同一個運動員得到兩個(或兩個以上)冠軍。
正確結果的判斷和結果的生成是同時進行的,對每個窮舉得到的結果開始判斷之前,result是空的。每個窮舉結果有8個描述,逐個進行判斷,當一個描述被標識爲正確描述,且和result中的現有描述不矛盾,則將這個描述加入到result中,如果一個描述被判定與result中已經的描述有矛盾,則說明這個結果中的8個描述有互相矛盾的地方,無法得到正確結果,直接返回錯誤。當某個窮舉結果的8個描述全部判斷完成,沒有返回錯誤,則說明得到了一個正確的結果。ParseResultFromDecisions()函數就是完成上述過程:
119 bool ParseResultFromDecisions(const std::vector<DescDecision>& decisions, 120 std::vector<Description>& result) 121 { 122 std::vector<DescDecision>::const_iterator cit; 123 for(cit = decisions.begin(); cit != decisions.end(); ++cit) 124 { 125 if(CheckDecision(result, *cit)) 126 { 127 if(cit->decision)//如果是不矛盾的真描述,就記錄結果 128 { 129 result.push_back(cit->desc); 130 } 131 } 132 else 133 { 134 return false; 135 } 136 } 137 138 //只有四個描述,需要補上第五個描述,且不能矛盾 139 PatchTheLastOne(result); 140 return true; 141 } |
CheckDecision()函數就是判斷當前的描述是否與result中已有的描述(被認定是正確的描述)衝突,主要方法就是逐項檢查result中的描述,判斷其是否與當前描述存在兩類矛盾的情況,實現方法如下:
70 bool CheckDecision(const std::vector<Description>& result, const DescDecision&decision) 71 { 72 std::vector<Description>::const_iterator cit; 73 for(cit = result.begin(); cit != result.end(); ++cit) 74 { 75 if(cit->athlete == decision.desc.athlete) 76 { 77 if(decision.decision && (decision.desc.result != cit->result)) 78 { 79 return false; 80 } 81 if(!decision.decision && (decision.desc.result == cit->result)) 82 { 83 return false; 84 } 85 } 86 if(cit->result == decision.desc.result) 87 { 88 if(decision.decision && (decision.desc.athlete != cit->athlete)) 89 { 90 return false; 91 } 92 if(!decision.decision && (decision.desc.athlete == cit->athlete)) 93 { 94 return false; 95 } 96 } 97 } 98 99 return true; 100 } |
最後說明一下PatchTheLastOne()函數的作用,對於一個完整的正確答案,應該是5個描述,但是四個人的描述只有4個正確描述,在互相不矛盾的基礎上,需要補上第5個描述,PatchTheLastOne()函數就是做這個事情。補的方法很簡單,就是前4個描述中沒有提到的那個運動員得到的是前4個描述中沒有提到的那個項目的冠軍。因爲簡單,這裏就不列出代碼了。
3.3 窮舉算法
猜結果遊戲的窮舉算法與排列組合算法類似,或者說就是使用排列組合的窮舉方式。《算法系列》的《排列組合算法》一文對常見的排列組合算法都有介紹,這裏不再贅述。只是在選擇多重循環還是遞歸方法上,我傾向於使用遞歸方法,原因就是代碼簡單易懂。本算法窮舉的主要思想就是對四個描述者的真假狀態逐個進行遍歷,使用遞歸結構,每次遍歷一個人的描述。用一個索引來標識當前要遍歷真假狀態的描述者,當索引達到最大值時,表示一組描述結果完成,可以嘗試判斷是否此結果就是正確結果。以下就是遞歸方式的窮舉算法主體部分代碼:
159 void EnumPeopleDescriptions(int peopleIdx, 160 std::vector<DescDecision>& decisions, 161 void (*callback)(const std::vector<DescDecision>&decisions)) 162 { 163 if(peopleIdx == peopleCount) 164 { 165 callback(decisions); 166 return; 167 } 168 169 EnumPeopleDescriptions(peopleIdx + 1, decisions, callback); 170 //翻轉描述者兩個描述的狀態,總是保持一對一錯 171 DescDecision& dd1 = decisions[peopleIdx * descPerPeople]; 172 dd1.decision = !dd1.decision; 173 DescDecision& dd2 = decisions[peopleIdx * descPerPeople + 1]; 174 dd2.decision = !dd2.decision; 175 EnumPeopleDescriptions(peopleIdx + 1, decisions, callback); 176 } |
peopleIdx參數就是描述者索引,decisions參數就是當前對所有描述的真假判斷結果,通過成對地翻轉一個描述者的兩個描述的真假狀態,達到枚舉所有結果的目的。callback回調函數負責判斷一組結果是否正確並打印正確的結果,DescriptionsCallback()函數就是本算法使用的callback回調函數:
148 void DescriptionsCallback(const std::vector<DescDecision>& decisions) 149 { 150 std::vector<Description> result; 151 152 if(ParseResultFromDecisions(decisions, result)) 153 { 154 PrintResult(result); 155 } 156 } |
ParseResultFromDecisions()函數在3.2節已經介紹過了,PrintResult()函數只是用來輸出正確結果,無需多做說明。
3.4 結果輸出
至此,完整的算法都已經介紹完畢,剩下的就是輸出結果,結果不出意外,只有一個正確答案:
丁獲得跳高冠軍
甲獲得一百米冠軍
丙獲得跳遠冠軍
戊獲得鉛球冠軍
乙獲得二百米冠軍
這個輸出是按照描述者的正確描述順序輸出的,不太符合生活習慣,調整其實也很簡單,只要在結果輸出前進行一次排序即可:
sort(result.begin(), result.end(), AthleteComparator);
AthleteComparator()函數比較運動員編號大小,排序後的輸出就是這個樣子了:
甲獲得一百米冠軍
乙獲得二百米冠軍
丙獲得跳遠冠軍
丁獲得跳高冠軍
戊獲得鉛球冠軍
3.5 另一個猜結果遊戲
此類型的猜結果遊戲很多,參照上面的代碼,稍加修改就可以解決類似的問題,比如這個猜結果遊戲:
有五個游泳運動員參加完比賽回來時有人詢問他們的比賽結果,他們說“我們每個人都告訴你兩個結果,其中一個正確,一個錯誤,你自己猜猜名次究竟如何?”
甲說:“乙第二,我第三”
乙說:“我第二,戊第四”
丙說:“我第一,丁第二”
丁說:“丙最後,我第三”
戊說:“我第四,甲第一”
名次究竟如何,你猜出來了嗎?
這個題目的描述者就是運動員本身,與前面討論的題目有些差異,但是本文介紹的算法並不關心描述者信息,只關心其描述的內容,因此前面的算法也適用於這個題目,只需把描述中標識自己的第一人稱“我”修改成相應的描述者就可以了,修改後的描述就變成和描述者無關的信息了:
甲說:“乙第二,甲第三”
乙說:“乙第二,戊第四”
丙說:“丙第一,丁第二”
丁說:“丙最後,丁第三”(丙最後,其實就是丙第五)
戊說:“戊第四,甲第一”
根據描述內容修改描述的定義如下:
32 typedef struct tagDescription 33 { 34 AthleteNumber athlete; 35 MatchResult result; 36 }Description; |
其中有改變的是MatchResult,定義如下:
22 typedef enum tagMatchResult 23 { 24 matchNo1 = 0, 25 matchNo2, 26 matchNo3, 27 matchNo4, 28 matchNo5 29 }MatchResult; |
最後把輸出結果的函數稍作調整,整體代碼無需做修改就可以得到結果了,有兩組與描述都不衝突的結果,第一組結果是:
甲獲得第五名
乙獲得第二名
丙獲得第一名
丁獲得第三名
戊獲得第四名
第二組結果是:
甲獲得第三名
乙獲得第一名
丙獲得第五名
丁獲得第二名
戊獲得第四名
四、 總結
窮舉類方法的重點就是兩個,一個是窮舉所有可能解的方法,另一個是判斷正確解的規則。前一個重點需要一些技巧來構造合適的窮舉算法,對於不同的問題來說,窮舉算法的差異很大,沒有統一的模板可以借用。後一個判斷正確解的規則可以根據題目自然語言的描述構造,也沒有定勢可用。當然,這兩者都是需要建立在適當的數學模型上的,明智地構造數學模型可以簡化算法的複雜度,提高效率,對於一個成功的算法來說,以上都是缺一不可的。