在算法的学习过程中,“递归”算法似乎显得很神秘,时常让学习者一头雾水,感觉莫名其妙,可是掌握递归又是一个绕不过去的坎,因为很多更高级的数据结构和算法思想就是以递归为基础的,比如数据结构中的树和图,比如算法中的动态规划。
递归函数之所以让我们觉得很玄幻,首先是无法理解函数自己调用自己, 其次是我们不清楚函数执行过程中的每一步都发生了什么,此时此刻,多么想单步调试代码啊,可是如果这么做了,就会发现仿佛进入了一个N维空间,一层又一层啊 ... ... 简直是有去无回,进去就出不来的感觉,放弃吧!
但今天我们就着一个简单的题目,执行一次单步调试,看看递归到底怎么执行的每一步。
做题目之前,我们只需要知道:递归就是把大事分解成若干件小事,把每一个小事的结果求出,然后通过一个决策过程就能得到大事的答案。
- 题目:
求数组arr[L...R] 中的最大值,用递归方法实现。
- 思路:利用二分法,将组数分为左、右两个部分,找到左侧数组的最大值,再找到右侧数组的最大值,两个最大值进行比较,较大的那个就是arr的最大值。
1)将L~ R 范围分成左右两个部分,左边:L~Mid, 右边:Mid~R;
2)求左边部分的最大值Max_L, 右边部分的最大值 Max_R;
3) L~R 范围上的最大值,就是 max{Max_L, Max_R}
递归过程发生在2),不断的利用二分法,求左右两个部分的最大值,直到左部分(或右部分)只有一个数,则结束二分,结束递归。
- 代码实现
#include <stdio.h>
int findMax(int* arr, int L, int R){
if(L == R ) {
return arr[L];
}
int mid = L + ((R-L)>>1);
int max_l = findMax(arr,L,mid);
int max_r = findMax(arr,mid+1,R);
return max_l>max_r?max_l:max_r;
}
int main(){
int arr[4] = {3,7,4,6};
printf("max = %d", findMax(arr,0,3));
return 0;
}
小技巧:
在求两个数L 和 R的中值的时候,代码中没有使用 mid = (L+R)/ 2, 而是利用了位运算,并且没有直接求两个数的和 而是去求了两个数的差,这是因为,如果L 和 R的值很大的话,它们的和有可能是个超大数,超出数据类型的表示范围,造成溢出报错。
所以,这行代码利用位运算 代替除法运算,提高了程序的运行效率,利用求差运算取代求和运算,避免了数据溢出。
int mid = L + ((R-L)>>1); // 两个数的中值 = 较小的那个数 + 两个数差的一半
- 运行结果
- 代码分析:
接下来,我们来分析每一步的递归调用都发生了什么。
数组值 | 3 | 7 | 4 | 6 |
下标 | 0 | 1 | 2 | 3 |
递归函数也是函数,它之所以可以顺利的完成了调用过程,就是因为利用了系统栈。这就是递归能够实现的真相。下图表格就代表系统栈。
当程序执行进入 findMax (0,3) 后,不满足 if(L== R)条件,程序继续执行,执行到第8 行时,出现了子函数的调用语句,此时就需要保护现场,将当前函数findMax(0,3),以及局部变量mid ( =1), max_L = ? 和当前中断的行号 (=8) 都存入系统栈,接着销毁findMax(0,3)的执行过程,进入子函数 find(0,1);
f(0,3), mid = 1, max_l = ? 行号:8 |
进入函数findMax(0,1) 后,不满足 if(L == R) ,程序继续执行到第8行,又遇到子程序调用,再次保存现场,将当前函数findMax(0,1),以及其对应的局部变量mid = 0, max_L=? , 行号=8 存入系统栈后,然后销毁findMax(0,1)的执行过程,进入子函数findMax(0,0);
f(0,1),mid=0, max_l= ? 行号:8 |
f(0,3), mid = 1, max_l = ? 行号:8 |
进入函数findMax(0,0) 后,满足L== R,函数返回。 返回值给系统栈的栈顶。此时的栈顶是findMax(0,1), 那么就重建函数findMax(0,1)的执行过程,等待接收返回值的是 findMax(0,1) 的max_l, 即max_l = 4, 此时的行号是8 ,就会继续往下执行第9行语句。
f(0,3), mid = 1, max_l = ? 行号:8 |
执行到第9行语句,又遇到子函数的调用,保存现场 findMax(0,1), mid=0,mar_l = 4, max_r = ? ,行号=9;销毁findMax(0,1)的执行过程,进入子函数findMax(1,1);
f(0,1),mid = 0,max_l = 4, max_r = ? ,行号: 9 |
f(0,3), mid = 1, max_l = ? 行号:8 |
进入到子函数findMax(1,1), 满足 L==R, 函数返回, 返回值给系统栈的栈顶。此时的栈顶是findMax(0,1), 那么就重建函数findMax(0,1)执行过程,等待接收返回值是 findMax(0,1) 的max_r, 即max_r = 7, 此时的行号是9 ,继续往下执行第10行语句:return max_l>max_r?max_l:max_r, 返回值为7 ,给到系统栈栈顶,此时的栈顶是findMax(0,3) ;
f(0,3), mid = 1, max_l = ? 行号:8 |
重新构建findMax(0,3)的执行过程,max_l = 7, 程序继续往下执行到第9行,遇到子函数调用,保存现场,当前函数是findMax(0,3), mid = 1, max_l = 7, max_r=?,行号=9, 销毁findMax(0,3)的执行过程,进入子函数findMax(2,3);
f(0,3), mid = 1, max_l = 7, max_r = ? 行号:9 |
进入函数findMax(2,3) , 不满足 if(L== R), 程序继续执行到第8行,遇到子函数调用,保存现场:findMax(2,3), mid = 2,max_l = ? 行号=8 ,销毁函数findMax(2,3) 的执行过程,进入子函数findMax(2,2);
f(2,3), mid = 2,max_l = ?,行号:8 |
f(0,3), mid = 1, max_l = 7, max_r = ? 行号:9 |
进入函数findMax(2,2), 满足条件 L==R, 函数返回。返回值给到系统栈栈顶,此时系统栈栈顶是findMax(2,3), 重新构建函数findMax(2,3) 的执行过程。 即max_l = 返回值4, 程序继续往下执行到第9行,遇到子函数调用,保存现场: f(2,3), mid = 2, max_l = 4, max_r = ? 行号9,然后销毁当前函数 findMax(2,3)的执行过程, 进入子函数findMax(3,3);
f(2,3), mid = 2,max_l = 4, max_r = ? 行号:9 |
f(0,3), mid = 1, max_l = 7, max_r = ? 行号:9 |
进入函数findMax(3,3), 满足条件L== R, 函数返回。返回值给到系统栈栈顶,此时系统栈栈顶是findMax(2,3), 重新构建函数findMax(2,3) 的执行过程。 max_r = 返回值6, 程序继续往下执行到第10行,return max_l >max_r? max_l:max_r, 所以返回 6.
f(0,3), mid = 1, max_l = 7, max_r = ? 行号:9 |
返回值给到系统栈栈顶,此时栈顶是函数findMax(0,3) , 重新构建函数findMax(0,3) 的执行过程,max_r = 返回值6, 程序继续往下执行到第10行, return max_l >max_r? max_l:max_r, 返回7.
栈空,程序执行结束。
- 分析总结
1. 整个分析过程虽然比较啰嗦,但我们通过“单步调试”这种方法,给递归调用做了揭秘,知道了递归函数在执行过程中到底是怎么操作的,系统栈就是它的大杀器。
2. 通过对调用过程的感性的直观认识,知道为什么“递归层数不能太深”了,因为如果递归层数太深,就会增大对系统栈的开销,有可能出现栈溢出。
3. 在用递归思想处理问题的时候,我们不可能每一次都做这样的“单步调试”的分析,也没有这样分析的必要,但是要做做到“心中有数”,大脑中是有一张脑图的👇
对程序的运行走向,也要略知一二👇:
第一步: 求0位置~3位置上的最大值 fun(0,3); 计算出中点 mid = 1;
第二步: 求0位置~1位置上的最大值 fun(0,1), 计算出中点 mid = 0;
第三步: 因为L== R== 0, 所以fun(0) = 3;
第四步: 因为L == R == 1, 所以 fun(1) = 7;
第五步: 0位置~1位置的最大值,是7
第六步:求2位置~3位置的最大值fun(2,3), mid = 2;
第七步:因为 L== R == 2, 所以 fun(2) = 4;
第八步:因为 L == R == 3,所以 fun(3) = 6;
第九步:2位置~3位置的最大值是6;
第十步:0位置~ 3位置的最大值是 7;