
완전 탐색, 순열(Permutation)
탐색할 수 있는 모든 상황을 전부 시도하여 정답을 찾는 알고리즘이다.
선형 자료에서의 완전 탐색은 Sequential Search로, 배열의 처음부터 끝까지 모두 찾아보는 방식이다. 시간 복잡도는 O(N)이다.
경우의 수를 따지는 문제들은 크게 세 가지다.
이것들을 구현하는 방법이 DFS, BackTracking이다. 비선형 자료(Tree, Graph)를 탐색하는 방법으로는 DFS(깊이 우선 탐색)와 BFS(너비 우선 탐색)가 있다.
완전 탐색
├── Sequential Search (선형)
└── 경우의 수 (순열/조합/부분집합)
└── DFS = BackTracking ← 오늘 핵심
└── Best First Search (우선순위 기반 탐색)
3개의 원소에서 3개를 뽑는 경우를 단순 for문으로 구현하면, 같은 원소가 중복으로 선택되는 중복 순열이 된다.
int[] data = {1, 2, 3};
int N = data.length;
int cnt = 0;
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
for (int k = 0; k < N; k++) {
cnt++;
System.out.printf("%d %d %d%n", data[i], data[j], data[k]);
}
}
}
// 경우의 수: N³ = 27
같은 인덱스를 두 번 쓰지 않도록 조건을 추가하면 순열이 된다.
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
if (i != j) { // i와 j가 다를 때만
for (int k = 0; k < N; k++) {
if (k != i && k != j) { // k가 i, j와 다를 때만
cnt++;
System.out.printf("%d %d %d%n", data[i], data[j], data[k]);
}
}
}
}
}
// 경우의 수: N! = 6
시간복잡도는 여전히 N³이지만, 실제 출력되는 경우의 수는 N!이다.
문제는 N이 커지거나 뽑는 개수(r)가 달라지면 for문을 그만큼 중첩해야 한다는 거다. r=5면 for문 5개를 중첩해야 한다. 이걸 재귀로 해결한다.
for문의 각 단계를 재귀 호출로 대체한다. depth가 R에 도달하면 하나의 순열 완성이다.
static int N, R;
static int[] input;
static int[] numbers; // 뽑은 순열 저장
public static void permutation(int depth) {
if (depth == R) { // 기저 조건: R개 다 뽑았으면
System.out.println(Arrays.toString(numbers));
return;
}
for (int i = 0; i < N; i++) {
numbers[depth] = input[i];
permutation(depth + 1); // 다음 자리 선택
}
}
// 시간복잡도: O(N^R) - 중복 허용
// 9TT8: 154ms (안전) / 9TT9: 1400ms (위험)
아직 중복 제거가 없어서 같은 원소를 여러 번 선택하는 중복 순열이다.
이미 뽑은 원소인지 numbers 배열을 뒤져서 확인하는 방법이다.
public static void permutation(int depth) {
if (depth == R) {
tc++;
return;
}
top:
for (int i = 0; i < input.length; i++) {
for (int j = 0; j < depth; j++) {
if (numbers[j] == input[i]) continue top; // 이미 선택된 원소면 skip
}
numbers[depth] = input[i];
permutation(depth + 1);
}
}
중복 제거는 되지만 매번 numbers 배열을 처음부터 순회해야 해서 비효율적이다. 이걸 visited 배열로 O(1)로 개선한다.
visited 배열로 이미 선택된 원소를 O(1)로 확인한다. 이것이 실전에서 가장 많이 쓰는 패턴이다.
static boolean[] visited = new boolean[N];
static int[] numbers = new int[R];
public static void permutation(int depth) {
if (depth == R) {
tc++;
System.out.println(Arrays.toString(numbers));
return;
}
for (int i = 0; i < input.length; i++) {
if (visited[i]) continue; // 이미 선택된 원소면 skip (O(1))
visited[i] = true; // ① 선택
numbers[depth] = input[i];
permutation(depth + 1); // ② 탐색
visited[i] = false; // ③ 선택 해제 (BackTracking 핵심!)
}
}
세 줄이 BackTracking의 전부다.
① visited[i] = true → 선택
② permutation(depth+1) → 탐색
③ visited[i] = false → 선택 해제 (되돌아가기)
선택 해제가 없으면 한 번 쓴 원소를 다시는 쓸 수 없다. visited가 Permutation2의 numbers 배열 순회를 O(1)로 단축시켜줘서 훨씬 빠르다.
| 방법 | 시간복잡도 | 경우의 수 |
|---|---|---|
| for문 중복 순열 | O(N³) | N³ |
| for문 순열 (중복 제거) | O(N³) | N! |
| 재귀 순열 (visited) | O(N!) | N! |
for문 버전은 r이 고정일 때만 쓸 수 있고, 재귀 버전은 r이 바뀌어도 depth 파라미터 하나만 조절하면 된다.
순열 BackTracking을 실전 문제에 바로 적용한 케이스다.
규영이 카드 9장이 주어지면 나머지 9장은 자동으로 인영이 카드가 된다. 인영이 카드의 모든 배치(순열)를 시도하면서 규영이의 승/패 횟수를 센다.
static boolean[] visited = new boolean[9];
public static void solve(int depth, int scoreQ, int scoreI) {
if (depth == 9) { // 9장 다 매칭되면 결과 판정
if (scoreQ > scoreI) win++;
else if (scoreQ < scoreI) lose++;
return;
}
for (int i = 0; i < 9; i++) {
if (!visited[i]) {
visited[i] = true;
int valQ = inputQ[depth]; // 규영이 depth번째 카드
int valI = inputI[i]; // 인영이 i번째 카드
if (valQ > valI) {
solve(depth + 1, scoreQ + valQ + valI, scoreI);
} else {
solve(depth + 1, scoreQ, scoreI + valQ + valI);
}
visited[i] = false;
}
}
}
Permutation3의 구조와 완전히 동일하다. visited로 중복 방지하고, 선택-탐색-선택해제. 달라진 건 기저 조건에서 점수를 비교하는 로직뿐이다.
오늘 풀면서 느낀 부족한 점 두 가지:
static boolean[] visited;
static int[] numbers;
static void dfs(int depth) {
if (depth == R) { // 기저 조건
// 결과 처리
return;
}
for (int i = 0; i < N; i++) {
if (visited[i]) continue;
visited[i] = true; // 선택
numbers[depth] = input[i];
dfs(depth + 1); // 탐색
visited[i] = false; // 선택 해제
}
}
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
int T = Integer.parseInt(br.readLine());
for (int tc = 1; tc <= T; tc++) {
StringTokenizer st = new StringTokenizer(br.readLine());
int N = Integer.parseInt(st.nextToken());
int M = Integer.parseInt(st.nextToken());
// 처리
sb.append("#").append(tc).append(" ").append(answer).append("\n");
}
System.out.print(sb);
정수의 이진수 표현을 자료구조로 쓰는 기법이다. 1이면 켜져있다/true/원소 있음, 0이면 꺼져있다/false/원소 없음을 의미한다.
수행 시간: 배열 O(N) → BitMask O(1)
코드 길이: 반복문 + 다중 연산자 → bit 연산 한 줄
메모리: 원소 수만큼 배열 → 변수 하나 (int=32개, long=64개 표현)
int bit1 = 0b10101;
int bit2 = 0b00000;
int bit3 = 0b11111;
bit1 | bit2 // OR → 0b10101 (하나라도 1이면 1)
bit1 & bit2 // AND → 0b00000 (둘 다 1이어야 1)
bit1 | bit3 // OR → 0b11111
bit1 & bit3 // AND → 0b10101
8 >> 1 // 오른쪽 1칸 이동 → 4 (2의 1승으로 나눈 효과)
8 << 1 // 왼쪽 1칸 이동 → 16 (2의 1승으로 곱한 효과)
int A = 0; // 공집합: 모든 bit가 0
int A = (1 << N) - 1; // 꽉찬 집합: N개의 bit가 모두 1
A |= (1 << K); // K번째 bit를 1로 설정
// 예: K=5 → A = 0b100000
A &= ~(1 << K); // K번째 bit를 0으로 설정
// ~(1<<K): K번째만 0이고 나머지는 모두 1인 마스크
// A와 AND하면 K번째만 0이 됨
if ((A & (1 << K)) == 0) {
// K번째 원소 없음 (0)
}
if ((A & (1 << K)) != 0) {
// K번째 원소 있음 (1)
}
visited 배열로 비교하면:
// boolean[] 방식
if (!visited[K]) { ... }
// BitMask 방식
if ((visited & (1 << K)) == 0) { ... }
int result = A & -A; // A의 이진수에서 가장 낮은 위치의 1을 추출
// 예: 0b110100 → 0b000100 (2번째 bit)
// 펜윅 트리(Fenwick Tree) 구현에서 사용
-A는 2의 보수라서 A의 맨 오른쪽 1만 살아남는다. 지금 당장 쓸 일은 없지만 나중에 세그먼트 트리, 펜윅 트리 배울 때 나온다.
static int visited = 0; // boolean[] visited 대체
public static void permutation(int depth) {
if (depth == R) {
System.out.println(Arrays.toString(numbers));
return;
}
for (int i = 0; i < N; i++) {
if ((visited & (1 << i)) != 0) continue; // 이미 선택됨
visited |= (1 << i); // 선택
numbers[depth] = input[i];
permutation(depth + 1);
visited &= ~(1 << i); // 선택 해제
}
}
오늘 배운 visited 패턴이 BackTracking이다.
선택 → 탐색 → 선택 해제
선택 해제가 핵심이다. 이게 없으면 DFS(깊이 우선 탐색)이고, 선택 해제가 있어서 되돌아올 수 있으면 BackTracking이다.
BackTracking이 DFS에 "가지치기(pruning)"를 추가한 개념이다. 유망하지 않은 경로는 탐색을 중단하고 되돌아온다.
오늘 순열을 for문 → 재귀로 발전시키는 과정을 보면서, 재귀가 왜 필요한지 완전히 이해됐다.
for문 순열의 문제는 r이 달라질 때마다 코드를 통째로 다시 짜야 한다는 거다. r=3이면 3중 for문, r=5면 5중 for문. 재귀는 depth 파라미터 하나로 이걸 해결한다. 코드 구조 자체가 달라지지 않는다.
visited 배열의 선택-해제 패턴은 앞으로 DFS, 백트래킹 문제에서 계속 나올 거다. 지금 확실히 이해해두는 게 중요하다.
완전 탐색 Brute-Force Sequential Search 경우의 수 순열 조합 부분집합 DFS BFS BackTracking BitMask