출시·고도화 중
탐욕 알고리즘 안내서 · 2/6
대부분의 탐욕 알고리즘은 같은 뼈대를 가집니다. 후보를 어떤 기준으로 정렬하거나 우선순위 큐에 넣고, 하나씩 꺼내면서 지금까지의 선택과 충돌하지 않으면 받아들입니다. 이 장에서는 작은 예제로 구간 스케줄링, 허프만 부호화, 회의실 배정이 실제로 어떻게 움직이는지 따라가 봅니다.
회의실 하나에 신청이 여러 개 들어왔고, 서로 겹치지 않게 가장 많은 회의를 넣고 싶습니다. 각 회의는 반열린 구간 [시작, 끝)이라서 한 회의가 4시에 끝나면 다음 회의는 4시에 시작할 수 있습니다. 탐욕 기준은 "끝나는 시간이 가장 이른 회의부터"입니다.
def trace(intervals):
last_end = float("-inf")
for start, end in sorted(intervals, key=lambda iv: iv[1]):
ok = start >= last_end
print(f"({start}, {end}) last_end={last_end} -> {'선택' if ok else '건너뜀'}")
if ok:
last_end = end
trace([(1, 3), (2, 5), (4, 7), (1, 8), (6, 9), (8, 10)])끝나는 시간순으로 정렬한 뒤의 진행은 다음과 같습니다.
| 순서 | 회의 | 직전 끝 시간 | 판단 | 새 끝 시간 |
|---|---|---|---|---|
| 1 | (1, 3) | 없음 | 선택 | 3 |
| 2 | (2, 5) | 3 | 2가 3보다 이르므로 건너뜀 | 3 |
| 3 | (4, 7) | 3 | 선택 | 7 |
| 4 | (1, 8) | 7 | 건너뜀 | 7 |
| 5 | (6, 9) | 7 | 건너뜀 | 7 |
| 6 | (8, 10) | 7 | 선택 | 10 |
결과는 (1, 3), (4, 7), (8, 10)의 3개이며, 이보다 많이 고를 수 없습니다.
그럴듯해 보이는 기준도 반례 하나로 무너집니다.
| 기준 | 반례 | 탐욕 결과 | 최적 |
|---|---|---|---|
| 가장 일찍 시작 | (0, 10), (1, 2), (3, 4) | 1개 | 2개 |
| 가장 짧은 회의 | (1, 5), (4, 7), (6, 10) | 1개 | 2개 |
| 겹침이 가장 적은 회의 | 더 큰 반례가 알려져 있음 | 최적보다 적음 | - |
가장 일찍 끝나는 회의를 고르면 남은 시간이 가장 많이 남습니다. 탐욕 해의 k번째 회의는 어떤 해의 k번째 회의보다 늦게 끝나지 않는다는 것을 귀납법으로 보일 수 있는데, 이것이 앞서 나가기 논법입니다.
허프만 부호화는 자주 나오는 기호에 짧은 비트열을, 드문 기호에 긴 비트열을 주어 전체 길이를 최소로 만드는 접두 부호를 만듭니다. 탐욕 기준은 "가장 가벼운 두 덩어리를 합친다"입니다. 빈도가 a=5, b=9, c=12, d=13, e=16, f=45인 예를 따라가 봅니다.
import heapq
freq = {"a": 5, "b": 9, "c": 12, "d": 13, "e": 16, "f": 45}
heap = [(w, ch) for ch, w in freq.items()]
heapq.heapify(heap)
total = 0
while len(heap) > 1:
w1, x = heapq.heappop(heap)
w2, y = heapq.heappop(heap)
total += w1 + w2
print(f"{x}({w1}) + {y}({w2}) = {w1 + w2}")
heapq.heappush(heap, (w1 + w2, x + y))
print("전체 비트 수:", total) # 224| 단계 | 꺼낸 두 덩어리 | 합친 무게 | 누적 비용 |
|---|---|---|---|
| 1 | a(5), b(9) | 14 | 14 |
| 2 | c(12), d(13) | 25 | 39 |
| 3 | ab(14), e(16) | 30 | 69 |
| 4 | cd(25), abe(30) | 55 | 124 |
| 5 | f(45), cdabe(55) | 100 | 224 |
완성된 트리에서 f는 깊이 1, c·d·e는 깊이 3, a·b는 깊이 4입니다. 각 기호의 부호 길이는 그 깊이와 같고, 빈도와 길이를 곱해 더하면 45 + 36 + 39 + 48 + 20 + 36 = 224비트입니다. 합칠 때마다 생긴 무게의 합(14 + 25 + 30 + 55 + 100)과 같다는 점도 확인할 수 있습니다. 고정 길이 3비트를 쓰면 300비트이므로 약 25%를 줄인 셈입니다.
가장 가벼운 두 기호가 가장 깊은 곳에 형제로 놓이는 최적 트리가 항상 존재한다는 것이 교환 논법으로 증명됩니다. 더 무거운 기호가 더 깊은 곳에 있다면 두 기호를 맞바꿔도 비용이 늘지 않기 때문입니다.
이번에는 모든 회의를 다 열어야 하고, 필요한 회의실 수의 최솟값을 구합니다. 기차역에서 필요한 플랫폼 수를 구하는 문제도 같은 구조입니다. 회의를 시작 시간순으로 보면서, 사용 중인 방들의 끝 시간을 최소 힙에 넣어 둡니다. 가장 먼저 비는 방이 지금 회의 시작 전에 비면 그 방을 다시 쓰고, 아니면 방을 하나 더 엽니다.
import heapq
def min_rooms(meetings):
ends = [] # 사용 중인 방들의 끝 시간(최소 힙)
for start, end in sorted(meetings):
if ends and ends[0] <= start:
heapq.heapreplace(ends, end) # 가장 먼저 빈 방을 다시 쓴다
else:
heapq.heappush(ends, end) # 새 방을 연다
return len(ends)
print(min_rooms([(9, 10), (9, 12), (10, 11), (11, 13), (12, 13)])) # 2| 회의 | 힙(끝 시간) 처리 전 | 동작 | 힙 처리 후 |
|---|---|---|---|
| (9, 10) | 비어 있음 | 새 방 | [10] |
| (9, 12) | [10] | 10이 9보다 늦음, 새 방 | [10, 12] |
| (10, 11) | [10, 12] | 10시에 비는 방 재사용 | [11, 12] |
| (11, 13) | [11, 12] | 11시에 비는 방 재사용 | [12, 13] |
| (12, 13) | [12, 13] | 12시에 비는 방 재사용 | [13, 13] |
기차 플랫폼처럼 도착과 출발이 같은 시각이면 플랫폼을 함께 쓸 수 없는 규칙이라면 ends[0] <= start를 ends[0] < start로 바꿉니다. 구간 끝을 포함하는지 여부는 탐욕 알고리즘에서 가장 흔한 실수의 원인입니다.
<와 <=)를 문제 조건에 맞게 정해야 합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.