java-数据结构与算法-02-数据结构-03-递归

news2024/9/22 12:32:19

1. 概述

定义

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

In computer science, recursion is a method of solving a computational
problem where the solution depends on solutions to smaller instances
of the same problem.

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

void f(Node node) {
    if(node == null) {
        return;
    }
    println("before:" + node.value)
    f(node.next);
    println("after:" + node.value)
}

说明:

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

原理

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

// 1 -> 2 -> 3 -> null  f(1)

void f(Node node = 1) {
    println("before:" + node.value) // 1
    void f(Node node = 2) {
        println("before:" + node.value) // 2
        void f(Node node = 3) {
            println("before:" + node.value) // 3
            void f(Node node = null) {
                if(node == null) {
                    return;
                }
            }
            println("after:" + node.value) // 3
        }
        println("after:" + node.value) // 2
    }
    println("after:" + node.value) // 1
}

思路

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

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

f ( n ) = { 停止 n = n u l l f ( n . n e x t ) n ≠ n u l l f(n) = \begin{cases} 停止& n = null \\ f(n.next) & n \neq null \end{cases} f(n)={停止f(n.next)n=nulln=null

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

2. 单路递归 Single Recursion

E01. 阶乘

用递归方法求阶乘

● 阶乘的定义 n ! = 1 ⋅ 2 ⋅ 3 ⋯ ( n − 2 ) ⋅ ( n − 1 ) ⋅ n n!= 1⋅2⋅3⋯(n-2)⋅(n-1)⋅n n!=123(n2)(n1)n,其中 n n n 为自然数,当然 0 ! = 1 0! = 1 0!=1
● 递推关系

f ( n ) = { 1 n = 1 n ∗ f ( n − 1 ) n > 1 f(n) = \begin{cases} 1 & n = 1\\ n * f(n-1) & n > 1 \end{cases} f(n)={1nf(n1)n=1n>1

代码

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

拆解伪码如下,假设 n 初始值为 3

f(int n = 3) { // 解决不了,递
    return 3 * f(int n = 2) { // 解决不了,继续递
        return 2 * f(int n = 1) {
            if (n == 1) { // 可以解决, 开始归
                return 1;
            }
        }
    }
}

E02. 反向打印字符串

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

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

递推关系
f ( n ) = { 停止 n = s t r . l e n g t h ( ) f ( n + 1 ) 0 ≤ n ≤ s t r . l e n g t h ( ) − 1 f(n) = \begin{cases} 停止 & n = str.length() \\ f(n+1) & 0 \leq n \leq str.length() - 1 \end{cases} f(n)={停止f(n+1)n=str.length()0nstr.length()1

代码为

public static void reversePrint(String str, int index) {
    if (index == str.length()) {
        return;
    }
    reversePrint(str, index + 1);
    System.out.println(str.charAt(index));
}

拆解伪码如下,假设字符串为 “abc”

void reversePrint(String str, int index = 0) {
    void reversePrint(String str, int index = 1) {
        void reversePrint(String str, int index = 2) {
            void reversePrint(String str, int index = 3) { 
                if (index == str.length()) {
                    return; // 开始归
                }
            }
            System.out.println(str.charAt(index)); // 打印 c
        }
        System.out.println(str.charAt(index)); // 打印 b
    }
    System.out.println(str.charAt(index)); // 打印 a
}

E03. 二分查找(单路递归)

public static int binarySearch(int[] a, int target) {
    return recursion(a, target, 0, a.length - 1);
}

public static int recursion(int[] a, int target, int i, int j) {
    if (i > j) {
        return -1;
    }
    int m = (i + j) >>> 1;
    if (target < a[m]) {
        return recursion(a, target, i, m - 1);
    } else if (a[m] < target) {
        return recursion(a, target, m + 1, j);
    } else {
        return m;
    }
}

E04. 冒泡排序(单路递归)

public static void main(String[] args) {
    int[] a = {3, 2, 6, 1, 5, 4, 7};
    bubble(a, 0, a.length - 1);
    System.out.println(Arrays.toString(a));
}

private static void bubble(int[] a, int low, int high) {
    if(low == high) {
        return;
    }
    int j = low;
    for (int i = low; i < high; i++) {
        if (a[i] > a[i + 1]) {
            swap(a, i, i + 1);
            j = i;
        }
    }
    bubble(a, low, j);
}

private static void swap(int[] a, int i, int j) {
    int t = a[i];
    a[i] = a[j];
    a[j] = t;
}
  • low 与 high 为未排序范围
  • j 表示的是未排序的边界,下一次递归时的 high
    • 发生交换,意味着有无序情况
    • 最后一次交换(以后没有无序)时,左侧 i 仍是无序,右侧 i+1 已然有序

E05. 插入排序(单路递归)

先复习下插入排序:
假设前面 n-1(其中 n>=2)个数已经是排好顺序的,现将第 n 个数插到前面已经排好的序列中,然后找到合适自己的位置,使得插入第n个数的这个序列也是排好顺序的。

按照此法对所有元素进行插入,直到整个序列排为有序的过程,称为插入排序。

从小到大的插入排序整个过程如图示:

第一轮:从第二位置的 6 开始比较,比前面 7 小,交换位置。

在这里插入图片描述
第二轮:第三位置的 9 比前一位置的 7 大,无需交换位置。

在这里插入图片描述
第三轮:第四位置的 3 比前一位置的 9 小交换位置,依次往前比较。
在这里插入图片描述
就这样依次比较到最后一个元素。

public static void main(String[] args) {
    int[] a = {3, 2, 6, 1, 5, 7, 4};
    insertion(a, 1, a.length - 1);
    System.out.println(Arrays.toString(a));
}

    /**
     * 使用插入排序算法对数组进行排序。
     * 该方法通过递归方式逐步将未排序的元素插入到已排序的序列中,直到所有元素都被排序。
     * 
     * @param a 待排序的数组。
     * @param low 当前需要排序的起始位置。
     */
    private static void insertion(int[] a, int low) {
        // 如果当前位置等于数组长度,说明所有元素都已经排序完成,递归结束。
        if (low == a.length) {
            return;
        }

        // 将当前需要排序的元素暂存到变量t中。
        int t = a[low];
        // 初始化已排序区域的指针i为当前需要排序位置的前一个位置。
        int i = low - 1; // 已排序区域指针

        // 向前移动已排序区域的指针i,直到找到t应该插入的位置。
        while (i >= 0 && t < a[i]) { // 没有找到插入位置
            // 将已排序区域的元素向后移动一位,为空出插入位置。
            a[i + 1] = a[i]; // 空出插入位置
            i--;
        }

        // 如果t的插入位置不是low位置,将t插入到正确的位置。
        // 找到插入位置
        if (i + 1 != low) {
            a[i + 1] = t;
        }

        // 递归调用插入排序函数,对下一个元素进行排序。
        insertion(a, low + 1);
    }
  • 已排序区域:[0 … i … low-1]
  • 未排序区域:[low … high]
  • 只考虑 low 边界的情况,参考以上代码,理解 low-1 … high 范围内的处理方法
  • 扩展:利用二分查找 leftmost 版本,改进寻找插入位置的代码

E06. 约瑟夫问题(单路递归)

n n n 个人排成圆圈,从头开始报数,每次数到第 m m m 个人( m m m 1 1 1 开始)杀之,继续从下一个人重复以上过程,求最后活下来的人是谁?

例如N=6,M=5,被杀掉的顺序是:5,4,6,2,3,1。
如图:
在这里插入图片描述
递归公式

f(n,m)=(f(n-1,m)+m-1)%n+1
f(n,m)指n个人,报第m个编号淘汰最终编号

推导过程:

当n=1时,最后一个淘汰也是第一个淘汰的为编号1

当n>1时,如下图所示

在这里插入图片描述
可以看出,设原编号为i,新编号为j,则可得到

i = (j + m - 1) % n + 1

最终递推式

f ( n , m ) = { ( f ( n − 1 , m ) + m ) % n n > 1 0 n = 1 f(n,m) = \begin{cases} (f(n-1,m) + m) \% n & n>1\\ 0 & n = 1 \end{cases} f(n,m)={(f(n1,m)+m)%n0n>1n=1

这样,我们就不用模拟操作,可以直接从数值的关系找到递推的关系,可以轻轻松松的写下代码:

import java.util.*;

public class Joseph {
    public int getResult(int n, int m) {
        if (n == 1) {
            return 1; 
        }
        return (getResult(n-1, m) + m - 1) % n + 1;
    }
}

迭代方式如下

public class Joseph {
    public int getResult(int n, int m) {
        int value = 1;
        for (int i = 1; i <= n; i++) {
            value = (value + m - 1) % i + 1;
        }
        return value;
    }
}

3. 多路递归 Multi Recursion

E01. 斐波那契数列-Leetcode 70

● 之前的例子是每个递归函数只包含一个自身的调用,这称之为 single recursion
● 如果每个递归函数例包含多个自身调用,称之为 multi recursion

🤔简单的介绍以下斐波那契数列是什么

💥斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardo
Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(0)=0,F(1)=1,
F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*).

斐波那契数列指的是这样一个数列:0,1,1,2,3,5,8,13,21,34,55,89…

这个数列从第3项开始,每一项都等于前两项之和。

了解之后,接下来来分析如何通过递归实现一个斐波那契数列。

递推关系

f ( n ) = { 0 n = 0 1 n = 1 f ( n − 1 ) + f ( n − 2 ) n > 1 f(n) = \begin{cases} 0 & n=0 \\ 1 & n=1 \\ f(n-1) + f(n-2) & n>1 \end{cases} f(n)= 01f(n1)+f(n2)n=0n=1n>1

下面的表格列出了数列的前几项

F0F1F2F3F4F5F6F7F8F9F10F11F12F13
01123581321345589144233

接下来实现一个通过我们输入一个数列数,来判断该数具体的数值为多少?比如输入第8项,会输出对应斐波那契数列中的13.斐波那契数列中依次是从左到右,由第一项开始。

实现思路:

    (一)首先我们得先找一下想要让此递归方法终止的条件是什么,再着手于每一次调用的变化。

    (二)斐波那契数列的第一项和第二项分别是0和1,而往后的第三项,第四项...都是其前两项的和,往往终止条件即是起始条件,我们可以把第一项和第二项视为终止条件,可以把往后的第N项拆分成若干个第一项和第二项相加。

    (三)以我们输入的第n项,进行第一次调用时判断是否满足终止条件,如不满足则进行“递”的过程,将第n项的前一项和前两项再进行传递,依次判断,到最后n为第一项或第二项时,进行“归”的过程,依次相加得出最后的结果。

在这里插入图片描述

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

执行流程

在这里插入图片描述

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

时间复杂度

  • 递归的次数也符合斐波那契规律, 2 ∗ f ( n + 1 ) − 1 2 * f(n+1)-1 2f(n+1)1
  • 时间复杂度推导过程
    • 斐波那契通项公式 f ( n ) = 1 5 ∗ ( 1 + 5 2 n − 1 − 5 2 n ) f(n) = \frac{1}{\sqrt{5}}*({\frac{1+\sqrt{5}}{2}}^n - {\frac{1-\sqrt{5}}{2}}^n) f(n)=5 1(21+5 n215 n)
    • 简化为: f ( n ) = 1 2.236 ∗ ( 1.618 n − ( − 0.618 ) n ) f(n) = \frac{1}{2.236}*({1.618}^n - {(-0.618)}^n) f(n)=2.2361(1.618n(0.618)n)
    • 带入递归次数公式 2 ∗ 1 2.236 ∗ ( 1.618 n + 1 − ( − 0.618 ) n + 1 ) − 1 2*\frac{1}{2.236}*({1.618}^{n+1} - {(-0.618)}^{n+1})-1 22.2361(1.618n+1(0.618)n+1)1
    • 时间复杂度为 Θ ( 1.61 8 n ) \Theta(1.618^n) Θ(1.618n)

E02. 汉诺塔(多路递归)

Tower of Hanoi,是一个源于印度古老传说:大梵天创建世界时做了三根金刚石柱,在一根柱子从下往上按大小顺序摞着 64 片黄金圆盘,大梵天命令婆罗门把圆盘重新摆放在另一根柱子上,并且规定
● 一次只能移动一个圆盘
● 小圆盘上不能放大圆盘

下面的动图演示了4片圆盘的移动方法
在这里插入图片描述

使用程序代码模拟圆盘的移动过程,并估算出时间复杂度

思路

● 假设每根柱子标号 a,b,c,每个圆盘用 1,2,3 … 表示其大小,圆盘初始在 a,要移动到的目标是 c
● 如果只有一个圆盘,此时是最小问题,可以直接求解
○ 移动圆盘1 a ↦ c a \mapsto c ac
在这里插入图片描述

  • 如果有两个圆盘,那么
    • 圆盘1 a ↦ b a \mapsto b ab
    • 圆盘2 a ↦ c a \mapsto c ac
    • 圆盘1 b ↦ c b \mapsto c bc

在这里插入图片描述

  • 如果有三个圆盘,那么
    • 圆盘12 a ↦ b a \mapsto b ab
    • 圆盘3 a ↦ c a \mapsto c ac
    • 圆盘12 b ↦ c b \mapsto c bc
      在这里插入图片描述
  • 如果有四个圆盘,那么
    • 圆盘 123 a ↦ b a \mapsto b ab
    • 圆盘4 a ↦ c a \mapsto c ac
    • 圆盘 123 b ↦ c b \mapsto c bc

在这里插入图片描述
综上我们可以将问题分解为以下三个步骤:

  • 将A柱上的n-1个盘子移动到B柱上
  • 将A柱上剩下的一个盘子移动到C柱上。
  • 将B柱上的n-1个盘子移动到C柱上。
    通过递归地执行这三个步骤,我们最终可以实现将所有盘子从A柱移动到C柱的目标。

【注意事项】

  • 递归的终止条件:当只有一个盘子时,可以直接将其从A柱移动到C柱,此时递归终止。
  • 递归的分解:将问题分解为三个步骤,每次递归调用都是为了完成这三个步骤中的一个。
    +递归的回溯:在完成一个递归调用后,需要将问题状态恢复到递归调用前的状态,以便进行下一个递归调用。
  • 递归的效率:汉诺塔问题的递归解法时间复杂度为O(2^n),其中n表示盘子的数量。因此,当盘子数量较大时,递归解法的时间复杂度会非常高。

解题

public class E02HanoiTower {


    /*
             源 借 目
        h(4, a, b, c) -> h(3, a, c, b)
                         a -> c
                         h(3, b, a, c)
     */
    static LinkedList<Integer> a = new LinkedList<>();
    static LinkedList<Integer> b = new LinkedList<>();
    static LinkedList<Integer> c = new LinkedList<>();

    static void init(int n) {
        for (int i = n; i >= 1; i--) {
            a.add(i);
        }
    }


    /**
     * <h3>移动圆盘</h3>
     *
     * @param n 圆盘个数
     * @param a 由
     * @param b 借
     * @param c 至
     */
    static void move(int n, LinkedList<Integer> a,
                     LinkedList<Integer> b,
                     LinkedList<Integer> c) {
        if (n == 0) {
            return;
        }
        move(n - 1, a, c, b);   // 把 n-1 个盘子由a,借c,移至b
        c.addLast(a.removeLast()); // 把最后的盘子由 a 移至 c
//        print();
        move(n - 1, b, a, c);   // 把 n-1 个盘子由b,借a,移至c
    }

    private static void print() {
        System.out.println("-----------------------");
        System.out.println(a);
        System.out.println(b);
        System.out.println(c);
    }

    public static void main(String[] args) {
        init(3);
        print();
        move(3, a, b, c);
    }
}

E03. 杨辉三角

在这里插入图片描述

杨辉三角形的特点:

  • 第 n 行有 n 个数字;
  • 每一行的开始和结尾数字都为 1;
  • 从第 3 行起,除去每一行的开始和结尾数字,其余每个数都满足以下条件:
    • 任意一个数等于上一行同列和上一行前一列的和,

如以下杨辉三角形中第 3 行第 2 列中的 2 等于它上一行同列(第 2 行第 2 列中的 1)和上一行前一列(第 2 行第 1 列中的 1)的和。

        1
      1   1
    1   2   1
  1   3   3   1
1   4   6   4   1

● 行 i i i,列 j j j,那么 [ i ] [ j ] [i][j] [i][j] 的取值应为 [ i − 1 ] [ j − 1 ] + [ i − 1 ] [ j ] [i-1][j-1] + [i-1][j] [i1][j1]+[i1][j]
● 当 j = 0 j=0 j=0 i = j i=j i=j 时, [ i ] [ j ] [i][j] [i][j] 取值为 1 1 1

题解

public static void print(int n) {
    for (int i = 0; i < n; i++) {
        if (i < n - 1) {
            System.out.printf("%" + 2 * (n - 1 - i) + "s", " ");
        }

        for (int j = 0; j < i + 1; j++) {
            System.out.printf("%-4d", element(i, j));
        }
        System.out.println();
    }
}

public static int element(int i, int j) {
    if (j == 0 || i == j) {
        return 1;
    }
    return element(i - 1, j - 1) + element(i - 1, j);
}

优化1

是 multiple recursion,因此很多递归调用是重复的,例如

  • recursion(3, 1) 分解为
    • recursion(2, 0) + recursion(2, 1)
  • 而 recursion(3, 2) 分解为
    • recursion(2, 1) + recursion(2, 2)

这里 recursion(2, 1) 就重复调用了,事实上它会重复很多次,可以用 static AtomicInteger counter = new AtomicInteger(0) 来查看递归函数的调用总次数

事实上,可以用memoization来进行优化:

public static void print1(int n) {
    int[][] triangle = new int[n][];
    for (int i = 0; i < n; i++) {
        // 打印空格
        triangle[i] = new int[i + 1];
        for (int j = 0; j <= i; j++) {
            System.out.printf("%-4d", element1(triangle, i, j));
        }
        System.out.println();
    }
}

    /**
     * 使用动态规划计算三角形中从顶部到指定位置的路径数。
     * 动态规划的思想是利用已经计算过的结果来推导当前结果,避免重复计算。
     * 此方法采用二维数组来存储中间结果,以提高计算效率。
     * <h3>优化1 - 使用二维数组记忆法</h3>
     *
     * @param triangle 二维数组
     * @param i        行坐标
     * @param j        列坐标
     * @return 该坐标元素值
     */
    private static int element1(int[][] triangle, int i, int j) {
        // 检查当前位置的值是否已经计算过,如果大于0则直接返回该值。
        if (triangle[i][j] > 0) {
            return triangle[i][j];
        }

        // 处理边界情况:当列索引为0或与行索引相等时,只有一条路径可达,直接返回1。
        if (j == 0 || i == j) {
            triangle[i][j] = 1;
            return 1;
        }
        // 计算当前位置的路径数,它等于上方和左上方两个位置的路径数之和。
        triangle[i][j] = element1(triangle, i - 1, j - 1) + element1(triangle, i - 1, j);
        return triangle[i][j];
    }

优化2

public static void print2(int n) {
    int[] row = new int[n];
    for (int i = 0; i < n; i++) {
        // 打印空格
        createRow(row, i);
        for (int j = 0; j <= i; j++) {
            System.out.printf("%-4d", row[j]);
        }
        System.out.println();
    }
}

private static void createRow(int[] row, int i) {
    if (i == 0) {
        row[0] = 1;
        return;
    }
    for (int j = i; j > 0; j--) {
        row[j] = row[j - 1] + row[j];
    }
}

注意:还可以通过每一行的前一项计算出下一项,不必借助上一行,这与杨辉三角的另一个特性有关,暂不展开了

4. 递归优化-记忆法

上述代码存在很多重复的计算,例如求 f ( 5 ) f(5) f(5) 递归分解过程
在这里插入图片描述
可以看到(颜色相同的是重复的):

  • f ( 3 ) f(3) f(3) 重复了 2 次
  • f ( 2 ) f(2) f(2) 重复了 3 次
  • f ( 1 ) f(1) f(1) 重复了 5 次
  • f ( 0 ) f(0) f(0) 重复了 3 次

随着 n n n 的增大,重复次数非常可观,如何优化呢?

Memoization 记忆法(也称备忘录)是一种优化技术,通过存储函数调用结果(通常比较昂贵),当再次出现相同的输入(子问题)时,就能实现加速效果,改进后的代码

public static void main(String[] args) {
    int n = 13;
    int[] cache = new int[n + 1];
    Arrays.fill(cache, -1);
    cache[0] = 0;
    cache[1] = 1;
    System.out.println(f(cache, n));
}

public static int f(int[] cache, int n) {
    if (cache[n] != -1) {
        return cache[n];
    }

    cache[n] = f(cache, n - 1) + f(cache, n - 2);
    return cache[n];
}

优化后的图示,只要结果被缓存,就不会执行其子问题
在这里插入图片描述

  • 改进后的时间复杂度为 O ( n ) O(n) O(n)
  • 请自行验证改进后的效果
  • 请自行分析改进后的空间复杂度

注意

  1. 记忆法是动态规划的一种情况,强调的是自顶向下的解决
  2. 记忆法的本质是空间换时间

5. 递归优化-尾递归

爆栈
用递归做
n+(n−1)+(n−2)…+1

public static long sum(long n) {
    if (n == 1) {
        return 1;
    }
    return n + sum(n - 1);
}

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

Exception in thread "main" java.lang.StackOverflowError
	at Test.sum(Test.java:10)
	at Test.sum(Test.java:10)
	at Test.sum(Test.java:10)
	at Test.sum(Test.java:10)
	at Test.sum(Test.java:10)
	...

为什么呢?

  • 每次方法调用是需要消耗一定的栈内存的,这些内存用来存储方法参数、方法内局部变量、返回地址等等
  • 方法调用占用的内存需要等到方法结束时才会释放
  • 而递归调用我们之前讲过,不到最深不会回头,最内层方法没完成之前,外层方法都结束不了
    • 例如, s u m ( 3 ) sum(3) sum(3) 这个方法内有个需要执行 3 + s u m ( 2 ) 3 + sum(2) 3+sum(2) s u m ( 2 ) sum(2) sum(2) 没返回前,加号前面的 3 3 3 不能释放
    • 看下面伪码
long sum(long n = 3) {
    return 3 + long sum(long n = 2) {
        return 2 + long sum(long n = 1) {
            return 1;
        }
    }
}

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

function a() {
    return b()
}

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

function a() {
    const c = b()
    return c
}

因为最后一步并非调用函数

function a() {
    return b() + 1
}

最后一步执行的是加法

function a(x) {
    return b() + x
}

● 最后一步执行的是加法

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

function a() {
    // 做前面的事
    return b() 
}

function b() {
    // 做前面的事
    return c()
}

function c() {
    return 1000
}

a()

没优化之前的伪码

function a() {
    return function b() {
        return function c() {
            return 1000
        }
    }
}

优化后伪码如下

a()
b()
c()

为何尾递归才能优化?

调用 a 时

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

调用 b 时

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

如果调用 a 时

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

尾递归

尾递归是尾调用的一种特例,也就是最后一步执行的是同一个函数

尾递归避免爆栈

安装 Scala

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

object Main {
  def main(args: Array[String]): Unit = {
    println("Hello Scala")
  }
}
  • Scala 是 java 的近亲,java 中的类都可以拿来重用
  • 类型是放在变量后面的
  • Unit 表示无返回值,类似于 void
  • 不需要以分号作为结尾,当然加上也对

还是先写一个会爆栈的函数

def sum(n: Long): Long = {
    if (n == 1) {
        return 1
    }
    return n + sum(n - 1)
}
  • Scala 最后一行代码若作为返回值,可以省略 return

不出所料,在 n = 11000 n = 11000 n=11000 时,还是出了异常

println(sum(11000))

Exception in thread "main" java.lang.StackOverflowError
	at Main$.sum(Main.scala:25)
	at Main$.sum(Main.scala:25)
	at Main$.sum(Main.scala:25)
	at Main$.sum(Main.scala:25)
	...

这是因为以上代码,还不是尾调用,要想成为尾调用,那么:

  1. 最后一行代码,必须是一次函数调用
  2. 内层函数必须摆脱与外层函数的关系,内层函数执行后不依赖于外层的变量或常量
def sum(n: Long): Long = {
    if (n == 1) {
        return 1
    }
    return n + sum(n - 1)  // 依赖于外层函数的 n 变量
}

如何让它执行后就摆脱对 n 的依赖呢?

  • 不能等递归回来再做加法,那样就必须保留外层的 n
  • 把 n 当做内层函数的一个参数传进去,这时 n 就属于内层函数了
  • 传参时就完成累加, 不必等回来时累加
sum(n - 1, n + 累加器)

改写后代码如下

@tailrec
def sum(n: Long, accumulator: Long): Long = {
    if (n == 1) {
        return 1 + accumulator
    } 
    return sum(n - 1, n + accumulator)
}
  • accumulator 作为累加器
  • @tailrec 注解是 scala 提供的,用来检查方法是否符合尾递归
  • 这回 sum(10000000, 0) 也没有问题,打印 50000005000000

执行流程如下,以伪码表示 s u m ( 4 , 0 ) sum(4, 0) sum(4,0)

// 首次调用
def sum(n = 4, accumulator = 0): Long = {
    return sum(4 - 1, 4 + accumulator)
}

// 接下来调用内层 sum, 传参时就完成了累加, 不必等回来时累加,当内层 sum 调用后,外层 sum 空间没必要保留
def sum(n = 3, accumulator = 4): Long = {
    return sum(3 - 1, 3 + accumulator)
}

// 继续调用内层 sum
def sum(n = 2, accumulator = 7): Long = {
    return sum(2 - 1, 2 + accumulator)
}

// 继续调用内层 sum, 这是最后的 sum 调用完就返回最后结果 10, 前面所有其它 sum 的空间早已释放
def sum(n = 1, accumulator = 9): Long = {
    if (1 == 1) {
        return 1 + accumulator
    }
}

本质上,尾递归优化是将函数的递归调用,变成了函数的循环调用

改循环避免爆栈

public static void main(String[] args) {
    long n = 100000000;
    long sum = 0;
    for (long i = n; i >= 1; i--) {
        sum += i;
    }
    System.out.println(sum);
}

6. 递归时间复杂度-Master theorem

若有递归式

T ( n ) = a T ( n b ) + f ( n ) T(n) = aT(\frac{n}{b}) + f(n) T(n)=aT(bn)+f(n)

其中

  • T ( n ) T(n) T(n) 是问题的运行时间, n n n 是数据规模
  • a a a 是子问题个数
  • T ( n b ) T(\frac{n}{b}) T(bn) 是子问题运行时间,每个子问题被拆成原问题数据规模的 n b \frac{n}{b} bn
  • f ( n ) f(n) f(n) 是除递归外执行的计算

x = log ⁡ b a x = \log_{b}{a} x=logba,即 x = log ⁡ 子问题缩小倍数 子问题个数 x = \log_{子问题缩小倍数}{子问题个数} x=log子问题缩小倍数子问题个数

那么

T ( n ) = { Θ ( n x ) f ( n ) = O ( n c ) 并且 c < x Θ ( n x log ⁡ n ) f ( n ) = Θ ( n x ) Θ ( n c ) f ( n ) = Ω ( n c ) 并且 c > x T(n) = \begin{cases} \Theta(n^x) & f(n) = O(n^c) 并且 c \lt x\\ \Theta(n^x\log{n}) & f(n) = \Theta(n^x)\\ \Theta(n^c) & f(n) = \Omega(n^c) 并且 c \gt x \end{cases} T(n)= Θ(nx)Θ(nxlogn)Θ(nc)f(n)=O(nc)并且c<xf(n)=Θ(nx)f(n)=Ω(nc)并且c>x

例1

T ( n ) = 2 T ( n 2 ) + n 4 T(n) = 2T(\frac{n}{2}) + n^4 T(n)=2T(2n)+n4

  • 此时 x = 1 < 4 x = 1 < 4 x=1<4,由后者决定整个时间复杂度 Θ ( n 4 ) \Theta(n^4) Θ(n4)
  • 如果觉得对数不好算,可以换为求【 b b b 的几次方能等于 a a a

例2

T ( n ) = T ( 7 n 10 ) + n T(n) = T(\frac{7n}{10}) + n T(n)=T(107n)+n

  • a = 1 , b = 10 7 , x = 0 , c = 1 a=1, b=\frac{10}{7}, x=0, c=1 a=1,b=710,x=0,c=1
  • 此时 x = 0 < 1 x = 0 < 1 x=0<1,由后者决定整个时间复杂度 Θ ( n ) \Theta(n) Θ(n)

例3

T ( n ) = 16 T ( n 4 ) + n 2 T(n) = 16T(\frac{n}{4}) + n^2 T(n)=16T(4n)+n2

  • a = 16 , b = 4 , x = 2 , c = 2 a=16, b=4, x=2, c=2 a=16,b=4,x=2,c=2
  • 此时 x = 2 = c x=2 = c x=2=c,时间复杂度 Θ ( n 2 log ⁡ n ) \Theta(n^2 \log{n}) Θ(n2logn)

例4

T ( n ) = 7 T ( n 3 ) + n 2 T(n)=7T(\frac{n}{3}) + n^2 T(n)=7T(3n)+n2

  • a = 7 , b = 3 , x = 1. ? , c = 2 a=7, b=3, x=1.?, c=2 a=7,b=3,x=1.?,c=2
  • 此时 x = log ⁡ 3 7 < 2 x = \log_{3}{7} < 2 x=log37<2,由后者决定整个时间复杂度 Θ ( n 2 ) \Theta(n^2) Θ(n2)

例5

T ( n ) = 7 T ( n 2 ) + n 2 T(n) = 7T(\frac{n}{2}) + n^2 T(n)=7T(2n)+n2

  • a = 7 , b = 2 , x = 2. ? , c = 2 a=7, b=2, x=2.?, c=2 a=7,b=2,x=2.?,c=2
  • 此时 x = l o g 2 7 > 2 x = log_2{7} > 2 x=log27>2,由前者决定整个时间复杂度 Θ ( n log ⁡ 2 7 ) \Theta(n^{\log_2{7}}) Θ(nlog27)

例6

T ( n ) = 2 T ( n 4 ) + n T(n) = 2T(\frac{n}{4}) + \sqrt{n} T(n)=2T(4n)+n

  • a = 2 , b = 4 , x = 0.5 , c = 0.5 a=2, b=4, x = 0.5, c=0.5 a=2,b=4,x=0.5,c=0.5
  • 此时 x = 0.5 = c x = 0.5 = c x=0.5=c,时间复杂度 Θ ( n   log ⁡ n ) \Theta(\sqrt{n}\ \log{n}) Θ(n  logn)

例7. 二分查找递归

int f(int[] a, int target, int i, int j) {
    if (i > j) {
        return -1;
    }
    int m = (i + j) >>> 1;
    if (target < a[m]) {
        return f(a, target, i, m - 1);
    } else if (a[m] < target) {
        return f(a, target, m + 1, j);
    } else {
        return m;
    }
}
  • 子问题个数 a = 1 a = 1 a=1
  • 子问题数据规模缩小倍数 b = 2 b = 2 b=2
  • 除递归外执行的计算是常数级 c = 0 c=0 c=0

T ( n ) = T ( n 2 ) + n 0 T(n) = T(\frac{n}{2}) + n^0 T(n)=T(2n)+n0

  • 此时 x = 0 = c x=0 = c x=0=c,时间复杂度 Θ ( log ⁡ n ) \Theta(\log{n}) Θ(logn)

例8. 归并排序递归

void split(B[], i, j, A[])
{
    if (j - i <= 1)                    
        return;                                
    m = (i + j) / 2;             
    
    // 递归
    split(A, i, m, B);  
    split(A, m, j, B); 
    
    // 合并
    merge(B, i, m, j, A);
}
  • 子问题个数 a = 2 a=2 a=2
  • 子问题数据规模缩小倍数 b = 2 b=2 b=2
  • 除递归外,主要时间花在合并上,它可以用 f ( n ) = n f(n) = n f(n)=n 表示

T ( n ) = 2 T ( n 2 ) + n T(n) = 2T(\frac{n}{2}) + n T(n)=2T(2n)+n

  • 此时 x = 1 = c x=1=c x=1=c,时间复杂度 Θ ( n log ⁡ n ) \Theta(n\log{n}) Θ(nlogn)

例9. 快速排序递归

algorithm quicksort(A, lo, hi) is 
  if lo >= hi || lo < 0 then 
    return
  
  // 分区
  p := partition(A, lo, hi) 
  
  // 递归
  quicksort(A, lo, p - 1) 
  quicksort(A, p + 1, hi)
  • 子问题个数 a = 2 a=2 a=2
  • 子问题数据规模缩小倍数
    • 如果分区分的好, b = 2 b=2 b=2
    • 如果分区没分好,例如分区1 的数据是 0,分区 2 的数据是 n − 1 n-1 n1
  • 除递归外,主要时间花在分区上,它可以用 f ( n ) = n f(n) = n f(n)=n 表示

情况1 - 分区分的好

T ( n ) = 2 T ( n 2 ) + n T(n) = 2T(\frac{n}{2}) + n T(n)=2T(2n)+n

  • 此时 x = 1 = c x=1=c x=1=c,时间复杂度 Θ ( n log ⁡ n ) \Theta(n\log{n}) Θ(nlogn)

情况2 - 分区没分好

T ( n ) = T ( n − 1 ) + T ( 1 ) + n T(n) = T(n-1) + T(1) + n T(n)=T(n1)+T(1)+n

  • 此时不能用主定理求解

7. 递归时间复杂度-展开求解

像下面的递归式,都不能用主定理求解

例1 - 递归求和

long sum(long n) {
    if (n == 1) {
        return 1;
    }
    return n + sum(n - 1);
}

T ( n ) = T ( n − 1 ) + c T(n) = T(n-1) + c T(n)=T(n1)+c T ( 1 ) = c T(1) = c T(1)=c

下面为展开过程

T ( n ) = T ( n − 2 ) + c + c T(n) = T(n-2) + c + c T(n)=T(n2)+c+c

T ( n ) = T ( n − 3 ) + c + c + c T(n) = T(n-3) + c + c + c T(n)=T(n3)+c+c+c

T ( n ) = T ( n − ( n − 1 ) ) + ( n − 1 ) c T(n) = T(n-(n-1)) + (n-1)c T(n)=T(n(n1))+(n1)c

  • 其中 T ( n − ( n − 1 ) ) T(n-(n-1)) T(n(n1)) T ( 1 ) T(1) T(1)
  • 带入求得 T ( n ) = c + ( n − 1 ) c = n c T(n) = c + (n-1)c = nc T(n)=c+(n1)c=nc

时间复杂度为 O ( n ) O(n) O(n)

例2 - 递归冒泡排序

void bubble(int[] a, int high) {
    if(0 == high) {
        return;
    }
    for (int i = 0; i < high; i++) {
        if (a[i] > a[i + 1]) {
            swap(a, i, i + 1);
        }
    }
    bubble(a, high - 1);
}

T ( n ) = T ( n − 1 ) + n T(n) = T(n-1) + n T(n)=T(n1)+n T ( 1 ) = c T(1) = c T(1)=c

下面为展开过程

T ( n ) = T ( n − 2 ) + ( n − 1 ) + n T(n) = T(n-2) + (n-1) + n T(n)=T(n2)+(n1)+n

T ( n ) = T ( n − 3 ) + ( n − 2 ) + ( n − 1 ) + n T(n) = T(n-3) + (n-2) + (n-1) + n T(n)=T(n3)+(n2)+(n1)+n

T ( n ) = T ( 1 ) + 2 + . . . + n = T ( 1 ) + ( n − 1 ) 2 + n 2 = c + n 2 2 + n 2 − 1 T(n) = T(1) + 2 + ... + n = T(1) + (n-1)\frac{2+n}{2} = c + \frac{n^2}{2} + \frac{n}{2} -1 T(n)=T(1)+2+...+n=T(1)+(n1)22+n=c+2n2+2n1

时间复杂度 O ( n 2 ) O(n^2) O(n2)

例3 - 递归快排

快速排序分区没分好的极端情况

T ( n ) = T ( n − 1 ) + T ( 1 ) + n T(n) = T(n-1) + T(1) + n T(n)=T(n1)+T(1)+n T ( 1 ) = c T(1) = c T(1)=c

T ( n ) = T ( n − 1 ) + c + n T(n) = T(n-1) + c + n T(n)=T(n1)+c+n

下面为展开过程

T ( n ) = T ( n − 2 ) + c + ( n − 1 ) + c + n T(n) = T(n-2) + c + (n-1) + c + n T(n)=T(n2)+c+(n1)+c+n

T ( n ) = T ( n − 3 ) + c + ( n − 2 ) + c + ( n − 1 ) + c + n T(n) = T(n-3) + c + (n-2) + c + (n-1) + c + n T(n)=T(n3)+c+(n2)+c+(n1)+c+n

T ( n ) = T ( n − ( n − 1 ) ) + ( n − 1 ) c + 2 + . . . + n = n 2 2 + 2 c n + n 2 − 1 T(n) = T(n-(n-1)) + (n-1)c + 2+...+n = \frac{n^2}{2} + \frac{2cn+n}{2} -1 T(n)=T(n(n1))+(n1)c+2+...+n=2n2+22cn+n1

时间复杂度 O ( n 2 ) O(n^2) O(n2)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1906911.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

swiftui中封装一个carditem视图,结合toolbar实现滚动的瀑布流,仿小红书首页

实现的效果如上图所示&#xff0c;支持左右滑动切换页面&#xff0c;也支持点击顶部的toolbar菜单切换页面&#xff0c;每个页面里面的每一项都是一个carditem.swift&#xff0c;这是我封装的一个card组件&#xff0c;用于展示每一个card内容&#xff0c;carditem.swift内容如下…

【Altium】AD-网络版一个用户非人为异常占用多个License的解决方法

【更多软件使用问题请点击亿道电子官方网站】 1、 文档目标 当出现一个用户同时占用多个授权&#xff0c;又无法单独释放一个授权的情况下&#xff0c;该如何解决。 2、 问题场景 一个用户获取网络版授权后&#xff0c;AD会自动重复获取授权&#xff0c;直到该license下所有授…

Qt 线程 QThread类详解

Qt 线程中QThread的使用 在进行桌面应用程序开发的时候&#xff0c; 假设应用程序在某些情况下需要处理比较复杂的逻辑&#xff0c; 如果只有一个线程去处理&#xff0c;就会导致窗口卡顿&#xff0c;无法处理用户的相关操作。这种情况下就需要使用多线程&#xff0c;其中一个…

盲盒小程序开发:互联网下的的盲盒发展

近些年来&#xff0c;盲盒行业发展的非常迅速&#xff0c;盲盒的不确定性吸引了无数玩家&#xff0c;盲盒的市场规模逐渐扩大&#xff0c;盲盒品牌也在不断出现&#xff0c;为盲盒消费者带来更多的新鲜体验&#xff0c; 随着互联网小程序的的快速发展&#xff0c;盲盒小程序为…

告别‘找文件’大战,可道云teamOS分区管理,文件秒定位

在数字化时代&#xff0c;数据已经成为企业最宝贵的资产之一。 如何高效、安全地管理这些数据&#xff0c;成为了每一个企业必须面对的问题。 企业网盘作为一种便捷的数据存储和共享工具&#xff0c;已经成为了企业日常运营中不可或缺的一部分。 然而&#xff0c;传统的网盘管…

人员定位系统于不同场景的实际应用

人员定位系统的应用&#xff0c;尽管还没有做到大范围的普及&#xff0c;但是这一系统在不同企业&#xff0c;不同单位的实际应用效果还是很好的&#xff0c;所以人员定位系统也应用于不同场景当中了&#xff0c;那么&#xff0c;本文就来讲讲这一系统在不同场景的实际应用。 人…

汽车信息安全--欧盟汽车法规

目录 General regulation 信息安全法规 R155《网络安全及网络安全管理系统》解析 R156《软件升级与软件升级管理系统》解析 General regulation 欧洲的汽车行业受到一系列法律法规的约束&#xff0c;包括 各个方面包括&#xff1a; 1.安全要求&#xff1a;《通用安全条例&a…

【Qt】对话框

1、自定义对话框并赋予ui界面&#xff0c;用按钮呼出 https://www.bilibili.com/video/BV1rK411A7qi/?spm_id_from333.999.0.0&vd_sourcefd6555f02904e7fa85526a2ff4b8b66e 新建 - 文件和类 - Qt - Qt设计师界面类在原来的父窗口cpp文件中初始化新窗口并调用exec显示模态…

什么软件能够监控电脑?五大好用监控电脑软件推荐

在当今的企业管理中&#xff0c;电脑监控软件已经成为必不可少的工具。它们不仅能提升员工的工作效率&#xff0c;还能保护企业的核心数据&#xff0c;防止信息泄露。以下将介绍几款市场上备受好评的电脑监控软件&#xff0c;特别是固信软&#xff0c;帮助企业找到最适合的解决…

软件测试下的AI之路(5)

😏作者简介:博主是一位测试管理者,同时也是一名对外企业兼职讲师。 📡主页地址:【Austin_zhai】 🙆目的与景愿:旨在于能帮助更多的测试行业人员提升软硬技能,分享行业相关最新信息。 💎声明:博主日常工作较为繁忙,文章会不定期更新,各类行业或职场问题欢迎大家…

掌握MySQL基础命令:数据更新操作详细操作(数据的增删改)

MySQL数据修改是指使用SQL语句&#xff08;如UPDATE、INSERT、DELETE&#xff09;对数据库表中的数据进行更改、添加或删除的操作&#xff0c;常见的操作包括更新表中的记录、插入新记录以及删除现有记录 。 一、数据插入 1插入完整的数据记录 2插入非完整的数据记录 3插入多…

SQL 之 concat_ws和concat的区别

concat_ws和concat都是用于连接字符串的函数&#xff0c;但它们在使用上有一些区别&#xff1a; 一、concat、concat_ws函数格式&#xff1a; concat格式&#xff1a; concat&#xff08;参数1,参数2,…参数n&#xff09;&#xff0c;如果要加’分隔符’直接写在 各参数中间就…

【知网CNKI-注册安全分析报告】

前言 由于网站注册入口容易被黑客攻击&#xff0c;存在如下安全问题&#xff1a; 暴力破解密码&#xff0c;造成用户信息泄露短信盗刷的安全问题&#xff0c;影响业务及导致用户投诉带来经济损失&#xff0c;尤其是后付费客户&#xff0c;风险巨大&#xff0c;造成亏损无底洞…

【Git基本操作】创建本地仓库 | 配置本地仓库 | 认识工作区、暂存区、版本库、对象库 | add和commit操作

目录 1.创建Git本地仓库 1.1创建仓库 1.2创建和初始化Git本地仓库 1.3查看隐藏目录.git 2.配置本地仓库 2.1新增配置 2.2删除重置配置 2.3查看配置选项 2.4全局范围的新增和删除配置 3.工作区、暂存区、版本库、对象库 ​4.add操作和commit操作 4.1add操作 4.2com…

AI 大模型系统实战

AI 大模型是什么&#xff1f; 维基百科对基础模型的定义是这样的&#xff0c;基础模型是一种大型机器学习模型&#xff0c;通常在大量数据上进行大规模训练&#xff08;通过自监督学习或半监督学习&#xff09;&#xff0c;以使它可以适应各类下游任务。因此&#xff0c;它需要…

从入门到精通:Shopee,lazada,temu自养号测评成本、步骤、技巧详解

测评对于卖家来说是一种成本低回报快的推广方式&#xff0c;可以减少高额的平台广告费用&#xff0c;因此是一种很好的辅助手段&#xff0c;对商品的曝光、流量、转化和权重等方面起到了很好的辅助作用 建议还是自己精养一批账号&#xff0c;账号在自己手里比较安全可控&#…

重塑消费体验:探索绿色消费增值模式的新篇章

我是吴军&#xff0c;就职于一家在数字创新领域屡获殊荣的软件企业&#xff0c;担任高级产品策略师。今天&#xff0c;我满怀热忱&#xff0c;想与您一同揭开一种前沿且极具吸引力的商业模式面纱——那就是绿色消费增值模式&#xff0c;一个正逐步改变我们消费习惯与商业生态的…

一.1 信息就是位+上下文

hello程序的生命周期是从一个源程序&#xff08;或者说源文件&#xff09;开始的&#xff0c;即程序员通过编辑器创建并保存的文本文件&#xff0c;文件名是hello.c。源程序实际上就是一个由0和1组成的位&#xff08;又称为比特&#xff09;序列&#xff0c;8个位被组织成一组&…

平面法向的角度表示以及坐标系变换

1.根据法线计算法线的垂直角sint和法线在水平投影与x轴的夹角phi double phi atan2(normal(1) , normal(0)); // atan2(y,x), 计算法向在xy平面上的投影和x轴之间的夹角double sint asin(normal(2)); //理论上是z轴和 该法向向量之间的夹角 2.根据角度计算法线 Eigen::Vec…

AnaPico为众多工厂产线老化测试提供高效经济的微波解决方案

在电子设备的生产中&#xff0c;老化测试在整个使用寿命期间的可靠性和对声明参数的保证起着重要作用&#xff0c;尤其是在特殊应用&#xff08;国防和航天工业、电信、医药等&#xff09;方面。即使经过成功的参数和功能测试&#xff0c;在实际操作条件下使用时也有可能出现设…