遞迴、分治和動態規劃

z_s_s發表於2024-12-04

遞迴、分治和動態規劃是演算法中的三種重要思想,儘管它們有一些相似之處,但在具體實現和應用上有所不同。下面我將逐一講解這三者的概念和區別。

1. 遞迴(Recursion)

遞迴是演算法中的一種思想,指的是透過將一個大問題分解為規模較小的相同問題來求解問題。遞迴透過函式自己呼叫自己來實現解決方案。遞迴的關鍵要點是:

  • 基本情況(Base Case):即遞迴的終止條件,避免無限遞迴。
  • 遞推關係:將大問題分解為相似的子問題。

例子:計算斐波那契數列(Fibonacci)

斐波那契數列的遞迴公式是:

F(n)=F(n−1)+F(n−2)(for n≥2)F(n) = F(n-1) + F(n-2) \quad \text{(for } n \geq 2\text{)}

基本情況:

F(0)=0,F(1)=1F(0) = 0, \quad F(1) = 1

遞迴實現:

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

2. 分治(Divide and Conquer)

分治法是一種將一個複雜問題分解為多個相同或類似的子問題來逐步求解的策略。分治法通常是遞迴的,但它的核心思想是透過“分”和“治”兩個步驟來解決問題:

  • 分(Divide):將問題分解成子問題。
  • 治(Conquer):遞迴地解決這些子問題,若子問題足夠小,則直接求解。
  • 合併(Combine):將子問題的解合併成原問題的解。

分治法強調的是透過將問題分解為多個獨立的小問題來簡化求解過程。

例子:歸併排序(Merge Sort)

歸併排序的核心思想就是將陣列分成兩半,分別排序,然後合併排序後的兩部分:

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(left, right):
    result = []
    while left and right:
        if left[0] < right[0]:
            result.append(left.pop(0))
        else:
            result.append(right.pop(0))
    result.extend(left or right)
    return result

我們來一步步看一下歸併排序的計算過程。假設我們要排序的陣列是 [38, 27, 43, 3, 9, 82, 10],我們將按遞迴步驟和合並過程來演示。

原始陣列:

[38, 27, 43, 3, 9, 82, 10]

步驟 1: 分解陣列

將陣列分成兩半,不斷遞迴分割直到每個子陣列只有一個元素。

  • [38, 27, 43, 3, 9, 82, 10][38, 27, 43][3, 9, 82, 10]
  • [38, 27, 43][38][27, 43]
  • [27, 43][27][43]
  • [3, 9, 82, 10][3, 9][82, 10]
  • [3, 9][3][9]
  • [82, 10][82][10]

現在我們已經分割到最小的子陣列 [38], [27], [43], [3], [9], [82], [10]

步驟 2: 合併並排序

接下來,我們開始從最小的子陣列開始合併。每次合併兩個已排序的子陣列。

  • 合併 [27][43][27, 43]
  • 合併 [38][27, 43][27, 38, 43]
  • 合併 [3][9][3, 9]
  • 合併 [82][10][10, 82]
  • 合併 [3, 9][10, 82][3, 9, 10, 82]
  • 合併 [27, 38, 43][3, 9, 10, 82][3, 9, 10, 27, 38, 43, 82]

最終合併結果:

最終的合併結果就是排序後的陣列: [3, 9, 10, 27, 38, 43, 82]


3. 動態規劃(Dynamic Programming,DP)

動態規劃是透過將複雜問題分解為更小的子問題,並儲存這些子問題的解,從而避免重複計算相同的子問題。與分治法的區別在於,分治法將問題分解為獨立的子問題,而動態規劃將問題分解為重疊的子問題,計算時會利用之前計算出的結果。動態規劃的關鍵要點是:

  • 子問題重疊:同一子問題會被多次計算。
  • 狀態轉移方程:透過遞推關係,從已知的解推匯出其他解。
  • 記憶化:儲存子問題的解,避免重複計算。

動態規劃有兩種實現方式:

  • 自頂向下(遞迴+記憶化):透過遞迴實現,同時利用一個表格儲存已經計算過的結果。
  • 自底向上:從最小的子問題開始,逐步推匯出最終的解。

例子:0/1揹包問題

假設有 n 個物品,每個物品有一個重量和一個價值,揹包的容量是 W,求在不超過揹包容量的情況下,能裝入揹包的最大價值。

動態規劃的狀態轉移方程為:

dp[i][w]=max⁡(dp[i−1][w],dp[i−1][w−wi]+vi)

其中:

  • dp[i][w] 表示前 i 個物品,揹包容量為 w 時的最大價值。
  • w_iv_i 分別是第 i 個物品的重量和價值。

簡而言之,如果作為新增加的物品的此行元素導致了累加質量超過了揹包容量的話,就是每列的元素值從上一下抄下來,即捨棄此處新增物品。第一行是第一個物品,第二行是第一、二個物品,第三行是第一、二、三個物品……

動態規劃實現:

def knapsack(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(1, capacity + 1):
            if weights[i - 1] <= w:
                dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1])
            else:
                dp[i][w] = dp[i - 1][w]

    return dp[n][capacity]

讓我們透過一個簡單的例子來一步步演示 0/1 揹包問題 的動態規劃過程。我們假設有 4 個物品,揹包容量是 5。

問題設定

  • 物品數量:4
  • 揹包容量:5
  • 物品的重量和價值
    • 物品 1:重量 = 2, 價值 = 3
    • 物品 2:重量 = 3, 價值 = 4
    • 物品 3:重量 = 4, 價值 = 5
    • 物品 4:重量 = 1, 價值 = 2

目標:在不超過揹包容量的情況下,選擇物品使得揹包的總價值最大。

初始化:

  • 我們先建立一個大小為 (n+1) x (W+1) 的二維 DP 表,其中 n 是物品的數量,W 是揹包的容量。所有表格元素初始化為 0
n\w012345
0 0 0 0 0 0 0
1 0 0 0 0 0 0
2 0 0 0 0 0 0
3 0 0 0 0 0 0
4 0 0 0 0 0 0

填充 DP 表:

物品 1(重量 = 2, 價值 = 3):

  • 對於容量 w < 2dp[1][w] 為 0(無法放入物品 1)。
  • 對於容量 w >= 2dp[1][w] 為物品 1 的價值 3。
n\w 012345
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 0 0 0 0 0
3 0 0 0 0 0 0
4 0 0 0 0 0 0

物品 2(重量 = 3, 價值 = 4):

  • 對於容量 w < 3,無法放入物品 2,所以 dp[2][w] = dp[1][w]
  • 對於容量 w >= 3
    • w = 3,可以選擇放物品 2,dp[2][3] = max(dp[1][3], dp[1][0] + 4) = max(3, 4) = 4
    • w = 4,可以選擇放物品 1 和物品 2,dp[2][4] = max(dp[1][4], dp[1][1] + 4) = max(3, 4) = 4
    • w = 5,可以選擇放物品 1 和物品 2,dp[2][5] = max(dp[1][5], dp[1][2] + 4) = max(3, 7) = 7
n\w 012345
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 0 3 4 4 7
3 0 0 0 0 0 0
4 0 0 0 0 0 0

物品 3(重量 = 4, 價值 = 5):

  • 對於容量 w < 4,無法放入物品 3,dp[3][w] = dp[2][w]
  • 對於容量 w >= 4
    • w = 4,可以選擇放物品 3,dp[3][4] = max(dp[2][4], dp[2][0] + 5) = max(4, 5) = 5
    • w = 5,可以選擇放物品 1 和物品 3,dp[3][5] = max(dp[2][5], dp[2][1] + 5) = max(7, 5) = 7
n\w012345
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 0 3 4 4 7
3 0 0 3 4 5 7
4 0 0 0 0 0 0

物品 4(重量 = 1, 價值 = 2):

  • 對於容量 w < 1dp[4][w] = dp[3][w]
  • 對於容量 w >= 1
    • w = 1,可以選擇放物品 4,dp[4][1] = max(dp[3][1], dp[3][0] + 2) = max(0, 2) = 2
    • w = 2,可以選擇放物品 4 和物品 1,dp[4][2] = max(dp[3][2], dp[3][1] + 2) = max(3, 2) = 3
    • w = 3,可以選擇放物品 4 和物品 1,dp[4][3] = max(dp[3][3], dp[3][2] + 2) = max(4, 5) = 5
    • w = 4,可以選擇放物品 4 和物品 2,dp[4][4] = max(dp[3][4], dp[3][3] + 2) = max(5, 6) = 6
    • w = 5,可以選擇放物品 4 和物品 2,dp[4][5] = max(dp[3][5], dp[3][4] + 2) = max(7, 7) = 7
n\w 012345
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 0 3 4 4 7
3 0 0 3 4 5 7
4 0 2 3 5 6 7

結果:

  • 最終的最大價值為 dp[4][5] = 7
  • 選擇的物品是:物品 1(重量 2,價值 3)、物品 2(重量 3,價值 4),

總結對比

特性/方法遞迴(Recursion)分治(Divide and Conquer)動態規劃(Dynamic Programming)
定義 透過函式自呼叫來求解問題 將問題分解為多個子問題,並遞迴求解 透過儲存子問題的結果來避免重複計算
子問題性質 子問題獨立 子問題獨立 子問題重疊
解決策略 基本情況 + 遞推關係 分解問題 + 合併子問題的解 儲存子問題解,避免重複計算
應用場景 適合數學遞推關係,如斐波那契數列等 適合排序、查詢等問題(如歸併排序) 適合有重疊子問題的最佳化問題(如揹包問題)
實現方式 遞迴呼叫 遞迴呼叫 + 合併 遞迴(帶記憶化)或自底向上迭代

總結

  • 遞迴是最基礎的思想,通常用於處理可以遞迴分解的問題。
  • 分治透過將問題分解為若干個獨立子問題來求解,特別適合排序、查詢等問題。
  • 動態規劃則是對重疊子問題的一種最佳化策略,透過記憶化或狀態轉移來減少計算量,適用於那些存在大量重疊子問題的問題。

相關文章