基礎資料結構之遞迴

程序员波特發表於2024-09-26

遞迴

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) = \begin{cases} 停止& n = null \\ f(n.next) & n \neq null \end{cases} \]

  • 深入到最裡層叫做
  • 從最裡層出來叫做
  • 的過程中,外層函式內的區域性變數(以及方法引數)並未消失,的時候還可以用到

2) 單路遞迴 Single Recursion

E01. 階乘

用遞迴方法求階乘

  • 階乘的定義 \(n!= 1⋅2⋅3⋯(n-2)⋅(n-1)⋅n\),其中 \(n\) 為自然數,當然 \(0! = 1\)

  • 遞推關係

\[f(n) = \begin{cases} 1 & n = 1\\ n * f(n-1) & n > 1 \end{cases} \]

程式碼

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) = \begin{cases} 停止 & n = str.length() \\ f(n+1) & 0 \leq n \leq str.length() - 1 \end{cases} \]

程式碼為

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](單路遞迴)

\(n\) 個人排成圓圈,從頭開始報數,每次數到第 \(m\) 個人(\(m\)\(1\) 開始)殺之,繼續從下一個人重複以上過程,求最後活下來的人是誰?

方法1

根據最後的存活者 a 倒推出它在上一輪的索引號

f(n,m) 本輪索引 為了讓 a 是這個索引,上一輪應當這樣排 規律
f(1,3) 0 x x x a (0 + 3) % 2
f(2,3) 1 x x x 0 a (1 + 3) % 3
f(3,3) 1 x x x 0 a (1 + 3) % 4
f(4,3) 0 x x x a (0 + 3) % 5
f(5,3) 3 x x x 0 1 2 a (3 + 3) % 6
f(6,3) 0 x x x a

方法2

設 n 為總人數,m 為報數次數,解返回的是這些人的索引,從0開始

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

一. 找出等價函式

規律:下次報數的起點為 \(k = m \% n\)

  • 首次出列人的序號是 \(k-1\),剩下的的 \(n-1\) 個人重新組成約瑟夫環
  • 下次從 \(k\) 開始數,序號如下
    • \(k,\ k+1, \ ...\ ,\ 0,\ 1,\ k-2\),如上例中 \(3\ 4\ 5\ 0\ 1\)

這個函式稱之為 \(g(n-1,m)\),它的最終結果與 \(f(n,m)\) 是相同的。

二. 找到對映函式

現在想辦法找到 \(g(n-1,m)\)\(f(n-1, m)\) 的對應關係,即

\[3 \rightarrow 0 \\ 4 \rightarrow 1 \\ 5 \rightarrow 2 \\ 0 \rightarrow 3 \\ 1 \rightarrow 4 \\ \]

對映函式為

\[mapping(x) = \begin{cases} x-k & x=[k..n-1] \\ x+n-k & x=[0..k-2] \end{cases} \]

等價於下面函式

\[mapping(x) = (x + n - k)\%{n} \]

代入測試一下

\[3 \rightarrow (3+6-3)\%6 \rightarrow 0 \\ 4 \rightarrow (4+6-3)\%6 \rightarrow 1 \\ 5 \rightarrow (5+6-3)\%6 \rightarrow 2 \\ 0 \rightarrow (0+6-3)\%6 \rightarrow 3 \\ 1 \rightarrow (1+6-3)\%6 \rightarrow 4 \\ \]

綜上有

\[f(n-1,m) = mapping(g(n-1,m)) \]

三. 求逆對映函式

對映函式是根據 x 計算 y,逆對映函式即根據 y 得到 x

\[mapping^{-1}(x) = (x + k)\%n \]

代入測試一下

\[0 \rightarrow (0+3)\%6 \rightarrow 3 \\ 1 \rightarrow (1+3)\%6 \rightarrow 4 \\ 2 \rightarrow (2+3)\%6 \rightarrow 5 \\ 3 \rightarrow (3+3)\%6 \rightarrow 0 \\ 4 \rightarrow (4+3)\%6 \rightarrow 1 \\ \]

因此可以求得

\[g(n-1,m) = mapping^{-1}(f(n-1,m)) \]

四. 遞推式

代入推導

\[\begin{aligned} 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) \% n \\ \end{aligned} \]

最後一步化簡是利用了模運演算法則

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

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

最終遞推式

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

3) 多路遞迴 Multi Recursion

E01. 斐波那契數列-Leetcode 70

  • 之前的例子是每個遞迴函式只包含一個自身的呼叫,這稱之為 single recursion
  • 如果每個遞迴函式例包含多個自身呼叫,稱之為 multi recursion

遞推關係

\[f(n) = \begin{cases} 0 & n=0 \\ 1 & n=1 \\ f(n-1) + f(n-2) & n>1 \end{cases} \]

下面的表格列出了數列的前幾項

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

實現

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\)
  • 時間複雜度推導過程
    • 斐波那契通項公式 \(f(n) = \frac{1}{\sqrt{5}}*({\frac{1+\sqrt{5}}{2}}^n - {\frac{1-\sqrt{5}}{2}}^n)\)
    • 簡化為:\(f(n) = \frac{1}{2.236}*({1.618}^n - {(-0.618)}^n)\)
    • 帶入遞迴次數公式 \(2*\frac{1}{2.236}*({1.618}^{n+1} - {(-0.618)}^{n+1})-1\)
    • 時間複雜度為 \(\Theta(1.618^n)\)
  1. 更多 Fibonacci 參考[8][9][^10]
  2. 以上時間複雜度分析,未考慮大數相加的因素

變體1 - 兔子問題[^8]

  • 第一個月,有一對未成熟的兔子(黑色,注意圖中個頭較小)
  • 第二個月,它們成熟
  • 第三個月,它們能產下一對新的小兔子(藍色)
  • 所有兔子遵循相同規律,求第 \(n\) 個月的兔子數

分析

兔子問題如何與斐波那契聯絡起來呢?設第 n 個月兔子數為 \(f(n)\)

  • \(f(n)\) = 上個月兔子數 + 新生的小兔子數
  • 而【新生的小兔子數】實際就是【上個月成熟的兔子數】
  • 因為需要一個月兔子就成熟,所以【上個月成熟的兔子數】也就是【上上個月的兔子數】
  • 上個月兔子數,即 \(f(n-1)\)
  • 上上個月的兔子數,即 \(f(n-2)\)

因此本質還是斐波那契數列,只是從其第一項開始

變體2 - 青蛙爬樓梯

  • 樓梯有 \(n\)
  • 青蛙要爬到樓頂,可以一次跳一階,也可以一次跳兩階
  • 只能向上跳,問有多少種跳法

分析

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 ... ...
  • 因此本質上還是斐波那契數列,只是從其第二項開始

  • 對應 leetcode 題目 70. 爬樓梯 - 力扣(LeetCode)

E02. 漢諾塔[^13](多路遞迴)

Tower of Hanoi,是一個源於印度古老傳說:大梵天建立世界時做了三根金剛石柱,在一根柱子從下往上按大小順序摞著 64 片黃金圓盤,大梵天命令婆羅門把圓盤重新擺放在另一根柱子上,並且規定

  • 一次只能移動一個圓盤
  • 小圓盤上不能放大圓盤

下面的動圖演示了4片圓盤的移動方法

使用程式程式碼模擬圓盤的移動過程,並估算出時間複雜度

思路

  • 假設每根柱子標號 a,b,c,每個圓盤用 1,2,3 ... 表示其大小,圓盤初始在 a,要移動到的目標是 c

  • 如果只有一個圓盤,此時是最小問題,可以直接求解

    • 移動圓盤1 \(a \mapsto c\)

  • 如果有兩個圓盤,那麼

    • 圓盤1 \(a \mapsto b\)
    • 圓盤2 \(a \mapsto c\)
    • 圓盤1 \(b \mapsto c\)

  • 如果有三個圓盤,那麼

    • 圓盤12 \(a \mapsto b\)
    • 圓盤3 \(a \mapsto c\)
    • 圓盤12 \(b \mapsto c\)

  • 如果有四個圓盤,那麼

    • 圓盤 123 \(a \mapsto b\)
    • 圓盤4 \(a \mapsto c\)
    • 圓盤 123 \(b \mapsto 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
  • \(i\),列 \(j\),那麼 \([i][j]\) 的取值應為 \([i-1][j-1] + [i-1][j]\)
  • \(j=0\)\(i=j\) 時,\([i][j]\) 取值為 \(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();
    }
}

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]\) 已經有值,說明該元素已經被之前的遞迴函式計算過,就不必重複計算了

最佳化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(3)\) 重複了 2 次
  • \(f(2)\) 重複了 3 次
  • \(f(1)\) 重複了 5 次
  • \(f(0)\) 重複了 3 次

隨著 \(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)\)
  • 請自行驗證改進後的效果
  • 請自行分析改進後的空間複雜度

注意

  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\) 時,爆棧了

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)\) 這個方法內有個需要執行 \(3 + sum(2)\)\(sum(2)\) 沒返回前,加號前面的 \(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
}
  • 最後一步執行的是加法

一些語言[^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 = 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)\)

// 首次呼叫
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(\frac{n}{b}) + f(n) \]

其中

  • \(T(n)\) 是問題的執行時間,\(n\) 是資料規模
  • \(a\) 是子問題個數
  • \(T(\frac{n}{b})\) 是子問題執行時間,每個子問題被拆成原問題資料規模的 \(\frac{n}{b}\)
  • \(f(n)\) 是除遞迴外執行的計算

\(x = \log_{b}{a}\),即 \(x = \log_{子問題縮小倍數}{子問題個數}\)

那麼

\[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} \]

例1

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

  • 此時 \(x = 1 < 4\),由後者決定整個時間複雜度 \(\Theta(n^4)\)
  • 如果覺得對數不好算,可以換為求【\(b\) 的幾次方能等於 \(a\)

例2

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

  • \(a=1, b=\frac{10}{7}, x=0, c=1\)
  • 此時 \(x = 0 < 1\),由後者決定整個時間複雜度 \(\Theta(n)\)

例3

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

  • \(a=16, b=4, x=2, c=2\)
  • 此時 \(x=2 = c\),時間複雜度 \(\Theta(n^2 \log{n})\)

例4

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

  • \(a=7, b=3, x=1.?, c=2\)
  • 此時 \(x = \log_{3}{7} < 2\),由後者決定整個時間複雜度 \(\Theta(n^2)\)

例5

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

  • \(a=7, b=2, x=2.?, c=2\)
  • 此時 \(x = log_2{7} > 2\),由前者決定整個時間複雜度 \(\Theta(n^{\log_2{7}})\)

例6

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

  • \(a=2, b=4, x = 0.5, c=0.5\)
  • 此時 \(x = 0.5 = c\),時間複雜度 \(\Theta(\sqrt{n}\ \log{n})\)

例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\)
  • 子問題資料規模縮小倍數 \(b = 2\)
  • 除遞迴外執行的計算是常數級 \(c=0\)

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

  • 此時 \(x=0 = c\),時間複雜度 \(\Theta(\log{n})\)

例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\)
  • 子問題資料規模縮小倍數 \(b=2\)
  • 除遞迴外,主要時間花在合併上,它可以用 \(f(n) = n\) 表示

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

  • 此時 \(x=1=c\),時間複雜度 \(\Theta(n\log{n})\)

例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\)
  • 子問題資料規模縮小倍數
    • 如果分割槽分的好,\(b=2\)
    • 如果分割槽沒分好,例如分割槽1 的資料是 0,分割槽 2 的資料是 \(n-1\)
  • 除遞迴外,主要時間花在分割槽上,它可以用 \(f(n) = n\) 表示

情況1 - 分割槽分的好

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

  • 此時 \(x=1=c\),時間複雜度 \(\Theta(n\log{n})\)

情況2 - 分割槽沒分好

\(T(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) + c\)\(T(1) = c\)

下面為展開過程

\(T(n) = T(n-2) + c + c\)

\(T(n) = T(n-3) + c + c + c\)

...

\(T(n) = T(n-(n-1)) + (n-1)c\)

  • 其中 \(T(n-(n-1))\)\(T(1)\)
  • 帶入求得 \(T(n) = c + (n-1)c = nc\)

時間複雜度為 \(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(1) = c\)

下面為展開過程

\(T(n) = T(n-2) + (n-1) + n\)

\(T(n) = T(n-3) + (n-2) + (n-1) + n\)

...

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

時間複雜度 \(O(n^2)\)

注:

  • 等差數列求和為 \(個數*\frac{\vert首項-末項\vert}{2}\)

例3 - 遞迴快排

快速排序分割槽沒分好的極端情況

\(T(n) = T(n-1) + T(1) + n\)\(T(1) = c\)

\(T(n) = T(n-1) + c + n\)

下面為展開過程

\(T(n) = T(n-2) + c + (n-1) + c + n\)

\(T(n) = T(n-3) + c + (n-2) + c + (n-1) + c + n\)

...

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

時間複雜度 \(O(n^2)\)

不會推導的同學可以進入 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

本文,已收錄於,我的技術網站 pottercoding.cn,有大廠完整面經,工作技術,架構師成長之路,等經驗分享!

相關文章