구현 방식 비교와 O(n) 힙 만들기는 팔만코딩경(42Seoul 커뮤니티 위키)의 「[자료구조] 우선순위큐와 힙」을 바탕으로 보강했다.
상위 k개를 크기 k짜리 힙으로 구하는 절은 Tech Interview Handbook의 「Heap cheatsheet」를 바탕으로 보강했다.
편향되지 않는 이유와 힙 정렬의 안정성은 fru1tworld/cs-basic 모의 면접 질문 목록의 「자료구조」 질문이 묻는 개념으로 보강했다.
BST는 값이 위치를 정한다. 정렬된 순서로 넣으면 새 값이 매번 같은 쪽 끝으로 가서 한쪽으로 뻗는다. 힙은 반대로 위치가 먼저 정해진다. 삽입은 언제나 배열의 다음 칸(마지막 레벨의 왼쪽부터 빈자리)에, 삭제는 언제나 마지막 칸의 원소를 루트로 옮기는 것으로 시작한다. 그 뒤 값은 부모-자식 사이의 순서만 맞추며 위아래로 자리를 바꿀 뿐 트리 모양은 바꾸지 않는다. 그래서 어떤 순서로 넣어도 모양이 늘 완전 이진 트리이고 높이가 ⌊log₂ n⌋로 고정된다. 대신 힙은 형제 사이에 순서가 없어 임의 값 탐색이 O(n)이다. 「가장 큰(작은) 것」만 빨리 알면 되는 구조다.
원소 n개를 하나씩 push하면 O(n log n)이다. 대신 배열을 그대로 둔 채 마지막 내부 노드 (n-2)//2부터 루트까지 거꾸로 내려가며 각 노드에 "아래로 내리기(sift down)"를 하면 O(n)에 힙이 된다. 노드의 대부분은 아래쪽에 있어 내려갈 거리가 짧기 때문이다. 파이썬 heapq.heapify가 이 방식이다.
def sift_down(a, i, n): # 최대 힙 기준 while True: big, l, r = i, 2 * i + 1, 2 * i + 2 if l < n and a[l] > a[big]: big = l if r < n and a[r] > a[big]: big = r if big == i: return a[i], a[big] = a[big], a[i] i = bigdef build_heap(a): for i in range((len(a) - 2) // 2, -1, -1): sift_down(a, i, len(a))
C처럼 배열 크기가 고정된 언어에서는 가득 차면 두 배 크기로 새로 할당해 옮긴다. 분할 상환하면 삽입당 O(1) 추가 비용이다.
heapq는 최소 힙만 제공하므로 최대 힙이 필요하면 값의 부호를 뒤집어 넣는다. 튜플은 첫 원소부터 비교되므로 (우선순위, 값) 형태로 넣으면 우선순위 큐가 된다.
import heapq# 최대 힙max_heap = []for v in [3, 10, 5]: heapq.heappush(max_heap, -v)print(-heapq.heappop(max_heap)) # 10# 우선순위 큐tasks = []heapq.heappush(tasks, (2, "write"))heapq.heappush(tasks, (1, "read"))print(heapq.heappop(tasks)) # (1, 'read')
Python 3.14부터는 heapq에 최대 힙 함수(heapify_max, heappush_max, heappop_max, heappushpop_max, heapreplace_max)가 추가되어 부호를 뒤집지 않아도 된다. 채점 환경의 파이썬이 3.14보다 낮으면 이 함수가 없으므로, 음수로 넣는 방법이 호환 범위가 가장 넓다. (Python 문서: heapq)
import heapq # Python 3.14+max_heap = []for v in [3, 10, 5]: heapq.heappush_max(max_heap, v)print(heapq.heappop_max(max_heap)) # 10
문제에 「가장 큰(작은) k개」, 「k번째로 큰 값」, 「가장 가까운 k개 점」이 나오면 힙을 떠올린다. 반대쪽 힙을 크기 k로 유지하면 된다.
가장 큰 k개를 구할 때는 최소 힙에 하나씩 넣고, 크기가 k를 넘으면 가장 작은 것을 뺀다. 끝까지 남은 k개가 답이고, 루트가 k번째로 큰 값이다.
전체를 정렬하면 O(n log n)이지만 이 방식은 O(n log k), 공간 O(k)다. 데이터가 스트림으로 들어와도 그대로 쓸 수 있다.
import heapqdef k_largest(nums, k): heap = [] for x in nums: heapq.heappush(heap, x) if len(heap) > k: heapq.heappop(heap) # 가장 작은 값을 버린다 return heap # heap[0]이 k번째로 큰 값
heapq.nlargest(k, nums), heapq.nsmallest(k, nums, key=...)가 같은 일을 한다. 데이터가 모두 메모리에 있고 k번째 값 하나만 필요하면 퀵 정렬의 분할을 한쪽에만 적용하는 퀵 셀렉트가 평균 O(n)으로 더 빠르다(병합 정렬과 퀵 정렬). 「빈도 상위 k개」는 해시 테이블로 빈도를 센 뒤 (빈도, 값) 쌍으로 같은 방법을 쓴다.