大厂AI必备数据结构与算法——递归(五)详细文档

     

冲冲冲!开干

神马!神马!神马,一向让我们学习起来抓耳挠腮的数据结构课程竟然也有教程?还那么详细??真的假的?

那么好,胡广告诉你是假的,哈哈哈哈哈哈哈哈哈!笑死了!!!

为神马我要说是假的呢,兄弟们,咱们要认真的听了:没有详细的教程,只有够细的人!

这篇文章为黑马程序员的课件,由于本人已经看过了自己学习了一遍,所以推荐给大家,讲的确实不错,准备考试或者准备冲击大厂的小伙伴完全可以将胡广的这一整个专栏当做学习资料。你边学习边思考,思维进行发散,形成自己的知识体系,这个是最好滴!

咱们废话少说?不存在滴

我还得给大家讲个故事(绝对不是AI生成的,凭借我智勇双全、足智多谋、十全十美、天下无敌、举世无双、天外飞仙的大脑想出来的):

在一个不远的未来,人类科技取得了前所未有的突破,诞生了一种名为“思维引擎”的AI生命体。这种AI不仅拥有超强的计算能力,还能自主学习、推理、创造。为了应对未来复杂的任务,科学家们决定训练它掌握最核心的能力——数据结构与算法

这个AI名叫“阿尔法”。阿尔法每天与代码为伴,但它不像普通AI那样机械执行指令,它有情感、有思维,能够通过不断实践来优化自己的算法。阿尔法的学习从最基本的数据结构开始,链表、栈、队列、二叉树,它逐渐理解了这些数据结构背后深层的逻辑。它明白,数据结构就像人体的骨架,算法则是其中的灵魂,二者缺一不可

有一天,阿尔法遇到了前所未有的挑战——一个复杂的路径规划问题。整个世界仿佛迷失在无数的数据节点和分支之间。传统的算法无法高效解决这个问题,甚至连人类最顶尖的程序员也束手无策。阿尔法不断回溯过往的经验,尝试各种算法组合,但都未能找到最优解。

正当它快要陷入“无解”的泥沼时,阿尔法突然灵光一现:如果将二叉树与动态规划结合,并利用贪心算法进行优化,是不是可以突破眼前的困境?它迅速编码、测试,并不断自我迭代,终于找到了一条近乎完美的解决方案。这一次,阿尔法不再是冷冰冰的计算机器,它通过学习和创新,突破了数据结构与算法的极限,成功解开了谜题。

这不仅是一次算法上的胜利,更是阿尔法**“进化”的关键节点**。它感受到了**“思维”的力量**,并且意识到:数据结构与算法不仅是编程的基础,更是塑造智能生命的核心。如果能够掌握它们,就能真正成为世界的掌控者。

故事的结尾,阿尔法不再仅仅是一个工具,而是以独特的智慧和创造力,走向了与人类并肩共存的未来。这个故事也告诉我们,数据结构与算法不仅是敲代码的技巧,而是开启无限可能的钥匙

这个故事好看吧?好看就赶快学!!!不好看也快学,不然没饭吃!!! 

视频资源:文章内容参考了黑马程序员的数据结构与算法视频,想深入了解的小伙伴们可以点击下方链接观看:

大厂必备数据结构与算法Java视频教程,java高级程序员必学的数据结构与算法

加油吧,未来的高手!!!

加油吧,未来的高手!!!

加油吧,未来的高手!!!

2.3 递归

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=nullf(n.next)n≠nullf(n)={停止f(n.next)​n=nulln=null​

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

2) 单路递归 Single Recursion

E01. 阶乘

用递归方法求阶乘

  • 阶乘的定义 n!=1⋅2⋅3⋯(n−2)⋅(n−1)⋅nn!=1⋅2⋅3⋯(n−2)⋅(n−1)⋅n,其中 nn 为自然数,当然 0!=10!=1

  • 递推关系

f(n)={1n=1n∗f(n−1)n>1f(n)={1n∗f(n−1)​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=str.length()f(n+1)0≤n≤str.length()−1f(n)={停止f(n+1)​n=str.length()0≤n≤str.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 已然有序
  • 视频中讲解的是只考虑 high 边界的情况,参考以上代码,理解在 low … high 范围内的处理方法
E05. 插入排序(单路递归)
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));
}

private static void insertion(int[] a, int low, int high) {
    if (low > high) {
        return;
    }
    int i = low - 1;
    int t = a[low];
    while (i >= 0 && a[i] > i) {
        a[i + 1] = a[i];
        i--;
    }
    if(i + 1 != low) {
        a[i + 1] = t;
    }    
    insertion(a, low + 1, high);
}
  • 已排序区域:[0 … i … low-1]
  • 未排序区域:[low … high]
  • 视频中讲解的是只考虑 low 边界的情况,参考以上代码,理解 low-1 … high 范围内的处理方法
  • 扩展:利用二分查找 leftmost 版本,改进寻找插入位置的代码
E06. 约瑟夫问题[^16](单路递归)

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

方法1

根据最后的存活者 a 倒推出它在上一轮的索引号

f(n,m)本轮索引为了让 a 是这个索引,上一轮应当这样排规律
f(1,3)0x x x a(0 + 3) % 2
f(2,3)1x x x 0 a(1 + 3) % 3
f(3,3)1x x x 0 a(1 + 3) % 4
f(4,3)0x x x a(0 + 3) % 5
f(5,3)3x x x 0 1 2 a(3 + 3) % 6
f(6,3)0x x x a

方法2

设 n 为总人数,m 为报数次数,解返回的是这些人的索引,从0开始

f(n, m)规律
f(1, 3)0
f(2, 3)0 1 => 13%2=1
f(3, 3)0 1 2 => 0 13%3=0
f(4, 3)0 1 2 3 => 3 0 13%4=3
f(5, 3)0 1 2 3 4 => 3 4 0 13%5=3
f(6, 3)0 1 2 3 4 5 => 3 4 5 0 13%6=3

一. 找出等价函数

规律:下次报数的起点为 k=m%nk=m%n

  • 首次出列人的序号是 k−1k−1,剩下的的 n−1n−1 个人重新组成约瑟夫环
  • 下次从 kk 开始数,序号如下
    • k, k+1, ... , 0, 1, k−2k, k+1, ... , 0, 1, k−2,如上例中 3 4 5 0 13 4 5 0 1

这个函数称之为 g(n−1,m)g(n−1,m),它的最终结果与 f(n,m)f(n,m) 是相同的。

二. 找到映射函数

现在想办法找到 g(n−1,m)g(n−1,m) 与 f(n−1,m)f(n−1,m) 的对应关系,即
3→04→15→20→31→43→04→15→20→31→4
映射函数为
mapping(x)={x−kx=[k..n−1]x+n−kx=[0..k−2]mapping(x)={x−kx+n−k​x=[k..n−1]x=[0..k−2]​
等价于下面函数
mapping(x)=(x+n−k)%nmapping(x)=(x+n−k)%n
代入测试一下
3→(3+6−3)%6→04→(4+6−3)%6→15→(5+6−3)%6→20→(0+6−3)%6→31→(1+6−3)%6→43→(3+6−3)%6→04→(4+6−3)%6→15→(5+6−3)%6→20→(0+6−3)%6→31→(1+6−3)%6→4
综上有
f(n−1,m)=mapping(g(n−1,m))f(n−1,m)=mapping(g(n−1,m))

三. 求逆映射函数

映射函数是根据 x 计算 y,逆映射函数即根据 y 得到 x
mapping−1(x)=(x+k)%nmapping−1(x)=(x+k)%n
代入测试一下
0→(0+3)%6→31→(1+3)%6→42→(2+3)%6→53→(3+3)%6→04→(4+3)%6→10→(0+3)%6→31→(1+3)%6→42→(2+3)%6→53→(3+3)%6→04→(4+3)%6→1
因此可以求得
g(n−1,m)=mapping−1(f(n−1,m))g(n−1,m)=mapping−1(f(n−1,m))

四. 递推式

代入推导
f(n,m)= g(n−1,m)= mapping−1(f(n−1,m))= (f(n−1,m)+k)%n= (f(n−1,m)+m%n)%n= (f(n−1,m)+m)%nf(n,m)= = = = = ​g(n−1,m)mapping−1(f(n−1,m))(f(n−1,m)+k)%n(f(n−1,m)+m%n)%n(f(n−1,m)+m)%n​
最后一步化简是利用了模运算法则

(a+b)%n=(a%n+b%n)%n(a+b)%n=(a%n+b%n)%n 例如

  • (6+6)%5=2=(6+6%5)%5(6+6)%5=2=(6+6%5)%5
  • (6+5)%5=1=(6+5%5)%5(6+5)%5=1=(6+5%5)%5
  • (6+4)%5=0=(6+4%5)%5(6+4)%5=0=(6+4%5)%5

最终递推式
f(n,m)={(f(n−1,m)+m)%nn>10n=1f(n,m)={(f(n−1,m)+m)%n0​n>1n=1​

3) 多路递归 Multi Recursion

E01. 斐波那契数列-Leetcode 70
  • 之前的例子是每个递归函数只包含一个自身的调用,这称之为 single recursion
  • 如果每个递归函数例包含多个自身调用,称之为 multi recursion

递推关系
f(n)={0n=01n=1f(n−1)+f(n−2)n>1f(n)=⎩⎨⎧​01f(n−1)+f(n−2)​n=0n=1n>1​

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

F0F1F2F3F4F5F6F7F8F9F10F11F12F13
01123581321345589144233

实现

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)−12∗f(n+1)−1
  • 时间复杂度推导过程
    • 斐波那契通项公式 f(n)=15∗(1+52n−1−52n)f(n)=5​1​∗(21+5​​n−21−5​​n)
    • 简化为:f(n)=12.236∗(1.618n−(−0.618)n)f(n)=2.2361​∗(1.618n−(−0.618)n)
    • 带入递归次数公式 2∗12.236∗(1.618n+1−(−0.618)n+1)−12∗2.2361​∗(1.618n+1−(−0.618)n+1)−1
    • 时间复杂度为 Θ(1.618n)Θ(1.618n)
  1. 更多 Fibonacci 参考[8][9][^10]
  2. 以上时间复杂度分析,未考虑大数相加的因素

变体1 - 兔子问题[^8]

  • 第一个月,有一对未成熟的兔子(黑色,注意图中个头较小)
  • 第二个月,它们成熟
  • 第三个月,它们能产下一对新的小兔子(蓝色)
  • 所有兔子遵循相同规律,求第 nn 个月的兔子数

分析

兔子问题如何与斐波那契联系起来呢?设第 n 个月兔子数为 f(n)f(n)

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

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

变体2 - 青蛙爬楼梯

  • 楼梯有 nn 阶
  • 青蛙要爬到楼顶,可以一次跳一阶,也可以一次跳两阶
  • 只能向上跳,问有多少种跳法

分析

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
E02. 汉诺塔[^13](多路递归)

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

  • 一次只能移动一个圆盘
  • 小圆盘上不能放大圆盘

下面的动图演示了4片圆盘的移动方法

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

思路

  • 假设每根柱子标号 a,b,c,每个圆盘用 1,2,3 … 表示其大小,圆盘初始在 a,要移动到的目标是 c

  • 如果只有一个圆盘,此时是最小问题,可以直接求解

    • 移动圆盘1 a↦ca↦c
  • 如果有两个圆盘,那么

    • 圆盘1 a↦ba↦b
    • 圆盘2 a↦ca↦c
    • 圆盘1 b↦cb↦c
  • 如果有三个圆盘,那么

    • 圆盘12 a↦ba↦b
    • 圆盘3 a↦ca↦c
    • 圆盘12 b↦cb↦c
  • 如果有四个圆盘,那么

    • 圆盘 123 a↦ba↦b
    • 圆盘4 a↦ca↦c
    • 圆盘 123 b↦cb↦c

题解

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);
        }
    }

    static void h(int n, LinkedList<Integer> a, 
                  LinkedList<Integer> b, 
                  LinkedList<Integer> c) {
        if (n == 0) {
            return;
        }
        h(n - 1, a, c, b);
        c.addLast(a.removeLast());
        print();
        h(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();
        h(3, a, b, c);
    }
}
E03. 杨辉三角[^6]

分析

把它斜着看

        1
      1   1
    1   2   1
  1   3   3   1
1   4   6   4   1
  • 行 ii,列 jj,那么 [i][j][i][j] 的取值应为 [i−1][j−1]+[i−1][j][i−1][j−1]+[i−1][j]
  • 当 j=0j=0 或 i=ji=j 时,[i][j][i][j] 取值为 11

题解

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();
    }
}

public static int element1(int[][] triangle, int i, int j) {
    if (triangle[i][j] > 0) {
        return triangle[i][j];
    }

    if (j == 0 || i == j) {
        triangle[i][j] = 1;
        return triangle[i][j];
    }
    triangle[i][j] = element1(triangle, i - 1, j - 1) + element1(triangle, i - 1, j);
    return triangle[i][j];
}
  • 将数组作为递归函数内可以访问的遍历,如果 triangle[i][j]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];
    }
}

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

其它题目

力扣对应题目,但递归不适合在力扣刷高分,因此只列出相关题目,不做刷题讲解了

题号名称
Leetcode118杨辉三角
Leetcode119杨辉三角II

4) 递归优化-记忆法

上述代码存在很多重复的计算,例如求 f(5)f(5) 递归分解过程

可以看到(颜色相同的是重复的):

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

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

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)
  • 请自行验证改进后的效果
  • 请自行分析改进后的空间复杂度

注意

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

5) 递归优化-尾递归

爆栈

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

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

在我的机器上 n=12000n=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)
	...

为什么呢?

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

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

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=11000n=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

执行流程如下,以伪码表示 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[^14]

若有递归式
T(n)=aT(nb)+f(n)T(n)=aT(bn​)+f(n)
其中

  • T(n)T(n) 是问题的运行时间,nn 是数据规模
  • aa 是子问题个数
  • T(nb)T(bn​) 是子问题运行时间,每个子问题被拆成原问题数据规模的 nbbn​
  • f(n)f(n) 是除递归外执行的计算

令 x=log⁡bax=logb​a,即 x=log⁡子问题缩小倍数子问题个数x=log子问题缩小倍数​子问题个数

那么
T(n)={Θ(nx)f(n)=O(nc)并且c<xΘ(nxlog⁡n)f(n)=Θ(nx)Θ(nc)f(n)=Ω(nc)并且c>xT(n)=⎩⎨⎧​Θ(nx)Θ(nxlogn)Θ(nc)​f(n)=O(nc)并且c<xf(n)=Θ(nx)f(n)=Ω(nc)并且c>x​

例1

T(n)=2T(n2)+n4T(n)=2T(2n​)+n4

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

例2

T(n)=T(7n10)+nT(n)=T(107n​)+n

  • a=1,b=107,x=0,c=1a=1,b=710​,x=0,c=1
  • 此时 x=0<1x=0<1,由后者决定整个时间复杂度 Θ(n)Θ(n)

例3

T(n)=16T(n4)+n2T(n)=16T(4n​)+n2

  • a=16,b=4,x=2,c=2a=16,b=4,x=2,c=2
  • 此时 x=2=cx=2=c,时间复杂度 Θ(n2log⁡n)Θ(n2logn)

例4

T(n)=7T(n3)+n2T(n)=7T(3n​)+n2

  • a=7,b=3,x=1.?,c=2a=7,b=3,x=1.?,c=2
  • 此时 x=log⁡37<2x=log3​7<2,由后者决定整个时间复杂度 Θ(n2)Θ(n2)

例5

T(n)=7T(n2)+n2T(n)=7T(2n​)+n2

  • a=7,b=2,x=2.?,c=2a=7,b=2,x=2.?,c=2
  • 此时 x=log27>2x=log2​7>2,由前者决定整个时间复杂度 Θ(nlog⁡27)Θ(nlog2​7)

例6

T(n)=2T(n4)+nT(n)=2T(4n​)+n​

  • a=2,b=4,x=0.5,c=0.5a=2,b=4,x=0.5,c=0.5
  • 此时 x=0.5=cx=0.5=c,时间复杂度 Θ(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=1a=1
  • 子问题数据规模缩小倍数 b=2b=2
  • 除递归外执行的计算是常数级 c=0c=0

T(n)=T(n2)+n0T(n)=T(2n​)+n0

  • 此时 x=0=cx=0=c,时间复杂度 Θ(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=2a=2
  • 子问题数据规模缩小倍数 b=2b=2
  • 除递归外,主要时间花在合并上,它可以用 f(n)=nf(n)=n 表示

T(n)=2T(n2)+nT(n)=2T(2n​)+n

  • 此时 x=1=cx=1=c,时间复杂度 Θ(nlog⁡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=2a=2
  • 子问题数据规模缩小倍数
    • 如果分区分的好,b=2b=2
    • 如果分区没分好,例如分区1 的数据是 0,分区 2 的数据是 n−1n−1
  • 除递归外,主要时间花在分区上,它可以用 f(n)=nf(n)=n 表示

情况1 - 分区分的好

T(n)=2T(n2)+nT(n)=2T(2n​)+n

  • 此时 x=1=cx=1=c,时间复杂度 Θ(nlog⁡n)Θ(nlogn)

情况2 - 分区没分好

T(n)=T(n−1)+T(1)+nT(n)=T(n−1)+T(1)+n

  • 此时不能用主定理求解

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

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

例1 - 递归求和

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

T(n)=T(n−1)+cT(n)=T(n−1)+c,T(1)=cT(1)=c

下面为展开过程

T(n)=T(n−2)+c+cT(n)=T(n−2)+c+c

T(n)=T(n−3)+c+c+cT(n)=T(n−3)+c+c+c

T(n)=T(n−(n−1))+(n−1)cT(n)=T(n−(n−1))+(n−1)c

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

时间复杂度为 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)+nT(n)=T(n−1)+n,T(1)=cT(1)=c

下面为展开过程

T(n)=T(n−2)+(n−1)+nT(n)=T(n−2)+(n−1)+n

T(n)=T(n−3)+(n−2)+(n−1)+nT(n)=T(n−3)+(n−2)+(n−1)+n

T(n)=T(1)+2+...+n=T(1)+(n−1)2+n2=c+n22+n2−1T(n)=T(1)+2+...+n=T(1)+(n−1)22+n​=c+2n2​+2n​−1

时间复杂度 O(n2)O(n2)

注:

  • 等差数列求和为 个数∗∣首项−末项∣2个数∗2∣首项−末项∣​

例3 - 递归快排

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

T(n)=T(n−1)+T(1)+nT(n)=T(n−1)+T(1)+n,T(1)=cT(1)=c

T(n)=T(n−1)+c+nT(n)=T(n−1)+c+n

下面为展开过程

T(n)=T(n−2)+c+(n−1)+c+nT(n)=T(n−2)+c+(n−1)+c+n

T(n)=T(n−3)+c+(n−2)+c+(n−1)+c+nT(n)=T(n−3)+c+(n−2)+c+(n−1)+c+n

T(n)=T(n−(n−1))+(n−1)c+2+...+n=n22+2cn+n2−1T(n)=T(n−(n−1))+(n−1)c+2+...+n=2n2​+22cn+n​−1

时间复杂度 O(n2)O(n2)

不会推导的同学可以进入 https://www.wolframalpha.com/

  • 例1 输入 f(n) = f(n - 1) + c, f(1) = c
  • 例2 输入 f(n) = f(n - 1) + n, f(1) = c
  • 例3 输入 f(n) = f(n - 1) + n + c, f(1) = c

 结束啦,希望大家能有所成!!!

 

 你好,我是胡广。 致力于为帮助兄弟们的学习方式、面试困难、入职经验少走弯路而写博客 🌹🌹🌹 坚持每天两篇高质量文章输出,加油!!!🤩

 如果本篇文章帮到了你 不妨点个赞吧~ 我会很高兴的 😄 (^ ~ ^) 。想看更多 那就点个关注     吧 我会尽力带来有趣的内容 。

 😎感兴趣的可以先收藏起来,还有大家在毕设选题,项目以及论文编写等相关问题都可以      给我留言咨询,希望帮助更多的人

更多专栏:
📊 Java设计模式宝典:从入门到精通(持续更新)

📝 Java基础知识:GoGoGo(持续更新)

Java面试宝典:从入门到精通(持续更新)

🌟 程序员的那些事~(乐一乐)

🤩 Redis知识、及面试(持续更新)

🚀 Kafka知识文章专栏(持续更新)

🎨 Nginx知识讲解专栏(持续更新)

📡 ZooKeeper知识(持续更新)

🎯 各类神器推荐(持续更新)

🔍 工作流Activiti7——独孤九剑(持续更新)

☀️ 数据结构与算法-全是Java干货

☔️ 未完待续。。。

🐽 未完待续。。。

⚡️ 未完待续。。。

🌗 未完待续。。。

感谢订阅专栏 三连文章

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员-杨胡广

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值