2.3 遞歸
概述
定義
計(jì)算機(jī)科學(xué)中,遞歸是一種解決計(jì)算問題的方法锌订,其中解決方案取決于同一類問題的更小子集
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)
}
說明:
- 自己調(diào)用自己竹握,如果說每個(gè)函數(shù)對(duì)應(yīng)著一種解決方案,自己調(diào)用自己意味著解決方案是一樣的(有規(guī)律的)
- 每次調(diào)用辆飘,函數(shù)處理的數(shù)據(jù)會(huì)較上次縮減(子集)啦辐,而且最后會(huì)縮減至無需繼續(xù)遞歸
- 內(nèi)層函數(shù)調(diào)用(子集處理)完成,外層函數(shù)才能算調(diào)用完成
原理
假設(shè)鏈表中有 3 個(gè)節(jié)點(diǎn)蜈项,value 分別為 1芹关,2,3紧卒,以上代碼的執(zhí)行流程就類似于下面的偽碼
// 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
}
思路
- 確定能否使用遞歸求解
- 推導(dǎo)出遞推關(guān)系侥衬,即父問題與子問題的關(guān)系,以及遞歸的結(jié)束條件
例如之前遍歷鏈表的遞推關(guān)系為
- 深入到最里層叫做遞
- 從最里層出來叫做歸
- 在遞的過程中跑芳,外層函數(shù)內(nèi)的局部變量(以及方法參數(shù))并未消失轴总,歸的時(shí)候還可以用到
單路遞歸 Single Recursion
E01. 階乘
用遞歸方法求階乘
階乘的定義
,其中
為自然數(shù)博个,當(dāng)然
遞推關(guān)系
代碼
private static int f(int n) {
if (n == 1) {
return 1;
}
return n * f(n - 1);
}
拆解偽碼如下怀樟,假設(shè) n 初始值為 3
f(int n = 3) { // 解決不了,遞
return 3 * f(int n = 2) { // 解決不了,繼續(xù)遞
return 2 * f(int n = 1) {
if (n == 1) { // 可以解決, 開始?xì)w
return 1;
}
}
}
}
E02. 反向打印字符串
用遞歸反向打印字符串,n 為字符在整個(gè)字符串 str 中的索引位置
- 遞:n 從 0 開始盆佣,每次 n + 1往堡,一直遞到 n == str.length() - 1
- 歸:從 n == str.length() 開始?xì)w,從歸打印罪塔,自然是逆序的
遞推關(guān)系
代碼為
public static void reversePrint(String str, int index) {
if (index == str.length()) {
return;
}
reversePrint(str, index + 1);
System.out.println(str.charAt(index));
}
拆解偽碼如下投蝉,假設(shè)字符串為 "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; // 開始?xì)w
}
}
System.out.println(str.charAt(index)); // 打印 c
}
System.out.println(str.charAt(index)); // 打印 b
}
System.out.println(str.charAt(index)); // 打印 a
}
多路遞歸 Multi Recursion
E01. 斐波那契數(shù)列
- 之前的例子是每個(gè)遞歸函數(shù)只包含一個(gè)自身的調(diào)用,這稱之為 single recursion
- 如果每個(gè)遞歸函數(shù)例包含多個(gè)自身調(diào)用征堪,稱之為 multi recursion
遞推關(guān)系
下面的表格列出了數(shù)列的前幾項(xiàng)
F0 | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 | F12 | F13 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 | 89 | 144 | 233 |
實(shí)現(xiàn)
public static int f(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return f(n - 1) + f(n - 2);
}
執(zhí)行流程
- 綠色代表正在執(zhí)行(對(duì)應(yīng)遞)瘩缆,灰色代表執(zhí)行結(jié)束(對(duì)應(yīng)歸)
- 遞不到頭,不能歸佃蚜,對(duì)應(yīng)著深度優(yōu)先搜索
時(shí)間復(fù)雜度
- 遞歸的次數(shù)也符合斐波那契規(guī)律庸娱,
- 時(shí)間復(fù)雜度推導(dǎo)過程
- 斐波那契通項(xiàng)公式
- 簡(jiǎn)化為:
- 帶入遞歸次數(shù)公式
- 時(shí)間復(fù)雜度為
- 斐波那契通項(xiàng)公式
- 更多 Fibonacci 參考[8][9][^10]
- 以上時(shí)間復(fù)雜度分析,未考慮大數(shù)相加的因素
變體1 - 兔子問題[^8]
- 第一個(gè)月谐算,有一對(duì)未成熟的兔子(黑色熟尉,注意圖中個(gè)頭較小)
- 第二個(gè)月洲脂,它們成熟
- 第三個(gè)月斤儿,它們能產(chǎn)下一對(duì)新的小兔子(藍(lán)色)
- 所有兔子遵循相同規(guī)律剧包,求第
個(gè)月的兔子數(shù)
分析
兔子問題如何與斐波那契聯(lián)系起來呢?設(shè)第 n 個(gè)月兔子數(shù)為
-
= 上個(gè)月兔子數(shù) + 新生的小兔子數(shù)
- 而【新生的小兔子數(shù)】實(shí)際就是【上個(gè)月成熟的兔子數(shù)】
- 因?yàn)樾枰粋€(gè)月兔子就成熟往果,所以【上個(gè)月成熟的兔子數(shù)】也就是【上上個(gè)月的兔子數(shù)】
- 上個(gè)月兔子數(shù)疆液,即
- 上上個(gè)月的兔子數(shù),即
因此本質(zhì)還是斐波那契數(shù)列陕贮,只是從其第一項(xiàng)開始
變體2 - 青蛙爬樓梯
- 樓梯有
階
- 青蛙要爬到樓頂堕油,可以一次跳一階,也可以一次跳兩階
- 只能向上跳肮之,問有多少種跳法
分析
n | 跳法 | 規(guī)律 |
---|---|---|
1 | (1) | 暫時(shí)看不出 |
2 | (1,1) (2) | 暫時(shí)看不出 |
3 | (1,1,1) (1,2) (2,1) | 暫時(shí)看不出 |
4 | (1,1,1,1) (1,2,1) (2,1,1)<br />(1,1,2) (2,2) | 最后一跳掉缺,跳一個(gè)臺(tái)階的,基于f(3)<br />最后一跳戈擒,跳兩個(gè)臺(tái)階的眶明,基于f(2) |
5 | ... | ... |
因此本質(zhì)上還是斐波那契數(shù)列,只是從其第二項(xiàng)開始
對(duì)應(yīng) leetcode 題目 70. 爬樓梯 - 力扣(LeetCode)
遞歸優(yōu)化-記憶法
上述代碼存在很多重復(fù)的計(jì)算峦甩,例如求 遞歸分解過程
可以看到(顏色相同的是重復(fù)的):
-
重復(fù)了 2 次
-
重復(fù)了 3 次
-
重復(fù)了 5 次
-
重復(fù)了 3 次
隨著 的增大赘来,重復(fù)次數(shù)非诚衷可觀凯傲,如何優(yōu)化呢?
Memoization 記憶法(也稱備忘錄)是一種優(yōu)化技術(shù)嗦篱,通過存儲(chǔ)函數(shù)調(diào)用結(jié)果(通常比較昂貴)冰单,當(dāng)再次出現(xiàn)相同的輸入(子問題)時(shí),就能實(shí)現(xiàn)加速效果灸促,改進(jìn)后的代碼
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];
}
優(yōu)化后的圖示诫欠,只要結(jié)果被緩存,就不會(huì)執(zhí)行其子問題
- 改進(jìn)后的時(shí)間復(fù)雜度為
- 請(qǐng)自行驗(yàn)證改進(jìn)后的效果
- 請(qǐng)自行分析改進(jìn)后的空間復(fù)雜度
注意
- 記憶法是動(dòng)態(tài)規(guī)劃的一種情況浴栽,強(qiáng)調(diào)的是自頂向下的解決
- 記憶法的本質(zhì)是空間換時(shí)間
遞歸優(yōu)化-尾遞歸
爆棧
用遞歸做
public static long sum(long n) {
if (n == 1) {
return 1;
}
return n + sum(n - 1);
}
在我的機(jī)器上 時(shí)荒叼,爆棧了
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)
...
為什么呢?
- 每次方法調(diào)用是需要消耗一定的棧內(nèi)存的典鸡,這些內(nèi)存用來存儲(chǔ)方法參數(shù)被廓、方法內(nèi)局部變量、返回地址等等
- 方法調(diào)用占用的內(nèi)存需要等到方法結(jié)束時(shí)才會(huì)釋放
- 而遞歸調(diào)用我們之前講過萝玷,不到最深不會(huì)回頭嫁乘,最內(nèi)層方法沒完成之前,外層方法都結(jié)束不了
- 例如球碉,
這個(gè)方法內(nèi)有個(gè)需要執(zhí)行
蜓斧,
沒返回前,加號(hào)前面的
不能釋放
- 看下面?zhèn)未a
- 例如球碉,
long sum(long n = 3) {
return 3 + long sum(long n = 2) {
return 2 + long sum(long n = 1) {
return 1;
}
}
}
尾調(diào)用
如果函數(shù)的最后一步是調(diào)用一個(gè)函數(shù)睁冬,那么稱為尾調(diào)用挎春,例如
function a() {
return b()
}
下面三段代碼不能叫做尾調(diào)用
function a() {
const c = b()
return c
}
- 因?yàn)樽詈笠徊讲⒎钦{(diào)用函數(shù)
function a() {
return b() + 1
}
- 最后一步執(zhí)行的是加法
function a(x) {
return b() + x
}
- 最后一步執(zhí)行的是加法
一些語言[^11]的編譯器能夠?qū)ξ舱{(diào)用做優(yōu)化,例如
function a() {
// 做前面的事
return b()
}
function b() {
// 做前面的事
return c()
}
function c() {
return 1000
}
a()
沒優(yōu)化之前的偽碼
function a() {
return function b() {
return function c() {
return 1000
}
}
}
優(yōu)化后偽碼如下
a()
b()
c()
為何尾遞歸才能優(yōu)化?
調(diào)用 a 時(shí)
- a 返回時(shí)發(fā)現(xiàn):沒什么可留給 b 的直奋,將來返回的結(jié)果 b 提供就可以了狼荞,用不著我 a 了,我的內(nèi)存就可以釋放
調(diào)用 b 時(shí)
- b 返回時(shí)發(fā)現(xiàn):沒什么可留給 c 的帮碰,將來返回的結(jié)果 c 提供就可以了相味,用不著我 b 了,我的內(nèi)存就可以釋放
如果調(diào)用 a 時(shí)
- 不是尾調(diào)用殉挽,例如 return b() + 1丰涉,那么 a 就不能提前結(jié)束,因?yàn)樗€得利用 b 的結(jié)果做加法
尾遞歸
尾遞歸是尾調(diào)用的一種特例斯碌,也就是最后一步執(zhí)行的是同一個(gè)函數(shù)
尾遞歸避免爆棧
安裝 Scala
Scala 入門
object Main {
def main(args: Array[String]): Unit = {
println("Hello Scala")
}
}
- Scala 是 java 的近親一死,java 中的類都可以拿來重用
- 類型是放在變量后面的
- Unit 表示無返回值,類似于 void
- 不需要以分號(hào)作為結(jié)尾傻唾,當(dāng)然加上也對(duì)
還是先寫一個(gè)會(huì)爆棧的函數(shù)
def sum(n: Long): Long = {
if (n == 1) {
return 1
}
return n + sum(n - 1)
}
- Scala 最后一行代碼若作為返回值投慈,可以省略 return
不出所料,在 時(shí)冠骄,還是出了異常
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)
...
這是因?yàn)橐陨洗a伪煤,還不是尾調(diào)用,要想成為尾調(diào)用凛辣,那么:
- 最后一行代碼抱既,必須是一次函數(shù)調(diào)用
- 內(nèi)層函數(shù)必須擺脫與外層函數(shù)的關(guān)系,內(nèi)層函數(shù)執(zhí)行后不依賴于外層的變量或常量
def sum(n: Long): Long = {
if (n == 1) {
return 1
}
return n + sum(n - 1) // 依賴于外層函數(shù)的 n 變量
}
如何讓它執(zhí)行后就擺脫對(duì) n 的依賴呢扁誓?
- 不能等遞歸回來再做加法防泵,那樣就必須保留外層的 n
- 把 n 當(dāng)做內(nèi)層函數(shù)的一個(gè)參數(shù)傳進(jìn)去,這時(shí) n 就屬于內(nèi)層函數(shù)了
- 傳參時(shí)就完成累加, 不必等回來時(shí)累加
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
執(zhí)行流程如下,以偽碼表示
// 首次調(diào)用
def sum(n = 4, accumulator = 0): Long = {
return sum(4 - 1, 4 + accumulator)
}
// 接下來調(diào)用內(nèi)層 sum, 傳參時(shí)就完成了累加, 不必等回來時(shí)累加寿谴,當(dāng)內(nèi)層 sum 調(diào)用后锁右,外層 sum 空間沒必要保留
def sum(n = 3, accumulator = 4): Long = {
return sum(3 - 1, 3 + accumulator)
}
// 繼續(xù)調(diào)用內(nèi)層 sum
def sum(n = 2, accumulator = 7): Long = {
return sum(2 - 1, 2 + accumulator)
}
// 繼續(xù)調(diào)用內(nèi)層 sum, 這是最后的 sum 調(diào)用完就返回最后結(jié)果 10, 前面所有其它 sum 的空間早已釋放
def sum(n = 1, accumulator = 9): Long = {
if (1 == 1) {
return 1 + accumulator
}
}
本質(zhì)上,尾遞歸優(yōu)化是將函數(shù)的遞歸調(diào)用拭卿,變成了函數(shù)的循環(huán)調(diào)用
改循環(huán)避免爆棧
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);
}
遞歸時(shí)間復(fù)雜度-Master theorem[^14]
若有遞歸式
其中
-
是問題的運(yùn)行時(shí)間,
是數(shù)據(jù)規(guī)模
-
是子問題個(gè)數(shù)
-
是子問題運(yùn)行時(shí)間响蕴,每個(gè)子問題被拆成原問題數(shù)據(jù)規(guī)模的
-
是除遞歸外執(zhí)行的計(jì)算
令 浦夷,即
那么
例1
- 此時(shí)
辖试,由后者決定整個(gè)時(shí)間復(fù)雜度
- 如果覺得對(duì)數(shù)不好算,可以換為求【
的幾次方能等于
】
例2
- 此時(shí)
劈狐,由后者決定整個(gè)時(shí)間復(fù)雜度
例3
- 此時(shí)
罐孝,時(shí)間復(fù)雜度
例4
- 此時(shí)
,由后者決定整個(gè)時(shí)間復(fù)雜度
例5
- 此時(shí)
肥缔,由前者決定整個(gè)時(shí)間復(fù)雜度
例6
- 此時(shí)
莲兢,時(shí)間復(fù)雜度
例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;
}
}
- 子問題個(gè)數(shù)
- 子問題數(shù)據(jù)規(guī)模縮小倍數(shù)
- 除遞歸外執(zhí)行的計(jì)算是常數(shù)級(jí)
- 此時(shí)
续膳,時(shí)間復(fù)雜度
例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);
}
- 子問題個(gè)數(shù)
- 子問題數(shù)據(jù)規(guī)母耐В縮小倍數(shù)
- 除遞歸外,主要時(shí)間花在合并上坟岔,它可以用
表示
- 此時(shí)
谒兄,時(shí)間復(fù)雜度
例9. 快速排序遞歸
algorithm quicksort(A, lo, hi) is
if lo >= hi || lo < 0 then
return
// 分區(qū)
p := partition(A, lo, hi)
// 遞歸
quicksort(A, lo, p - 1)
quicksort(A, p + 1, hi)
- 子問題個(gè)數(shù)
- 子問題數(shù)據(jù)規(guī)模縮小倍數(shù)
- 如果分區(qū)分的好社付,
- 如果分區(qū)沒分好承疲,例如分區(qū)1 的數(shù)據(jù)是 0,分區(qū) 2 的數(shù)據(jù)是
- 如果分區(qū)分的好社付,
- 除遞歸外鸥咖,主要時(shí)間花在分區(qū)上燕鸽,它可以用
表示
情況1 - 分區(qū)分的好
- 此時(shí)
,時(shí)間復(fù)雜度
情況2 - 分區(qū)沒分好
- 此時(shí)不能用主定理求解
遞歸時(shí)間復(fù)雜度-展開求解
像下面的遞歸式扛或,都不能用主定理求解
例1 - 遞歸求和
long sum(long n) {
if (n == 1) {
return 1;
}
return n + sum(n - 1);
}
绵咱,
下面為展開過程
...
- 其中
即
- 帶入求得
時(shí)間復(fù)雜度為
例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);
}
,
下面為展開過程
...
時(shí)間復(fù)雜度
注:
- 等差數(shù)列求和為
![]()
例3 - 遞歸快排
快速排序分區(qū)沒分好的極端情況
熙兔,
下面為展開過程
...
時(shí)間復(fù)雜度
不會(huì)推導(dǎo)的同學(xué)可以進(jìn)入 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