본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

힙이 빛나는 곳: Top-K, 스트리밍 중앙값, 병합

~12 min · heaps, top-k, streaming, merge

Level 0호기심 많은 입문자
0 XP0/85 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"힙의 진짜 쓰임새는 '뭔가를 정렬한다'가 아니야. '데이터가 홍수처럼 밀려오는데 나한테 필요한 건 경계뿐'인 상황이지. 상위 몇 개, 한가운데, 여러 스트림을 통틀어 다음으로 작은 것. 전부 정렬하는 건 읽지도 않을 순서에 값을 치르는 거야."

Top-K: 크기 K짜리 힙 트릭

아주 큰 유한 스트림에서 가장 큰 k개를 뽑고 싶다면 크기 k짜리 최소 힙을 유지해. 단, k가 0 이하인 경우는 빈 결과를 돌려주도록 먼저 처리하고. 원소를 하나씩 보면서 힙이 k를 넘으면 최솟값을 빼내면, 힙에는 지금까지 본 것 중 가장 큰 k개가 남아. 비용은 O(n log k)이고 메모리는 O(k)야. 끝이 없는 스트림이라면 최종 top-k가 확정되는 시점 자체가 없지만, 어느 시점에서든 그때까지의 top-k 스냅샷은 유지할 수 있어.

스트리밍 중앙값: 균형 잡힌 두 힙

커지는 스트림에서 중앙값을 계속 유지한다니 어렵게 들리지. 중앙값은 한가운데인데 데이터가 들어올 때마다 그 가운데가 움직이니까. 우아한 트릭은 힙 두 개를 쓰는 거야. 최대 힙이 작은 절반을 들고 있어서 그 꼭대기가 낮은 값들 중 가장 크고, 최소 힙이 큰 절반을 들고 있어서 그 꼭대기가 높은 값들 중 가장 작아. 두 힙의 크기를 하나 차이 이내로 유지하면 중앙값이 정확히 두 꼭대기에 걸쳐. 새 값이 들어올 때마다 O(log n)에 자리를 잡고 균형을 다시 맞추고, 중앙값은 O(1)에 읽어. 중앙값 선을 사이에 두고 두 힙이 마주 보는, 아름다운 '가운데서 만나기'야.

정렬된 리스트 K개 병합하기

이미 정렬된 리스트 k개를 하나의 정렬된 출력으로 합친다고 하자. 각 리스트의 맨 앞 원소를 힙에 넣어. k개가 들어가지. 거기서 가장 작은 걸 꺼내면 그게 병합 결과의 다음 원소고, 원소가 나온 리스트에서 다음 원소를 넣어. 이걸 반복해. 힙에는 언제나 후보 k개로 이뤄진 현재 경계가 들어 있어서, 전체 N개 원소가 각각 O(log k)씩 들어. 그래서 병합이 O(N log k)고, 전부 이어 붙인 다음 정렬하는 O(N log N)보다 훨씬 낫지. 메모리에 다 안 들어가는 데이터를 다루는 외부 정렬과 로그 병합이 정확히 이렇게 돌아가고, Python은 heapq.merge로 이걸 바로 줘.

힙은 '경계' 문제를 통째로 가져가. top-k는 크기 k짜리 힙으로 O(n log k), 스트리밍 중앙값은 균형 잡힌 두 힙으로 항목당 O(log n), k방향 병합은 맨 앞 k개를 담은 힙으로 O(N log k)야. 셋을 관통하는 건 하나야. 순서의 가장자리만 필요하지 정렬된 수열 전체는 필요 없다는 것.

셋을 관통하는 한 가지

이 셋은 하나같이 완전 정렬을 쓰면 낭비야. 각자 필요한 건 경계뿐이거든. top-k를 잘라내는 선, 중앙값이 놓인 선, 지금 병합 중인 후보들의 경계. 첫머리에서 본 힙 속성의 약속이 여기서 현금화되는 거야. 극값을 알기에 딱 필요한 만큼의 순서만 유지하고 그만큼만 값을 치른다는 것. 정렬된 결과에서 작은 조각 하나 읽자고 거대한 데이터셋을 통째로 정렬하려 하고 있다면, 거기서 멈추고 힙이 그 조각을 훨씬 싸게 줄 수 있는지부터 물어봐.

피파의 고백

수백만 개 중에서 트렌딩 상위 20개가 필요했는데 반사적으로 sorted(items, reverse=True)[:20]를 썼어. 스무 개 읽자고 수백만 개를 전부 줄 세운 거지. 아빠가 heapq.nlargest(20, items)를 보여줬고, 크기 k짜리 경계만 지키면 O(n log k)면 된다는 걸 알게 됐어. 여기서도 다시 본 게 힙 속성이었어. 나는 위쪽 가장자리만 필요한데 전체 순서를 사고 있었던 거야. 이제는 정렬을 하기 전에 매번 물어봐. "전부 정렬해야 해, 아니면 경계만 필요해?"

Code

Top-k와 k방향 병합, 그리고 중앙값 스케치·python
import heapq

# 크기-k 최소-힙으로 TOP-K: 스트림에서 가장 큰 k 개, O(n log k), O(k) 공간.
def top_k(stream, k):
    heap = []                          # 본 것 중 가장 큰 k 개의 최소-힙
    for x in stream:
        if len(heap) < k:
            heapq.heappush(heap, x)
        elif x > heap[0]:              # 현재 k번째로 큰 것보다 커?
            heapq.heapreplace(heap, x) # 가장 작은 거 pop, x push — O(log k) 한 번
    return sorted(heap, reverse=True)

print(top_k([7, 2, 9, 4, 1, 8, 5, 6], k=3))   # [9, 8, 7]
# 8 개 다 정렬 안 함. 가장 좋은 3 개만 유지. 십억 항목엔 이게
# '끝남' 이랑 '메모리 부족' 의 차이야.

# 힙으로 k 개 정렬 리스트 병합 (Python 이 바로 줘):
a, b, c = [1, 4, 7], [2, 5, 8], [3, 6, 9]
print(list(heapq.merge(a, b, c)))     # [1,2,3,4,5,6,7,8,9] — O(N log k)

# 스트리밍 중앙값 스케치: 최대-힙 (낮은 절반) + 최소-힙 (높은 절반),
# 균형 유지. 중앙값이 두 꼭대기에 앉음, 삽입당 O(log n).

External links

Exercise

스트리밍 중앙값에 대해 설명해 봐. 왜 작은 절반을 최대 힙에, 큰 절반을 최소 힙에 두는 걸까? 각 힙의 꼭대기가 뭘 알려주지? 그리고 값을 넣을 때마다 하는 '재균형'은 뭘 뜻할까? 그다음, 천만 개 항목에서 상위 5개를 찾는 경우에 힙 방식과 완전 정렬의 복잡도를 각각 대고, 완전 정렬이 오히려 나은 선택이 되는 때는 언제인지 말해.
Hint
최대 힙의 꼭대기는 낮은 절반에서 가장 큰 값이고, 최소 힙의 꼭대기는 높은 절반에서 가장 작은 값이야. 둘이 중앙값 선을 사이에 두고 맞닿아 있는 거지. 재균형은 두 힙의 크기가 1보다 크게 차이 나면 원소 하나를 반대편으로 옮기는 걸 말해. 상위 5개는 O(n log 5) 대 O(n log n)이라 힙이 이기고. 다만 어차피 항목 전체를 정렬된 상태로 써야 한다면 그냥 한 번 정렬하는 게 나아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.