8.1 排列與組合的枚舉#
概念說明#
排列(permutation)是 n 個相異元素的所有排序方式,共 n! 種;組合(combination)是從 n 個中不計順序選 k 個,共 C(n,k) 種。枚舉它們是暴力搜尋、回溯演算法、測資產生的基礎設施。
兩個核心技巧:
- 字典序法生成下一個排列:不用遞迴、狀態只有一個陣列,每一步 O(n) 就能從當前排列跳到「字典序的下一個」。C++ 標準庫的
next_permutation就是它。步驟是:從右往左找第一個「下降點」i(a[i] < a[i+1]),再從右找最小的大於 a[i] 的元素與之交換,最後把 i 之後的段落反轉。 - 組合枚舉的位元表示:用一個整數的位元代表「選了哪些」,k 個 1 的所有整數就是所有組合。Gosper's hack 用純位元運算在 O(1) 內跳到「下一個擁有 k 個 1 的整數」,是位元運算美學的代表作。
Python 標準庫 itertools.permutations 與 itertools.combinations 提供現成工具,但理解內部機制,才能在需要「自訂順序、中途剪枝、串流處理」時自己動手。
演算法步驟#
字典序下一個排列:
- 從右端向左掃,找最大的 i 使 a[i] < a[i+1](找不到代表已是最後一個排列)。
- 從右端向左掃,找最大的 j 使 a[j] > a[i]。
- 交換 a[i] 與 a[j]。
- 反轉 a[i+1:](此段原本是遞減,反轉後變遞增,即最小字典序)。
Gosper's hack(下一個含 k 個 1 的整數):
- 設 c = 選擇集的位元表示。
- 計算 u = c & −c(最低的 1)、v = u + c。
- 新集合 c' = v + (((v ^ c) / u) >> 2)。
Python 程式碼範例#
def next_permutation(a):
"""就地修改 a 為下一個排列;若已是最後一個則回傳 False"""
n = len(a)
i = n - 2
while i >= 0 and a[i] >= a[i + 1]:
i -= 1
if i < 0:
return False
j = n - 1
while a[j] <= a[i]:
j -= 1
a[i], a[j] = a[j], a[i]
a[i + 1:] = reversed(a[i + 1:])
return True
def all_permutations(items):
a = sorted(items)
yield tuple(a)
while next_permutation(a):
yield tuple(a)
def gosper_next(c):
"""下一個與 c 同樣位元數(同 k)的整數"""
u = c & -c
v = u + c
return v + (((v ^ c) // u) >> 2)
def all_combinations(n, k):
"""以位元集枚舉 C(n,k):位元 i 為 1 表示選了元素 i"""
if k == 0:
yield ()
return
c = (1 << k) - 1 # 最小:低位 k 個 1
limit = (1 << n)
while c < limit:
yield tuple(i for i in range(n) if c & (1 << i))
c = gosper_next(c)
# 驗證與對照
print("排列:", list(all_permutations([2, 1, 3])))
print("組合 C(5,3):", list(all_combinations(5, 3)))
import itertools
assert set(all_permutations([2, 1, 3])) == set(itertools.permutations([2, 1, 3]))
assert {frozenset(t) for t in all_combinations(5, 3)} == \
{frozenset(t) for t in itertools.combinations(range(5), 3)}
print("與 itertools 結果一致 ✓")
# 計數公式對照
import math
assert sum(1 for _ in all_combinations(7, 4)) == math.comb(7, 4)
assert sum(1 for _ in all_permutations([1, 2, 3, 4])) == math.factorial(4)
print("枚舉總數與公式一致 ✓")
複雜度分析#
生成全部排列需 Θ(n · n!) 時間(n! 個排列、每個長 n)——這是下限,因為光是輸出就有這麼大。下一個排列的單步成本 O(n),遠優於每次重新排序。組合枚舉總成本 Θ(k · C(n,k)),Gosper 單步 O(1)(整數運算,實務上受位長影響)。這些數字提醒我們枚舉的適用邊界:n = 13 時 13! ≈ 6.2×10⁹ 已要數分鐘,n = 15 起就必須改用剪枝、動態規劃或第 2 章的模運算計數。另一個重要變形是第 k 個排列直接跳躍(用階乘進位 factoradic 表示,O(n log n) 不需逐個列舉)與隨機排列(Fisher-Yates 洗牌,O(n),9.1 節會驗證其均勻性)。回溯搜尋(如八皇后)本質上是「排列枚舉 + 提前剪枝」,剪掉一棵子樹就省掉其下所有排列,這是組合枚舉在實戰中最有價值的心法。
練習#
- 實作 factoradic 表示法:寫
kth_permutation(n, k)直接回傳 n 個元素的第 k 個(0-indexed)排列,並用all_permutations抽查驗證 20 個隨機 k。 - 用 Gosper's hack 枚舉 C(10, 5) = 252 個子集,統計「選中元素 0」的子集數,驗證恰好是 C(9,4) = 126(對稱性)。
- 八皇后問題:以排列枚舉(每列一個皇后、排列值為行號)+ 對角線剪枝,計算 8 皇后共有 92 解;再量測剪枝前後的節點拜訪數差異。