从三道例题谈谈递归
写在前面
递归三要素:边界条件,递归前进段,递归返回段。递归作为最基础的算法之一,最常见的表现形式就是函数直接调用自身。递归的难点在于如何设计“退路”,也就是找到边界条件,处理好递归返回段。本文将从三道例题出发简单聊聊递归。
例题一:阶乘
给定一个正整数n,求它的阶乘n!,即 n * (n-1) * (n-2)……* 2 * 1
这是一道想法直观、实现简单的运用递归的例题
我们用F(n)来表示n的阶乘,那么 F(n)=n * F(n-1)
- 首先明确边界条件,当某层递归F(m)中m不等于1时,继续执行递归前进段,在F(m)中调用F(m-1)
- 到达F(1)后,开始执行递归返回段,也就是退路
C语言代买如下
int F(int n)
{
if (n != 1)
return n * F(n - 1);
else
return 1;
}
例题二:快速幂
编写函数myPow(double x, int n),实现计算x的n次幂
其中,x∈[-100.0,100.0],n∈[-231,231-1]
一个简单粗暴的想法是逐个相乘,比如 28=2 * 2 * 2 * 2 * 2 * 2 * 2 * 2
但是可以进一步优化,如果已经求得 24,只需将这个数求平方就能得到结果;而如果已经求得 22,也只需将其平方就能得到24
这样,我们自然就有了运用递归的想法
这道题的边界条件也很明显
我们用F(n)来表示xn,那么F(n)=[F(n/2)]2,而F(n/2)=[F(n/2/2)]2,依次类推,直到F(2) = [F(1)]2,到达边界,开始执行递归返回段
我们以26为例,画出递归的过程如下:
- 递归前进段
- 在上图中,由于整型运算中3 / 2 = 1,而[F(1)]2=F(2)而不是我们想要的F(3),所以这个程序中还有一个很重要的设计:在递归返回段中,判断当前得到的幂是否是所期望的,如果不是,只需再乘以一个底数x
C语言代码如下
//递归函数
//value_current是当前值,pow_expect是当前所期望的次幂
int Recurse(double x, int n, double *value_current, int pow_expect)
{
int pow_current = 1; //当前次幂
if (pow_expect != 1) //边界条件
{
pow_current = Recurse(x, n, value_current, pow_expect / 2); //递归前进段
}
if (pow_current == pow_expect - 1) //当前次幂与当前所期望次幂不相等,需要补充乘以一个底数x
{
(*value_current) *= x;
}
if (pow_expect != n) //递归返回段
{
(*value_current) *= (*value_current);
return pow_expect * 2;
}
return -1; //递归结束
}
double myPow(double x, int n)
{
double value_current = x;
int n_symbol = 1; //指数n的符号
int int_min_flag = 0;
//特殊值判断
if (x == 0)
return 0;
else if (x == 1)
return 1;
else if (x == -1)
{
if (n % 2)
return -1;
else
return 1;
}
//因为n的取值可能是-2^31,将其符号取反后是2^31,超过了int的取值范围,所以要进行一些操作
if (n == INT_MIN)
{
n += 1;
int_min_flag = 1;
}
if (n < 0)
{
n_symbol = -1;
n = -n;
}
else if (n == 0)
{
return 1;
}
Recurse(x, n, &value_current, n);
if (int_min_flag)
{
value_current *= x;
}
if (n_symbol == 1)
return value_current;
else
return 1/value_current;
}
例题三:生成有效括号
这是LeetCode题库中的22题,原题如下:
给出 n 代表生成括号的对数,请你写出一个函数,使其能够生成所有可能的并且有效的括号组合。
例如,给出 n = 3,生成结果为:
[
“((()))”,
“(()())”,
“(())()”,
“()(())”,
“()()()”
]
明确边界条件:
在生成括号的过程中,
(1)左括号数>=右括号数
(2)左括号数/有括号数<=n
当边界条件都满足时,执行递归前进段。本题中,递归前进段的设计是:能放置左括号就先放置左括号,然后再放置右括号。
当任一边界条件不满足时,就进入递归返回段。本题中的退路设计是:去掉最后放置的括号。
为了叙事方便,我们以n=2为例,画出递归的过程如下:
-
执行递归前进段,能放置左括号就先放置左括号
-
连续放置3个括号后,不满足边界条件2,进入递归返回段,去掉最后放置的括号,返回((后又进入递归前进段,添加右括号
-
同上,此处得到了第一个有效的排列组合(()),之后再进入递归前进段得到的排列组合都将违反边界条件2,所以将一路返回到(
-
(的右支与左支大同小异
-
得到第二个有效的排列组合()()
-
右括号的数量大于左括号的数量,不满足边界条件1
至此,我们得到了n=2的全部有效的括号的排列组合:(())()()
C语言代码如下
//递归函数
//一维字符数组temp用于临时存放正在生成中的括号的组合,left_num、right_num是当前左、右括号数量
int Recurse(char *temp, int *temp_index, char **ret, int *ret_row, int n, int *left_nums, int *right_nums)
{
if (*left_nums < n) //边界条件
{
temp[*temp_index] = '('; //递归前进段1:放置左括号
(*temp_index)++;
(*left_nums)++;
Recurse(temp, temp_index, ret, ret_row, n, left_nums, right_nums);
temp[*temp_index] = '\0'; //递归返回段:去掉最后放置的括号
(*temp_index)--;
(*left_nums)--;
}
if (*right_nums < *left_nums) //边界条件
{
temp[*temp_index] = ')'; //递归前进段2:放置右括号
(*temp_index)++;
(*right_nums)++;
Recurse(temp, temp_index, ret, ret_row, n, left_nums, right_nums);
temp[*temp_index] = '\0'; //递归返回段:去掉最后放置的括号
(*temp_index)--;
(*right_nums)--;
}
if (*left_nums == n && *right_nums == n) //左括号数=有括号数=n,则生成了有效的排列组合
{
ret[*ret_row] = (char *)malloc((2 * n + 1) * sizeof(char));
strcpy(ret[*ret_row], temp); //将有效的排列组合复制到二维字符数组ret中
ret[*ret_row][2 * n] = '\0';
(*ret_row)++;
}
return 0;
}
char ** generateParenthesis(int n, int* returnSize)
{
char *ret[20000];
char *temp = (char *)malloc((2 * n + 1) * sizeof(char));
int left_nums = 0, right_nums = 0, ret_row = 0, temp_index = 0;
Recurse(temp, &temp_index, ret, &ret_row, n, &left_nums, &right_nums);
*returnSize = ret_row;
free(temp);
return ret;
}
结语
正如开头所说,递归的难点在于如何找到边界条件、如何设计退路
在前两到例题中,递归仿佛像是一个“上下井”的过程,递归前进段是沿着井往下走,边界条件是井底,触到井底后就一直往上走,即递归返回段
但第三道例题有在井里反复上下的感觉,原因是在Recurse()函数中两次调用自身,与二叉树的深度优先遍历十分相像。
本文意在与大家聊聊我在学习递归时的一些心得体会,如有疏漏,望指正