백트래킹
가능한 모든 후보를 시도하되, 유효하지 않다고 판단되는 순간 즉시 되돌아가는(가지치기) 탐색 알고리즘입니다.
핵심 개념
- 기반: DFS (깊이 우선 탐색) — 재귀 호출로 구현
- 가지치기 (Pruning): 현재 경로가 답이 될 수 없으면 더 탐색하지 않고 되돌아감
- 완전 탐색과의 차이: 완전 탐색은 모든 경우를 다 보지만, 백트래킹은 유망하지 않은 가지를 일찍 잘라냄
- 상태 공간 트리: 모든 후보를 트리 형태로 표현하고, 각 노드에서 유효성 검사 후 진행 여부 결정
[ 상태 공간 트리 — N=3 순열 예시 ]
start
/ | \
1 2 3
/ \ / \ / \
2 3 1 3 1 2
| | | | | |
3 2 3 1 2 1
완전 탐색: 모든 리프 방문 (6개)
백트래킹: 조건 위반 시 해당 가지 전체 건너뜀
기본 구조
void backtrack(상태) {
if (종료 조건) {
결과 저장;
return;
}
for (후보 in 후보_목록) {
if (!유효한(후보)) continue; // 가지치기
선택(후보); // 상태 변경
backtrack(다음 상태); // 재귀 탐색
취소(후보); // 상태 복원 ← 핵심
}
}
"선택 → 탐색 → 취소" 3단계가 핵심. 취소(복원)가 없으면 백트래킹이 아님.
패턴 1: 순열 (Permutation)
n개 중 r개를 순서 있게 나열 — nPr
static int[] arr = {1, 2, 3};
static boolean[] used = new boolean[3];
static int[] result = new int[3];
void permutation(int depth, int r) {
if (depth == r) {
System.out.println(Arrays.toString(result));
return;
}
for (int i = 0; i < arr.length; i++) {
if (used[i]) continue; // 이미 선택한 원소 건너뜀
used[i] = true; // 선택
result[depth] = arr[i];
permutation(depth + 1, r);
used[i] = false; // 취소
}
}
// permutation(0, 3) → {1,2,3}, {1,3,2}, {2,1,3}, ... (6가지)
패턴 2: 조합 (Combination)
n개 중 r개를 순서 없이 선택 — nCr
순열과의 차이: start 인덱스로 이전 원소 재선택을 막음.
static int[] arr = {1, 2, 3, 4};
static int[] result = new int[2];
void combination(int start, int depth, int r) {
if (depth == r) {
System.out.println(Arrays.toString(result));
return;
}
for (int i = start; i < arr.length; i++) {
result[depth] = arr[i]; // 선택
combination(i + 1, depth + 1, r); // i+1부터 — 순서 중복 방지
// 취소: 배열 덮어쓰므로 별도 복원 불필요
}
}
// combination(0, 0, 2) → {1,2}, {1,3}, {1,4}, {2,3}, {2,4}, {3,4}
패턴 3: 부분집합 (Subset)
n개 원소로 만들 수 있는 모든 부분집합 — 2^n개
각 원소에 대해 "포함 / 미포함" 두 선택지.
static int[] arr = {1, 2, 3};
static boolean[] selected = new boolean[3];
void subset(int idx) {
if (idx == arr.length) {
for (int i = 0; i < arr.length; i++) {
if (selected[i]) System.out.print(arr[i] + " ");
}
System.out.println();
return;
}
selected[idx] = true; // 포함
subset(idx + 1);
selected[idx] = false; // 미포함
subset(idx + 1);
}
// {} {3} {2} {2,3} {1} {1,3} {1,2} {1,2,3} (8가지)
패턴 4: N-Queens (가지치기가 핵심)
N×N 체스판에 N개의 퀸을 서로 공격 못 하게 배치.
static int N = 4;
static int[] queen = new int[N]; // queen[row] = col
int solve(int row) {
if (row == N) return 1; // N개 모두 배치 성공
int count = 0;
for (int col = 0; col < N; col++) {
if (!isValid(row, col)) continue; // 가지치기
queen[row] = col; // 배치
count += solve(row + 1);
// 취소: 다음 반복에서 queen[row] 덮어씀
}
return count;
}
boolean isValid(int row, int col) {
for (int r = 0; r < row; r++) {
if (queen[r] == col) return false; // 같은 열
if (Math.abs(queen[r] - col) == row - r) return false; // 대각선
}
return true;
}
[ N=4 탐색 과정 — 가지치기 효과 ]
배치 시도: 256가지 (4^4, 완전 탐색)
실제 탐색: ~50가지 (가지치기 후)
유효한 배치: 2가지
순열 vs 조합 vs 부분집합
| 순열 | 조합 | 부분집합 | |
|---|---|---|---|
| 경우의 수 | nPr = n!/(n-r)! | nCr = n!/r!(n-r)! | 2^n |
| 순서 | 있음 | 없음 | 없음 |
| 중복 방지 | visited[] 배열 | start 인덱스 | — |
| 예시 | {1,2} ≠ {2,1} | {1,2} = {2,1} | {1}, {2}, {1,2} |
가지치기 전략
가지치기의 질이 성능을 결정. 조건이 구체적일수록 잘라내는 가지가 많아짐.
[ 가지치기 조건 예시 ]
합 제한: 현재까지 합이 목표 초과 → 더 더해도 소용없음 → 중단
N-Queens: 같은 열 / 대각선에 퀸 존재 → 해당 열 건너뜀
방문 제한: 이미 방문한 노드 → 재방문 건너뜀
범위 초과: 인덱스가 배열 범위 벗어남 → 즉시 return
시간복잡도
가지치기 효과가 있어서 최선/평균/최악이 크게 갈림.
| 상황 | 복잡도 |
|---|---|
| 완전 탐색 (가지치기 없음) | O(n!) or O(2^n) |
| 가지치기 적용 후 | 문제마다 다름 — 분석 어려움 |
| 실용적 판단 기준 | n ≤ 10 이면 순열(n!), n ≤ 20 이면 부분집합(2^n) 가능 |
자주 하는 실수
상태 복원 누락
// X visited 복원 안 함 → 뒤 경로에서 원소가 막혀있음
used[i] = true;
backtrack(depth + 1);
// used[i] = false; ← 빠뜨리면 오답
// O
used[i] = true;
backtrack(depth + 1);
used[i] = false;
조합에서 start 누락
// X i=0부터 시작 → 순열처럼 동작 (순서 다른 중복 발생)
for (int i = 0; i < n; i++) { ... }
// O start부터 시작 → 이전 원소 재선택 방지
for (int i = start; i < n; i++) { ... }
가지치기 조건 범위 오류
// X 너무 엄격한 가지치기 → 유효한 답을 잘라낼 수 있음
if (sum >= target) return; // sum == target인 경우도 잘라냄
// O
if (sum > target) return; // 초과할 때만 자름
핵심 정리
- 백트래킹 = DFS + 가지치기 (유망하지 않으면 즉시 되돌아감)
- "선택 → 탐색 → 취소(복원)" 3단계 — 취소가 없으면 상태가 오염됨
- 순열(
visited[]), 조합(start인덱스), 부분집합(포함/미포함) 3가지 패턴이 기본 - 가지치기 조건이 구체적일수록 탐색 공간이 줄어 성능이 향상됨
- n ≤ 10~15 범위 문제에서 주로 사용 (그 이상이면 DP나 다른 접근 필요)