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

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) // 1void f(Node node = 2) {println("before:" + node.value) // 2void f(Node node = 3) {println("before:" + node.value) // 3void 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 -> ch(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,移至bc.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)的和。

        11   11   2   11   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.StackOverflowErrorat 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.StackOverflowErrorat 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.mzph.cn/diannao/42928.shtml

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

相关文章

IT项目经理转行大模型,项目经理的进来,你想知道的都在这里非常详细

大模型&#xff08;如人工智能、机器学习和深度学习模型&#xff09;可以通过提供数据驱动的决策支持、自动化流程和预测分析来赋能IT项目经理。这些工具可以帮助项目经理更有效地管理项目&#xff0c;预测潜在的风险&#xff0c;并提高项目成功的可能性。以下是IT项目经理如何…

IPD流程学习笔记

一、前言 互联网行业讲究的是快速试错&#xff0c;所以IPD流程的应用并不多&#xff0c;但是传统企业数字化转型中&#xff0c;怎么确保项目投入可以切合市场需求&#xff0c;有较好的ROI及落地性&#xff0c;轻量级的IPD还是很有必要了解下的&#xff0c;特别是重资产类的业务…

探讨大数据在视频汇聚平台LntonCVS国标GB28181协议中的应用

随着摄像头和视频监控系统的普及和数字化程度的提高&#xff0c;视频监控系统产生的数据量急剧增加。大数据技术因其优秀的数据管理、分析和利用能力&#xff0c;成为提升视频监控系统效能和价值的重要工具。 大数据技术可以将视频监控数据与其他数据源进行融合分析&#xff0c…

libaom 编码器实验 AV1 标准 SVC 分层编码

SVC编码 视频SVC编码&#xff0c;即Scalable Video Coding&#xff08;可适性视讯编码或可分级视频编码&#xff09;&#xff0c;是H.264/MPEG-4 AVC编码的一种扩展&#xff0c;它提供了更大的编码弹性&#xff0c;并且具有时间可适性&#xff08;Temporal Scalability&#x…

【基础算法总结】分治—归并

分治—归并 1.排序数组2.交易逆序对的总数3.计算右侧小于当前元素的个数4.翻转对 点赞&#x1f44d;&#x1f44d;收藏&#x1f31f;&#x1f31f;关注&#x1f496;&#x1f496; 你的支持是对我最大的鼓励&#xff0c;我们一起努力吧!&#x1f603;&#x1f603; 1.排序数组 …

NFTScan | 07.01~07.07 NFT 市场热点汇总

欢迎来到由 NFT 基础设施 NFTScan 出品的 NFT 生态热点事件每周汇总。 周期&#xff1a;2024.07.01~ 2024.07.07 NFT Hot News 01/ 数据&#xff1a;Mad Lads 地板价回升至 100 SOL 上方&#xff0c;24 小时涨幅为 13.65% 7 月 1 日&#xff0c;据数据显示&#xff0c;Solana…

ARM架构以及程序运行解析

文章目录 1. ARM架构 2. ARM处理器程序运行的过程 3. 示例 3. 基于ARM架构的STM32单片机 1. 运行模式 2. 寄存器组 3. STM32的基本结构 4. STM32的运行模式 4. 寄存器组详解 1. 未备份寄存器 2. 备份寄存器 3. 程序计数器 4. 程序状态寄存器 5. CPSR和SPSR寄存器…

【Unity】UGUI的基本介绍

Unity的UGUI&#xff08;Unity User Interface&#xff09;是Unity引擎内自带的UI系统&#xff0c;官方称之为UnityUI&#xff0c;是目前Unity商业游戏开发中使用最广泛的UI系统开发解决方案。以下是关于Unity的UGUI的详细介绍&#xff1a; 一、UGUI的特点 灵活性&#xff1a…

Python 爬虫 tiktok API接口获取tiktok用户关注列表

此接口可获取tiktok用户关注列表。若有需要&#xff0c;请点击文末链接联系我们。 详细采集页面如下https://www.tiktok.com/quanap_official 请求API http://api.xxxx.com/tt/user/following?user_id7252644648840381445&count10&offset0&tokentest 请求参数 返…

改进Transformer模型其实也不难

声明&#xff1a;文章是从本人公众号中复制而来&#xff0c;因此&#xff0c;想最新最快了解各类智能优化算法及其改进的朋友&#xff0c;可关注我的公众号&#xff1a;强盛机器学习&#xff0c;不定期会有很多免费代码分享~ 目录 原理简介 数据介绍 结果展示 完整代码 之前…

【学术会议征稿】第五届计算机工程与智能控制学术会议(ICCEIC 2024)

第五届计算机工程与智能控制学术会议&#xff08;ICCEIC 2024) 2024 5th International Conference on Computer Engineering and Intelligent Control 第五届计算机工程与智能控制学术会议&#xff08;ICCEIC 2024&#xff09;将于2024年10月18日至22日在广州举办&#xff0…

LDR6282-显示器:从技术革新到视觉盛宴

显示器&#xff0c;作为我们日常工作和娱乐生活中不可或缺的一部分&#xff0c;承载着将虚拟世界呈现为现实图像的重要使命。它不仅是我们与电子设备交互的桥梁&#xff0c;更是我们感知信息、享受视觉盛宴的重要窗口。显示器在各个领域的应用也越来越广泛。在办公领域&#xf…

Gradle使用插件SonatypeUploader-v2.6上传到maven组件到远程中央仓库

本文基于sonatypeUploader 2.6版本 插件的使用实例&#xff1a;https://github.com/jeadyx/SonatypeUploaderSample 发布步骤 提前准备好sonatype账号和signing配置 注&#xff1a;如果没有&#xff0c;请参考1.0博文的生成步骤&#xff1a; https://jeady.blog.csdn.net/art…

收银系统源码-营销活动-幸运抽奖

1. 功能描述 营运抽奖&#xff1a;智慧新零售收银系统&#xff0c;线上商城营销插件&#xff0c;商户/门店在小程序商城上设置抽奖活动&#xff0c;中奖人员可内定&#xff1b; 2.适用场景 新店开业、门店周年庆、节假日等特定时间促销&#xff1b;会员拉新&#xff0c;需会…

SQLServer连接异常

2. 文件夹对应的是[internal].[folders]表&#xff0c;与之相关的权限在[internal].[folder_permissions]表 项目对应的是[internal].[projects]表&#xff0c;与之相关的权限在[internal].[project_permissions]&#xff0c;版本在[internal].[object_versions]表。 环境对应…

MongoDB本地配置分片

mongodb server version: 7.0.12 社区版 mongo shell version: 2.2.10 平台&#xff1a;win10 64位 控制台&#xff1a;Git Bash 分片相关节点结构示意图 大概步骤 1. 配置 配置服务器 副本集 &#xff08;最少3个节点&#xff09; -- 创建数据目录 mkdir -p ~/dbs/confi…

华为eNSP:HCIA汇总实验

本次拓扑实验需求&#xff1a; 1、内网地址用DHCP 2、VLAN10不能访问外网 3、使用静态NAT 实验用到的技术有DHCP、划分VLAN、IP配置、VLAN间的通信&#xff1a;单臂路由、VLANIF&#xff0c;静态NAT、基本ACL DHCP是一种用于自动分配IP地址和其他网络参数的协议。 划分VLA…

新型模型架构(参数化状态空间模型、状态空间模型变种)

文章目录 参数化状态空间模型状态空间模型变种Transformer 模型自问世以来,在自然语言处理、计算机视觉等多个领域得到了广泛应用,并展现出卓越的数据表示与建模能力。然而,Transformer 的自注意力机制在计算每个词元时都需要利用到序列中所有词元的信息,这导致计算和存储复…

简单介绍 Dagger2 的入门使用

依赖注入 在介绍 Dagger2 这个之前&#xff0c;必须先解释一下什么是依赖注入&#xff0c;因为这个库就是用来做依赖注入的。所以这里先简单用一句话来介绍一下依赖注入&#xff1a; 依赖注入是一种设计模式&#xff0c;它允许对象在运行时注入其依赖项。而不是在编译时确定&a…

Andorid 11 InputDispatcher FocusedApplication设置过程分析

在Input ANR中&#xff0c;有一类ANR打印的reason 为 “xx does not have a focused window” &#xff0c;表明 输入事件 5s 内&#xff0c;只有FocusedApplication&#xff0c;而没找到focused window。本文分析下FocusedApplication的设置过程。 setFocusedApp 源码路径&am…