【算法】递归

一、定义

递归是一种解决计算问题的方法,解决方案取决于同一类问题的更小子集。
特点:

  • 自己调用自己,如果说每个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的(有规律的)
  • 每次调用,函数处理的数据会较上次缩减(子集),而且最后会缩减至无需继续递
  • 内层函数调用(子集处理) 完成,外层函数才能算调用完成

单链表递归遍历:

    private static void loop(SingleNode node) {
        if (node == null) {
            return;
        }
        System.out.println("before:" + node.value);
        loop(node.next);
        System.out.println("after:" + node.value);
    }

    public static void main(String[] args) {
        SingleLinkedList.addLast(1);
        SingleLinkedList.addLast(2);
        SingleLinkedList.addLast(3);
        loop(findNode(0));
    }
before:1
before:2
before:3
after:3
after:2
after:1

以上递归代码写成伪代码如下:

    private static void loop(node == 1) {
        System.out.println("before:" + node.value);  // 1
        private static void loop(node == 2) {
            System.out.println("before:" + node.value);  // 2
            private static void loop(node == 3) {
                System.out.println("before:" + node.value);  // 3
                private static void loop(SingleNode node) {
                    if (node == null) {
                        return;
                    }
                }
                System.out.println("after:" + node.value);  // 3
            }
            System.out.println("after:" + node.value);  // 2
        }
        System.out.println("after:" + node.value);  // 1
    }

二、解题思路

  • 确定能否使用递归求解
  • 推导出递推关系,即父问题与子问题的关系,以及递归的结束条件

如遍历链表的递推关系:
f(n) = \begin{cases} 停止,\,\,n=null\\ f(n.next),\,\,x\ne null\\ \end{cases}

  • 深入到最里层叫递
  • 从最里层出来叫归
  • 在递的过程中,外层函数内的局部变量(以及方法参数)并未消失,归的时候还可以用到。

三、求阶乘

用递归方法求阶乘,

  • 阶乘的定义:n!=1·2·3···(n-2)·(n-1)·n,其中n为自然数,0!=1。
  • 递推关系:
    f(n) = \begin{cases} 1,\,\,n=1\\ n*f(n-1),\,\,n>1\\ \end{cases}
public class Factorial {

    public static int f(int n) {
        if (n == 1) {
            return 1;
        }
        return n * f(n - 1);
    }

    public static void main(String[] args) {
        int factorial = f(3);
        System.out.println(factorial);
    }
}

伪代码如下:

f(int n=3){
  return 3*f(int n=2){
    return 2*f(int n=1){
      if(n == 1){
        return 1;
      }
    }
 }

四、反向打印字符串

用递归反向打印字符串,n为字符在整个字符串 str 中的索引位置

  • 递:n从0开始,每次n+1,一直递到n==str.length()-1
  • 归:从n== str.length()开始归,从归打印,自然是逆序的

递推关系
f(n) = \begin{cases} 停止,\,\,n=str.length\\ f(n+1),\,\,0 \leq n \leq str.length-1\\ \end{cases}

    public static void reversePrint(String str,int index) {
        if (index == str.length()) {
            return;
        }
        reversePrint(str, index + 1);
        System.out.println(str.charAt(index));
    }
    public static void main(String[] args) {
        reversePrint("abcd",0);
    }

伪代码:

    reversePrint(String str,0) {
        reversePrint(str, 1) {
            reversePrint(str, 2) {
                reversePrint(str, 3) {
                    reversePrint(str, 4) {
                        if (3 == str.length()) {
                            return;
                        }
                    }
                    System.out.println(str.charAt(3));
                }
                System.out.println(str.charAt(2));
            }
            System.out.println(str.charAt(1));
        }
        System.out.println(str.charAt(0));
    }

五、使用递归实现冒泡排序

1.冒泡排序基本思想:

通过对待排序序列从前向后(从下标较小的元素开始),依次对相邻两个元素的值进行两两比较,若发现逆序则交换,使值较大的元素逐渐从前移向后移,就如果水底下的气泡一样逐渐向上冒。

2.步骤

待排序数组:[8,5,1,2,0]

第一轮排序(索引0~4中):[8,5,1,2,0]

  • 索引0和索引1比较,前者大,交换:[5,8,1,2,0]
  • 索引1和索引2比较,前者大,交换:[5,1,8,2,0]
  • 索引2和索引3比较,前者大,交换:[5,1,2,8,0]
  • 索引3和索引4比较,前者大,交换:[5,1,2,0,8]
    这一轮结束 ,最大的元素已经排在了最后

第二轮排序(索引0~3中):[5,1,2,0,8]

  • 索引0和索引1比较,前者大,交换:[1,5,2,0,8]
  • 索引1和索引2比较,前者大,交换:[1,2,5,0,8]
  • 索引2和索引3比较,前者大,交换:[1,2,0,5,8]
    这一轮结束 ,倒数第二大的的元素已经排在倒数第二个位置

第三轮排序(索引0~2中):[1,2,0,5,8]

  • 索引0和索引1比较,后者大,不交换:[1,2,0,5,8]
  • 索引1和索引2比较,前者大,交换:[1,0,2,5,8]
    这一轮结束 ,倒数第三大的的元素已经排在倒数第三个位置

第四轮排序(索引0~1中):[1,0,2,5,8]

  • 索引0和索引1比较,前者大,交换:[0,1,2,5,8]
    这一轮结束 ,倒数第四大的的元素已经排在倒数第四个位置
    一共5个元素,排序结束。

第一层for循环控制总轮次,第二层for循环控制交换的次数,因为每一轮都有一个大的排好了序在后面,所以内存循环的次数是随着每一轮排好的元素逐渐递减的。

迭代实现:

    public static void bubbleSort(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            for (int j = 0; j < arr.length - 1-i; j++) {
                if (arr[j] > arr[j + 1]) {
                    swap(arr, j, j + 1);
                }
            }
        }
    }
  
    public static void swap(int[] arr,int a ,int b){
        int temp = arr[a];
        arr[a] = arr[b];
        arr[b] = temp;
    }

递归实现:

    /**
     * 递归冒泡
     * @param arr
     * @param right
     */
    public static void bubbleRecursion(int[] arr,int right) {
        if (right == 0) {
            return;
        }
        for (int i = 0; i < right; i++) {
            if (arr[i] > arr[i + 1]) {
                swap(arr, i, i + 1);
            }
        }
        bubbleRecursion(arr, right - 1);
    }

递归优化:在冒泡的过程中,如果两个相邻元素发生了交换,意味着有无序的情况,如果没发生交换,意味着有序;所以最后一次发生交换的时候,意味着后续的都是有序的了。所以最后一次交换,i索引的位置就是有序和无序的分界点,i的右侧一定都是有序的了,i的左侧可能还是无序的。使用一个标志位记录下这个位置,下一趟递归,直接从这个标志位开始,就可以减少无效的递归次数了(上面的写法是每次都减少1),现在缩减成直接从未排序的标志点开始递归(缩减的可能比1要多,所以可以减少递归的次数)。

    /**
     * 递归冒泡优化
     * @param arr
     * @param right
     */
    public static void bubbleRecursion1(int[] arr,int right) {
        if (right == 0) {
            return;
        }
        int flag = 0;
        for (int i = 0; i < right; i++) {
            if (arr[i] > arr[i + 1]) {
                swap(arr, i, i + 1);
                flag = i;
            }
        }
        bubbleRecursion1(arr, flag);
    }

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

1.插入排序基本思想

通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

2.步骤

将第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面)

待排序数组:[8,5,1,2,0]

  • 取出无序部分[1~4]的首个元素5,在有序部分从后向前比较,插入到合适的位置:[5,8,1,2,0]
  • 取出无序部分[2~4]的首个元素1,在有序部分从后向前比较,插入到合适的位置:[1,5,8,2,0]
  • 取出无序部分[3~4]的首个元素2,在有序部分从后向前比较,插入到合适的位置:[1,2,5,8,0]
  • 取出无序部分[3~4]的首个元素8,在有序部分从后向前比较,插入到合适的位置:[1,2,5,8,0]
  • 取出无序部分[4]的首个元素0,在有序部分从后向前比较,插入到合适的位置:[0,1,2,5,8]

迭代实现:

    public static void insert(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            //要插入的元素
            int current = arr[i];
            //要比较的索引,有序部分从后向前比较
            int j = i - 1;
            //寻找比当前元素小的元素的位置
            while (j >= 0 && arr[j] > current) {
                //依次空出一个位置
                arr[j + 1] = arr[j];
                j--;
            }
            //要插入的位置
            arr[j + 1] = current;
        }
    }

递归实现:

    /**
     * 递归插入排序
     * @param arr
     * @param insertEleIndex 要开始准备插入的元素的索引,未排序区域的左边界
     */
    public static void insertRecursion(int[] arr,int insertEleIndex) {
        if (insertEleIndex == arr.length) {
            return;
        }
        //要插入的元素
        int current = arr[insertEleIndex];
        //要比较的索引
        int j = insertEleIndex - 1;
        //寻找比当前元素小的元素的位置
        while (j >= 0 && arr[j] > current) {
            //依次空出一个位置
            arr[j + 1] = arr[j];
            j--;
        }
        //要插入的位置
        arr[j + 1] = current;
        insertRecursion(arr, insertEleIndex + 1);
    }

七、Leetcode509.斐波那契数

  • 每个递归函数只包含一个自身调用,称为单路递归 single recursion
  • 每个递归包含多个自身调用,称为多路递归 multi recursion

递推关系:
f(n) = \begin{cases} 0,\,\,n=0\\ 1,\,\,n=1\\ f(n-1)+f(n-2),\,\,n>1\\ \end{cases}
数列的前几项:

F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12
0 1 1 2 3 5 8 13 21 34 55 89 144

迭代方式:

    public static int fib(int n) {
        if (n <= 1) {
            return n;
        }
        int[] arr = new int[n + 1];
        arr[0] = 0;
        arr[1] = 1;
        for (int i = 2; i <= n; i++) {
            arr[i] = arr[i - 1] + arr[i - 2];
        }
        return arr[n];
    }

递归方式:

    public static int f(int n) {
        if (n <= 1) {
            return n;
        }
        return f(n - 1) + f(n - 2);
    }

递归过程:


递归过程.gif
  • 绿色代表正在执行(对应递),灰色代表执行结束(对应归)
  • 递不到头,不能归,对应深度优先搜索

八、兔子问题

古典问题:3个月起每个月都生一对兔子,小兔子长到第三个月后每个月又生一对兔子,假如兔子都不死,求第n个月的兔子数

设第 n 个月兔子数为 f(n)

  • f(n) = 上个月兔子数 + 新生的小兔子数
  • 而【新生的小兔子数】实际就是【上个月成熟的兔子数】
  • 因为需要一个月兔子就成熟,所以【上个月成熟的兔子数】也就是【上上个月的兔子数】
  • 上个月兔子数,即 f(n-1)
  • 上上个月的兔子数,即 f(n-2)

因此本质还是斐波那契数列

代码实现:

    public static int sumRabbit(int month) {
        if (month <= 2) {
            return 1;
        }
        return sumRabbit(month - 1) + sumRabbit(month - 1);
    }

九、Leetcode70.爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

分析

n 跳法 规律
1 (1) 暂时看不出
2 (1,1) (2) 暂时看不出
3 (1,1,1) (1,2) (2,1) 暂时看不出
4 (1,1,1,1) (1,2,1) (2,1,1)
(1,1,2) (2,2)
最后一跳,跳一个台阶的,基于f(3)
最后一跳,跳两个台阶的,基于f(2)
5 ... ...

因此本质上还是斐波那契数列,只是从其第二项开始

    public int climbStairs(int n) {
        if (n <= 2) {
            return n;
        }
        return climbStairs(n - 2) + climbStairs(n - 1);
    }

此解法在力扣上超出时间限制

使用迭代:

    public int climbStairs(int n) {
        if (n <= 2) {
            return n;
        }
        int[] arr = new int[n + 1];
        arr[0] = 0;
        arr[1] = 1;
        arr[2] = 2;
        for (int i = 3; i <= n; i++) {
            arr[i] = arr[i - 1] + arr[i - 2];
        }
        return arr[n];
    }

十、使用记忆法(备忘录)优化递归

在之前的递归过程中,存在很多重复的计算,为了减少这些重复的计算,可以把已经计算好的结果暂存起来,用到的时候直接取。

    public static int fibonacciImprove(int n) {
        int[] cache = new int[n + 1];
        for (int i = 0; i < n + 1; i++) {
            cache[i] = -1;
        }
        cache[0] = 0;
        cache[1] = 1;
        return fibonacciRecursion(n, cache);
    }

    public static int fibonacciRecursion(int n, int[] cache) {
        //用数组缓存已经计算好的结果
        if (cache[n] != -1) {
            return cache[n];
        }
        int lastOne = fibonacciRecursion(n - 1, cache);
        int lastTwo = fibonacciRecursion(n - 2, cache);
        cache[n] = lastOne + lastTwo;
        return cache[n];
    }

十一、爆栈问题

1.问题现象

    public static void main(String[] args) {
        long sum = sum(15000);
        System.out.println(sum);
    }
    
    public static long sum(long n) {
        if (n == 1) {
            return n;
        }
        return sum(n - 1) + n;
    }
Exception in thread "main" java.lang.StackOverflowError
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)
    at com.hcx.algorithm.recursion.RecursionSum.sum(RecursionSum.java:24)

递是压栈,归是弹栈:
在递的过程中,必须递到最里层,即n=1的时候,才开始归,即开始弹栈;每个方法的调用都要存储方法相关的信息,如参数,返回地址等,当调用的过程很深的时候,占用的内存逐渐增加,并且都没有释放,因为还没有开始归,只有拿到了返回值,对应的方法内存才释放,所以会导致栈内存占用太高,栈内存耗尽,抛出栈溢出错误。

2.尾递归与尾调用

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

function(){
  return a()
}

反例:以下不是尾调用

function(){
  int result = a();
  return result; // 不是调用函数
}
function(){
  return a()+1; // 虽然调用了,但是又用到了外层函数的数值1,最后一步实际是加法操作,不是函数调用
}
function(){
  return a()+b; // 虽然调用了,但是又用到了外层函数的变量b,最后一步是加法操作
}

编译器的优化
一些语言的编译器能对尾调用做优化:

a(){
  return b();
}

b(){
  return c();
}

c(){
  return 100;
}

a();

没优化:最内层的c没有返回之前,a和b都不能结束。

a(){
  b(){
    c(){
      return 100;
    }
  }
}

优化后:平级调用,a调用完,不需要等待b返回,直接释放内存。

a();
b();
c();

因为尾调用的操作,自己要做的事情已经全部执行完了,只需要等待调用方的结果就可以了,所以可以把自己的内存释放掉了。上面的例子中,return a()+1;这种情况,当拿到了a的返回值,函数本身还要拿着结果去执行+1的操作,所以不能提前结束。

尾递归:最后调用的函数是自己。

支持尾递归和尾调用优化的语言有scala和C++

3.使用尾递归避免爆栈问题

import scala.annotation.tailrec

object Main {
  def main(args: Array[String]): Unit = {
    println(sum(10000000, 0)) 
  }

  @tailrec
  def sum(n: Long, accumulator: Long): Long = {
    if (n == 1) {
      return 1 + accumulator
    }
    return sum(n - 1, n + accumulator)
  }

调用过程:

  sum(n=3, accumulator=0): Long = {
    return sum(2, 3)
  }
  sum(n=2, accumulator=3): Long = {
    return sum(1, 5)
  }
  sum(n=1, accumulator=5): Long = {
    if (n == 1) {
      return 1 + 5
    }
  }

4.使用迭代避免爆栈问题

    public static long sum(long n){
        long sum = 0;
        for (long i = 0; i <= n; i++) {
            sum = sum + i;
        }
        return sum;
    }

理论上所有的递归都能使用迭代写出来

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容