개념

완전 이진 트리 기반의 힙(Heap) 자료구조를 이용한 정렬

최대 힙을 구성한 후, 최댓값을 차례대로 추출하여 정렬


시간복잡도

케이스시간복잡도
최선O(n log n)
평균O(n log n)
최악O(n log n)
공간복잡도O(1)

특징

장점

  • 항상 O(n log n): 최악의 경우에도 보장
  • 제자리 정렬: 추가 메모리 O(1)
  • 힙 자료구조 활용: 우선순위 큐 구현에 유용

단점

  • 불안정 정렬: 같은 값의 순서가 바뀔 수 있음
  • 캐시 비효율: 퀵 정렬보다 느림
  • 복잡한 구현: 이해하기 어려움

힙(Heap) 이란?

최대 힙 (Max Heap)

부모 노드 ≥ 자식 노드

        50
       /  \
      30   40
     / \   /
    10 20 35

최소 힙 (Min Heap)

부모 노드 ≤ 자식 노드

        10
       /  \
      20   30
     / \   /
    40 50 35

배열로 표현

# 인덱스 0부터 시작
arr = [50, 30, 40, 10, 20, 35]
 
# 부모-자식 관계
parent(i) = (i - 1) // 2
left_child(i) = 2 * i + 1
right_child(i) = 2 * i + 2

구현 방법

기본 구현

def heap_sort(arr):
    n = len(arr)
 
    # 1. 최대 힙 구성 (Build Max Heap)
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)
 
    # 2. 하나씩 추출하며 정렬
    for i in range(n - 1, 0, -1):
        # 루트(최댓값)를 끝으로 이동
        arr[0], arr[i] = arr[i], arr[0]
 
        # 힙 속성 복구
        heapify(arr, i, 0)
 
    return arr
 
def heapify(arr, n, i):
    """부분 트리를 힙으로 만들기"""
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2
 
    # 왼쪽 자식이 더 크면
    if left < n and arr[left] > arr[largest]:
        largest = left
 
    # 오른쪽 자식이 더 크면
    if right < n and arr[right] > arr[largest]:
        largest = right
 
    # 부모가 최댓값이 아니면 교환하고 재귀
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)
 
# 테스트
print(heap_sort([12, 11, 13, 5, 6, 7]))
# [5, 6, 7, 11, 12, 13]

반복문 버전 (재귀 없음)

def heap_sort_iterative(arr):
    n = len(arr)
 
    # 1. 최대 힙 구성
    for i in range(n // 2 - 1, -1, -1):
        heapify_iterative(arr, n, i)
 
    # 2. 정렬
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]
        heapify_iterative(arr, i, 0)
 
    return arr
 
def heapify_iterative(arr, n, i):
    """반복문으로 heapify"""
    while True:
        largest = i
        left = 2 * i + 1
        right = 2 * i + 2
 
        if left < n and arr[left] > arr[largest]:
            largest = left
 
        if right < n and arr[right] > arr[largest]:
            largest = right
 
        if largest == i:
            break
 
        arr[i], arr[largest] = arr[largest], arr[i]
        i = largest
 
# 테스트
print(heap_sort_iterative([12, 11, 13, 5, 6, 7]))
# [5, 6, 7, 11, 12, 13]

Python heapq 모듈 활용

import heapq
 
def heap_sort_heapq(arr):
    """Python heapq 사용 (최소 힙)"""
    # 최소 힙으로 변환
    heapq.heapify(arr)
 
    # 작은 값부터 추출
    result = []
    while arr:
        result.append(heapq.heappop(arr))
 
    return result
 
# 테스트
print(heap_sort_heapq([12, 11, 13, 5, 6, 7]))
# [5, 6, 7, 11, 12, 13]
 
# 주의: heapq는 최소 힙

동작 과정

초기: [4, 10, 3, 5, 1]

1단계: 최대 힙 구성
[10, 5, 3, 4, 1]
     10
    /  \
   5    3
  / \
 4   1

2단계: 최댓값(10)을 끝으로
[1, 5, 3, 4 | 10]  ← 10 확정

3단계: 힙 복구 후 최댓값(5)을 끝으로
[4, 1, 3 | 5, 10]  ← 5 확정

4단계: 힙 복구 후 최댓값(4)을 끝으로
[3, 1 | 4, 5, 10]  ← 4 확정

5단계: 힙 복구 후 최댓값(3)을 끝으로
[1 | 3, 4, 5, 10]  ← 3 확정

완료: [1, 3, 4, 5, 10]

템플릿

def heap_sort_template(arr):
    n = len(arr)
 
    # Phase 1: Build Max Heap
    # 마지막 부모 노드부터 루트까지
    for i in range(n // 2 - 1, -1, -1):
        heapify_template(arr, n, i)
 
    # Phase 2: Extract Max
    for i in range(n - 1, 0, -1):
        # 루트(최댓값)를 끝으로 이동
        arr[0], arr[i] = arr[i], arr[0]
 
        # 힙 크기를 줄이고 복구
        heapify_template(arr, i, 0)
 
    return arr
 
def heapify_template(arr, heap_size, root_idx):
    """힙 속성 유지"""
    largest = root_idx
    left = 2 * root_idx + 1
    right = 2 * root_idx + 2
 
    # 자식 중 더 큰 값 찾기
    if left < heap_size and arr[left] > arr[largest]:
        largest = left
 
    if right < heap_size and arr[right] > arr[largest]:
        largest = right
 
    # 부모와 교환 후 재귀
    if largest != root_idx:
        arr[root_idx], arr[largest] = arr[largest], arr[root_idx]
        heapify_template(arr, heap_size, largest)

핵심 요약

개념설명
원리최대 힙에서 최댓값을 차례로 추출
시간복잡도O(n log n) - 항상 보장
공간복잡도O(1)
안정성불안정 정렬
특징제자리 정렬 + O(n log n) 보장

다른 정렬과 비교

정렬시간복잡도 (평균)최악공간복잡도안정성특징
힙 정렬O(n log n)O(n log n)O(1)불안정메모리 효율
병합 정렬O(n log n)O(n log n)O(n)안정최악 보장
퀵 정렬O(n log n)O(n²)O(log n)불안정가장 빠름

주의사항

1. 인덱스 계산

# 0-based 인덱스
parent(i) = (i - 1) // 2
left(i) = 2 * i + 1
right(i) = 2 * i + 2
 
# 1-based 인덱스 (책에서 많이 사용)
parent(i) = i // 2
left(i) = 2 * i
right(i) = 2 * i + 1

2. 불안정 정렬

# 같은 값의 순서가 바뀔 수 있음
arr = [(5, 'a'), (3, 'b'), (5, 'c'), (2, 'd')]
# 정렬 후: (5, 'c')가 (5, 'a')보다 앞에 올 수 있음

3. 캐시 효율

# 힙은 트리 구조라 캐시 미스 발생
# 퀵 정렬이 실제로는 더 빠름
 
# 하지만 O(1) 메모리 + 최악 O(n log n) 보장

자주 나오는 문제 유형

1. K번째 큰 수 찾기

문제: 배열에서 K번째로 큰 수 찾기

import heapq
 
def find_kth_largest(nums, k):
    """최소 힙 이용 (크기 k 유지)"""
    heap = []
 
    for num in nums:
        heapq.heappush(heap, num)
 
        # 힙 크기가 k를 초과하면 최솟값 제거
        if len(heap) > k:
            heapq.heappop(heap)
 
    # 힙의 루트가 K번째 큰 수
    return heap[0]
 
# 테스트
print(find_kth_largest([3, 2, 1, 5, 6, 4], 2))  # 5
 
# 시간복잡도: O(n log k)
# 공간복잡도: O(k)

핵심: 크기 k인 최소 힙 유지


2. 배열의 중앙값 (Median) 찾기

문제: 스트림에서 중앙값을 계속 구하기

import heapq
 
class MedianFinder:
    def __init__(self):
        # 작은 절반: 최대 힙 (음수로 저장)
        self.small = []
        # 큰 절반: 최소 힙
        self.large = []
 
    def add_num(self, num):
        # 작은 쪽에 추가 (최대 힙)
        heapq.heappush(self.small, -num)
 
        # 균형 조정: 작은 쪽 최댓값을 큰 쪽으로
        if self.small and self.large and (-self.small[0] > self.large[0]):
            val = -heapq.heappop(self.small)
            heapq.heappush(self.large, val)
 
        # 크기 균형: 차이가 2 이상이면 조정
        if len(self.small) > len(self.large) + 1:
            val = -heapq.heappop(self.small)
            heapq.heappush(self.large, val)
 
        if len(self.large) > len(self.small):
            val = heapq.heappop(self.large)
            heapq.heappush(self.small, -val)
 
    def find_median(self):
        if len(self.small) > len(self.large):
            return -self.small[0]
        return (-self.small[0] + self.large[0]) / 2
 
# 테스트
mf = MedianFinder()
mf.add_num(1)
mf.add_num(2)
print(mf.find_median())  # 1.5
mf.add_num(3)
print(mf.find_median())  # 2
 
# 시간복잡도: add O(log n), find O(1)

핵심: 두 개의 힙으로 중앙값 유지


3. Top K Frequent Elements

문제: 가장 빈번한 K개의 요소 찾기

import heapq
from collections import Counter
 
def top_k_frequent_heap(nums, k):
    """최소 힙 이용"""
    count = Counter(nums)
 
    # 크기 k인 최소 힙 유지
    heap = []
    for num, freq in count.items():
        heapq.heappush(heap, (freq, num))
        if len(heap) > k:
            heapq.heappop(heap)
 
    return [num for freq, num in heap]
 
# 테스트
print(top_k_frequent_heap([1, 1, 1, 2, 2, 3], 2))
# [2, 1]
 
# 시간복잡도: O(n log k)

핵심: 빈도수를 키로 힙에 저장


4. 배열을 거의 정렬하기 (K-Sorted)

문제: 각 요소가 최대 K칸 떨어진 위치에 있는 배열 정렬

import heapq
 
def sort_k_sorted_array(arr, k):
    """거의 정렬된 배열 정렬"""
    result = []
    heap = []
 
    # 처음 k+1개 요소를 힙에 추가
    for i in range(min(k + 1, len(arr))):
        heapq.heappush(heap, arr[i])
 
    # 나머지 요소 처리
    for i in range(k + 1, len(arr)):
        result.append(heapq.heappop(heap))
        heapq.heappush(heap, arr[i])
 
    # 힙에 남은 요소 추가
    while heap:
        result.append(heapq.heappop(heap))
 
    return result
 
# 테스트
print(sort_k_sorted_array([3, 2, 1, 5, 4, 7, 6, 5], 3))
# [1, 2, 3, 4, 5, 5, 6, 7]
 
# 시간복잡도: O(n log k)

핵심: 크기 k+1인 힙만 유지


5. 가장 가까운 K개의 점

문제: 원점에서 가장 가까운 K개의 점 찾기

import heapq
 
def k_closest_points(points, k):
    """원점에서 가장 가까운 K개의 점"""
    # 최대 힙 (거리의 음수 저장)
    heap = []
 
    for x, y in points:
        distance = -(x*x + y*y)  # 음수로 최대 힙
 
        heapq.heappush(heap, (distance, [x, y]))
 
        if len(heap) > k:
            heapq.heappop(heap)
 
    return [point for dist, point in heap]
 
# 테스트
points = [[1, 3], [-2, 2], [5, 8], [0, 1]]
print(k_closest_points(points, 2))
# [[0, 1], [-2, 2]] 또는 [[-2, 2], [0, 1]]
 
# 시간복잡도: O(n log k)

핵심: 거리 기준으로 최대 힙 유지


추천 연습 문제

기초

중급

고급


힙 정렬 vs 우선순위 큐

힙 정렬

# 전체 배열을 한 번에 정렬
arr = [5, 3, 8, 1, 2]
heap_sort(arr)  # [1, 2, 3, 5, 8]

우선순위 큐

import heapq
 
# 동적으로 요소 추가/제거
heap = []
heapq.heappush(heap, 5)
heapq.heappush(heap, 3)
min_val = heapq.heappop(heap)  # 3
 
# 스트림 데이터 처리에 유용

언제 사용할까?

사용 가능

  • 메모리 제한: O(1) 공간복잡도
  • 최악 보장: O(n log n) 항상 보장
  • 우선순위 큐: Top K, 중앙값 등
  • K개 요소 찾기: O(n log k)

사용 불가

  • 안정 정렬 필요: 병합 정렬 사용
  • 평균 성능 중요: 퀵 정렬이 더 빠름
  • 작은 데이터: 삽입 정렬이 더 나음

결론: 메모리 효율 + 최악 보장이 필요하거나, 우선순위 큐 문제에 최적!