バックトラッキングのテンプレート:選択、探索、選択解除
3段階のバックトラッキングの骨組みを実装し、小さな例でトレースして、枝刈り条件をどこに組み込むかを確認します。
「バックトラッキングのテンプレート:選択、探索、選択解除」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。
バックトラッキングとは
バックトラッキングとは、すべて(または一部)の解を見つけるために候補を1つずつ体系的に探索し、その枝から有効な解が得られないと判断した時点で、その枝を放棄(枝刈り)する方法です。Sudoku の解法、順列の生成、すべての有効な組み合わせの探索などに使われるアルゴリズムです。これは決定木に対する深さ優先探索だと考えるとよいでしょう。
# Mental model: backtracking explores a decision tree
# At each node you make a choice, go deeper, then undo it
#
# Tree for generating subsets of [1,2,3]:
# []
# / \
# [1] []
# / \ / \
# [1,2][1][2] []
# ...
# Every leaf is a potential solution
# Pruning cuts branches early based on constraints
print('Backtracking = DFS on decision tree with pruning')3段階のテンプレート
すべてのバックトラッキング関数は、次の3段階に従います。Choose — 利用可能な選択肢から次の候補を選びます。Explore — その選択を使って再帰し、決定木を1段階深く探索します。Unchoose — 再帰から戻った後で選択を取り消し、次の候補のために状態を元に戻します。このパターンは、状況によって add/recurse/remove または mark/recurse/unmark とも呼ばれます。
def backtrack(current_state, choices, results):
# Base case: is current_state a complete solution?
if is_complete(current_state):
results.append(list(current_state)) # record solution
return
for choice in choices:
if is_valid(choice, current_state): # pruning condition
# 1. CHOOSE
current_state.append(choice)
# 2. EXPLORE
backtrack(current_state, choices, results)
# 3. UNCHOOSE (backtrack)
current_state.pop()
# Placeholder functions — filled per problem
def is_complete(state): return True
def is_valid(choice, state): return True最も単純な例:すべての部分集合
[1, 2, 3] のすべての部分集合を生成します。各インデックスで、その要素を含めるか除外するかを選びます。各呼び出しの後で開始インデックスを進めるため、以前の要素を再訪しません。制約の確認は必要ありません。部分的な状態もすべて有効だからです。これにより 2ⁿ 個の部分集合が生成されます。Unchoose の処理は、再帰呼び出しの後に実行する path.pop() です。
def subsets(nums):
result = []
def backtrack(start, path):
result.append(list(path)) # every state is a valid subset
for i in range(start, len(nums)):
path.append(nums[i]) # CHOOSE
backtrack(i + 1, path) # EXPLORE
path.pop() # UNCHOOSE
backtrack(0, [])
return result
print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]枝刈り条件の特定
総当たりに対するバックトラッキングの強みは、枝刈りにあります。つまり、部分的なパスが有効な解につながらないことを早期に認識します。Combination Sum(目標合計と上限がある問題)では、現在の合計が目標値を超えた時点で、それより深い枝の合計は大きくなるだけです。そのため、すぐに戻って枝刈りします。N-queens では、クイーンが既存のクイーンを攻撃する場合、その列をスキップします。枝刈りによって、指数的な木構造を扱いやすい探索に変えられます。
def combination_sum(candidates, target):
result = []
candidates.sort() # sort enables early termination
def backtrack(start, path, remaining):
if remaining == 0:
result.append(list(path))
return
for i in range(start, len(candidates)):
c = candidates[i]
if c > remaining: break # PRUNE: sorted, so rest are bigger too
path.append(c) # CHOOSE
backtrack(i, path, remaining - c) # EXPLORE (reuse allowed)
path.pop() # UNCHOOSE
backtrack(0, [], target)
return result
print(combination_sum([2, 3, 6, 7], 7)) # [[2,2,3],[7]]状態の復元が重要
バックトラッキングでよくあるバグは、次の反復の前に状態を完全に復元し忘れることです。可変データ構造(リスト、集合、グリッド)を使う場合、Choose で行った変更はすべて Unchoose で元に戻す必要があります。たとえば、Sudoku や Word Search のようにグリッドを変更する場合は、再帰呼び出しの後でセルを空に戻します。これを忘れると、同じ親から分岐した枝の探索に壊れた状態が引き継がれます。
# Bug: forgetting to unmark in word search
# Correct pattern for grid backtracking:
def word_search(board, word):
m, n = len(board), len(board[0])
def dfs(r, c, k):
if k == len(word): return True
if not (0<=r<m and 0<=c<n): return False
if board[r][c] != word[k]: return False
temp, board[r][c] = board[r][c], '#' # CHOOSE (mark visited)
found = any(dfs(r+dr, c+dc, k+1)
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)])
board[r][c] = temp # UNCHOOSE (restore cell)
return found
return any(dfs(r, c, 0) for r in range(m) for c in range(n))
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']]
print(word_search([row[:] for row in board], 'ABCCED')) # True決定木をたどる
[2, 3, 6, 7] と目標値 7 の Combination Sum で木構造をたどってみましょう。ルートでは 2 を試します。2 からはもう一度 2 を試します(remaining=3)。2+2 からさらに 2 を試します(remaining=1)。2>1 なので枝刈りします。3 を試しても 3>1 なので枝刈りします。バックトラックします。2+2 から 3 を試すと(remaining=3)、3 が残りの値と一致するため、[2,2,3] を記録します。バックトラックして探索を続けます。このトレースから、枝刈りによって無効な結果を生成する前に枝を排除できることがわかります。
def combination_sum_trace(candidates, target):
result = []
candidates.sort()
def backtrack(start, path, remaining, depth):
indent = ' ' * depth
print(f'{indent}explore({path}, remaining={remaining})')
if remaining == 0:
result.append(list(path))
print(f'{indent}FOUND: {path}')
return
for i in range(start, len(candidates)):
c = candidates[i]
if c > remaining:
print(f'{indent}PRUNE at {c}')
break
path.append(c)
backtrack(i, path, remaining - c, depth + 1)
path.pop()
backtrack(0, [], target, 0)
return result
combination_sum_trace([2, 3, 6, 7], 7)バックトラッキングと総当たり
総当たりでは、考えられる完全な解をすべて試してから、それぞれを検証します。バックトラッキングでは構築中に枝刈りするため、無効なパスを完成させることはありません。N=8 の N-queens では、総当たりは 8^8 = 16 million 通りの配置を確認します。バックトラッキングなら、これを約 2,057 回の再帰呼び出しに削減できます。N が大きくなると差は劇的に広がります。N=12 では、総当たりは 8.9 billion 通りの配置を試すのに対し、バックトラッキングが探索するのは木構造のごく一部だけです。
# Compare call counts: brute force vs backtracking for permutations
import sys
calls_brute = [0]
calls_back = [0]
def brute_force_perms(nums):
from itertools import permutations
return list(permutations(nums))
def backtrack_perms(nums):
result = []
used = [False] * len(nums)
def bt(path):
calls_back[0] += 1
if len(path) == len(nums):
result.append(list(path))
return
for i, n in enumerate(nums):
if not used[i]:
used[i] = True
path.append(n)
bt(path)
path.pop()
used[i] = False
bt([])
return result
backtrack_perms([1,2,3,4])
print(f'Backtrack calls for 4 items: {calls_back[0]}')収集と早期リターン
バックトラッキングの問題は、2つのカテゴリに分けられます。すべての解を列挙する(完全なパスをすべて収集する)か、いずれか1つの解を見つける(パスが成功した時点で True を返す)かです。列挙する場合は、必ず結果のリストに追加します。いずれか1つを見つける場合は、再帰呼び出しから直ちに True を返し、それを上位へ伝播させます。any(backtrack(...)) を返すか、if backtrack(...): return True と書くことで短絡評価を実装できます。
# Enumerate all: collect in results list
def all_solutions(candidates):
results = []
def bt(path, remaining):
if remaining == 0:
results.append(list(path))
return
for c in candidates:
if c <= remaining:
path.append(c); bt(path, remaining - c); path.pop()
bt([], 5)
return results
# Find any one: return True on first success
def any_solution(candidates, target):
def bt(path, remaining):
if remaining == 0: return True
for c in candidates:
if c <= remaining:
path.append(c)
if bt(path, remaining - c): return True # short-circuit
path.pop()
return False
path = []
return bt(path, target), pathバックトラッキングとメモ化
純粋なバックトラッキングはキャッシュなしですべてのパスを探索します。これはすべての解が必要な場合には問題ありません。ただし、バックトラッキングの問題の中には、重複する部分問題を持つものがあります。たとえば Word Break II は、バックトラッキングとメモ化を組み合わせて解けます。各開始インデックスから作れる文のリストをキャッシュするのです。これにより、最悪時に指数時間となるバックトラッキングを多項式時間のアルゴリズムに変えられます。部分問題が繰り返されていることに気付いたら、このハイブリッド手法を適用してください。
from functools import lru_cache
def word_break_all(s, wordDict):
words = set(wordDict)
@lru_cache(maxsize=None)
def bt(start):
if start == len(s): return [''] # empty suffix
result = []
for end in range(start + 1, len(s) + 1):
word = s[start:end]
if word in words:
for rest in bt(end):
result.append(word if not rest else word + ' ' + rest)
return result
return bt(0)
print(word_break_all('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']バックトラッキングの時間計算量
バックトラッキングの時間計算量は、決定木の葉の数と各ノードで行う処理の積に依存します。部分集合では O(n × 2ⁿ)、順列では O(n × n!) です。Combination Sum では、最悪の場合 O(target/min_candidate ^ n) です。枝刈りによって定数倍の部分は小さくなりますが、漸近的な上限は変わりません。面接で計算量を尋ねられたら、最悪時の木のサイズを答え、実際には枝刈りによって通常は大幅に高速になることも伝えてください。
# Complexity quick reference:
# Subsets of n elements: O(n * 2^n) - 2^n subsets, each copied in O(n)
# Permutations of n: O(n * n!) - n! perms, each copied in O(n)
# Combination sum (target T): O(T^n / n!) worst case without pruning
# N-Queens: O(n!) - prune reduces practical count
# For n=10 permutations: 10! = 3,628,800 paths
import math
n = 10
print(f'n={n}: n!={math.factorial(n):,} paths')
print(f'n={n}: 2^n={2**n:,} subsets')バックトラッキング問題の見分け方
問題がバックトラッキングを必要とするサインには、次のようなものがあります。(1) すべてを見つける、またはすべてを生成する組み合わせ、順列、部分集合。(2) 制約の下でアイテムや人を配置する問題(N-queens、Sudoku)。(3) 解空間は指数的ですが、制約によってほとんどの枝を早期に排除できる問題。(4) 状態を再訪する可能性があるグラフやグリッド上のパスを探索する必要がある問題。これらのサインを見つけたら、choose-explore-unchoose テンプレートを使いましょう。
# Common backtracking problem types:
# 1. Subsets / Power set
# 2. Permutations (with/without duplicates)
# 3. Combinations (k from n, combination sum)
# 4. Grid path finding (word search, unique paths with visited tracking)
# 5. Constraint satisfaction (N-queens, Sudoku solver)
# 6. String partitioning (palindrome partition, word break all)
# Template reminder:
def backtrack(start, path):
# base case: add to results or return True
for choice in get_choices(start):
if is_valid(choice, path): # prune
path.append(choice) # choose
backtrack(start+1, path) # explore
path.pop() # unchoose
def get_choices(start): return []
def is_valid(c, p): return Trueクイックチェック
このレッスンの Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、バックトラッキングのテンプレートには Choose、Explore、Unchoose の3段階があり、それぞれ選択の追加、再帰、選択の削除に対応すること、枝刈り条件によって枝を早期に排除でき、それが総当たりと比べてバックトラッキングを実用的にすること、そして同じ親から分岐した枝の状態を壊さないために、各再帰呼び出しの後で状態を完全に復元する必要があることを学びました。次は、このテンプレートを使ってすべての Subsets と Power Set を生成します。
よくある質問
「バックトラッキングのテンプレート:選択、探索、選択解除」レッスンは無料ですか?
はい。「バックトラッキングのテンプレート:選択、探索、選択解除」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。
「バックトラッキングのテンプレート:選択、探索、選択解除」で何を学びますか?
3段階のバックトラッキングの骨組みを実装し、小さな例でトレースして、枝刈り条件をどこに組み込むかを確認します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
DSA Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。
「バックトラッキングのテンプレート:選択、探索、選択解除」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このDSA Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- バックトラッキングのテンプレート:選択、探索、選択解除
- 部分集合とべき集合
- 順列と組み合わせ
- N-Queensと制約伝播