跳轉到

8.3 卡特蘭數與遞迴計數#

概念說明#

許多組合問題表面上毫無關聯,解開後卻發現共享同一個答案數列:

  • n 對括號的合法配對方式數
  • n 個節點可構成的相異二元搜尋樹棵數
  • 凸 (n+2) 邊形切成三角形的剖分方式數
  • n 個元素依序進出堆疊的合法輸出序列數
  • 沿格線走、不越過對角線的單調路徑數

這些問題的答案全部是卡特蘭數(Catalan number):1, 1, 2, 5, 14, 42, 132, …

它的遞迴式來自「枚舉第一刀/第一個配對」的分解思想。以括號為例:n 對括號的合法序列,第一個 ( 必然配到某個 ),把序列切成「內部 i 對」與「外部 n−1−i 對」兩段,兩段各自獨立合法,於是

Cₙ = Σ_{i=0}^{n−1} Cᵢ·C_{n−1−i},C₀ = 1

這正是二元樹計數的遞迴(左子樹 i 個節點、右子樹 n−1−i 個)。更妙的是它有封閉公式:

Cₙ = C(2n, n)/(n+1)

由「反射技巧」(把越界的路徑在第一次越界處翻折,可與壞路徑一一對應)可優雅證明。這條「遞迴式 → 動態規劃 → 封閉公式」的升級路線,是組合計數的標準作業程序,也是動態規劃教學的黃金範例。

演算法步驟#

  1. 遞迴定義:直接套 Cₙ = Σ CᵢC_{n−1−i}(指數時間,僅教學)。
  2. 動態規劃:由小到大填表,每項 O(n),總 O(n²)。
  3. 封閉公式:Cₙ = C(2n,n)/(n+1),配合 Python 大整數精確求值,O(n) 次大數乘法。
  4. 驗證:對小 n 用 8.1 節的枚舉(如枚舉所有括號序列)數出實際數量比對。

Python 程式碼範例#

import math
from functools import lru_cache

# --- 方法一:遞迴(含記憶化) ---
@lru_cache(maxsize=None)
def catalan_rec(n):
    if n == 0:
        return 1
    return sum(catalan_rec(i) * catalan_rec(n - 1 - i) for i in range(n))

# --- 方法二:動態規劃填表 ---
def catalan_dp(N):
    c = [0] * (N + 1)
    c[0] = 1
    for n in range(1, N + 1):
        c[n] = sum(c[i] * c[n - 1 - i] for i in range(n))
    return c

# --- 方法三:封閉公式(大整數精確) ---
def catalan_formula(n):
    return math.comb(2 * n, n) // (n + 1)

c_dp = catalan_dp(20)
assert all(catalan_rec(i) == c_dp[i] == catalan_formula(i)
           for i in range(21))
print("前 10 個卡特蘭數:", c_dp[:10])
print("C(100) 的位數:", len(str(catalan_formula(100))))

# --- 驗證:暴力枚舉合法括號序列 ---
def count_valid_parens(n):
    def gen(prefix, open_cnt, close_cnt):
        if len(prefix) == 2 * n:
            return 1
        total = 0
        if open_cnt < n:
            total += gen(prefix + "(", open_cnt + 1, close_cnt)
        if close_cnt < open_cnt:
            total += gen(prefix + ")", open_cnt, close_cnt + 1)
        return total
    return gen("", 0, 0)

for n in [1, 2, 3, 4, 5]:
    brute = count_valid_parens(n)
    print(f"n={n}: 枚舉={brute}, 卡特蘭={catalan_formula(n)}")
    assert brute == catalan_formula(n)

# --- 應用:n 個節點的二元搜尋樹計數 ---
def count_bst(n):
    return catalan_formula(n)
print("10 個節點的相異 BST 樹數 =", count_bst(10))

# --- 遞迴計數的另一例:走格線不越過對角線 ---
def lattice_paths(n):
    """從 (0,0) 到 (n,n),每步向右或向上,恆不越過 y=x"""
    return catalan_formula(n)

複雜度分析#

三種方法的成本階梯清晰可見:純遞迴不記憶化是 O(4ⁿ/n^1.5)(因為卡特蘭數本身以 Cₙ ~ 4ⁿ/(n^1.5√π) 成長);記憶化或 DP 填表 O(n²) 次大數乘法;封閉公式 O(n)。注意數值陷阱:用浮點數算 C(2n,n)/(n+1) 在 n ≈ 30 後就會因溢位與捨入失真,Python 的整數是精確的(呼應 1.3 節),math.comb 內部用高效大數演算法。這套「分解—遞迴—計數」的思維可以推廣到一整族問題:動態規劃計數的本質就是「把計數問題的遞迴結構找出來、用表格避免重複子問題、最後試著化成封閉式」。類似的經典數列還有:分割數 p(n)(生成函數 ∏1/(1−xᵏ))、斯特林數、Bell 數——每一個都值得用本章三段式方法走一遍。枚舉 vs 計數的分水嶺也在此浮現:n = 20 時合法括號序列有 6.5×10⁹ 條,枚舉已不現實,但 DP 計數瞬間完成——「知道總數」與「列出每一個」是複雜度天差地別的兩件事。

練習#

  1. 用 8.1 節的位元枚舉法,列出 n = 3 的所有 5 條「不越過對角線」格線路徑,與卡特蘭數 C₃ = 5 對照。
  2. 修改 count_valid_parens,讓它同時枚舉 n 對括號的所有序列並回傳清單,n = 3 時印出全部 5 條;解釋為何直接枚舉 2^(2n) 條再過濾會慢很多。
  3. 證明並用程式驗證恆等式 Cₙ₊₁ = Cₙ·(4n+2)/(n+2):用 math.comb 精確計算到 n = 1000 檢查等式成立,並由此寫出一個 O(n) 的迭代版卡特蘭生成器。