백트래킹: 상태 공간 탐색과 가지치기

백트래킹의 핵심 개념인 상태 공간 트리, 가지치기(Pruning), 그리고 N-Queens·순열·부분 집합 합 문제를 Python으로 완전 구현합니다. 재귀 템플릿과 최적화 기법까지 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 DP로 최적해를 구하는 법을 다뤘다면, 백트래킹은 제약 조건을 만족하는 모든 해 또는 하나의 해를 체계적으로 탐색하는 기법입니다. 브루트 포스와의 결정적 차이는 바로 가지치기(Pruning) — 불가능한 경로를 조기에 포기해 탐색 공간을 극적으로 줄입니다.

백트래킹이란

백트래킹은 선택 → 제약 확인 → 실패 시 되돌림 사이클을 반복하는 완전 탐색 전략입니다.

선택 가능한 옵션 반복:
  1. 옵션 선택 (상태 변경)
  2. 제약 조건 확인
     - 위반 → 즉시 백트랙 (가지치기)
     - 통과 → 다음 단계 재귀
  3. 선택 취소 (상태 복원)

상태 공간을 트리로 표현했을 때, 유효하지 않은 서브트리를 통째로 건너뛰는 것이 핵심입니다.

백트래킹 탐색 트리

백트래킹 템플릿

def backtrack(state, choices):
    # 기저 조건: 해 발견
    if is_solution(state):
        record(state)
        return

    for choice in choices:
        # 가지치기: 유효하지 않은 선택 스킵
        if not is_valid(state, choice):
            continue

        # 선택
        apply(state, choice)

        # 재귀
        backtrack(state, remaining_choices(choices, choice))

        # 취소 (백트랙)
        undo(state, choice)

이 구조는 거의 모든 백트래킹 문제에 그대로 적용됩니다.

N-Queens 문제

n×n 체스판에 n개의 퀸을 서로 공격하지 않도록 배치하는 문제.

def solve_nqueens(n: int):
    result = []
    cols = [-1] * n           # cols[row] = 배치된 열
    col_used = [False] * n
    diag1 = [False] * (2 * n) # row - col + n
    diag2 = [False] * (2 * n) # row + col

    def backtrack(row: int):
        if row == n:
            result.append(cols[:])
            return
        for col in range(n):
            d1 = row - col + n
            d2 = row + col
            # 가지치기: 같은 열 또는 대각선
            if col_used[col] or diag1[d1] or diag2[d2]:
                continue
            # 배치
            cols[row] = col
            col_used[col] = diag1[d1] = diag2[d2] = True
            backtrack(row + 1)
            # 취소
            col_used[col] = diag1[d1] = diag2[d2] = False

    backtrack(0)
    return result

solutions = solve_nqueens(4)
print(len(solutions))  # 2
print(solutions[0])    # [1, 3, 0, 2]

N-Queens 백트래킹

순열 생성

def permutations(nums: list) -> list:
    result = []
    used = [False] * len(nums)
    path = []

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

    backtrack()
    return result

print(permutations([1, 2, 3]))
# [[1,2,3], [1,3,2], [2,1,3], ...]  총 6가지

중복 원소가 있을 때는 정렬 후 if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue 조건으로 중복 건너뛰기.

부분 집합 합 (Subset Sum)

def subset_sum(nums: list, target: int) -> list:
    result = []
    nums.sort()  # 정렬로 가지치기 효율 향상

    def backtrack(start: int, path: list, remaining: int):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(nums)):
            # 가지치기: 현재 값이 남은 합보다 크면 이후도 무의미
            if nums[i] > remaining:
                break
            path.append(nums[i])
            backtrack(i + 1, path, remaining - nums[i])
            path.pop()

    backtrack(0, [], target)
    return result

print(subset_sum([2, 3, 6, 7], 7))
# [[7], [3, 4]] → [[7]]  (정확한 조합)

nums.sort() + if nums[i] > remaining: break 두 줄이 가지치기의 핵심입니다. 정렬된 배열에서 남은 합보다 큰 원소는 이후 원소도 모두 불필요하기 때문입니다.

스도쿠 풀이

9×9 스도쿠는 백트래킹의 대표 응용 사례입니다.

def solve_sudoku(board: list) -> bool:
    empty = find_empty(board)
    if not empty:
        return True  # 모든 칸 채움 — 해 발견

    row, col = empty
    for num in range(1, 10):
        if is_valid_sudoku(board, row, col, num):
            board[row][col] = num
            if solve_sudoku(board):
                return True
            board[row][col] = 0  # 백트랙

    return False  # 현재 경로로는 해 없음

가지치기 전략

1. 앞향 검사 (Forward Checking)

선택할 때마다 남은 변수들의 가능한 값 도메인을 갱신. 도메인이 비면 즉시 백트랙.

2. 제약 전파 (Constraint Propagation)

하나의 선택이 연쇄적으로 다른 변수의 도메인을 줄이도록 반영. Arc Consistency(AC-3 알고리즘)가 대표적.

3. 발생 순서 휴리스틱 (Variable Ordering)

가능한 값이 가장 적은 변수를 먼저 처리(MRV — Minimum Remaining Values). 조기에 실패를 발견해 전체 탐색을 줄입니다.

def select_unassigned(variables, domains):
    # MRV 휴리스틱: 가장 도메인이 작은 변수 선택
    return min(
        (v for v in variables if not is_assigned(v)),
        key=lambda v: len(domains[v])
    )

복잡도 분석

문제최악 시간가지치기 효과
순열(n)O(n!)중복 제거 시 ~n배 감소
N-QueensO(n!)실제 ~n^(n/2)
스도쿠(9×9)O(9^81)제약 전파로 수백만 배 감소
부분 집합 합O(2^n)정렬+가지치기로 ~절반

백트래킹의 성능은 가지치기 품질에 달려 있습니다. 제약이 강할수록 탐색 트리가 작아지고 실행 시간이 극적으로 줄어듭니다.

백트래킹 vs 다른 기법

기법사용 시점
백트래킹제약 조건 있는 조합 탐색
동적 프로그래밍최적 부분 구조 + 중복 부분 문제
그리디지역 최적이 전역 최적 보장 시
BFS/DFS최단 경로 / 연결성 탐색

지난 글: 편집 거리 (Edit Distance): 레벤슈타인 알고리즘 완전 분석

다음 글: 비트 조작 (Bit Manipulation): 비트 연산과 마스킹 완전 정복


읽어주셔서 감사합니다. 😊