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)
由「反射技巧」(把越界的路徑在第一次越界處翻折,可與壞路徑一一對應)可優雅證明。這條「遞迴式 → 動態規劃 → 封閉公式」的升級路線,是組合計數的標準作業程序,也是動態規劃教學的黃金範例。
演算法步驟#
- 遞迴定義:直接套 Cₙ = Σ CᵢC_{n−1−i}(指數時間,僅教學)。
- 動態規劃:由小到大填表,每項 O(n),總 O(n²)。
- 封閉公式:Cₙ = C(2n,n)/(n+1),配合 Python 大整數精確求值,O(n) 次大數乘法。
- 驗證:對小 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 計數瞬間完成——「知道總數」與「列出每一個」是複雜度天差地別的兩件事。
練習#
- 用 8.1 節的位元枚舉法,列出 n = 3 的所有 5 條「不越過對角線」格線路徑,與卡特蘭數 C₃ = 5 對照。
- 修改
count_valid_parens,讓它同時枚舉 n 對括號的所有序列並回傳清單,n = 3 時印出全部 5 條;解釋為何直接枚舉 2^(2n) 條再過濾會慢很多。 - 證明並用程式驗證恆等式 Cₙ₊₁ = Cₙ·(4n+2)/(n+2):用
math.comb精確計算到 n = 1000 檢查等式成立,並由此寫出一個 O(n) 的迭代版卡特蘭生成器。