关于递归问题的一点见解
原文出自https://blog.csdn.net/DongChengRong/article/details/77657318,欢迎大家访问我的博客
背景
前不久参加牛客网的有书共读活动意外的获得了一本《具体数学》,对此感谢牛客。其次领书是要条件的,条件就是写读书笔记。搞ACM的都知道具体数学是本什么样的神书(也可能是我太弱了),不过没办法,规则要写那就要写,好在第一章讲递归,还在我的承受范围之内。
正文
什么是递归
既然是讲递归那么我们首先就要明白一件事,那就是什么是递归。
递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。[1]
这是维基百科对于递归的非正式定义,我深以为然。但是对于没有接触过的人来说可能比较抽象,下面我来举个例子
从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?“从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?‘从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?……’”[2]
大家看了这个例子是不是清晰了很多,这就是递归(个人认为这比上面的定义好多了,如果不是科学要严谨的话,完全可以用它来替代那乏味的定义)
递归问题有什么特征
这个问题也可以反过来,就是我们是根据什么特征来判断一个问题是否是递归问题的
《具体数学》在最开始的地方提到,每一个问题的解都依赖于统一的问题的更小实例的解[3]。我们再来举例分析、
求解n的阶乘,首先我们要求n-1的阶乘然后在乘以n,这就是每一个问题都依赖于更小实例解的体现。换言之,当我们求问题规模为n的问题时,我们必须知道规模为n-1的问题的解。我们把具备这个特征的问题叫做递归问题
如何使用编程语言解决递归问题
这里以C语言为例
在大多数编程语言当中都允许函数自己调用自己,在计算机科学中,我们把这一现象叫做递归
下面我们来解决计算n的阶乘问题
按上面所说的,我们不断的缩小数据规模就可以了,但是还有一个问题,那就是什么时候终止。因为理论上函数是可以不断的调用自己的,数据规模也可以不断的减少(理论上可以一直逼近负无穷)
不知大家注意到了没有我这里是说的都是理论上,那么实际上呢?
实际上计算机的资源是有限的,它不能一直重复一个工作,例如,int的范围是-2147483648~2147483647,当你的计算机运行到-2147483648时,在进行运算就会产生溢出,所以你计算机无法正常(这里的正常是指按程序的意图工作)工作;在比如,函数的调用是通过一个叫做调用栈(Call Statck)的栈来完成的。当你 的程序递归到一定程度时,栈就会满了,此时继续递归调用会爆栈,程序会非正常终止(不信的同学可以试一试,看看非正常终止的程序和正常终止的程序返回值有什么区别,codeblocks调试时最后会输出返回值)
说点题外话,人其实也是不能一直(或者说无间断的)重复同一个工作的,因为人总要吃饭,总要喝水,总要睡觉。人只可以在某个时间段内重复同一个工作,从这里看(只考虑持续工作时间)同一件事情用机器做要比用人做合适多了,因为机器只要电量充足,程序正确,机器可以持续工作的时间要远大于人类。所以那些懒惰的人还是请尽快学些有用的东西吧。AI的风会刮到哪没有人会知道,等到AI全部替换底层工人时在转行已经迟了。推荐看一下李开复的《人工智能》,算是科普文吧。
上面的内容可能有点偏题,我们再回到原来的问题,即递归问题除了要写递归式还要写什么?答案是终止条件,我们必须告诉计算机什么时候终止,否则就会发生我刚才提到的问题,如果是程序设计竞赛或一些认证考试的话,缺少(不是没有)终止条件也有可能会使你的程序运行时间过长从而导致超时而使得你不得分
因此在设计递归函数时我们不得不考虑两个问题,一个是递归式,一个终止条件。
以下给出解决n!问题的参考代码
- #include <stdio.h>
-
- int f(int n) {
- if (n == 1 || n == 0) return 1; //终止条件
- return f(n - 1) * n; //递归式
- }
-
- int main() {
- int n;
- while (scanf("%d", &n) != EOF) {
- printf("%d\n", f(n));
- }
- return 0;
- }
大家觉得上面的代码怎么样?很好吗?
很好你就错了,虽然我们求阶乘时都是输入一个非负整数,但是你并不能保证输入数据都是负数 你不能避免奇葩客户,所以当你输入-1时程序就会崩溃,如图
我们的程序应当具有良好的鲁棒性,所以我们应当预先判断非法数据
改进后的代码如下
- #include <stdio.h>
-
- int f(int n) {
- if (n == 1 || n == 0) return 1; //终止条件
- return f(n - 1) * n; //递归式
- }
-
- int main() {
- int n;
- while (scanf("%d", &n) != EOF) {
- if (n < 0) puts("您输入的数据非法,请重新输入");
- else printf("%d\n", f(n));
- }
- return 0;
- }
递归问题的递推实现
递归问题其实可以用递推实现,关于递归与递推的区别可以看一下知乎的一篇讨论, 递推和递归的区别是什么?[4]
同样是刚才那个问题,我给出它的递推实现(假设所有的同学都会递推)
- #include <stdio.h>
-
- #define N 550
-
- int dp[N];
-
- int main() {
- int n;
- while (scanf("%d", &n) != EOF) {
- if (n < 0) puts("您输入的数据非法,请重新输入");
- else {
- int i;
- dp[0] = dp[1] = 1;
- for (i = 2; i <= n; ++i) dp[i] = dp[i - 1] * i;
- printf("%d\n", dp[n]);
- }
- }
- return 0;
- }
上面的代码可以通过一个预处理来优化,因为讲的是递归所以在这里不进行讲解,有兴趣的同学可以私聊我
大家可以看到我们仅仅通过一个for循环而不用函数自调就完成了计算过程,所以我们不用隐式的调用栈,从这里讲递推的效率比递归要高。但是有一些问题又特别适合用递归(原谅我没有找到例子,找到的同学可以私聊我),我们就必须适用递归。这种性质在动态规划中得到了充分体现,就我个人而言,出于效率考虑比较倾向于递推,但一些比较复杂的问题比较喜欢用递归,因为简单直观
编程实战
因为题目描述过长,所以我在这里仅给出题目链接,分析和参考代码
例题1 蟠桃记
解法一:设 t(n)代表天数为n时的答案,则当n = 1时,t(1) = 1; 这一点很重要,它是边界条件
其次,一天吃一半多一点,也就是剩下的加 1 恰好是吃之前的一半,因此 t(n) = (t(n - 1) + 1) * 2; 这也很重要,它是递归式
有了递归式和终止条件我们不难写出如下代码
- #include <stdio.h>
-
- int t(int n) {
- if (n == 1) return 1;
- return (t(n - 1) + 1) * 2;
- }
-
- int main() {
- int n;
- while (scanf("%d", &n) != EOF) {
- printf("%d\n", t(n));
- }
- return 0;
- }
解法二
我们把前10项列出来,是下面这个样子的
找规律可得t(n) = 3 * 2 ^(n - 1) - 2
规律很难找怎么办?没有关系,我们可以借助一个叫做OEIS的网站帮助我们完成这项工作,虽然有种作弊的感觉
参考代码
- #include <stdio.h>
- #include <math.h>
-
- int main() {
- int n;
- while (scanf("%d", &n) != EOF) {
- int tmp = pow(2, n - 1); //记录2 ^ (n - 1)
- printf("%d\n", 3 * tmp - 2);
- }
- return 0;
- }
例题二 母牛的故事
分析:和斐波那契数列差不多。把前5项写下来可以发现规律f(n) = f(n - 1) + f(n - 3) ,其中,边界条件是f(0) = f(1) = 1; f(2) = 2; f(3) = 3; f(4) = 4;
参考代码
- #include <stdio.h>
- #include <math.h>
-
- #define N 110
-
- int dp[N];
-
- int main() {
- int n;
- dp[0] = dp[1] = 1; dp[2] = 2; dp[3] = 3;
- for (int i = 4; i <= 55; ++i) dp[i] = dp[i - 3] + dp[i - 1];
- while (scanf("%d", &n) != EOF && n) {
- printf("%d\n", dp[n]);
- }
- return 0;
- }
例题三大数阶乘
这个题用到了java中的BigInteger,如果有不会用java的同学可以参考这篇博客https://blog.csdn.net/DongChengRong/article/details/78848399
分析:因为m很小只有5000所以我们可以从1开始打一个阶乘表,这样的话当有输入的时候我们就可以用O(1)的复杂度解决它
参考代码
- import java.math.BigInteger;
- import java.util.Scanner;
-
- public class Main {
- public static void main(String[] args) {
- BigInteger f[] = new BigInteger[5500];
- f[0] = f[1] = BigInteger.ONE;
- for (int i = 2; i <= 5000; ++i) {
- f[i] = f[i - 1].multiply(BigInteger.valueOf(i));
- }
- Scanner cin = new Scanner(System.in);
- while (cin.hasNext()) {
- int m = cin.nextInt();
- System.out.println(f[m]);
- }
- }
- }
参考文献
[1]https://zh.wikipedia.org/wiki/%E9%80%92%E5%BD%92
[2]https://zh.wikipedia.org/wiki/%E9%80%92%E5%BD%92#%E8%AF%AD%E8%A8%80%E4%BE%8B%E5%AD%90
[3]Ronald L.Graham; Donald E.Knuth; Oren Patashnik. 具体数学 计算机科学基础(第二版) 人民邮电出版社 .2018. ISBN 978-7-115-30810-8