개념

모든 경우의 수를 탐색하되, 조건을 만족하지 않으면 되돌아가는 알고리즘

DFS + **가지치기(Pruning)**로 불필요한 탐색 제거


특징

백트래킹 vs 브루트포스

특성브루트포스백트래킹
탐색모든 경우 탐색조건 불만족 시 중단
효율비효율적상대적으로 효율적
구현간단조건 판단 필요

장점

  • 모든 경우를 확인하면서도 가지치기로 시간 단축
  • 해가 존재하는지 확인 가능
  • 제약 조건이 있는 문제에 효과적

단점

  • 최악의 경우 모든 경우를 탐색 (지수 시간)
  • 조건 설정이 중요

백트래킹 템플릿

def backtrack(path, candidates):
    # 1. 목표 달성 (Base Case)
    if is_goal(path):
        result.append(path[:])
        return
 
    # 2. 가지치기 (Pruning)
    if not is_valid(path):
        return
 
    # 3. 후보 탐색
    for candidate in candidates:
        # 선택
        path.append(candidate)
 
        # 재귀 호출
        backtrack(path, next_candidates)
 
        # 선택 취소 (백트래킹)
        path.pop()

기본 예제

1. N-Queen 문제

문제: N×N 체스판에 N개의 퀸을 서로 공격하지 않게 배치

def solve_n_queens(n):
    result = []
 
    def is_valid(board, row, col):
        # 같은 열 체크
        for i in range(row):
            if board[i] == col:
                return False
 
        # 왼쪽 대각선 체크
        for i, j in zip(range(row - 1, -1, -1), range(col - 1, -1, -1)):
            if board[i] == j:
                return False
 
        # 오른쪽 대각선 체크
        for i, j in zip(range(row - 1, -1, -1), range(col + 1, n)):
            if board[i] == j:
                return False
 
        return True
 
    def backtrack(row, board):
        # 모든 행에 퀸 배치 완료
        if row == n:
            result.append(board[:])
            return
 
        # 각 열에 퀸 배치 시도
        for col in range(n):
            if is_valid(board, row, col):
                board[row] = col
                backtrack(row + 1, board)
                board[row] = -1  # 백트래킹
 
    backtrack(0, [-1] * n)
    return result
 
# 테스트
solutions = solve_n_queens(4)
print(f"4-Queen 해의 개수: {len(solutions)}")  # 2
 
# 시간복잡도: O(n!)
# 공간복잡도: O(n)

핵심: 각 행에 하나씩 배치하며, 유효성 검사로 가지치기


2. 스도쿠 해결

문제: 9×9 스도쿠 퍼즐 풀기

def solve_sudoku(board):
    def is_valid(board, row, col, num):
        # 행 체크
        if num in board[row]:
            return False
 
        # 열 체크
        if num in [board[i][col] for i in range(9)]:
            return False
 
        # 3×3 박스 체크
        box_row, box_col = 3 * (row // 3), 3 * (col // 3)
        for i in range(box_row, box_row + 3):
            for j in range(box_col, box_col + 3):
                if board[i][j] == num:
                    return False
 
        return True
 
    def backtrack():
        for i in range(9):
            for j in range(9):
                if board[i][j] == 0:
                    for num in range(1, 10):
                        if is_valid(board, i, j, num):
                            board[i][j] = num
 
                            if backtrack():
                                return True
 
                            board[i][j] = 0  # 백트래킹
 
                    return False
        return True
 
    backtrack()
    return board
 
# 테스트
board = [
    [5,3,0,0,7,0,0,0,0],
    [6,0,0,1,9,5,0,0,0],
    [0,9,8,0,0,0,0,6,0],
    [8,0,0,0,6,0,0,0,3],
    [4,0,0,8,0,3,0,0,1],
    [7,0,0,0,2,0,0,0,6],
    [0,6,0,0,0,0,2,8,0],
    [0,0,0,4,1,9,0,0,5],
    [0,0,0,0,8,0,0,7,9]
]
solve_sudoku(board)

핵심: 빈 칸마다 1-9를 시도하며, 규칙 위반 시 백트래킹


자주 나오는 문제 유형

1. 조합의 합 (Combination Sum)

문제: 합이 target이 되는 숫자 조합 찾기 (중복 선택 가능)

def combination_sum(candidates, target):
    result = []
 
    def backtrack(start, path, total):
        # 목표 달성
        if total == target:
            result.append(path[:])
            return
 
        # 가지치기
        if total > target:
            return
 
        for i in range(start, len(candidates)):
            path.append(candidates[i])
            # 같은 숫자 재사용 가능하므로 start=i
            backtrack(i, path, total + candidates[i])
            path.pop()
 
    backtrack(0, [], 0)
    return result
 
# 테스트
print(combination_sum([2, 3, 6, 7], 7))
# [[2, 2, 3], [7]]
 
# 시간복잡도: O(n^(target/min))

핵심: 같은 원소 재사용 가능, 합이 target 초과 시 가지치기


문제: 2D 보드에서 단어를 찾을 수 있는지 확인

def word_search(board, word):
    rows, cols = len(board), len(board[0])
 
    def backtrack(r, c, index):
        # 단어를 모두 찾음
        if index == len(word):
            return True
 
        # 범위 체크 및 문자 일치 확인
        if (r < 0 or r >= rows or c < 0 or c >= cols or
            board[r][c] != word[index]):
            return False
 
        # 방문 표시
        temp = board[r][c]
        board[r][c] = '#'
 
        # 4방향 탐색
        found = (backtrack(r + 1, c, index + 1) or
                backtrack(r - 1, c, index + 1) or
                backtrack(r, c + 1, index + 1) or
                backtrack(r, c - 1, index + 1))
 
        # 백트래킹 (방문 표시 복원)
        board[r][c] = temp
 
        return found
 
    # 모든 위치에서 시작
    for i in range(rows):
        for j in range(cols):
            if backtrack(i, j, 0):
                return True
 
    return False
 
# 테스트
board = [
    ['A','B','C','E'],
    ['S','F','C','S'],
    ['A','D','E','E']
]
print(word_search(board, "ABCCED"))  # True
print(word_search(board, "SEE"))     # True
print(word_search(board, "ABCB"))    # False
 
# 시간복잡도: O(m*n*4^L) - L: 단어 길이

핵심: 방문 표시와 복원으로 중복 방문 방지


3. 괄호 생성

문제: N쌍의 괄호로 만들 수 있는 모든 유효한 조합

def generate_parentheses(n):
    result = []
 
    def backtrack(path, open_count, close_count):
        # 완성
        if len(path) == 2 * n:
            result.append(''.join(path))
            return
 
        # 여는 괄호 추가
        if open_count < n:
            path.append('(')
            backtrack(path, open_count + 1, close_count)
            path.pop()
 
        # 닫는 괄호 추가 (여는 괄호보다 적을 때만)
        if close_count < open_count:
            path.append(')')
            backtrack(path, open_count, close_count + 1)
            path.pop()
 
    backtrack([], 0, 0)
    return result
 
# 테스트
print(generate_parentheses(3))
# ["((()))", "(()())", "(())()", "()(())", "()()()"]
 
# 시간복잡도: O(4^n / √n) - Catalan Number

핵심: 여는 괄호 개수가 N 이하, 닫는 괄호는 여는 괄호보다 적게


4. 부분집합 합 문제

문제: 합이 target인 부분집합이 존재하는지 확인

def subset_sum(nums, target):
    def backtrack(index, current_sum):
        # 목표 달성
        if current_sum == target:
            return True
 
        # 가지치기
        if index >= len(nums) or current_sum > target:
            return False
 
        # 현재 원소 포함
        if backtrack(index + 1, current_sum + nums[index]):
            return True
 
        # 현재 원소 미포함
        if backtrack(index + 1, current_sum):
            return True
 
        return False
 
    return backtrack(0, 0)
 
# 테스트
print(subset_sum([3, 34, 4, 12, 5, 2], 9))  # True (4 + 5)
print(subset_sum([3, 34, 4, 12, 5, 2], 30)) # False
 
# 시간복잡도: O(2^n)

핵심: 각 원소를 포함/미포함하는 두 가지 선택


5. 분할된 회문 (Palindrome Partitioning)

문제: 문자열을 모든 부분이 회문이 되도록 분할

def partition(s):
    result = []
 
    def is_palindrome(string):
        return string == string[::-1]
 
    def backtrack(start, path):
        # 문자열 끝까지 도달
        if start == len(s):
            result.append(path[:])
            return
 
        # 가능한 모든 분할 지점 시도
        for end in range(start + 1, len(s) + 1):
            substring = s[start:end]
 
            # 회문인 경우만 진행
            if is_palindrome(substring):
                path.append(substring)
                backtrack(end, path)
                path.pop()
 
    backtrack(0, [])
    return result
 
# 테스트
print(partition("aab"))
# [["a", "a", "b"], ["aa", "b"]]
 
print(partition("aabb"))
# [["a","a","b","b"], ["a","a","bb"], ["aa","b","b"], ["aa","bb"]]
 
# 시간복잡도: O(n * 2^n)

핵심: 회문인 경우만 재귀 호출하여 가지치기


6. 전화번호 문자 조합

문제: 전화번호 숫자를 문자로 변환한 모든 조합

def letter_combinations(digits):
    if not digits:
        return []
 
    phone = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
 
    result = []
 
    def backtrack(index, path):
        # 모든 숫자 처리 완료
        if index == len(digits):
            result.append(''.join(path))
            return
 
        # 현재 숫자에 해당하는 문자들 시도
        for letter in phone[digits[index]]:
            path.append(letter)
            backtrack(index + 1, path)
            path.pop()
 
    backtrack(0, [])
    return result
 
# 테스트
print(letter_combinations("23"))
# ["ad","ae","af","bd","be","bf","cd","ce","cf"]
 
# 시간복잡도: O(4^n) - 최악의 경우 (7, 9는 4글자)

핵심: 각 숫자마다 가능한 문자를 순차적으로 선택


추천 연습 문제

기초

중급

고급


핵심 요약

개념설명
기본 원리DFS + 조건 검사 + 되돌아가기
가지치기조건 불만족 시 탐색 중단
시간복잡도주로 지수 시간 (O(2^n), O(n!))
활용순열, 조합, 제약 만족 문제

백트래킹 작성 팁

1. 상태 관리

# 상태를 명확히 관리
def backtrack(index, path, used):
    # used: 사용 여부 추적
    # path: 현재까지 선택한 요소
    # index: 현재 위치

2. 가지치기 조건

# 조기 종료 조건 명확히
if current_sum > target:  # 더 이상 진행 불필요
    return
 
if remaining_count < needed:  # 목표 달성 불가
    return

3. 중복 제거

# 정렬 + 이전 값과 비교
candidates.sort()
for i in range(start, len(candidates)):
    if i > start and candidates[i] == candidates[i-1]:
        continue  # 중복 건너뛰기
    backtrack(i + 1, ...)

4. 방문 표시

# 2D 배열에서 방문 관리
visited = [[False] * cols for _ in range(rows)]
 
def backtrack(r, c):
    visited[r][c] = True
    # 탐색...
    visited[r][c] = False  # 백트래킹

최적화 기법

1. 비트마스킹

# used 배열 대신 비트마스크 사용
def backtrack(used_mask):
    for i in range(n):
        if not (used_mask & (1 << i)):
            backtrack(used_mask | (1 << i))

2. 메모이제이션

# 중복 상태 저장
def backtrack(state, memo):
    if state in memo:
        return memo[state]
    # 계산...
    memo[state] = result
    return result

3. 조기 종료

# 하나만 찾으면 되는 경우
def backtrack():
    if found_solution:
        return True
    # 탐색...

백트래킹 vs DFS

특성백트래킹DFS
목적해 찾기전체 탐색
가지치기있음없음
효율상대적으로 효율적모든 노드 방문
사용제약 조건 문제그래프 순회