バックトラッキング#

バックトラッキング(Backtracking) は、再帰的に候補を構築しながら、制約を満たさないと判明した時点で引き返す(バックトラック)全探索の手法

純粋な全列挙と異なり、枝刈り(pruning) によって探索空間を大幅に削減できる

solve(状態):
    if 完成:
        結果を記録
        return
    for 候補 in 次の選択肢:
        if 候補が制約を満たす:
            状態に候補を追加
            solve(次の状態)      # 再帰
            状態から候補を除去   # バックトラック

例1: 部分和問題#

整数のリストから、合計が目標値 target になる組み合わせをすべて列挙する

def subset_sum(nums: list[int], target: int) -> list[list[int]]:
    results = []

    def backtrack(start: int, current: list[int], remaining: int):
        if remaining == 0:
            results.append(current[:])
            return
        for i in range(start, len(nums)):
            if nums[i] > remaining:  # 枝刈り(ソート済みなら以降も不要)
                break
            current.append(nums[i])
            backtrack(i + 1, current, remaining - nums[i])
            current.pop()  # バックトラック

    backtrack(0, [], target)
    return results


nums = sorted([3, 1, 4, 2, 6, 5])
print(subset_sum(nums, target=7))
[[1, 2, 4], [1, 6], [2, 5], [3, 4]]

例2: N-クイーン問題#

\(N \times N\) のチェスボードに、\(N\) 個のクイーンをお互いに攻撃し合わない配置をすべて列挙する

  • 各行に必ず1つのクイーンを置く

  • 列・斜めが重複しないかをチェックして枝刈り

def n_queens(n: int) -> list[list[int]]:
    """queens[row] = col: row行目のクイーンが置かれた列"""
    results = []
    queens = []
    cols = set()
    diag1 = set()  # row - col が同じ = 右下がり斜め
    diag2 = set()  # row + col が同じ = 右上がり斜め

    def backtrack(row: int):
        if row == n:
            results.append(queens[:])
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue  # 枝刈り
            queens.append(col)
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            backtrack(row + 1)
            queens.pop()       # バックトラック
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    backtrack(0)
    return results


solutions = n_queens(6)
print(f'6-クイーンの解の数: {len(solutions)}')
print(f'最初の解 (列インデックス): {solutions[0]}')
6-クイーンの解の数: 4
最初の解 (列インデックス): [1, 3, 5, 0, 2, 4]

例3: 順列生成(重複なし)#

itertools.permutations と同じ結果を、バックトラッキングで実装する例

def permutations_bt(nums: list) -> list[list]:
    results = []
    used = [False] * len(nums)

    def backtrack(current: list):
        if len(current) == len(nums):
            results.append(current[:])
            return
        for i, num in enumerate(nums):
            if used[i]:
                continue
            used[i] = True
            current.append(num)
            backtrack(current)
            current.pop()
            used[i] = False

    backtrack([])
    return results


print(permutations_bt([1, 2, 3]))
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

枝刈りの重要性#

バックトラッキングの効率は枝刈りの質で決まる

問題

枝刈りなし

枝刈りあり

8-クイーン

\(8^8 = 16{,}777{,}216\)

92解(探索ノード数は大幅削減)

部分和

\(2^N\)

目標超過で早期打ち切り

枝刈りのパターン:

  • 実行可能性チェック: 現在の部分解が制約を満たさないなら即リターン

  • 上界チェック: これ以上改善できないなら打ち切り(Branch and Bound)

  • 対称性の除去: 等価な解を同一視して探索数を削減