
완전 탐색은 가능한 모든 경우를 직접 확인하여 답을 찾는 가장 확실한 방법입니다.
완전 탐색을 알아보기 전에 알고리즘 설계 기법에 대해 먼저 살펴보겠습니다.
알고리즘 설계 기법은 다양한 문제를 효율적으로 해결하기 위한 체계적인 접근 방법입니다.
프로그래밍에서 마주하는 문제는 무궁무진합니다.
하지만 놀랍게도 대부분의 문제는 몇 가지 기본 패턴으로 해결할 수 있습니다.
실생활 비유:
알고리즘 설계 기법도 마찬가지입니다. 기본적인 몇 가지 사고 패턴을 익히면, 새로운 문제를 만나도 "아, 이건 분할 정복으로 풀면 되겠구나" 하고 접근할 수 있습니다.
1. 문제 해결의 틀 제공
복잡한 문제를 보고 막막할 때, "어떤 기법을 쓸까?"라고 생각하면 실마리가 보입니다.
문제: "배열에서 가장 큰 합을 가진 연속 부분 배열을 찾아라"
해결 과정: 막막함 → "음... 동적 계획법으로 접근하면?" → 점화식 세우기 → 해결!
2. 최적화의 방향 제시
처음엔 느린 알고리즘이라도, 설계 기법을 바꾸면 극적으로 빨라질 수 있습니다.
완전 탐색: O(n!) → 너무 느림
분할 정복: O(n log n) → 빠름!
3. 알고리즘 분석 능력 향상
남이 짠 코드를 보고 "아, 이건 그리디 알고리즘이네"라고 파악할 수 있습니다.
문제 특성 → 추천 기법
------------------------------------------------
경우의 수가 적음 → 완전 탐색
문제를 나눌 수 있음 → 분할 정복
매 순간 최선의 선택 → 그리디
중복 계산이 많음 → 동적 계획법
제약 조건이 복잡함 → 백트래킹
최적화 + 가지치기 필요 → 분기 한정
그래프/네트워크 문제 → 그래프 알고리즘
문자열 패턴/검색 → 문자열 알고리즘
허용 시간 경우의 수 추천 기법
------------------------------------------------
1초 10^8 이하 완전 탐색
1초 10^6 O(n log n) - 분할 정복
1초 10^4 O(n^2) - 동적 계획
1초 10^3 O(n^3) - 동적 계획
주요 기법 요약
기법 핵심 아이디어 복잡도 예시
------------------------------------------------------
완전 탐색 모두 확인 O(n!), O(2^n)
분할 정복 나누어 해결 O(n log n)
그리디 지금 최선 선택 O(n), O(n log n)
동적 계획법 중복 제거 O(n), O(n^2)
백트래킹 되돌아가기 O(2^n) 개선
분기 한정 한계값 계산 최적화 문제
문제 해결 프로세스
완전 탐색(Brute Force)은 문제의 모든 가능한 경우를 하나도 빠짐없이 확인하여 답을 찾는 방법입니다.
실생활 비유:
완전 탐색의 특징:
# 4자리 비밀번호 찾기 (완전 탐색)
def find_password(correct_password):
"""0000 ~ 9999 모두 시도"""
for password in range(10000):
if password == correct_password:
return password
return None
# 최악의 경우 10,000번 시도!
완전 탐색은 비효율적인데 왜 배울까요?
1. 정답을 보장합니다
다른 알고리즘이 복잡하거나 불확실할 때, 완전 탐색은 항상 정답을 찾습니다.
2. 기준점이 됩니다
다른 알고리즘의 성능을 비교할 때 완전 탐색을 기준으로 사용합니다.
완전 탐색: O(2^n)
최적화: O(n log n)
→ 얼마나 빨라졌는지 확인 가능!
3. 실제로 충분히 빠를 수 있습니다
경우의 수가 적으면 완전 탐색이 가장 좋은 선택입니다.
경우의 수가 1,000개 → 완전 탐색으로 충분!
경우의 수가 1,000,000,000개 → 다른 방법 필요
4. 다른 알고리즘의 기초입니다
백트래킹, 분기한정 등은 완전 탐색을 개선한 것입니다.
가장 기본적인 형태로, 모든 경우를 순차적으로 확인합니다.
예시: 배열에서 최댓값 찾기
def find_max(arr):
"""
모든 원소를 확인하여 최댓값 찾기: 모든 원소를 한 번씩 확인
"""
max_val = arr[0]
# 모든 원소를 확인
for num in arr:
if num > max_val:
max_val = num
return max_val
# 시간복잡도: O(n)
예시: 두 수의 합이 target인 쌍 찾기
def find_pair(arr, target):
"""
두 원소의 합이 target인 쌍 찾기: 모든 쌍을 확인
"""
n = len(arr)
# 모든 쌍 (i, j)를 확인
for i in range(n):
for j in range(i + 1, n):
if arr[i] + arr[j] == target:
return (arr[i], arr[j])
return None
# 시간복잡도: O(n^2)
# 쌍의 개수: n * (n-1) / 2
순서가 있는 모든 배열을 생성합니다.
순열이란?
n개 중에서 r개를 순서를 고려하여 선택하는 경우의 수입니다.
*순열에서 '순서를 고려한다'는 말은 똑같은 구성 요소를 뽑더라도 나열된 차례가 다르면 서로 다른 경우로 취급하겠다는 뜻임.
*순서를 고려하지 않는 경우 조합(Combination)
[1, 2, 3]에서 2개를 뽑는 순열:
(1, 2), (1, 3)
(2, 1), (2, 3)
(3, 1), (3, 2)
총 3 × 2 = 6가지
공식: nPr = n! / (n-r)!
순열 생성 방법:
def permutations(arr, r):
"""
arr에서 r개를 뽑는 모든 순열 생성: [1,2,3]에서 2개 → (1,2), (1,3), (2,1), (2,3), (3,1), (3,2)
arr: 원소 리스트
r: 선택할 개수
Returns: 모든 순열의 리스트
방법: 재귀
- 하나를 선택하고
- 나머지에서 (r-1)개 선택
시간복잡도: O(n!)
"""
result = []
# 기저 조건: 0개를 선택하는 경우
if r == 0:
return [[]] # 빈 리스트 하나만 반환 (아무것도 선택 안 함)
# arr의 각 원소를 "첫 번째 선택"으로 시도
for i in range(len(arr)):
elem = arr[i] # 선택한 현재 원소
# 현재 원소를 제외한 나머지 원소(rest) = 현재 원소 앞의 모든 원소(arr[:i]) + 현재 원소 뒤의 모든 원소(arr[i+1:])
rest = arr[:i] + arr[i+1:] # 예: arr=[1,2,3], i=1이면 rest = [1] + [3] = [1,3]
# 나머지 원소들에서 (r-1)개를 선택하는 모든 순열
# 재귀 호출로 부분 문제 해결: 예를 들어 rest=[1,3], r-1=1이면 [[1], [3]] 반환
for p in permutations(rest, r - 1):
result.append([elem] + p) # 현재 원소를 맨 앞에 붙이기
# 예: elem=2, p=[1]이면 [2] + [1] = [2, 1]
return result
# 실행 예시로 이해하기:
# permutations([1, 2, 3], 2) 호출
# 1단계: i=0, elem=1, rest=[2,3]
# permutations([2,3], 1) 호출 → [[2], [3]] 반환
# [1] + [2] = [1,2]
# [1] + [3] = [1,3]
# 2단계: i=1, elem=2, rest=[1,3]
# permutations([1,3], 1) 호출 → [[1], [3]] 반환
# [2] + [1] = [2,1]
# [2] + [3] = [2,3]
# 3단계: i=2, elem=3, rest=[1,2]
# permutations([1,2], 1) 호출 → [[1], [2]] 반환
# [3] + [1] = [3,1]
# [3] + [2] = [3,2]
# 최종 결과: [[1,2], [1,3], [2,1], [2,3], [3,1], [3,2]]
Python 내장 함수 사용:
from itertools import permutations
arr = [1, 2, 3]
perms = list(permutations(arr, 2))
print(perms)
# [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]
순서를 고려하지 않고 선택합니다.
조합이란?
n개 중에서 r개를 순서 상관없이 선택하는 경우의 수입니다.
[1, 2, 3]에서 2개를 뽑는 조합:
{1, 2}, {1, 3}, {2, 3}
총 3가지
공식: nCr = n! / (r! × (n-r)!)
순열 vs 조합:
순열: (1, 2)와 (2, 1)은 다름
조합: {1, 2}와 {2, 1}은 같음
조합 생성 방법:
def combinations(arr, r):
"""
arr에서 r개를 뽑는 모든 조합 생성: [1,2,3]에서 2개 → {1,2}, {1,3}, {2,3}
arr: 원소 리스트
r: 선택할 개수
Returns: 모든 조합의 리스트
방법: 재귀 + 인덱스
- 현재 원소를 포함하거나
- 포함하지 않거나
시간복잡도: O(2^n)
"""
result = []
def combine(start, current):
"""
조합 생성 헬퍼 함수
start: 탐색을 시작할 인덱스 (이전에 선택한 원소 다음부터 선택)
current: 현재까지 선택한 원소들
핵심 아이디어:
- start 인덱스를 유지하여 "뒤에 있는 원소만 선택" → 중복 조합 방지
- 예: [1,2]와 [2,1]은 같은 조합이므로 [1,2]만 생성
"""
# 기저 조건: r개를 모두 선택했으면
if len(current) == r:
# 현재 조합을 결과에 추가
result.append(current[:]) # current[:]는 current의 복사본(그냥 current를 추가하면 나중에 변경될 수 있음)
return
# start부터 배열 끝까지 탐색 → 이전에 선택한 원소는 다시 안 봄
for i in range(start, len(arr)):
current.append(arr[i]) # 1. 현재 원소(arr[i])를 선택에 추가
combine(i + 1, current) # 2. 다음 원소부터 탐색 (i+1)
# i+1: 같은 원소를 두 번 선택 방지 + 순서 없는 조합이므로 뒤의 원소만 고려
current.pop() # 3. 백트래킹: 선택 취소(다른 경우를 시도하기 위해 마지막 원소 제거)
combine(0, []) # 인덱스 0부터 시작, 빈 리스트에서 시작
return result
# 실행 예시로 이해하기:
# combinations([1, 2, 3], 2) 호출
# combine(0, []) 시작
# 1단계: i=0, arr[0]=1
# current = [1]
# combine(1, [1]) 호출
#
# i=1, arr[1]=2
# current = [1, 2] ← 길이가 2이므로 result에 추가
# combine(2, [1, 2]) 호출 → 즉시 return
# current = [1] (pop)
#
# i=2, arr[2]=3
# current = [1, 3] ← 길이가 2이므로 result에 추가
# combine(3, [1, 3]) 호출 → 즉시 return
# current = [1] (pop)
#
# current = [] (pop)
# 2단계: i=1, arr[1]=2
# current = [2]
# combine(2, [2]) 호출
#
# i=2, arr[2]=3
# current = [2, 3] ← 길이가 2이므로 result에 추가
# combine(3, [2, 3]) 호출 → 즉시 return
# current = [2] (pop)
#
# current = [] (pop)
# 3단계: i=2, arr[2]=3
# current = [3]
# combine(3, [3]) 호출 → range(3, 3)이므로 반복문 실행 안 됨
# current = [] (pop)
# 최종 result: [[1, 2], [1, 3], [2, 3]]
# 왜 start 인덱스가 중요한가?
# start 없이 매번 0부터 시작하면: [1,2], [2,1]이 둘 다 생성됨 (중복!)
# start를 사용하면: i=0일 때 1을 선택 → 다음은 i=1부터 (2, 3만 고려)
# i=1일 때 2를 선택 → 다음은 i=2부터 (3만 고려)
# → [1,2]는 생성되지만 [2,1]은 생성 안 됨 (중복 방지!)
Python 내장 함수:
from itertools import combinations
arr = [1, 2, 3]
combs = list(combinations(arr, 2))
print(combs)
# [(1, 2), (1, 3), (2, 3)]
모든 부분집합을 생성합니다.
부분집합이란?
집합의 원소 중 일부 (또는 전부, 또는 하나도 없이)를 선택한 집합입니다.
{1, 2, 3}의 모든 부분집합: {}, {1}, {2}, {3}, {1,2}, {1,3}, {2,3}, {1,2,3}
총 2^3 = 8가지
공식: 2^n
방법 1: 재귀
def subsets_recursive(arr):
"""
모든 부분집합 생성 (재귀)
각 원소마다:
- 포함하거나
- 포함하지 않거나
"""
result = []
def generate(index, current):
"""
index: 현재 확인할 원소의 인덱스
current: 현재까지 선택한 원소들
"""
# 모든 원소를 확인했으면
if index == len(arr):
result.append(current[:])
return
# 현재 원소를 포함하지 않음
generate(index + 1, current)
# 현재 원소를 포함
current.append(arr[index])
generate(index + 1, current)
current.pop()
generate(0, [])
return result
# 사용 예시
arr = [1, 2, 3]
print(subsets_recursive(arr)) # [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]
방법 2: 비트마스크
비트마스크를 사용하면 부분집합을 매우 효율적으로 생성할 수 있습니다.
def subsets_bitmask(arr):
"""
모든 부분집합 생성 (비트마스크)
원리:
- n개 원소 → 2^n개의 부분집합을 가짐
- 2^n을 이진수로 변환 → 각 비트가 원소 포함여부를 표현(0: 없음, 1: 있음)
- 예: 3개 원소 집합 → 8개 부분집합 → 0 ~ 7을 이진수로 000 ~ 111 표현
"""
n = len(arr)
result = []
# mask는 0부터 2^n - 1까지
for mask in range(1 << n): # 1 << n = 2^n
subset = []
# 각 비트 확인
for i in range(n):
if mask & (1 << i): # i번째 비트가 1이면 arr[i] 포함
# & 연산이므로 mask와 i가 모두 1일 때 1
subset.append(arr[i])
result.append(subset)
return result
# 사용 예시
arr = [1, 2, 3] # [1번 원소, 2번 원소, 3번 원소]
print(subsets_bitmask(arr))
# 비트마스크 동작 원리: mask 이진수 3자리와 대응되는 arr 은 (3번 원소, 2번 원소, 1번 원소)
# mask=0 (000): [] * 해당 원소 없음
# mask=1 (001): [1] * 1번 원소만 존재
# mask=2 (010): [2] * 2번 원소만 존재
# mask=3 (011): [1, 2] * 1, 2번 원소 존재
# mask=4 (100): [3] * 3번 원소만 존재
# mask=5 (101): [1, 3] * 1, 3번 원소 존재
# mask=6 (110): [2, 3] * 2, 3번 원소 존재
# mask=7 (111): [1, 2, 3] * 1, 2, 3번 원소 존재
비트마스크의 원리와 다양한 활용법은 다음 섹션에서 자세히 다룹니다.
비트마스크를 이해하려면 먼저 플래그(Flag) 개념을 알아야 합니다.
플래그는 켜짐/꺼짐을 나타내는 표시입니다.
실생활 예시:
전등 스위치:
- ON (1) 또는 OFF (0)
체크리스트:
할일1: ☑ (완료)
할일2: ☐ (미완료)
할일3: ☑ (완료)
→ 각 항목이 하나의 플래그
프로그래밍에서 플래그:
# 일반적인 플래그 사용
has_apple = True # 사과가 있는가?
has_banana = False # 바나나가 있는가?
has_orange = True # 오렌지가 있는가?
# 문제점: 과일이 100개면? 변수 100개?
여러 개의 플래그를 효율적으로 관리하는 방법이 바로 비트마스크입니다.
비트마스크(Bitmask)는 정수의 각 비트를 플래그로 사용하는 기법입니다.
마스크(mask)는 특정 비트 패턴을 사용하여 데이터의 원하는 부분만 선택, 수정, 삭제, 조회하기 위해 필터 역할을 하는 이진수입니다.
핵심 아이디어:
정수 하나로 여러 개의 플래그 관리!
10진수 5 = 2진수 101
비트 위치: 2 1 0
비트 값: 1 0 1
↓ ↓ ↓
의미: ON OFF ON
예시: 과일 바구니
# 3가지 과일: 사과(0번), 바나나(1번), 오렌지(2번)
# 5 = 101(2진수)
# 비트 2: 1 → 오렌지 있음 * 오른쪽부터 3번째 비트(1)
# 비트 1: 0 → 바나나 없음 * 가운데 비트(0)
# 비트 0: 1 → 사과 있음 * 오른쪽부터 1번째 비트(1)
basket = 5 # 사과와 오렌지가 있음
# 단 하나의 정수로 3가지 상태 표현!
1. 메모리 효율적
# 일반 방법: 3개 변수
has_apple = True
has_banana = False
has_orange = True
# 메모리: 3 × 1바이트 = 3바이트 (최소)
# 비트마스크: 1개 정수
basket = 5 # 0b101
# 메모리: 4바이트에 32가지 상태 저장 가능!
2. 연산이 빠름
비트 연산은 CPU가 직접 지원하는 하드웨어 명령어입니다.
# 일반 방법
if has_apple and has_orange:
print("사과와 오렌지 있음")
# 비트마스크 (더 빠름)
if basket & 0b101 == 0b101:
print("사과와 오렌지 있음")
3. 집합 연산이 간편
basket1 = 5 # 0b101: 사과, 오렌지
basket2 = 3 # 0b011: 사과, 바나나
# 합집합 (OR)
basket1 | basket2 # 0b111: 사과, 바나나, 오렌지
# 교집합 (AND)
basket1 & basket2 # 0b001: 사과
# 차집합 (XOR)
basket1 ^ basket2 # 0b110: 바나나, 오렌지
비트마스크를 사용하려면 비트 연산을 알아야 합니다.
AND 연산 (&): 둘 다 1이면 1
a = 5 # 0b0101
b = 3 # 0b0011
& # ------
# 0b0001 = 1
print(a & b) # 1
# 활용: 특정 비트가 켜져 있는지 확인
# "사과가 있는가?"
if basket & 0b001: # 0번 비트 확인
print("사과 있음")
OR 연산 (|): 하나라도 1이면 1
a = 5 # 0b0101
b = 3 # 0b0011
| # ------
# 0b0111 = 7
print(a | b) # 7
# 활용: 특정 비트 켜기
# "바나나 추가"
basket = basket | 0b010 # 1번 비트 켜기
# 또는: basket |= 0b010
XOR 연산 (^): 다르면 1
a = 5 # 0b0101
b = 3 # 0b0011
^ # ------
# 0b0110 = 6
print(a ^ b) # 6
# 활용: 특정 비트 토글 (반전)
# "사과 있으면 제거, 없으면 추가"
basket = basket ^ 0b001 # 0번 비트 반전
NOT 연산 (~): 모든 비트 반전
a = 5 # 0b0101
print(~a) # -6
# 주의: Python은 부호 있는 정수라 음수로 표현됨
# 비트마스크에서는 주로 다른 연산과 함께 사용
시프트 연산 (<<, >>): 비트 이동
# 왼쪽 시프트: 2를 곱하는 효과
a = 5 # 0b0101
a << 1 # 0b1010 = 10 (5 × 2)
a << 2 # 0b10100 = 20 (5 × 4)
# 오른쪽 시프트: 2로 나누는 효과
a = 5 # 0b0101
a >> 1 # 0b0010 = 2 (5 ÷ 2)
a >> 2 # 0b0001 = 1 (5 ÷ 4)
# 활용: 특정 비트 위치의 마스크 만들기
1 << 0 # 0b0001: 0번 비트만 1
1 << 1 # 0b0010: 1번 비트만 1
1 << 2 # 0b0100: 2번 비트만 1
비트마스크로 자주 하는 4가지 연산입니다.
1. i번째 비트 확인 (Check)
"i번째 플래그가 켜져 있는가?"
def check_bit(mask, i):
"""
mask의 i번째 비트가 1인가?
방법:
1. (1 << i): i번째만 1인 마스크 생성
2. mask & (1 << i): i번째 비트만 추출
3. 0이 아니면 True
"""
return (mask & (1 << i)) != 0
# 예시: 과일 바구니(사과(0번), 바나나(1번), 오렌지(2번))
basket = 5 # 0b101
print(check_bit(basket, 0)) # True (사과 있음)
print(check_bit(basket, 1)) # False (바나나 없음)
print(check_bit(basket, 2)) # True (오렌지 있음)
# 동작 원리:
# basket = 5 = 0b101
# 1 << 0 = 0b001
# 0b101 & 0b001 = 0b001 ≠ 0 → True
2. i번째 비트 켜기 (Set)
"i번째 플래그를 ON으로"
def set_bit(mask, i):
"""
mask의 i번째 비트를 1로 설정
방법: OR 연산으로 특정 비트만 1로 (다른 비트는 그대로 유지)
"""
return mask | (1 << i)
# 예시: 바나나 추가
basket = 5 # 0b101 (사과, 오렌지)
basket = set_bit(basket, 1) # 바나나 추가
print(basket) # 7 = 0b111 (사과, 바나나, 오렌지)
# 동작 원리:
# basket = 5 = 0b101
# 1 << 1 = 0b010
# 0b101 | 0b010 = 0b111 = 7
3. i번째 비트 끄기 (Clear)
"i번째 플래그를 OFF로"
def clear_bit(mask, i):
"""
mask의 i번째 비트를 0으로 설정
방법:
1. (1 << i): i번째만 1인 마스크
2. ~(1 << i): i번째만 0인 마스크
3. AND 연산으로 i번째만 0으로
"""
return mask & ~(1 << i)
# 예시: 사과 제거
basket = 5 # 0b101 (사과, 오렌지)
basket = clear_bit(basket, 0)
print(basket) # 4 = 0b100 (오렌지만)
# 동작 원리:
# basket = 5 = 0b101
# 1 << 0 = 0b001
# ~(0b001) = 0b...11110 (모든 비트 1, 0번만 0)
# 0b101 & 0b...11110 = 0b100 = 4
4. i번째 비트 토글 (Toggle)
"i번째 플래그를 반전 (ON ↔ OFF)"
def toggle_bit(mask, i):
"""
mask의 i번째 비트 반전
방법: XOR 연산 (같으면 0, 다르면 1)
"""
return mask ^ (1 << i)
# 예시: 사과 토글
basket = 5 # 0b101 (사과 있음)
basket = toggle_bit(basket, 0)
print(basket) # 4 = 0b100 (사과 제거)
basket = toggle_bit(basket, 0)
print(basket) # 5 = 0b101 (사과 다시 추가)
# 동작 원리:
# XOR의 특성: A ^ 0 = A, A ^ 1 = ~A
# 0b101 ^ 0b001 = 0b100
# 0b100 ^ 0b001 = 0b101
이제 비트마스크로 부분집합을 생성하는 원리를 완전히 이해할 수 있습니다.
핵심 아이디어:
원소 3개: [1, 2, 3]
부분집합 개수: 2^3 = 8개
0 ~ 7을 이진수로 표현:
0 = 000 → 아무것도 선택 안 함 → []
1 = 001 → 0번만 선택 → [1]
2 = 010 → 1번만 선택 → [2]
3 = 011 → 0, 1번 선택 → [1, 2]
4 = 100 → 2번만 선택 → [3]
5 = 101 → 0, 2번 선택 → [1, 3]
6 = 110 → 1, 2번 선택 → [2, 3]
7 = 111 → 모두 선택 → [1, 2, 3]
각 비트 = 각 원소의 포함 여부!
상세한 구현:
def subsets_bitmask_explained(arr):
"""
비트마스크로 모든 부분집합 생성 (상세 설명 버전)
"""
n = len(arr)
result = []
# 0부터 2^n - 1까지 모든 정수
# 각 정수가 하나의 부분집합을 나타냄
for mask in range(1 << n): # 1 << n = 2^n
subset = []
# 각 비트 확인
for i in range(n):
if mask & (1 << i): # i번째 비트가 1이면
subset.append(arr[i]) # arr[i]를 부분집합에 포함
result.append(subset)
return result
# 상세 실행 과정
arr = ['A', 'B', 'C']
# mask=0 (0b000), i=0: 0 & 1 = 0 (포함 안 함)
# i=1: 0 & 2 = 0 (포함 안 함)
# i=2: 0 & 4 = 0 (포함 안 함)
# → []
# mask=5 (0b101), i=0: 5 & 1 = 1 ≠ 0 (포함!) → 'A'
# i=1: 5 & 2 = 0 (포함 안 함)
# i=2: 5 & 4 = 4 ≠ 0 (포함!) → 'C'
# → ['A', 'C']
1. 전체 집합 표현
# n개 원소를 모두 선택한 상태
n = 3
full_set = (1 << n) - 1 # 2^n - 1
# 예: n=3
# (1 << 3) = 8 = 0b1000
# 8 - 1 = 7 = 0b0111 (모든 비트 1)
2. 공집합 확인
# 아무것도 선택 안 함
if mask == 0:
print("공집합")
3. 부분집합 개수
# mask에서 1인 비트 개수 (선택된 원소 수)
def count_bits(mask):
count = 0
while mask:
count += mask & 1 # 최하위 비트 확인
mask >>= 1 # 오른쪽으로 1비트 이동
return count
# 또는 Python 내장 함수
bin(mask).count('1')
4. 두 집합의 합집합/교집합/차집합
set1 = 5 # 0b101
set2 = 3 # 0b011
# 합집합
union = set1 | set2 # 0b111
# 교집합
intersection = set1 & set2 # 0b001
# 차집합 (set1에만 있는 것)
difference = set1 & ~set2 # 0b100
# 대칭 차집합 (둘 중 하나에만)
symmetric_diff = set1 ^ set2 # 0b110
완전 탐색 최적화는 가능한 모든 경우의 수를 확인하는 방식에서 가지치기(Pruning) 등을 적용해 불필요한 연산을 줄여 효율성을 극대화하는 기법입니다.
즉, 탐색 범위를 좁혀 시간 복잡도를 개선하는 핵심적인 알고리즘 설계 전략입니다.
가지치기는 명백히 답이 될 수 없는 경우를 미리 제외하는 기법입니다.
실생활 비유:
미로 탈출을 생각해 봅시다. 모든 길을 다 가보는 대신:
이렇게 불필요한 탐색을 미리 차단하는 것이 가지치기입니다.
전체 탐색:
├─ 경로 A (막힘) ✗
├─ 경로 B
│ ├─ B-1 (이미 방문) ✗
│ └─ B-2 (계속 탐색)
└─ 경로 C (출구 반대) ✗
가지치기로 3개 경로 제외!
예시: N-Queen 문제
문제 설명:
N×N 체스판에 N개의 퀸(Queen)을 배치하되, 서로 공격할 수 없게 놓아야 합니다.
체스에서의 퀸:
퀸의 이동 규칙: 가로, 세로, 대각선 방향으로 무제한 이동 가능
4×4 체스판에서 퀸의 공격 범위:
0 1 2 3
┌───┬───┬───┬───┐
0 │ X │ X │ X │ X │
├───┼───┼───┼───┤
1 │ X │ X │ X │ X │
├───┼───┼───┼───┤
2 │ X │ X │ Q │ X │ ← (2,2)에 퀸
├───┼───┼───┼───┤
3 │ X │ X │ X │ X │
└───┴───┴───┴───┘
(2,2) 퀸이 공격 가능한 곳:
- 같은 행(2행): 모든 칸
- 같은 열(2열): 모든 칸
- 대각선: 모든 대각선 칸
→ 표시된 X 위치에는 다른 퀸을 놓을 수 없음!
가지치기 없이 완전 탐색:
# 4×4 체스판에 4개 퀸 배치
# 각 칸에 놓거나 안 놓거나 → 2^16 = 65,536가지
# 문제점: 대부분이 규칙 위반!
# 예: 같은 행에 2개 퀸 → 즉시 공격 → 불가능
가지치기 적용:
# 각 행마다 정확히 1개씩만 놓기
# → 4 × 4 × 4 × 4 = 256가지로 줄어듦!
# 더 나아가: 공격 범위 확인
# "이미 다른 퀸이 공격 가능한 위치면" → 건너뛰기
# → 실제 확인: 훨씬 적은 경우만 확인
가지치기 구현:
def is_safe(board, row, col, n):
"""
(row, col)에 퀸을 놓을 수 있는가?
체스 규칙상 확인할 것:
1. 같은 열에 다른 퀸이 있는가?
2. 왼쪽 위 대각선에 다른 퀸이 있는가?
3. 오른쪽 위 대각선에 다른 퀸이 있는가?
(같은 행은 확인 불필요 - 각 행에 1개씩만 놓으므로)
board: board[i] = j는 i번 행의 퀸이 j번 열에 있음
row: 현재 퀸을 놓을 행
col: 현재 퀸을 놓을 열
n: 체스판 크기
"""
# 1. 같은 열 확인
# 이전 행들(0 ~ row-1)에서 같은 열에 퀸이 있는가?
for i in range(row):
if board[i] == col:
return False # 같은 열에 있음 → 공격 가능 → 불가
# 2. 왼쪽 위 대각선 확인 (\ 방향)
# (row, col)에서 왼쪽 위로 올라가면서 확인
i, j = row - 1, col - 1
while i >= 0 and j >= 0:
if board[i] == j:
return False # 대각선에 있음 → 공격 가능 → 불가
i -= 1
j -= 1
# 3. 오른쪽 위 대각선 확인 (/ 방향)
# (row, col)에서 오른쪽 위로 올라가면서 확인
i, j = row - 1, col + 1
while i >= 0 and j < n:
if board[i] == j:
return False # 대각선에 있음 → 공격 가능 → 불가
i -= 1
j += 1
# 모든 확인 통과 → 놓을 수 있음
return True
def solve_n_queens(n):
"""
N-Queen 문제 해결 (가지치기 적용)
전략:
- 각 행에 정확히 1개씩 퀸 배치
- 놓을 수 없는 위치는 미리 제외 (가지치기!)
n: 체스판 크기 (n×n)
Returns: 모든 가능한 배치의 리스트
"""
result = []
board = [-1] * n # board[i]: i번 행의 퀸이 어느 열에 있는지
def backtrack(row):
"""
row번 행에 퀸 배치
가지치기 핵심:
- 각 열을 시도하되
- is_safe로 먼저 확인
- 불가능하면 아예 시도 안 함!
"""
# 기저 조건: 모든 행에 퀸을 배치했으면 성공
if row == n:
result.append(board[:]) # 현재 배치 저장
return
# 현재 행의 각 열에 퀸을 놓아보기
for col in range(n):
# 🌟 가지치기: 놓을 수 없으면 건너뛰기
if not is_safe(board, row, col, n):
continue # 다음 열 시도
board[row] = col # 퀸 배치
backtrack(row + 1) # 다음 행으로 이동
board[row] = -1 # 복원 (백트래킹)
backtrack(0)
return result
# 4×4 체스판 예시
solutions = solve_n_queens(4)
print(f"해의 개수: {len(solutions)}") # 2가지
print("해:", solutions)
# [[1, 3, 0, 2], [2, 0, 3, 1]]
# 해석:
# [1, 3, 0, 2]는:
# 0행 1열, 1행 3열, 2행 0열, 3행 2열에 퀸 배치
# 시각화:
# 0 1 2 3
# 0 - Q - -
# 1 - - - Q
# 2 Q - - -
# 3 - - Q -
가지치기의 효과:
가지치기 없이:
- 모든 경우: 2^16 = 65,536가지 확인
가지치기 적용:
- 실제 확인: 약 수십 가지만 확인
- 100배 이상 빠름!
핵심: "어차피 안 되는 경우"를 미리 걸러냄
다른 예시: 숫자 합 만들기
목표 합을 만들 수 없으면 조기 종료:
def subset_sum_with_pruning(arr, target):
"""
합이 target인 부분집합 찾기 (가지치기)
가지치기:
- 현재 합이 이미 target 초과 → 더 추가해도 소용없음
- 남은 원소를 다 더해도 target 미달 → 불가능
"""
n = len(arr)
arr.sort() # 정렬하면 가지치기 효과적
def backtrack(index, current_sum, selected):
# 목표 달성
if current_sum == target:
return selected[:]
# 🌟 가지치기 1: 이미 목표 초과
if current_sum > target:
return None # 더 이상 진행 불가
# 끝까지 확인
if index == n:
return None
# 🌟 가지치기 2: 남은 것 다 더해도 부족
remaining_sum = sum(arr[index:])
if current_sum + remaining_sum < target:
return None # 불가능
# 현재 원소 포함
selected.append(arr[index])
result = backtrack(index + 1, current_sum + arr[index], selected)
if result:
return result
selected.pop()
# 현재 원소 미포함
result = backtrack(index + 1, current_sum, selected)
if result:
return result
return None
return backtrack(0, 0, [])
arr = [3, 34, 4, 12, 5, 2]
print(subset_sum_with_pruning(arr, 9)) # [3, 4, 2] 또는 [4, 5]
# 가지치기 효과:
# - "합이 20인데 target이 9?" → 즉시 중단
# - "남은 원소 합쳐도 5인데 9 필요?" → 즉시 중단
가지치기 설계 팁:
답을 찾으면 즉시 종료합니다.
def find_subset_sum(arr, target):
"""
합이 target인 부분집합 찾기
조기 종료: 하나만 찾으면 종료
"""
n = len(arr)
# 모든 부분집합 확인
for mask in range(1 << n):
subset_sum = 0
for i in range(n):
if mask & (1 << i):
subset_sum += arr[i]
# 조기 종료: 찾으면 바로 반환
if subset_sum == target:
subset = [arr[i] for i in range(n) if mask & (1 << i)]
return subset
return None
arr = [3, 34, 4, 12, 5, 2]
print(find_subset_sum(arr, 9)) # [4, 5] 또는 [3, 4, 2]
정렬(Sorting)은 배열의 원소들을 특정 순서(보통 오름차순이나 내림차순)로 재배치하는 문제입니다.
왜 정렬이 중요한가?
정렬된 데이터:
- 탐색이 빠름 (이진 탐색 가능)
- 중복 제거 쉬움
- 패턴 발견 용이
- 많은 알고리즘의 전처리 단계
실생활 예시:
- 파일 목록 정렬
- 성적 순위
- 가격 비교
- 검색 결과 순위
완전 탐색 방식으로 정렬하면 어떻게 될까요? 가장 단순하지만 비효율적인 기초 정렬 알고리즘들을 살펴 봅시다.
버블 정렬은 인접한 두 원소를 비교하며 큰 것을 뒤로 보내는 방법입니다.
핵심 아이디어:
거품(Bubble)이 위로 떠오르듯, 큰 값이 배열 끝으로 이동
한 번 순회할 때마다 가장 큰 원소가 맨 뒤로 확정됨
동작 과정:
[5, 2, 8, 1, 9]
1회전: 인접 원소 비교 & 교환
5, 2 비교 → [2, 5, 8, 1, 9]
5, 8 비교 → [2, 5, 8, 1, 9] (교환 X)
8, 1 비교 → [2, 5, 1, 8, 9]
8, 9 비교 → [2, 5, 1, 8, 9] (교환 X)
→ 9 확정!
2회전:
2, 5 비교 → [2, 5, 1, 8, 9]
5, 1 비교 → [2, 1, 5, 8, 9]
5, 8 비교 → [2, 1, 5, 8, 9]
→ 8 확정!
3회전:
2, 1 비교 → [1, 2, 5, 8, 9]
2, 5 비교 → [1, 2, 5, 8, 9]
→ 5 확정!
4회전:
1, 2 비교 → [1, 2, 5, 8, 9]
→ 완료!
구현:
def bubble_sort(arr):
"""
버블 정렬
arr: 정렬할 배열
시간복잡도: O(n²)
- 외부 루프: n-1번
- 내부 루프: n-1번
- 총: (n-1) × (n-1) ≈ n²
공간복잡도: O(1)
- 제자리 정렬 (추가 배열 불필요)
특징:
- 가장 단순한 정렬
- 안정 정렬 (같은 값의 순서 유지)
- 실무에서는 거의 사용 안 함
"""
n = len(arr)
# 외부 루프: n-1번 회전
# 왜 n-1번? 마지막 원소는 자동 정렬됨
for i in range(n - 1):
# 최적화: 교환이 없으면 이미 정렬됨
swapped = False
# 내부 루프: 인접 원소 비교
# 왜 n-1-i? 뒤에서부터 i개는 이미 확정됨
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]: # 왼쪽이 오른쪽보다 크면 교환
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped: # 교환이 없었다면 이미 정렬됨 (최적화)
break
return arr
# 사용 예시
arr = [5, 2, 8, 1, 9]
print(f"정렬 전: {arr}")
bubble_sort(arr)
print(f"정렬 후: {arr}")
# 실행 과정 시각화
def bubble_sort_verbose(arr):
"""버블 정렬 과정을 출력하는 버전"""
n = len(arr)
print(f"초기 상태: {arr}")
for i in range(n - 1):
print(f"\n{i+1}회전 시작:")
swapped = False
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]:
print(f" {arr[j]} > {arr[j+1]} → 교환")
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
else:
print(f" {arr[j]} ≤ {arr[j+1]} → 유지")
print(f" 결과: {arr}")
if not swapped:
print(" → 정렬 완료!")
break
return arr
arr = [5, 2, 8, 1, 9]
bubble_sort_verbose(arr)
버블 정렬의 특징:
장점:
- 구현이 매우 간단
- 안정 정렬 (같은 값의 순서 유지)
- 제자리 정렬 (추가 메모리 불필요)
단점:
- O(n²)로 매우 느림
- 교환 횟수가 많음
언제 사용?
- 교육용 (정렬 개념 이해)
- 데이터가 거의 정렬된 경우 (최적화 버전)
- 절대 실무에서는 사용 X
선택 정렬은 매번 최솟값을 찾아서 앞으로 보내는 방법입니다.
핵심 아이디어:
1. 전체에서 최솟값 찾기
2. 맨 앞과 교환
3. 나머지에서 반복
동작 과정:
[5, 2, 8, 1, 9]
1회전: 최솟값 1 찾기
[5, 2, 8, 1, 9] → 1과 5 교환
[1, 2, 8, 5, 9] → 1 확정!
2회전: 나머지에서 최솟값 2 찾기
[1, 2, 8, 5, 9] → 이미 위치 맞음
[1, 2, 8, 5, 9] → 2 확정!
3회전: 나머지에서 최솟값 5 찾기
[1, 2, 8, 5, 9] → 5와 8 교환
[1, 2, 5, 8, 9] → 5 확정!
4회전: 나머지에서 최솟값 8 찾기
[1, 2, 5, 8, 9] → 이미 위치 맞음
[1, 2, 5, 8, 9] → 8 확정!
→ 9 자동 확정! 완료!
구현:
def selection_sort(arr):
"""
선택 정렬
arr: 정렬할 배열
시간복잡도: O(n²)
- 외부 루프: n-1번
- 내부 루프: n-1, n-2, ..., 1번
- 총: (n-1) + (n-2) + ... + 1 = n(n-1)/2 ≈ n²
공간복잡도: O(1)
특징:
- 교환 횟수가 적음 (최대 n-1번)
- 불안정 정렬 (같은 값의 순서 바뀔 수 있음)
- 어떤 경우든 항상 O(n²)
"""
n = len(arr)
# 외부 루프: 확정할 위치 (0부터 n-2까지)
for i in range(n - 1):
min_idx = i # 현재 위치를 최솟값 인덱스로 가정
# 내부 루프: i+1부터 끝까지 중 최솟값 찾기
for j in range(i + 1, n):
if arr[j] < arr[min_idx]: # 더 작은 값을 찾으면 인덱스 갱신
min_idx = j
# 최솟값을 현재 위치와 교환
# 자기 자신이면 교환 불필요
if min_idx != i:
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
# 사용 예시
arr = [5, 2, 8, 1, 9]
print(f"정렬 전: {arr}")
selection_sort(arr)
print(f"정렬 후: {arr}")
# 실행 과정 시각화
def selection_sort_verbose(arr):
"""선택 정렬 과정을 출력하는 버전"""
n = len(arr)
print(f"초기 상태: {arr}")
for i in range(n - 1):
print(f"\n{i+1}회전:")
min_idx = i
# 최솟값 찾기
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j
print(f" 최솟값: {arr[min_idx]} (인덱스 {min_idx})")
# 교환
if min_idx != i:
print(f" {arr[i]} ↔ {arr[min_idx]} 교환")
arr[i], arr[min_idx] = arr[min_idx], arr[i]
else:
print(f" 이미 위치 맞음")
print(f" 결과: {arr}")
print(f" 확정: {arr[:i+1]}")
return arr
arr = [5, 2, 8, 1, 9]
selection_sort_verbose(arr)
선택 정렬의 특징:
장점:
- 교환 횟수가 적음 (최대 n-1번)
- 메모리 쓰기가 비싼 환경에서 유리
단점:
- O(n²)로 느림
- 불안정 정렬
- 어떤 경우든 항상 O(n²) (최적화 불가)
버블 정렬과 비교:
- 버블: 비교 많고 교환 많음
- 선택: 비교 많고 교환 적음
언제 사용?
- 메모리 쓰기 비용이 비쌀 때
- 교환 횟수를 최소화해야 할 때
- 실무에서는 거의 사용 X
삽입 정렬은 이미 정렬된 부분에 새 원소를 올바른 위치에 삽입하는 방법입니다.
핵심 아이디어:
카드 게임에서 패 정리하는 방법:
1. 왼손에 정렬된 카드들
2. 오른손에서 카드 하나 뽑기
3. 왼손의 적절한 위치에 삽입
동작 과정:
[5, 2, 8, 1, 9]
초기: [5] | 2, 8, 1, 9
정렬됨 | 미정렬
1회전: 2를 삽입
[5] → 2 삽입 → [2, 5] | 8, 1, 9
5를 오른쪽으로 → 2 삽입
2회전: 8을 삽입
[2, 5] → 8 삽입 → [2, 5, 8] | 1, 9
이미 위치 맞음
3회전: 1을 삽입
[2, 5, 8] → 1 삽입 → [1, 2, 5, 8] | 9
8, 5, 2를 오른쪽으로 → 1 삽입
4회전: 9를 삽입
[1, 2, 5, 8] → 9 삽입 → [1, 2, 5, 8, 9]
이미 위치 맞음
완료!
구현:
def insertion_sort(arr):
"""
삽입 정렬
arr: 정렬할 배열
시간복잡도:
- 최선: O(n) - 이미 정렬된 경우
- 평균: O(n²)
- 최악: O(n²) - 역순 정렬된 경우
공간복잡도: O(1)
특징:
- 안정 정렬
- 거의 정렬된 데이터에 매우 빠름
- 온라인 알고리즘 (실시간 데이터 정렬 가능)
- Timsort (Python 기본 정렬)의 일부
"""
n = len(arr)
# i: 삽입할 원소의 인덱스 (1부터 시작)
# 왜 1부터? 0번째는 이미 "정렬됨"으로 간주
for i in range(1, n):
key = arr[i] # 삽입할 값을 임시 저장
j = i - 1 # j: 정렬된 부분에서 비교할 인덱스
# key보다 큰 원소들을 오른쪽으로 이동
# 조건 2개:
# 1. j >= 0: 배열 범위 체크
# 2. arr[j] > key: key보다 큰 원소 찾기
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j] # 오른쪽으로 이동
j -= 1
arr[j + 1] = key # 빈 자리에 key 삽입
return arr
# 사용 예시
arr = [5, 2, 8, 1, 9]
print(f"정렬 전: {arr}")
insertion_sort(arr)
print(f"정렬 후: {arr}")
# 실행 과정 시각화
def insertion_sort_verbose(arr):
"""삽입 정렬 과정을 출력하는 버전"""
n = len(arr)
print(f"초기 상태: {arr}")
print(f"정렬됨: [{arr[0]}] | 미정렬: {arr[1:]}\n")
for i in range(1, n):
key = arr[i]
print(f"{i}회전: {key}를 삽입")
print(f" 정렬됨: {arr[:i]}")
j = i - 1
moves = 0
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
moves += 1
arr[j + 1] = key
if moves > 0:
print(f" {moves}개 원소를 오른쪽으로 이동")
else:
print(f" 이미 위치 맞음")
print(f" 결과: {arr}")
print(f" 정렬됨: {arr[:i+1]} | 미정렬: {arr[i+1:]}\n")
return arr
arr = [5, 2, 8, 1, 9]
insertion_sort_verbose(arr)
삽입 정렬의 특징:
장점:
- 거의 정렬된 데이터에 매우 빠름 (O(n))
- 안정 정렬
- 온라인 알고리즘 (실시간 처리 가능)
- 작은 배열에 효율적
- 실무에서 실제 사용됨!
단점:
- 일반적으로 O(n²)
언제 사용?
- 작은 배열 (< 50개)
- 거의 정렬된 데이터
- 온라인 정렬 (데이터가 계속 들어올 때)
- Timsort, Introsort의 부분 알고리즘
실무 활용:
- Python의 Timsort: 작은 부분에 삽입 정렬 사용
- Java의 Arrays.sort(): 작은 배열에 삽입 정렬
시간복잡도:
알고리즘 최선 평균 최악
-----------------------------------------------
버블 정렬 O(n) O(n²) O(n²)
선택 정렬 O(n²) O(n²) O(n²)
삽입 정렬 O(n) O(n²) O(n²)
특징 비교:
특징 버블 선택 삽입
--------------------------------------------------
구현 난이도 쉬움 쉬움 쉬움
안정 정렬 O X O
제자리 정렬 O O O
교환 횟수 많음 적음 중간
실무 사용 X X O (작은 배열)
거의 정렬된 데이터 느림 느림 빠름!
언제 어떤 정렬?
교환 비용이 비쌀 때 → 선택 정렬 (교환 최소)
거의 정렬된 데이터 → 삽입 정렬 (O(n))
작은 배열 (< 50) → 삽입 정렬
큰 배열 → 다음 글의 병합/퀵 정렬 사용!
왜 이렇게 느릴까?
공통점: 이중 반복문 → O(n²)
개선 방법: 분할 정복(병합 정렬, 퀵 정렬) 사용! → O(n log n)
예:
n = 1,000일 때
O(n²) = 1,000,000 연산
O(n log n) = 약 10,000 연산 → 100배 빠름!
다음 글에서 배울 내용!
핵심 교훈:
완전 탐색 방식의 정렬:
- 모든 쌍을 비교
- 이중 반복문
- O(n²)
더 나은 방법:
- 분할 정복 (다음 글)
- 병합 정렬: O(n log n)
- 퀵 정렬: O(n log n)
완전 탐색을 사용할 때
순열 vs 조합 선택
비트마스크 활용
최적화 순서
완전 탐색의 본질
주요 유형
유형 경우의 수 예시
--------------------------------------------
순열 (nPr) n!/(n-r)! 비밀번호, 순위
조합 (nCr) n!/r!(n-r)! 팀 구성
부분집합 2^n 메뉴 선택
정렬 (기초) O(n²) 버블/선택/삽입
정렬 알고리즘 (완전 탐색 방식)
알고리즘 시간복잡도 특징
--------------------------------------------
버블 정렬 O(n²) 가장 단순
선택 정렬 O(n²) 교환 최소
삽입 정렬 O(n²) 거의 정렬된 데이터에 빠름
비트마스크
최적화 기법
시간복잡도
단순 반복: O(n), O(n^2)
순열: O(n!)
조합: O(2^n)
부분집합: O(2^n)
[06-02] 분할 정복 (Divide & Conquer)
이전 글: [05-05] 특수 자료구조
다음 글: [06-02] 분할 정복
시리즈: P1. Computer Science