实用指南:方法-递归/算法-递归
递归,直观的解释就是在方法中调用自己。
在目标明确的方法中(如计算y=3x+1)看上去不太合理,以至于有些同学可能不太理解为什么要自己调用自己,我们来聊一聊这个话题。
以求最大公约数(gcd)为例,如果用while写,大概是下面的结构:
//用while循环求最大公约数
int max_gcd = a;
while(b!= 0) {
int temp = b;
b = a % b;
a = temp;
}
cout<<"最大公约数为:"<
(这里就不解释辗转相除的原理了)
注意这个算法中a、b一直在不停的变化,直到满足了某个条件(b==0)循环停止,输出结果(当前最新的a)。
对于这种参数不停变化、有明确结束条件的算法,我们可以用递归来实现:
int gcd(int a, int b) {
if(b == 0)
return a;
else
return gcd(b, a%b);
}
第一次看到递归的做法,可能有的同学是难以理解的:为什么它可以通过调用自己来实现类似循环的效果。
其实最好的理解方式,是带入一个a、b,然后模拟这个递归方法调用的全过程,来帮助你理解递归的工作原理。
这里带入a=16,b=12
turn 1:a=16 b=12
if(b==0) 不成立
那么把返回结果交给 gcd(12,4),然后开始第二轮调用(第二次调用gcd)
turn 2:a=12,b=4
if(b==0) 不成立
那么把返回结果交给 gcd(4,0),然后开始第三轮调用(第三次调用gcd)
turn 3:a=4,b=0
if(b==0) 成立
第三次调用gcd的结果是4,向第二轮调用返回a的值4
turn2: 回到了第二轮调用 gcd(4,0)的位置,这个值是4,这个值返回到第一轮调用gcd的位置
turn1:回到了第一轮调用gcd(12,4)的位置,这个值是4,意味着最开始gcd(16,12)的值返回4,返回结果4
可能有同学想说对于递归使用gcd是否多此一举,明明循环可以做到的事情,为什么要用递归来做呢,又复杂又不便于理解。
确实,对例如算阶乘、斐波那契数列等递归并不是必须的,但是递归思想你必须得理解,因为有些特定的场合是非递归不可。
场景1:深度优先搜索
广搜和深搜都是先定义好方向的顺序,然后开始搜索。与广度优先搜索(按方向螺旋扩散)不同,深度优先搜索思想按一个方向走到黑,然后再退回来尝试其他方向。
用递归非常契合深度优先搜索的感念,通过递归散开每一个层级,每一个层级都有4个搜索方向,但是因为代码已经规定好了搜索的方向顺序,所以第一个方向会被一直调用,直到这个方向无法往下走,转到当前层级的下一个方向。
//迷宫深度优先搜索算法
int dx[4] = {0, 1, 0, -1};
int dy[4] = {1, 0, -1, 0};
bool getRoad=0;
//x,y表示当前点的坐标,n,m表示迷宫的行列数
void dfs(int x, int y, int n, int m) {
visited[x][y] = 1;
if(x == n-1 && y == m-1) {
//cout<<"找到路径!"<= 0 && nx < n && ny >= 0 && ny < m && map[nx][ny] == 0 && visited[nx][ny] == 0) {
return dfs(nx, ny, n, m);
}
}
//第一个点的4个方向都探索完了
//本例不需要返回值,所以这里无需处理
}
在本例中,可以继续探索的前提是当前点没有超过边界、当前点可以走、没有被走过,直到所有的点都探索完毕就没有点可以继续走,程序自然退出结束,程序也可以继续优化为当已经有通路了就返回不再探索(深搜是搜不出来最佳路径的,只能搜出来通路)
深度优先搜索并不是不能用非递归来实现,但是这样会让代码更加复杂(使用stack),所以递归被视为深度优先搜索的最佳方案。
场景2:非确定数量输入的全排列
输入:
3
a b c
第一行的3表示有3个字符,第二行表示的是这3个字符
要求输出a b c 的不重复的全排列( abc、acb、bac、bca、cab、cba)
本地最大的挑战在于数据字符数量的不确定性,如果是固定3个数并不难, 写一个3重循环即可。但是当数量不确定时,我怎么知道要写几重循环呢?如果有9种情况,是不是1-9要做9次判断,那代码要多长?
本题通过递归的特性,巧妙的解决这个问题:
int a;
char c[9];
char out[9];
void permute(int k) {
if (k == a) {
for (int i = 0; i < a; i++) {
cout << out[i];
}
cout << endl;
return;
}
/*
* 如果当前字符已经被标记为已使用(即c[i]等于'\0'),则通过continue跳过该循环迭代。
* 否则,将当前字符c[i]赋值给输出数组out[k],表示当前位置k使用了字符c[i]。
将c[i]标记为已使用,即c[i] = '\0'。
递归调用permute(k + 1),尝试下一个位置的字符排列。
递归调用返回后,恢复c[i]的值,以便尝试其他字符的排列(即c[i] = out[k])。/
*/
for (int i = 0; i < a; i++) {
if (c[i] == '\0') {
continue;
}
out[k] = c[i]; //使用当前字符
c[i] = '\0'; //标记当前字符已使用
permute(k + 1); //递归调用,下一轮
c[i] = out[k];//恢复当前字符的值
}
}
int main() {
cin >> a;
for (int i = 0; i < a; i++) {
cin >> c[i];
}
permute(0);
return 0;
}
要完全理解递归代码中循环的意义,需要带入数值模拟。
本例在递归的循环中,通过k控制递归的深度(也就是字符的数量),通过循环i控制备选输出的长度。本例稍微修改一下,可以实现如“输入5个字符,取3个做全排列(也就是A(5,3)个))”的全排列输出。
对于循环次数都不确定的问题,通过两个因子去控制递归的总次数,从而实现非递归代码难以实现的复杂问题,是递归的优势之一。
总结:
在小部分场合,还真是非递归不可,比如求a的k次方对p的余数(快速幂,当k很大时),掌握递归的算法思想在信息学学习中是非常必要的。
浙公网安备 33010602011771号