[递归]一文看懂递归
1. 遞歸的定義
編程語言中,函數 Func(Type a,……) 直接或間接調用函數本身,則該函數稱為「遞歸函數」。
在實現遞歸函數之前,有兩件重要的事情需要弄清楚:
- 遞推關系:一個問題的結果與其子問題的結果之間的關系。
- 基本情況:不需要進一步的遞歸調用就可以直接計算答案的情況。可理解為遞歸跳出條件。
一旦我們計算出以上兩個元素,再想要實現一個遞歸函數,就只需要根據遞推關系調用函數本身,直到其抵達基本情況。
遞歸函數的編寫看起來比較難,其實是有套路可尋的,本文在力扣刷題階段總結了寫遞歸的一些范式技巧并在后續實戰中進行驗證,深入理解其中思維過程再去刷題時感覺輕而易舉了。
1.1 遞推關系
下面的插圖給出了一個 5 行的帕斯卡三角,根據上面的定義,我們生成一個具有確定行數的帕斯卡三角形。
首先,我們定義一個函數 f(i,j),它將會返回帕斯卡三角形第 i 行、第 j列的數字。可以看到,每行的最左邊和最右邊的數字是基本情況,它總是等于 11。
每個數是它左上方和右上方的數的和。
- 遞推關系:f(i,j)=f(i?1,j?1)+f(i?1,j)
- 基本情況:f(i,j)=1f(i,j)=1 ,當 j=1j=1 或者 i=ji=j 時。
再看一個「二叉樹的最大深度」遞推關系推導的案例:
二話不多,先定義一個 f(node),尋找當前節點 node 與當前節點子節點的關系,子節點可能是左、可能是右。
所以有子節點有 f(node.left)、f(node.right) 兩種情況,然后尋找 f(node)與它們的關系。當前節點的最大深度 = 他子節點的最大深度 + 1。
-
遞推關系:f(node)=Max(f(node.left),f(node.right))+1
-
基本情況:當前節點不存在時,高度為 0
對于二叉樹的算法題,我們會推導遞推關系時,所有相關的算法一下就變得很容易。但是有了遞推關系后如何寫出遞歸函數來呢?
介于最近對算法的研究,發現大部分所謂的動態規劃、回溯其實都是寫遞歸函數的一些思維過程。本文總結了寫遞歸的范式。有了這些范式,我們直接拿著題目套入就很容易寫出一個通過率很高的函數。
1.2 尾遞歸
尾遞歸:尾遞歸函數是遞歸函數的一種,其中遞歸調用是遞歸函數中的最后一條指令。并且在函數中應該只有一次遞歸調用。
尾遞歸的好處是,它可以避免遞歸調用期間棧空間開銷的累積,因為系統可以為每個遞歸調用重用棧中的固定空間。形象的理解參考 2.2.3 節內容中關于自頂向下的示例圖。
2. 寫遞歸函數的招式
下面我們以累加的示例說明寫遞歸的思路。
1+2+3+4+…+n,函數表達式為 f(n)=f(n?1)+n
2.1 尋找基本情況
累加示例中,基本情況為 n=1 時,f(1)=1。
你也可以設定為 f(2)=1+2=3,只要能正確跳出遞歸即可。
2.2 尋找遞推關系(難點)
累加示例中,遞推關系為 f(n)=f(n?1)+n,f(n) 每次計算時依賴 f(n?1) 的結果,所以我們把 f(n?1)的結果看作是中間變量。
中間變量其實就是聯系遞歸函數的紐帶,分析出中間變量遞歸函數也就實現了 80%。
大白話:當前結果必須借助前一個結果,前一個借助前前一個… 一直到時我們找到了「基本情況」。
然后拿到「基本情況」開始往回計算。這個過程我們簡稱為「自底向上」。
下面我們用 f(5)=1+2+3+4+5=15 這個過程進行分析。
2.2.1 自底向上
自底向上:在每個遞歸層次上,我們首先遞歸地調用自身,然后根據返回值進行計算。(依賴返回值)
大白話:將問題細化分解,例如計算 1-n 的和,可以逐步分解為 f(n) = f(n-1) + n
/** * 模擬程序執行過程:* 5 + sum(4)* 5 + (4 + sum(3)* 5 + 4 + (3 + sum(2))* 5 + 4 + 3 + (2 + sum(1))* ------------------> 到達基本情況 sum(1) = 1 ,開始執行 ③ 行代碼* 5 + 4 + 3 + (2 + 1)* 5 + 4 + (3 + 3)* 5 + (4 + 6)* (5 + 10)* 15* <p>* 自底向上:最終從 1 + 2 + 3 + 4 + 5 計算...* 遞歸函數「開始」部分調用自身,這個過程就是找到基本情況),然后根據返回值進行計算。*/ public int sum(int n) {if (n < 2) return n; // ① 遞歸基本情況int childSum = sum(n - 1); // ② 尋找基本情況return n + childSum; // ③ 根據返回值運算 }自底向上的過程其實是一個先尋找基本情況(跳出條件),然后根據基本情況計算它的父問題,一直到最后一個父問題計算后,返回最終結果。
本示例中基本情況是 sum(1) = 1,基本情況的父問題是 sum(2) = 2 + sum(1)。即從 N 加到 1,到達 1 時觸發結果開始逐個返回子問題的結果。
自底向上-范式
- 尋找遞歸遞推關系
- 尋找遞歸基本情況,跳出時返回基本情況的結果
- 修改遞歸函數的參數
- 遞歸調用并返回中間變量
- 使用遞歸函數的返回值與當前參數進行計算,并返回最終結果
2.2.2 自頂向下
假如我們換個思路,f(n)=f(n?1)+n 中我們把 f(n?1) 的結果(中間變量)提取出來 f(n,SUM)=SUM+n,
每次計算都帶著它,這樣我們可以先計算,然后把計算好的結果傳遞給遞歸函數進行下一次計算,這個過程我們稱為「自頂向下」。
自頂向下:在遞歸層級中,我們根據當前「函數參數」計算出一些值,并在遞歸調用函數時將這些值傳給自身。(依賴函數參數)
大白話:從最子問題逐步計算出最終問題,例如計算 1-n 的和,可以逐步分解為 n + sum(n-1) = sum(n),即從 1 加到 N。
/*** 模擬程序執行過程:* sum(5, 0)* sum(4, 5)* sum(3, 9)* sum(2, 12)* sum(1, 14)* 15* <p>* 自頂向下:最終從 5 + 4 + 3 + 2 + 1 計算...* 遞歸函數「末尾」部分調用自身,根據邏輯先進行計算,然后把計算的中間變量傳遞調用函數。* <p>* 這種在函數末尾調用自身的遞歸函數叫做「尾遞歸」*/ public int sum2(int n, int sum) {if (n < 2) return 1 + sum;sum += n;return sum2(n - 1, sum); }自頂向下-范式
- 尋找遞歸遞推關系
- 創建新函數,將「自底向上-范式」中的最終結果計算依賴的中間變量提取為函數的參數
- 尋找遞歸基本情況,跳出時返回基本情況的結果與中間變量的計算結果(最終結果)
- 根據函數參數與中間變量重新計算出新的中間變量
- 修改參數
- 遞歸調用并返回(該處的返回由基本情況觸發)
2.2.3 自底向上、自頂向下的區別
兩者最大的區別在于對中間變量的處理,參與計算的中間變量是參數提供的還是返回值提供的,這個過程最終決定了基本情況的返回值處理邏輯、遞歸函數的執行位置。
遞歸函數在計算前先找到基本情況再算還是先算再找基本情況,這個過程也就是「自底向上、自頂向下」的本質差異。
2.3 優化遞歸函數
優化點總結為:
-
充分分析基本情況(跳出條件),避免臨界值跳不出遞歸,導致棧溢出。
-
分析遞歸深度,太深的遞歸容易導致棧溢出。
-
分析是否有重復計算問題,主要分析函數參數值是否會出現重復,直接代入遞歸的遞推關系中運算即可。如果會出現重復使用數據結構記錄(記憶化消除重復)。
比如:斐波那契數列 f(n)=f(n?1)+f(n?2),如果直接采用該公式進行遞歸會重復計算很多表達式。
-
分析數據溢出問題
-
因為遞歸會對棧及中間變量的狀態保存有額外的開銷,將「自底向上」優化為「自頂向下」,再改寫為尾遞歸,再退化為循環結構。
尾遞歸是我們可以實現的遞歸的一種特殊形式。與記憶化技術不同的是,尾遞歸通過消除遞歸帶來的堆棧開銷,優化了算法的空間復雜度。更重要的是,有了尾遞歸,就可以避免經常伴隨一般遞歸而來的堆棧溢出問題,而尾遞歸的另一個優點是,與非尾遞歸相比,尾部遞歸更容易閱讀和理解。這是由于尾遞歸不存在調用后依賴(即遞歸調用是函數中的最后一個動作),這一點不同于非尾遞歸,因此,只要有可能,就應該盡量運用尾遞歸。
2.4 改為循環
遞歸本身的風險比較高,實際項目不推薦采用。部分編程語言可以對尾遞歸進行編譯優化(優化為循環結構),比如 Scala 語言。但是部分語言不支持,比如 Java。
函數式編程時推薦尾遞歸寫法并加標識讓編譯器進行優化,下面是 Scala 語言優化的一個案例:
// Scala 編譯前的尾遞歸寫法,并注解為尾遞歸 @scala.annotation.tailrec def sum2(n: Int, sum: Int): Int = {if (n < 2) return sum + nsum2(n - 1, sum + n) }// 編譯后優化為循環結果 public int sum2(int n, int sum) {while (true) {if (n < 2) return sum + n; sum += n;n--;} }一個不是尾遞歸的案例:
// 并不是最后一行遞歸調用就是尾遞歸,下面例子其實是一個自底向上的遞歸寫法,返回值與 n 有關。 def sum(n: Int): Int = {if (n < 2) return nreturn n + sum(n - 1) }3. 案例實戰-遞歸乘法
對于有些算法,遞歸比循環實現簡單,比如二叉樹的前中后序遍歷。但是大部分時候循環比遞歸更直觀更容易理解。
下面我們以力扣一個算法題 遞歸乘法 進行實戰,實戰前請花 10 min 時間嘗試自我完成。
如果只是局限于看小說似的閱讀,現在就可以「ALT+F4」了。 🙈
遞歸乘法。 寫一個遞歸函數,不使用 * 運算符, 實現兩個正整數的相乘。
可以使用加號、減號、位移,但要吝嗇一些。
3.1 審題思路
乘法本身是加法的變種,A * B = A 個 B 相加。
尋找基本情況的條件為:A 個 B 相加一次 A - 1,A 如果為 1 時即找到最后一個 B 。
首先我們用循環完成。
public int multiplyFor(int A, int B) {int sum = 0;while (A-- > 0) sum += B;return sum; }3.2 嘗試遞歸
尋找遞推關系 f(a,b)=b+f(a?1,b),基本情況條件為 a<2a<2 時表示找到最后一個 bb
套入「自底向上」的范式如下:
- 尋找遞歸遞推關系 - 尋找遞歸基本情況,跳出時返回基本情況的結果 -> B - 修改遞歸函數的參數,遞歸調用并返回中間變量 -> return sum(中間變量) - 使用遞歸函數的返回值進行計算并返回最終結果 -> sum + Bpublic int multiply(int A, int B) {if (A < 2) return B; // 跳出時返回基本情況的結果int sum = multiply(A - 1, B); // 先遞歸return sum + B; // 再計算,依賴遞歸的返回值 }嘗試轉換為遞歸「自頂向下」(尾遞歸),依賴中間結果(每次的和),先計算再遞歸。
f(a,b)=sum+f(a?n,b),sum為已經計算了的 n 個 b 的和。
- 尋找遞推關系 - 創建新函數,將「自底向上-范式」中的最終結果計算依賴的中間變量提取為函數的參數 -> multiply1Help 函數 sum 為中間變量 - 尋找遞歸基本情況,跳出時返回基本情況的結果與中間變量的計算結果(最終結果) -> return B + sum - 根據函數參數與中間變量重新計算出新的中間變量 -> sum += B - 修改參數 -> A - 1 - 遞歸調用并返回(該處的返回由基本情況條件觸發)-> B + sumpublic int multiply1(int A, int B) {return multiply1Help(A, B, 0); }public int multiply1Help(int A, int B, int sum) {if (A < 2) return B + sum; // 跳出時返回基本情況的結果與中間變量的計算結果sum += B; // 根據函數參數與中間變量重新計算出新的中間變量return multiply1Help(A - 1, B, sum);// 由基本情況條件觸發決定,最終返回 B + sum }至此,兩個遞歸寫法已實現,實際編碼中「自頂向下」比「自底向上」更容易理解,因為我們的思維從上向下思考容易,但是逆著思考就比較抽象了。
這個抽象過程需要大量的練習,末尾推薦了部分遞歸的算法題。
3.3 嘗試優化
分析上述實現的兩個遞歸時間復雜度為 O(n)O(n),n=Max(A,B),考慮如何優化時間復雜度。
- 如果 A > B ,處理 A 次,因此考慮使用 M=MIN(A,B)M=MIN(A,B) 作為循環次數。比如 100?1100?1 時可優化 100 倍
- 分析重復計算問題,目前沒有。
- 分析參數的邊界問題,目前沒有。題目給定約束都是正整數。
- 優化時間復雜度,重新分析遞推關系為 a?b=(a/2)?(b?2),直到 a/2a/2 等于 1 時,b 就是最終結果,我們基于二進制的位移操作進行優化,
但是要考慮如果是奇數除以 2 時會丟失一個 b,這樣復雜度優化為 O(log2n)
?
將上述過程轉換為「自頂向下」尾遞歸代碼實現(你可以嘗試「自底向上」實現,可以套入范式進行驗證):
public int multiply2(int A, int B) {return (A < B) ? multiply2Help(A, B, 0) : multiply2Help(B, A, 0); // 尋找最小循序次數 }// missPart 為奇數除以 2 時丟失的部分 public int multiply2Help(int A, int B, int missPart) {if (A < 2) return missPart + B; // 最終結果 = 丟失的部分 + 最終 B 的結果missPart += (A & 1) == 1 ? B : 0; // 是否為奇數,奇數時記錄丟失的部分return multiply2Help(A >> 1, B << 1, missPart); // 位移運算優化 }4. 案例實戰-青蛙跳臺階
一只青蛙一次可以跳上 1 級臺階,也可以跳上 2 級臺階。求該青蛙跳上一個 n 級的臺階總共有多少種跳法。 n = 0 時忽略。
尋找基本情況:剩余一個臺階時只能有一種跳法 f(1)=1,剩余兩個臺階時只能有兩種跳法 f(2)=2,或者剩余 3 個臺階時有 3 種跳法(1-1-1、1-2、2-1)
尋找遞推關系:如果跳 1 級臺階就少一個,結果為 f(n?1) 種,如果跳 2 級臺階就少 2 個,結果為 f(n?2)種。
所以推導遞推關系為 f(n)=f(n?1)+f(n?2)
優化遞歸:考慮重復計算問題,因為遞推關系中 n 涉及減法運算,肯定會出現重復代入 f(n) 計算,因此考慮使用數據結構保存計算過的結果。分析數據溢出問題,如果沒有給定約束條件,要考慮返回跳法是否會溢出。
自底向上的范式套入實現:
/*** - 尋找遞推關系 f(n)=f(n-1)+f(n-2)* - 尋找遞歸基本情況,跳出時返回基本情況的結果 f(1) = 1,f(2) = 2* - 修改遞歸函數的參數,遞歸調用 -> 套入遞推關系,當前 n 臺階跳法為 count=f(n-1)+f(n-2)* - 使用遞歸函數的返回值進行計算并返回最終結果 -> 遞歸返回跳法數 count 即為最終結果 */ public int numWays1(int n) {if (n == 1) return 1;if (n == 2) return 2;int count = numWays1(n - 1) + numWays1(n - 2);return count; }// 優化上述初步完成的遞歸思路: // 2 個 if 可有優化 為 if (n <= 2) return n; 減少執行次數 // 遞歸函數重復計算問題,使用臨時變量保存private final Map<Integer, Integer> statusRecord = new HashMap<>();public int numWays(int n) {if (n <= 2) return n; // if 判斷比計算狀態判斷開銷小,因此先進行 iffinal Integer integer = statusRecord.get(n); // 計算狀態判斷,已經計算直接返回if (integer != null) return integer;int count = 0; // 最終結果int count1 = numWays(n - 1); // 返回中間變量int count2 = numWays(n - 1); // 返回中間變量int count = count1 + count2; // 中間變量計算結果為最終結果statusRecord.put(n, count); // 計算的結果保存至狀態表return count; }// 至此除了數據溢出問題沒有處理,重復計算已優化。自頂向下的范式套入實現:
/*** 自頂向下的范式套入實現:* * - 尋找遞推關系* 自底向上遞推關系為,f(n) = f(n-1) + f(n-2) 相當于從 n-1 的計算過程,先從 n 找到 1,然后在從 1 累加到 n 的過程* 我們改為從 1-n 的過程,f(i+1) = f(i) + f(i-1) , i+1==n 時計算結束,累加的過程變量需要我們提取為中間變量參數* * - 創建新函數,將「自底向上-范式」中的最終結果計算依賴的中間變量提取為函數的參數* 將 f(i),f(i-1) 的變量保存,初始調用我們使用 f(2) = f(1) + f(0) = 1 + 1 作為初始狀態* * - 尋找基本情況,跳出時返回基本情況的結果與中間變量的計算結果(最終結果)-> if (i >= n) return a + b;* * - 根據函數參數與中間變量重新計算出新的中間變量* f(i) = f(i-1) + f(i-2) = a + b* f(i+1) = f(i) + f(i-1) = (a+b) + b* * - 修改參數 -> i + 1 遞進一步* * - 遞歸調用并返回(該處的返回由基本情況條件觸發)*/ public int numWaysTail(int n) {if (n < 2) return n;return numWaysTailHelp(n, 2, 1, 1); }private int numWaysTailHelp(int n, int i, int a, int b) {if (i >= n) return a + b;return numWaysTailHelp(n, i + 1, a + b, a); }// 因為是從 1-n 的計算,所以不會出現重復計算過程。自頂向下的尾遞歸再優化為循環結構:(也稱為動態規劃 )
public int numWaysFor(int n) {if (n < 2) return n;int i = 2; int a = 1; int b = 1; // 與尾遞歸 numWaysTailHelp 一致int count = a + b; // 保存次數,將尾遞歸的返回值提取為變量while (i <= n) { // 1-n 的過程 // 因為 f(i) = f(i-1) + f(i-2) = a + b // 下次迭代時 f(i+1) = f(i) + f(i-1) = (a+b) + bcount = a + b;b = a;a = count;i++;}return count; }5. 案例實戰-合并兩個有序的鏈表(多遞推公式情況)
因為合并是一個條件判斷的過程,因此我們在分析中要充分分析不同的條件分支。
- 基本情況:L1 或者 L2有一個為空時,不為空的節點即為頭節點
- 遞推公式:因為合并時涉及條件判斷,所以有兩種遞推公式
- L1>=L2時:merge(L1,L2)=L1+merge(L1.next,L2),此時 L1 為頭節點
- L1<L2時:merge(L1,L2)=L2+merge(L1,L2.next),此時 L2 為頭節點
自底向上的范式驗證:
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {if (null == l1) return l2; // 基本情況,返回頭節點if (null == l2) return l1; // 基本情況,返回頭節點if (l1.val <= l2.val) { // 決定使用哪個遞推公式ListNode mergeResult = mergeTwoLists(l1.next, l2); // 尋找基本情況l1.next = mergeResult; // 使用遞歸函數的返回值與當前參數進行計算,該處計算為鏈表鏈接指向return l1; // 返回計算后的頭節點} else {ListNode mergeResult = mergeTwoLists(l1, l2.next); // 尋找基本情況l2.next = mergeResult; // 使用遞歸函數的返回值與當前參數進行計算,該處計算為鏈表鏈接指向return l2; // 返回計算后的頭節點} }當涉及到條件判斷時,可能會出現多個基本情況、遞推公式,我們在遞歸函數中逐一處理即可。
這個多公式案例理解讓我們可以分析更為復雜遞推關系,本質上就是范式的逐步套入、優化的過程。
6. 再談自頂向下、自底向上
一般情況,我們說遞歸時指的是「自底向上」,因為「自頂向下」的過程往往需要創建新函數去完成,更甚至「自頂向下」其實就是循環結構封裝為函數式編程的寫法,也叫尾遞歸。
自底向上轉換為自頂向下的過程其實就是轉換為循環結構寫法的過程。
遞歸難以理解的地方在于自底向上的過程,其實細化該難點可以分為「基本情況」->「改變參數繼續遞歸」->「拿到遞歸返回值與當前參數計算」。
實際編碼中我們只要按上述提到的范式進行代碼編寫,上述示例中的基本情況比較單一,中間變量也只涉及一個,對于復雜的跳出及中間變量的處理只要按范式步驟進行分析然后再優化一定可以寫出一個遞歸函數。
對于遞推關系的尋找過程,沒有范式可尋,需要見多識廣(🙉刷刷刷🙉),不斷總結。
7. 遞歸算法推薦
- 力扣-遞歸標簽相關算法
- 力扣-卡片-二叉樹
- [力扣-卡片-遞歸] I(https://leetcode-cn.com/explore/featured/card/recursion-i/)
總結
簡單的總結為:
- 你要寫哪種類型的遞歸,從上算還是從下算,這決定了你如何確認遞推關系
- 分析基本情況
- 尋找遞推關系,在遞推關系中提取中間變量
- 套入上文中的遞歸范式
- 按上文中的優化點進行優化
題外話:對于自上而下的計算不是必須創建新函數去傳入中間變量,因為有時我們可以使用全局變量保存、或者直接修改當前遞推關系中的變量即可。
推薦使用總結內容「自頂向下、自底向上、循環結構」三種方法完成力扣-206. 反轉鏈表
題解鏈接
轉載鏈接:https://leetcode-cn.com/circle/article/koSrVI/
總結
以上是生活随笔為你收集整理的[递归]一文看懂递归的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Android横竖屏切换的生命周期
- 下一篇: 2020省赛第八次训练赛题解