当前位置:   article > 正文

【数据结构和算法8】递归的思想,爆栈和尾递归

【数据结构和算法8】递归的思想,爆栈和尾递归

目录

1、示例和原理

2、单路递归和多路递归

3、示例展示

4、爆栈和尾调用


1、示例和原理

计算机科学中,递归是一种解决计算问题的方法,其中解决方案取决于同一类问题的更小子集

比如单链表递归遍历的例子:

  1.  void f(Node node) {
  2.      if(node == null) {
  3.          return;
  4.     }
  5.      println("before:" + node.value)
  6.      f(node.next);
  7.      println("after:" + node.value)
  8.  }

说明:

  1. 自己调用自己,如果说每个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的(有规律的)

  2. 每次调用,函数处理的数据会较上次缩减(子集),而且最后会缩减至无需继续递归

  3. 内层函数调用(子集处理)完成,外层函数才能算调用完成

原理

假设链表中有 3 个节点,value 分别为 1,2,3,以上代码的执行流程就类似于下面的伪码

  1.  // 1 -> 2 -> 3 -> null f(1)
  2.  ​
  3.  void f(Node node = 1) {
  4.      println("before:" + node.value) // 1
  5.      void f(Node node = 2) {
  6.          println("before:" + node.value) // 2
  7.          void f(Node node = 3) {
  8.              println("before:" + node.value) // 3
  9.              void f(Node node = null) {
  10.                  if(node == null) {
  11.                      return;
  12.                 }
  13.             }
  14.              println("after:" + node.value) // 3
  15.         }
  16.          println("after:" + node.value) // 2
  17.     }
  18.      println("after:" + node.value) // 1
  19.  }

思路

  1. 确定能否使用递归求解

  2. 推导出递推关系,即父问题与子问题的关系,以及递归的结束条件

例如之前遍历链表的递推关系为

 

  • 深入到最里层叫做

  • 从最里层出来叫做

  • 的过程中,外层函数内的局部变量(以及方法参数)并未消失,的时候还可以用到

2、单路递归和多路递归

在使用递归的思想求解问题的过程中只有一条递归路线,所以都属于单路递归(例如求n的阶乘 n!);多路递归是指有多条递归路线(如斐波那契数列)

3、示例展示

3.1、使用递归的思想求解 n!

 ​
  1.      /**
  2.       * 使用递归求就一个数的阶乘
  3.       *
  4.       * @param n
  5.       * @return
  6.       */
  7.      public static int factorial(int n) {
  8.          if (n <= 1) {
  9.              return 1;
  10.         }
  11.          return n * factorial(n - 1);
  12.     }

3.2、使用递归的思想实现冒泡排序

  1.   /**
  2.       * 冒泡排序
  3.       *
  4.       * @param a
  5.       */
  6.      public static void bubbleSort(int[] a) {
  7.          bubble(a, a.length - 1);
  8.     }
  9.  ​
  10.  ​
  11.      /**
  12.       * 冒泡递归
  13.       *
  14.       * @param a
  15.       * @param j 表示未排序的右侧区域
  16.       */
  17.      private static void bubble(int[] a, int j) {
  18.          if (j == 0) {
  19.              return;
  20.         }
  21.          // x 表示未排序的和已排序的分界点
  22.          int x = 0;
  23.          //冒泡排序
  24.          for (int i = 0; i < j; i++) {
  25.              if (a[i] > a[i + 1]) {
  26.                  int t = a[i];
  27.                  a[i] = a[i + 1];
  28.                  a[i + 1] = t;
  29.                  x = i;
  30.             }
  31.         }
  32.          //递归遍历
  33.          bubble(a, x);
  34.     }

3.3、使用递归实现插入排序

  1.    /**
  2.       * 插入排序
  3.       * @param a 排序数组 a
  4.       */
  5.      public static void sort(int[] a) {
  6.          insert(a, 1);
  7.     }
  8.  ​
  9.      /**
  10.       * 插入排序的递归操作
  11.       *
  12.       * @param a
  13.       * @param low 无序和有序的分界点
  14.       */
  15.      public static void insert(int[] a, int low) {
  16.          if (low == a.length) {
  17.              return;
  18.         }
  19.          //插入操作
  20.          int t = a[low];
  21.          int i = low - 1;
  22.          // 从当前位置一直向前遍历,只有遇到了比当前值大的数据就进行替换
  23.          while (i >= 0 && a[i] > t) {
  24.              a[i + 1] = a[i];
  25.              i--;
  26.         }
  27.          a[i + 1] = t;
  28.          //递归插入
  29.          insert(a, low + 1);
  30.     }

4、爆栈和尾调用

爆栈

用递归做 n + (n-1) + (n-2) ... + 1

  1.  public static long sum(long n) {
  2.      if (n == 1) {
  3.          return 1;
  4.     }
  5.      return n + sum(n - 1);
  6.  }

在我的机器上 n = 12000 时,爆栈了

  1.  Exception in thread "main" java.lang.StackOverflowError
  2.   at Test.sum(Test.java:10)
  3.   at Test.sum(Test.java:10)
  4.   at Test.sum(Test.java:10)
  5.   at Test.sum(Test.java:10)
  6.   at Test.sum(Test.java:10)
  7.   ...

为什么呢?

  • 每次方法调用是需要消耗一定的栈内存的,这些内存用来存储方法参数、方法内局部变量、返回地址等等

  • 方法调用占用的内存需要等到方法结束时才会释放

  • 而递归调用我们之前讲过,不到最深不会回头,最内层方法没完成之前,外层方法都结束不了

    • 例如,sum(3) 这个方法内有个需要执行 3 + sum(2),sum(2) 没返回前,加号前面的 3 不能释放

    • 看下面伪码

  1.  long sum(long n = 3) {
  2.      return 3 + long sum(long n = 2) {
  3.          return 2 + long sum(long n = 1) {
  4.              return 1;
  5.         }
  6.     }
  7.  }

尾调用

使用尾调用的递归方法称为尾递归

如果函数的最后一步是调用一个函数,那么称为尾调用,例如

  1.  function a() {
  2.      return b()
  3.  }

下面三段代码不能叫做尾调用

  1.  function a() {
  2.      const c = b()
  3.      return c
  4.  }
  • 因为最后一步并非调用函数

  1.  function a() {
  2.      return b() + 1
  3.  }
  • 最后一步执行的是加法

  1.  function a(x) {
  2.      return b() + x
  3.  }
  • 最后一步执行的是加法

一些语言的编译器能够对尾调用做优化,例如

  1.  function a() {
  2.      // 做前面的事
  3.      return b()
  4.  }
  5.  ​
  6.  function b() {
  7.      // 做前面的事
  8.      return c()
  9.  }
  10.  ​
  11.  function c() {
  12.      return 1000
  13.  }
  14.  ​
  15.  a()

没优化之前的伪码

  1.  function a() {
  2.      return function b() {
  3.          return function c() {
  4.              return 1000
  5.         }
  6.     }
  7.  }

优化后伪码如下

  1.  a()
  2.  b()
  3.  c()

为何尾递归才能优化?

调用 a 时

  • a 返回时发现:没什么可留给 b 的,将来返回的结果 b 提供就可以了,用不着我 a 了,我的内存就可以释放

调用 b 时

  • b 返回时发现:没什么可留给 c 的,将来返回的结果 c 提供就可以了,用不着我 b 了,我的内存就可以释放

如果调用 a 时

  • 不是尾调用,例如 return b() + 1,那么 a 就不能提前结束,因为它还得利用 b 的结果做加法

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/116131
推荐阅读
相关标签
  

闽ICP备14008679号