我们先来看一个另外的名词的定义:分治。什么叫分治,顾名思义:“分而治之”,也就是说,分治法将原问题分成若干个规模较小而结构与原问题相同或相似的子问题,然后分别解决这些问题,最后合并子问题的解,即可得到原问题的解。
好,我们言归正传,先来看看一个看似玩笑的对递归的定义:“要理解递归,你要先理解递归,直到你能理解递归”。是不是有点绕?然而这对递归的解释却是十分直观的。递归就在于反复调用自身函数,但是每次把问题范围缩小到可以直接得到边界数据的结果,然后再在返回的路上求出各个子问题的对应的解。不妨和分治的思想比较一下,可以说是完美契合了,从这点上来看,递归是很适合用来实现分治思想的。
其中,自身函数就好比递归式,得到边界数据的结果就好比递归边界。可以这么说,递归边界和递归式是递归的逻辑中最重要的两个概念,理解了这两点,递归也就理解了。刚才对递归式和递归边界只是比方了一下,现在给出严格的定义:
递归式是将原问题分解为若干个子问题的手段,
而递归边界则是分解的尽头。
可以想象,如果使用递归式不断递归而不进行阻止,那么最后这将是个黑洞式的无穷无尽的算法。
1、先来看一个经典的例子:使用递归求解n的阶乘。
分析:
我们先来找出其中的递归式:
我们知道n! =1*2 …*n,这个式子写成递推的形式就是n! = n * (n-1)!
,这样一来就把规模为n的问题转换为求解规模为(n-1)的问题了。如果用F(n)表示n!,就可以得到递归式了:F(n) = F(n-1) * n,那么什么时候递归到尽头呢,显然0! = 1,因此不妨把F(0) = 1作为递归边界,即当规模减小至n = 0时开始“回头”。
给出代码:
#include<stdio.h>
int F(int n){
if(n==0)return 1;
else return F(n-1)*n;
}
int main(){
int n;
scanf("%d",&n);
printf("%d\n",F(n));
return 0;
}
2、再来看另一个经典例子:求Fibonacci数列的第n项。
分析:
参照如上的分析过程:不难知道 递归边界为F(0) = 1和F(1) = 1,递归式为F(n) = F(n-1) + F(n-2) (n>=2)。
给出代码:
#include<stdio.h>
int F(int n){
if(n == 0 || n == 1)return 1;
else return F(n-1)+F(n-2);
}
int main(){
int n;
scanf("%d",&n);
printf("%d\n",F(n));
return 0;
}
由上面两个经典例子可以知道,如果要实现一个递归函数,就要有两样东西:递归式和递归边界。无论递归程序有多么复杂,他们都是书写递归的两个关键。
3、接下来我们看个稍微复杂些的例子:
实现按字典序从小到大的顺序输出1~n的全排列,其中(a1,a2,…an)的字典序小于(b1,b2,…bn)是指存在一个i,使得a1 = b1,a2 = b2,…,ai-1 = bi-1,ai<bi 成立。
分析:
先把问题分解为若干个子问题:“输出以1开头的全排列”,“输出以2开头的全排列”,…,“输出以n开头的全排列”。于是设定一个数组P,用来存放当前的排列;设定一个散列数组hashTable,其中hashTable[x]当整数x已经在数组P中时为True。
现在按顺序往数组P中填入数字。不妨假设当前已经填好了P[1]~P[index-1],正准备填P[index]。显然需要枚举 1 ~ n,如果当前枚举的数字x还没有在P[1] ~ P[index-1]中(即hashTable[x]==false),那么就把它填入P[index],同时将hashTable[x]置为true,然后去处理P的第(index+1)位(即进行递归),而当递归完成时,再将hashTable[x]还原为false,以便让P[index]填入下一个数字。
那么这题的递归边界是什么呢?显然,当index达到n+1时,说明P的第1 ~ n 位都已经填好了,此时可以把数组P输出,表示生成了一个排列,然后直接return即可。
#include<cstdio>
const int maxn = 11;
//P为当前排列,hashTable记录整数x是否已经在P中
int n,P[maxn],hashTable[maxn] = {false};
void generateP(int index){
if(index == n+1){//递归边界,已经处理完排列的第1~n位
for(int i = 1;i<=n;i++){
printf("%d",P[i]);//输出当前排列
}
printf("\n");
return;
}
for(int x = 1;x<=n;x++){//枚举1~n,试图将x填入P[index]
if(hashTable[x] == false){//如果x不在P[0]-p[index-1]中
P[index] = x;
hashTable[x] = true;
generateP(index+1);// 处理排列的第index+1号位
hashTable[x] = false;
}
}
}
int main(){
n = 3;
generateP(1);
return 0;
}
4、最后我们来品味一下最经典的n皇后问题。
n皇后问题是指在一个n*n的国际象棋棋盘上放置n个皇后,使得这n个皇后两两均不在同一行、同一列、同一条对角线上,求合法的方案数。
分析:
对于这个问题,如果采取组合数的方式,即从n^2中选择n个位置,那么需要C(n ^2,n),当n = 8时就是54502232次枚举,显然n再大程序会爆掉。
但是换个思路,考虑到每行只能放置一个皇后、每列也只能放置一个皇后,那么如果把n列皇后所在的行号依次写出,就是1 ~ n的一个全排列。于是就只需要 n! 的枚举,当n = 8时就是40320次枚举。
模仿上题全排列,
给出代码:
void generateP(int index){
if(index == n+1){
bool flag =true;
for(int i = 1;i<=n;i++){
for(int j = i + 1; j<=n;j++){
if(abs(i-j) == abs(P[i]-P[j])){
flag = false;
}
}
}
if(flag)count++;
return;
}
for(int x = 1;x<=n;x++){
if(hashTable[x] == false){
P[index] = x;
hashTable[x] = true;
generateP(index+1);
hashTable[x] = false;
}
}
}
然而事实上,这仍然不是最好的做法。这种枚举所有情况然后判断每一种情况是否合法的做法是非常朴素的。或者不客气地说,是暴力的。稍加思考不难发现,当已经放置了一部分皇后时(对应于生成了排列的一部分),可能剩余的皇后无论怎样放置都不可能合法,此时就没必要再递归了,直接返回上层即可,这样就可以减少很多计算量。像这种如果在到达递归边界前的某层,由于一些事实导致已经不再需要往任何一个子问题递归,直接返回上一层的做法称为回溯法。
请自己尝试写出此题回溯法的代码。
总结
对于初学者而言,很容易陷入一层层数下去结果把自己绕晕了的怪圈,建议初学的时候多画出递归图,因为这样可以把递归放到一个平面上来思考,会容易很多。