一、 引言
穷举是解决问题的一种常用思路,当对一个问题无从下手的时候,可以考虑在问题域允许的范围内将所有可能的结果穷举出来,然后根据正确结果的判断规则对这些结果逐个验证,从而找出正确的结果。采用穷举的方法求解问题的答案比较适合计算机做,对这种体力活它们没有怨言,本文就以常见的两个猜结果的题目为例,介绍一下如何通过计算机程序解决此类问题,顺便介绍一下穷举法常见的算法结构和实现方式。
二、 猜结果游戏的分析过程
先来看一个问题,有五个运动员(甲、乙、丙、丁、戊)参加运动会,分别获得了一百米、二百米、跳高、跳远和铅球冠军,现在有另外四个人(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++语言,因此用枚举类型来定义它们:
13typedef enum tagAthleteNumber 14{ 15 athleteJia = 0, 16 athleteYi, 17 athleteBing, 18 athleteDing, 19 athleteWu 20}AthleteNumber; 21 22typedef enum tagChampionResult 23{ 24 ShotChampion = 0, 25 HighJumpChampion, 26 LongJumpChampion, 27 OneHMChampion, 28 TwoHMChampion 29}ChampionResult; |
描述的数学模型就可以这样定义:
32typedef struct tagDescription 33{ 34 AthleteNumber athlete; 35 ChampionResult result; 36}Description; |
最终自然语言描述的问题就可以量化为如下二维数组:
44Description 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,另一部分是描述真假标志,具体定义如下:
38typedef 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()函数就是完成上述过程:
119bool 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中的描述,判断其是否与当前描述存在两类矛盾的情况,实现方法如下:
70bool 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 穷举算法
猜结果游戏的穷举算法与排列组合算法类似,或者说就是使用排列组合的穷举方式。《算法系列》的《排列组合算法》一文对常见的排列组合算法都有介绍,这里不再赘述。只是在选择多重循环还是递归方法上,我倾向于使用递归方法,原因就是代码简单易懂。本算法穷举的主要思想就是对四个描述者的真假状态逐个进行遍历,使用递归结构,每次遍历一个人的描述。用一个索引来标识当前要遍历真假状态的描述者,当索引达到最大值时,表示一组描述结果完成,可以尝试判断是否此结果就是正确结果。以下就是递归方式的穷举算法主体部分代码:
159void 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回调函数:
148void 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 另一个猜结果游戏
此类型的猜结果游戏很多,参照上面的代码,稍加修改就可以解决类似的问题,比如这个猜结果游戏:
有五个游泳运动员参加完比赛回来时有人询问他们的比赛结果,他们说“我们每个人都告诉你两个结果,其中一个正确,一个错误,你自己猜猜名次究竟如何?”
甲说:“乙第二,我第三”
乙说:“我第二,戊第四”
丙说:“我第一,丁第二”
丁说:“丙最后,我第三”
戊说:“我第四,甲第一”
名次究竟如何,你猜出来了吗?
这个题目的描述者就是运动员本身,与前面讨论的题目有些差异,但是本文介绍的算法并不关心描述者信息,只关心其描述的内容,因此前面的算法也适用于这个题目,只需把描述中标识自己的第一人称“我”修改成相应的描述者就可以了,修改后的描述就变成和描述者无关的信息了:
甲说:“乙第二,甲第三”
乙说:“乙第二,戊第四”
丙说:“丙第一,丁第二”
丁说:“丙最后,丁第三”(丙最后,其实就是丙第五)
戊说:“戊第四,甲第一”
根据描述内容修改描述的定义如下:
32typedef struct tagDescription 33{ 34 AthleteNumber athlete; 35 MatchResult result; 36}Description; |
其中有改变的是MatchResult,定义如下:
22typedef enum tagMatchResult 23{ 24 matchNo1 = 0, 25 matchNo2, 26 matchNo3, 27 matchNo4, 28 matchNo5 29}MatchResult; |
最后把输出结果的函数稍作调整,整体代码无需做修改就可以得到结果了,有两组与描述都不冲突的结果,第一组结果是:
甲获得第五名
乙获得第二名
丙获得第一名
丁获得第三名
戊获得第四名
第二组结果是:
甲获得第三名
乙获得第一名
丙获得第五名
丁获得第二名
戊获得第四名
四、 总结
穷举类方法的重点就是两个,一个是穷举所有可能解的方法,另一个是判断正确解的规则。前一个重点需要一些技巧来构造合适的穷举算法,对于不同的问题来说,穷举算法的差异很大,没有统一的模板可以借用。后一个判断正确解的规则可以根据题目自然语言的描述构造,也没有定势可用。当然,这两者都是需要建立在适当的数学模型上的,明智地构造数学模型可以简化算法的复杂度,提高效率,对于一个成功的算法来说,以上都是缺一不可的。