쇼핑몰 첫 화면에 "지금 인기 상품 10"이 있다. 상품마다 최근 한 시간 클릭 수로 점수를 매기고, 1분마다 상위 10개를 다시 뽑는다. 첫 구현은 이렇다.
const top10 = products.toSorted((a, b) => b.score - a.score).slice(0, 10)
상품이 100만 개면 1분마다 100만 개를 줄 세운 뒤 10개만 남기고 버린다. 필요한 것은 맨 앞 10개뿐이다. 우선순위가 높은 작업부터 처리하는 작업 큐도 같은 모양이다. 매번 가장 급한 것 하나만 꺼내면 되는데, 작업이 들어올 때마다 전체를 정렬할 필요가 있을까?
이 장은 "가장 작은(또는 큰) 것 하나"를 빠르게 꺼내는 자료 구조, 힙(heap)을 본다. 정의는 이진 힙과 우선순위 큐 노트에 있다.
힙: 배열에 담은 트리
이진 힙(binary heap)은 위층부터, 한 층 안에서는 왼쪽부터 빈틈없이 채운 이진 트리(binary tree, 노드마다 자식이 많아야 둘인 트리. 트리 용어는 『개발 수학』 Unit 10)다. 빈틈이 없어서 트리를 배열 하나에 층 순서대로 담을 수 있다. 0부터 세면 인덱스 i의 자식은 2i + 1과 2i + 2, 부모는 (i - 1) / 2를 내린 값이다(1번 칸의 부모는 (1 − 1) / 2 = 0, 4번 칸의 부모는 3 / 2 = 1.5 → 1). 자식·부모를 가리키는 참조를 따로 두지 않고 계산만으로 위아래를 오간다.
칸 i | 자식 2i + 1, 2i + 2 | 부모 |
|---|---|---|
| 0 | 1, 2 | 없음(루트) |
| 1 | 3, 4 | 0 |
| 2 | 5, 6 | 0 |
| 3 | 7, 8 | 1 |
| 4 | 9, 10 | 1 |
배열 [2, 5, 3, 9, 6]을 이 규칙으로 그리면 3층짜리 트리다.
2 ← 0번 칸
/ \
5 3 ← 1번, 2번 칸
/ \
9 6 ← 3번, 4번 칸
최소 힙(min-heap)은 여기에 조건 하나를 더 건다. 모든 부모는 자식보다 작거나 같다. 그래서 가장 작은 값은 늘 루트 heap[0]에 있다. 형제끼리, 다른 가지끼리는 순서를 따지지 않는다. 전체를 정렬해 두는 것보다 훨씬 느슨한 조건이고, 이 느슨함 덕분에 고치는 비용이 싸다.
Python 표준 라이브러리 heapq가 정확히 이 방식이다. 보통 리스트를 그대로 힙으로 쓰고, heap[k] <= heap[2*k+1], heap[k] <= heap[2*k+2]를 유지하며, heap[0]이 가장 작다(Python 문서: heapq). 최대 힙(max-heap) 함수(heappush_max 등)는 Python 3.14에 추가됐다. 그 전 버전에서는 값의 부호를 뒤집어 넣는 방법을 흔히 쓴다. JavaScript에는 내장 힙이 없다. 표준 내장 객체 목록의 컬렉션은 Array·형식화 배열·Map·Set·WeakMap·WeakSet뿐이다(MDN: Standard built-in objects). 그래서 이 장의 TypeScript 코드는 힙을 직접 짠다.
넣기와 꺼내기: 한 줄기만 고친다
넣을 때는 배열 맨 끝에 붙인다. 트리 모양은 여전히 빈틈이 없지만 새 값이 부모보다 작으면 힙 조건이 깨진다. 그러면 부모와 자리를 바꾸고, 다시 새 부모와 비교한다. 이렇게 위로 올라가는 동작을 위로 올리기(sift up)라고 한다.
최소 힙 [2, 5, 3, 9, 6]에 1을 넣는다. 자리 바꾸기(swap)는 몇 번 일어날까?
선택지와 해설 보기
실험 넣기: 끝에 붙이고 올라간다
위는 트리 모양, 아래는 실제 배열입니다. 칸 위 숫자는 배열 인덱스입니다.
const heap: number[] = [2, 5, 3, 9, 6]function add(x: number) { heap.push(x) let i = heap.length - 1 while (i > 0) { const p = Math.floor((i - 1) / 2) if (heap[p] <= heap[i]) break [heap[p], heap[i]] = [heap[i], heap[p]] i = p }}for (const x of [1]) add(x)console.log(heap.join(' '))
- heap
- [2, 5, 3, 9, 6]
- 02
- 15
- 23
- 39
- 46
- 해볼 것
- [2, 5, 3, 9, 6]에 1을 넣어 끝까지 실행해 본다
- 빈 힙에 네 개를 차례로 넣어 본다
1은 5번 칸에서 2번 칸, 0번 칸으로 두 번 올라가 1 5 2 9 6 3이 됐다. 5, 9, 6은 한 번도 비교하지 않았다. 새 값은 자기 조상들, 곧 루트까지 이어진 한 줄기하고만 비교한다. 층마다 칸이 두 배(1, 2, 4, 8…)로 늘어나니 원소가 n개인 힙의 높이는 약 log₂ n이고(6장에서 n을 절반으로 접은 횟수와 같은 수. 『개발 수학』 Unit 2), 넣기는 O(log n)이다. n이 100만이어도 층은 20개다.
꺼낼 때는 반대다. 루트를 꺼내면 맨 위가 빈다. 맨 끝 원소를 떼어 루트에 올리고(빈 칸이 트리 끝에서 생기게 하려는 것), 두 자식 중 더 작은 쪽보다 크면 자리를 바꾸며 내려간다. 이것이 아래로 내리기(sift down)다.
실험 꺼내기: 끝 원소를 루트에 올리고 내려간다
루트를 꺼낸 뒤 맨 끝 원소를 루트에 올리고, 더 작은 자식과 바꾸며 내려갑니다.
const heap = [1, 2, 3, 9, 6, 5]function take(): number { const top = heap[0] const last = heap.pop()! if (heap.length === 0) return top heap[0] = last let i = 0 while (true) { const left = 2 * i + 1 const right = left + 1 let m = i if (left < heap.length && heap[left] < heap[m]) m = left if (right < heap.length && heap[right] < heap[m]) m = right if (m === i) break [heap[m], heap[i]] = [heap[i], heap[m]] i = m } return top}for (let n = 0; n < 2; n++) { console.log(`꺼냄 ${take()} / 남은 힙 ${heap.join(' ')}`)}
- heap
- [1, 2, 3, 9, 6, 5]
- 01
- 12
- 23
- 39
- 46
- 55
- 해볼 것
- [1, 2, 3, 9, 6, 5]에서 두 번 꺼내 본다
[1, 2, 3, 9, 6, 5]에서 1을 꺼내면 맨 끝의 5가 루트로 올라간다. 자식 2와 3 중 작은 2와 바꾸고, 다시 자식 9와 6 중 작은 6과 비교해 5가 더 작으니 멈춘다. 결과는 2 5 3 9 6이고 루트에는 다음으로 작은 2가 와 있다. 꺼내기도 한 줄기만 내려가니 O(log n)이다. 가장 작은 것을 보기만 할 때는 heap[0]을 읽으면 되니 O(1)이다.
정리하면 힙은 "가장 작은 것 하나"를 묻는 질문에만 빠르다. 3번째로 작은 값을 찾거나 특정 값을 찾으려면 배열 전체를 봐야 한다. 그 대신 넣기와 꺼내기가 모두 O(log n)이라서, 원소가 계속 들어오고 나가는 우선순위 큐(priority queue)의 표준 구현이 된다.
상위 k개: 크기 k짜리 최소 힙
점수가 하나씩 들어올 때 상위 3개를 계속 들고 있으려 한다. 힙 크기를 3으로 제한한다면 어떤 힙이 맞을까?
선택지와 해설 보기
상위 k개를 들고 있을 힙은 크기 k 최소 힙이다. 루트, 곧 고른 k개 중 가장 작은 점수가 상위 k개에 들어가기 위한 문턱이다. 새 점수가 문턱 이하면 비교 한 번으로 버린다. 크면 문턱을 밀어내고 그 자리에 넣은 뒤 아래로 내리기 한 번으로 새 문턱을 찾는다.
실험 크기 k 최소 힙으로 상위 k개 고르기
루트 heap[0]은 지금까지 고른 상위 k개 중 가장 작은 값입니다. 새 점수는 이 값보다 커야 들어옵니다.
const scores = [12, 40, 7, 33, 25, 50, 18, 41]const k = 3const heap = scores.slice(0, k).sort((a, b) => a - b)function sink(i: number) { while (true) { const left = 2 * i + 1 const right = left + 1 let m = i if (left < heap.length && heap[left] < heap[m]) m = left if (right < heap.length && heap[right] < heap[m]) m = right if (m === i) return [heap[m], heap[i]] = [heap[i], heap[m]] i = m }}for (const x of scores.slice(k)) { if (x <= heap[0]) continue heap[0] = x sink(0)}console.log(heap.sort((a, b) => b - a).join(' '))
- scores
- [12, 40, 7, 33, 25, 50, 18, 41]
- 비어 있음
- 해볼 것
- 점수 8개 입력을 끝까지 실행해 본다
- 오름차순 입력으로 바꿔 매번 교체되는 경우를 본다
점수 8개 입력에서는 처음 세 개 [7, 12, 40]이 정렬돼 담기고, 뒤의 다섯 개 중에서는 18 하나만 문턱 33에 못 미쳐 비교 한 번으로 버려졌다. 결과는 50 41 40이다.
점수가 오름차순으로 들어오면 들어오는 점수마다 문턱을 넘어서 매번 교체한다. 이것이 최악의 경우다.
최대 힙에 전부 넣고 k번 꺼내도 답은 같다. 하지만 그러면 힙에 n개를 모두 들고 있어야 한다. 크기 k 최소 힙은 메모리가 k칸이면 되고, 점수가 끝없이 들어오는 스트림에도 그대로 쓸 수 있다.
얼마나 아낄까
점수 n개에서 상위 k개를 고르는 비용을 견줘 보자. 7장에서 본 비교 정렬(comparison sort)은 전체 O(n log n)이니, 원소당으로 나누면 약 번 비교한다(n = 100만이면 약 20번). 크기 k 힙은 원소마다 문턱과 한 번 비교하고, 들어갈 때만 높이 만큼 내려가서 최악에도 O(n log k)다.
| 방법 | 시간 | 추가 메모리 | n = 100만, k = 10일 때 원소당 비교 |
|---|---|---|---|
| 전체 정렬 후 앞 k개 | O(n log n) | O(n)(복사본) | 약 20번 |
| 크기 k 최소 힙 | O(n log k) | O(k) | 최악 약 7번(문턱 1번 + 루트에서 3걸음 내려가며 걸음마다 2번. 10개는 1 + 2 + 4 + 3으로 4층이라 내려가는 걸음은 3번), 대부분은 1번 |
반대로 k가 n에 가까우면 이득이 사라진다. Python 문서도 heapq.nlargest·nsmallest는 고를 개수(이 장의 k)가 작을 때 가장 좋고, 클 때는 sorted()가 더 효율적이며, 하나만 필요하면 min()·max()가 낫다고 적는다(Python 문서: heapq). 상위 1개라면 힙도 필요 없이 한 번 훑으면 된다.
작업 큐에 쓸 때 하나 더 챙길 것이 있다. 힙은 우선순위가 같은 작업끼리 들어온 순서를 따로 기억하지 않는다. 올리기·내리기가 자리를 바꾸며 그 순서가 섞인다. Python 문서는 (우선순위, 들어온 순번, 작업)처럼 순번을 둘째 값으로 넣어 같은 우선순위는 먼저 들어온 것부터 나오게 하는 방법을 소개한다. 튜플은 앞 원소부터 견주고 같을 때만 다음 원소로 넘어가므로(7장 4절), 순번이 모두 다르면 비교가 셋째 값인 작업 객체까지 가는 일도 막는다.
코드 리뷰: 작업 큐
함정 찾기 AI가 짠 작업 큐, 작업이 쌓이면 어디가 느려질까?
“우선순위가 높은 작업부터 꺼내는 작업 큐와, 점수 목록에서 상위 k개를 고르는 함수를 짜 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
정리하면 느려지는 줄은 넣을 때마다 전체 정렬, shift()로 꺼내기, 상위 k개를 위해 전체 정렬이다. 힙이면 넣기·꺼내기가 O(log n), 상위 k개는 O(n log k)다.
확인 문제 장을 덮기 전에 떠올려 보기
1. 배열로 담은 최소 힙 [1, 4, 2, 7, 5]에서 1번 칸(값 4)의 자식은 몇 번 칸일까? (0부터 센다)
2. 점수 n개에서 상위 k개를 크기 k짜리 최소 힙으로 고를 때 최악의 시간 복잡도(time complexity)는?
3. Python heapq.heappop(h)는 무엇을 돌려줄까? (h는 heapq로 관리하는 리스트)
더 풀어 보기(선택) · 실시간 중앙값
응답 시간 측정값이 하나씩 들어온다. 값이 들어올 때마다 지금까지의 중앙값(median)을 출력한다(개수가 짝수면 가운데 두 값의 평균). 매번 정렬하면 값 하나에 O(n log n)이다. 값 하나에 O(log n)으로 해 보자.
5, 15, 1, 3, 8, 7 → 5, 10, 5, 4, 5, 6