前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >什么是递归--What does resursion mean?

什么是递归--What does resursion mean?

作者头像
Fisherman渔夫
发布2019-07-31 14:57:04
5690
发布2019-07-31 14:57:04
举报
文章被收录于专栏:渔夫

问题的提出:

在Google.com.hk或者在Google.com上搜索 递归或者recursion 发现Google“抽了”,明明搜索正确,为啥还显示一个查询错误的提示?如下两图:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

谷歌实际上是调皮了一下,搜索递归,继续搜索递归

可能很多人在大一的时候,就已经接触了递归了,不过,我敢保证很多人初学者刚开始接触递归的时候,是一脸懵逼的,我当初也是,给我的感觉就是,递归太神奇了!可能也有一大部分人知道递归,也能看的懂递归,但在实际做题过程中,却不知道怎么使用,有时候还容易被递归给搞晕。也有好几个人来问我有没有快速掌握递归的捷径啊。 说实话,哪来那么多捷径啊,不过,我还是想写一篇文章,谈谈我的一些经验,或许,能够给你带来一些帮助。

转载的声明:

本文的案例用了知乎:帅地的回答案例,但是将主要难点更浅显地说明了一番,故另作一文。

递归的三大要素

说明:刚开始学习递归可能会使自己一头雾水,看别人写的递归程序总是能够读懂,但是自己实际操作却困难重重,所以,在开始学习之前,不妨掌握递归的精髓,即我们自己写是应当以怎么样的看法去看待递归问题的代码写法。算法具体实现总是千奇百怪,但是重要的是道而不是术

  • 第一要素:明确你这个函数想要干什么

对于递归,我觉得很重要的一个事就是,这个函数的功能是什么,他要完成什么样的一件事,而这个,是完全由你自己来定义的。也就是说,我们先不管函数里面的代码什么,而是要先明白,你这个函数是要用来干什么。

例如,我定义了一个函数

代码语言:javascript
复制
// 算 n 的阶乘(假设n不为0)
int f(int n){

}

这个函数的功能是算 n 的阶乘。好了,我们已经定义了一个函数,并且定义了它的功能是什么,接下来我们看第二要素。

  • 第二要素:寻找递归结束条件

所谓递归,就是会在函数内部代码中,调用这个函数本身,所以,我们必须要找出递归的结束条件,不然的话,会一直调用自己,进入无底洞。也就是说,我们需要找出当参数为啥时,递归结束,之后直接把结果返回,请注意,这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。 一般递归结束的条件是,当n取数非常小的执行方法,因为当n越小时,我们越容易直观看出或者算出f(n)的大小。 例如,上面那个例子,当 n = 1 时,那你应该能够直接知道 f(n) 是啥吧?此时,f(1) = 1。完善我们函数内部的代码,把第二要素加进代码里面,如下

代码语言:javascript
复制
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n == 1){
    return 1;
}
}

有人可能会说,当 n = 2 时,那我们可以直接知道 f(n) 等于多少啊,那我可以把 n = 2 作为递归的结束条件吗?

当然可以,只要你觉得参数是什么时,你能够直接知道函数的结果,那么你就可以把这个参数作为结束的条件,所以下面这段代码也是可以的。记住:n的取值大小不意为着问题规模的大小,我们显然可以做个有限大小的大表,存储我们认为的“结束条件”。

代码语言:javascript
复制
// 算 n 的阶乘(假设n>=2)
int f(int n){
if(n == 2){
    return 2;
}
}

注意我代码里面写的注释,假设 n >= 2,因为如果 n = 1时,会被漏掉,当 n <= 2时,f(n) = n,所以为了更加严谨,我们可以写成这样:

代码语言:javascript
复制
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
    return n;
}
}
  • 第三要素:找出函数的等价关系式

第三要素就是,我们要不断缩小参数的范围,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。 在接下来的例子中,你会发现,函数等价关系查找关键的一步就是从1、2推向3,小规模推向大规模n的飞跃;

例如,f(n) 这个范围比较大,我们可以让 f(n) = n * f(n-1)。这样,范围就由 n 变成了 n-1 了,范围变小了,并且为了原函数f(n) 不变,我们需要让 f(n-1) 乘以 n。

说白了,就是要找到原函数的一个等价关系式,f(n) 的等价关系式为 n * f(n-1),即 f(n) = n * f(n-1)。

这个等价关系式的寻找,可以说是最难的一步了,如果你不大懂也没关系,因为你不是天才,你还需要多接触几道题,我会在接下来的文章中,找 10 道递归题,让你慢慢熟悉起来。 找出了这个等价,继续完善我们的代码,我们把这个等价式写进函数里。如下:

代码语言:javascript
复制
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
    return n;
}
// 把 f(n) 的等价操作写进去
return f(n-1) * n;
}

至此,递归三要素已经都写进代码里了,所以这个 f(n) 功能的内部代码我们已经写好了。

这就是递归最重要的三要素,每次做递归的时候,你就强迫自己试着去寻找这三个要素。

还是不懂?没关系,我再按照这个模式讲一些题。

有些有点小基础的可能觉得我写的太简单了,没耐心看?少侠,请继续看,我下面还会讲如何优化递归。当然,大佬请随意,可以直接拉动最下面留言给我一些建议,万分感谢!

案例1:斐波那契数列

斐波那契数列的是这样一个数列:1、1、2、3、5、8、13、21、34…, 即第一项 f(1) = 1,第二项 f(2) = 1…,第 n 项目为 f(n) = f(n-1) + f(n-2)。 求第 n 项的值是多少。 1、第一递归函数功能 假设 f(n) 的功能是求第 n 项的值,代码如下:

代码语言:javascript
复制
int f(int n){

}

2、找出递归结束的条件 显然,当 n = 1 或者 n = 2 ,我们可以轻易着知道结果 f(1) = f(2) = 1。所以递归结束条件可以为 n <= 2。代码如下:

代码语言:javascript
复制
int f(int n){
if(n <= 2){
    return 1;
}
}

3、找出函数的等价关系式 题目已经把等价关系式给我们了,所以我们很容易就能够知道 f(n) = f(n-1) + f(n-2)。我说过,等价关系式是最难找的一个,而这个题目却把关系式给我们了,这也太容易,好吧,我这是为了兼顾几乎零基础的读者。 所以最终斐波那函数的代码如下:

代码语言:javascript
复制
int f(int n){
// 1.先写递归结束条件
if(n <= 2){
    return 1;
}
// 2.接着写等价关系式
return f(n-1) + f(n - 2);
}

搞定,是不是很简单? 零基础的可能还是不大懂,没关系,之后慢慢按照这个模式练习!好吧,有大佬可能在吐槽太简单了。 案例3:反转单链表。 反转单链表。例如链表为:1->2->3->4。反转后为 4->3->2->1 链表的节点定义如下:

代码语言:javascript
复制
class Node{
int date;
Node next;
}

1、定义递归函数功能 假设函数 reverseList(head) 的功能是反转但链表,其中 head 表示链表的头节点。代码如下:

代码语言:javascript
复制
Node reverseList(Node head){

}

2. 寻找结束条件 当链表只有一个节点,或者如果是空表的话,你应该知道结果吧?直接啥也不用干,直接把 head 返回呗。代码如下:

代码语言:javascript
复制
Node reverseList(Node head){
if(head == null || head.next == null){
    return head;
}
}

3. 寻找等价关系 这个的等价关系不像 n 是个数值那样,比较容易寻找。但是我告诉你,它的等价条件中,一定是范围不断在缩小,对于链表来说,就是链表的节点个数不断在变小,所以,如果你实在找不出,你就先对 reverseList(head.next) 递归走一遍,看看结果是咋样的。例如链表节点如下

在这里插入图片描述
在这里插入图片描述

我们就缩小范围,先对 2->3->4递归下试试,即代码如下

代码语言:javascript
复制
Node reverseList(Node head){
if(head == null || head.next == null){
    return head;
}
// 我们先把递归的结果保存起来,先不返回,因为我们还不清楚这样递归是对还是		错。,
Node newList = reverseList(head.next);
}

我们在第一步的时候,就已经定义了 reverseLis t函数的功能可以把一个单链表反转,所以,我们对 2->3->4反转之后的结果应该是这样:

在这里插入图片描述
在这里插入图片描述

我们把 2->3->4 递归成 4->3->2。不过,1 这个节点我们并没有去碰它,所以 1 的 next 节点仍然是连接这 2。接下来呢?该怎么办?其实,接下来就简单了,我们接下来只需要把节点 2 的 next 指向 1,然后把 1 的 next 指向 null,不就行了?,即通过改变 newList 链表之后的结果如下:

在这里插入图片描述
在这里插入图片描述

也就是说,reverseList(head) 等价于 reverseList(head.next) + 改变一下1,2两个节点的指向。好了,等价关系找出来了,代码如下(有详细的解释):

代码语言:javascript
复制
//用递归的方法反转链表
public static Node reverseList2(Node head){
// 1.递归结束条件
if (head == null || head.next == null) {
         return head;
     }
     // 递归反转 子链表
     Node newList = reverseList2(head.next);
     // 改变 1,2节点的指向。
     // 通过 head.next获取节点2
     Node t1  = head.next;
     // 让 2 的 next 指向 2
     t1.next = head;
     // 1 的 next 指向 null.
    head.next = null;
    // 把调整之后的链表返回。
    return newList;
}

这道题的第三步看的很懵?正常,因为你做的太少了,可能没有想到还可以这样,多练几道就可以了。但是,我希望通过这三道题,给了你以后用递归做题时的一些思路,你以后做题可以按照我这个模式去想。通过一篇文章是不可能掌握递归的,还得多练,我相信,只要你认真看我的这篇文章,多看几次,一定能找到一些思路!! 我已经强调了好多次,多练几道了,所以呢,后面我也会找大概 10 道递归的练习题供大家学习,不过,我找的可能会有一定的难度。不会像今天这样,比较简单,所以呢,初学者还得自己多去找题练练,相信我,掌握了递归,你的思维抽象能力会更强!接下来我讲讲有关递归的一些优化。

4.单向链表翻转总结 1)递归里变量名一致的确会使自己产生疑惑,比如此例中的Node newList =reverseList2(head.next);方法,虽然reverseList2方法定义入口为head名, 就拿最简单的1个节点,其直接返回head,但是我们要知道返回处,也即调用这个值的递归层数上其head却另有其人,返回节点只是用于赋值给newList

2)还有一个疑问:递归的确是将大问题分解为规模更小的相似问题,但是比如123 先简化为翻转23,得到 1 32 ;但是2的指针现在指向了null,我们如何让其指向1,难道一次次访问next遍历到末尾节点吗?;从代码结果上来看,已经是达到效果了,究其原因,发现1的指针仍然指向2,那么用1.next.next=1,1.next=null就能够实现重新指向。

3)我们虽然当节点1,2个的时候把问题归结得很简单,但是个数3往上时,问题实现的关键就是将其看作一个指向已经逆序的末尾节点的节点和已逆序链表即,如何将单个节点也加入到逆序节点的问题;递归问题的成功实现不仅仅在于将问题规模缩小到一眼看出,还要看到若增加一个新的规模元素,对于问题提出了哪些新的要求,是否可以进一步推广;此处将n个节点翻转的n维复杂度问题利用迭代化简为单个节点+已翻转链表的“2维问题”所以每次递归时用的语句是:Node newHead = reverse(head.next);当前节点不参与后续的翻转,意在规模缩小1,之后的链表也是如此看待其本身的逆序问题

4)链表中节点顺序的该变并不会导致原节点在内存空间地址的变更,改变的只是若干节点中next属性的取值而已,只是说这个属性是特殊的,存储着下一个节点的地址;这点在递归翻转单向链表的算法中有所体现:比如我们将原本1、2、3、4这样顺序存储的链表要翻转,首先简化为只逆转后3个元素,而头节点head不考虑进去,那么此操作的结果就是如下图,1不是指向4而是仍然指向2;直观上想1234就后3翻转不就是1432吗,的确地址连续确实这样,比如数组,但是链表靠的是next属性相互连接,而不是地址的连续性,所以1仍然指向2;这是非常特别的。

5)把案例三的程序分解为这三个部分:递归结束语句、递归体、递归体下面语句,好说明一下问题:你会不会有以下疑惑:在案例3中,由于程序编码的顺序执行,对于一次递归体的调用只有以下两种可能性:1)要么直接通过结束语句返回,2)要么执行下一个递归体;那么何时才能运行递归体下面的语句呢?实际上下面语句的执行是递归体返回时,将会顺序执行的,一次递归所要完成的任务不仅仅在于递归调用处有正确的值返回,还在于如何将此值用于下一次递归运算。除了n=1,2结束语句直接返回,其余要调用递归体的n取值大小不可避免地有返回时执行递归体下面的语句

在这里插入图片描述
在这里插入图片描述

有关递归的一些优化思路

1. 考虑是否重复计算

告诉你吧,如果你使用递归的时候不进行优化,是有非常非常非常多的子问题被重复计算的。 啥是子问题? f(n-1),f(n-2)…就是 f(n) 的子问题了。 例如对于案例2那道题,f(n) = f(n-1) + f(n-2)。递归调用的状态图如下:

在这里插入图片描述
在这里插入图片描述

看到没有,递归计算的时候,重复计算了两次 f(5),五次 f(4)。。。。这是非常恐怖的,n 越大,重复计算的就越多,所以我们必须进行优化。如何优化?一般我们可以把我们计算的结果保证起来(以空间换取时间),例如把 f(4) 的计算结果保证起来,当再次要计算 f(4) 的时候,我们先判断一下,之前是否计算过,如果计算过,直接把 f(4) 的结果取出来就可以了,没有计算过的话,再递归计算。 用什么保存呢?可以用数组或者 HashMap 保存,我们用数组来保存把,把 n 作为我们的数组下标,f(n) 作为值,例如 arr[n] = f(n)。f(n) 还没有计算过的时候,我们让 arr[n] 等于一个特殊值,例如 arr[n] = -1。当我们要判断的时候,如果 arr[n] = -1,则证明 f(n) 没有计算过,否则, f(n) 就已经计算过了,且 f(n) = arr[n]。直接把值取出来就行了。代码如下: // 我们实现假定 arr 数组已经初始化好的了。

代码语言:javascript
复制
int f(int n){
if(n <= 1){
    return n;
}
//先判断有没计算过
if(arr[n] != -1){
    //计算过,直接返回
    return arr[n];
}else{
    // 没有计算过,递归计算,并且把结果保存到 arr数组里
    arr[n] = f(n-1) + f(n-1);
    reutrn arr[n];
}
}

也就是说,使用递归的时候,必要 须要考虑有没有重复计算,如果重复计算了,一定要把计算过的状态保存起来。

2. 考虑是否可以自底向上

对于递归的问题,我们一般都是从上往下递归的,直到递归到最底,再一层一层着把值返回。不过,有时候当 n 比较大的时候,例如当 n = 10000 时,那么必须要往下递归10000层直到 n <=1 才将结果慢慢返回,如果n太大的话,可能栈空间会不够用。对于这种情况,其实我们是可以考虑自底向上的做法的。例如我知道f(1) = 1;f(2) = 2;那么我们就可以推出 f(3) = f(2) + f(1) = 3。从而可以推出f(4),f(5)等直到f(n)。因此,我们可以考虑使用自底向上的方法来取代递归,代码如下:

代码语言:javascript
复制
public int f(int n) {
   if(n <= 2)
       return n;
   int f1 = 1;
   int f2 = 2;
   int sum = 0;

   for (int i = 3; i <= n; i++) {
       sum = f1 + f2;
       f1 = f2;
       f2 = sum;
   }
   return sum;
   }

这种方法,其实也被称之为递推,或者是迭代不再是递归

最后总结

其实,递归不一定总是从上往下,也是有很多是从下往上的,例如 n = 1 开始,一直递归到 n = 1000,例如一些排序组合。对于这种从下往上的,也是有对应的优化技巧,不过,我就先不写了,后面再慢慢写。这篇文章写了很久了,脖子有点受不了了,,,,颈椎病?害怕。。。。说实话,对于递归这种比较抽象的思想,要把他讲明白,特别是讲给初学者听,还是挺难的,这也是我这篇文章用了很长时间的原因,不过,只要能让你们看完,有所收获,我觉得值得!

转载来源:

知乎:帅地

疑惑

1)递归结束的判断条件是否总是在于递归体前? 回答:本身这个问题的回答是出于对于递归问题不够了解而产生的,实际上告诉递归的一般样式,此问也就解开了。 递归函数的一般结构:

  • 1.小规模(可数且为常数)问题的方法结束调用返回
  • 2.递归体调用(比如:f(n)=f(n-1)+f(n-2))
  • 3.递归体返回值的处理(比如f(n-1)值算出来后返回给f(n)=f(n-1)+f(n-2))

2)递归的结束本身是由方法整个执行过一遍了,还是就是靠return语句提前结束方法的调用? 回答:这个问题的回答可以是这样的,如果递归函数需要返回一个数值或者对象,那么递归的深度决定了return的次数,return起到了提前结束方法的作用;另一方面,不是所有的函数都有返回值,递归函数也是可以没有返回值的脚本函数(其目的主要是在于逐行执行代码),比如以下代码:

代码语言:javascript
复制
public class PrintWithoutReturnStatement {
public static void main(String[] args) {
printDemo.print(3);
}
}
class printDemo{
public static void print(int n){
    if(n>=1){
        System.out.println(n);
        print(n-1);
    }
}
}

上述示例会正确输出3、2、1;如果交换System.out.println(n)print(n-1)的顺序则会输出1、2、3;可见,方法的调用结束也可以致使递归深度的减1

3)递归方法中的函数关系:是从n非常大的值处找关系的吗?其为普遍意义上的关系递归关系,而且一般是f(n)、f(n-1)、f(n-2)等的关系式? 回答:递归的实现需要一般的数学关系式加以支撑,此关系就如同高中学过的n项数列递推关系式那般,求法有一定难度。

4)要素三中关系的查找有时并不是意为步骤上的,比如翻转一个单向链表,我们一般会想最后一个节点调到头节点之后,最后一个节点就变最后第二个节点,在对其进行相同的操作,直至所有操作都完成;最终结束条件是,原本第一个节点到最后一个节点了;但是实际上**结束条件不是从时间运行顺序上看的,而是从最简单的结构所得的,比如一个长单向链表最简单就是无节点、或者是一个节点的情况。**一个走台阶问题最简单就是总共1个台阶让你走,或者总共2台阶问你走法。一个斐波那契数列最简单的情况就是求第1个和第二个。这不是看了递归解法之后,发现原来最先出结果(调用return)的是1,2对应的斐波那契数,于是我们就用方法返回时间线的角度去考虑问题。而是我们将一个大问题(普通的n),化简为最简单的1、2问题。

补充-递归在内存空间的栈实现

在这里插入图片描述
在这里插入图片描述

递归的实现可以看作是一次次的压栈操作,我们每调用一次递归体,比如print(n-1),就会一次一次地压栈。为何无return的递归语句能够实现,主要原因有在栈中递归体的执行顺序是先进后出原则,每次运行完一次递归体(比如n-2)之后,继续执行栈语句相当于进入下一个递归体(n-1)。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019年06月18日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 问题的提出:
  • 转载的声明:
  • 递归的三大要素
  • 案例1:斐波那契数列
  • 有关递归的一些优化思路
  • 最后总结
  • 转载来源:
  • 疑惑
  • 补充-递归在内存空间的栈实现
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档