实现穷举搜索的方法有很多,有时可以用循环就能简单实现,比如:从0 -- 100中找到所有的质数并输出,你可以用for循环从0 -- 100一个个数字的判断当前数字是否为质数。但在这篇文章中我想记录基于递归的穷举搜索实现方法。
递归(Recursion)主要有两个重要的特点。
第一:自我相似性(self-similar)
第二:有基准情况(base case)
递归中的自我相似性(self-similar)意味着一个大问题可以被分成很多相似的小问题来解决,有基准情况(base case)意味着你要解决的问题的一定会有最简单的一种情况。
到底什么意思呢,我们来打个比方:完成从1--n的阶乘。
int factorial(int n){
if(n < 2){
return 1;//基准情况(base case)
}else{
return n * factorial(n - 1);//自我相似性(self-similar)
}
}
如果你要完成一个1 -- n的阶乘,那么这个问题的自我相似性就是当前数字与之后所有数字的乘积相乘,也就是:return n * factorial(n - 1);。并且这个问题最简单的情况就是只算从1 到 1的情况,所以它的基准情况(base case)就是if(n < 2){return 1;}。
在用递归实现穷举搜索的问题中,我们也会用很多次递归调用来解决一个大问题,所以自我相似性(self-similar)在这里也同样适用。并且,我们可以将这里的递归调用作为一种“做选择”的行为。一次调用就是做一次选择,而后续的调用会做出连续的选择,我们通过一系列的递归调所做出的选择来构建一个选择序列(sequence)(来自cs106x)。而这里的base case就有所不同了,我们刚刚提到过在一般的递归问题中,base case 是一个问题的最简单的情况,但在这,base case并不意味着根本没有选项可供选择,而是我已经做出了所有需要做的选择,我已经构建了一个足够高的stack,不需要再增加高度,此时我呈现出我所构建的内容,这就是我的base case(来自cs106x)。
下图为穷举搜索伪代码(截图自cs106x):
我们再来打个比方:打印所有n位二进制数的程序(用递归*)。
void printBinary(int digits, string prefix){ // prefix默认值为“”
if (digits == 0) {
cout << prefix << endl; //base case
}else{
//digits >= 2
printBinary(digits - 1, prefix + "0");//每次递归调用的意义为做出选择
printBinary(digits - 1, prefix + "1");
}
}
在这段代码中else分支里的递归调用就是在做出选择,我们用树状图会更好理解(截图自cs106x,其中soFar对应代码中的prefix):
当然,我们在使用递归解决问题时也可以适当使用循环语句。比如当我们要生成所有n位十进制数时:
如果这样写,也不是不行,但是会“有点”麻烦:
void printDecimal(int digits, string prefix){
if (digits == 0) {
cout << prefix << endl;
}else{
printDecimal(digits - 1, prefix + “0”);
printDecimal(digits - 1, prefix + “1”);
printDecimal(digits - 1, prefix + “2”);
printDecimal(digits - 1, prefix + “3”);
printDecimal(digits - 1, prefix + “4”);
printDecimal(digits - 1, prefix + “5”);
printDecimal(digits - 1, prefix + “6”);
printDecimal(digits - 1, prefix + “7”);
printDecimal(digits - 1, prefix + “8”);
printDecimal(digits - 1, prefix + “9”);
}
}
我们可以通过循环代替重复的选择工作:
void printDecimal(int digits, string prefix){
if (digits == 0) {
cout << prefix << endl;
}else{
for(int i = 0; i <= 9; i++){
printDecimal(digits - 1, prefix + to_string(i));
}
}
}
接下来,我们来增加一点难度。让我们来实现排列函数permute(),permute()可以将给定的字符串中所有字母重新排列,以得到所有排列可能(例题来自CS106X)。
以MARTY为例,在每个递归中,我们要选择下一个应该放在递归中的字母,在选定后,我们继续选择跟随该字母的四个字母的组合,所以我们可以通过循环来实现。
void permute(string s,string prefix){
if(s.length() == 0){
cout << prefix << endl; //base case
}else{
for(int i = 0 ;i < s.length();i++){//选择一个字母
char ch = s[i];
string s2 = s.strsub(0,i) + s.strsub(i + 1);
permute(s2,prefix + ch);
}
}
}
我们还是用base case和自我相似性(self-similar)来对这个代码进行分析,我们在上面提到过,穷举搜索中的base case和一般递归的理解方式不一样,这里我们解释为,当已经没有字母可供选择(也就是说,已经用所有字母形成一个排列可能时),我们将这个结果输出。那么在else中的内容,我们可以解释为选择一个字母,并继续选择跟随该字母的四个字母的组合。在代码中:
①char ch = s[i]; 为选择一个字母
②string s2 = s.strsub(0,i) + s.strsub(i + 1);为整合剩下的字母
③permute(s2,prefix + ch);为继续选择跟随该字母的字母组合
我们通过将字符一个个加到prefix后面来组成一个排列可能,并将其输出,这种传递方式在递归中很常见,在上面的printBinary()函数中我们用的也是这个方法。