힙 정렬 (Heap Sort)

한눈에 보기

배열을 최대 힙으로 만들고 루트를 끝으로 보내며 정렬하는 힙 정렬을 다룬다. 배열 기반 힙 표현, 힙 구성이 O(n)인 이유, 최악에도 O(n log n)인 근거와 구현을 정리한다.

최종 업데이트 2026.09.17

힙 정렬(Heap Sort)은 배열을 힙이라는 구조로 만든 뒤, 매번 최댓값을 뽑아 뒤쪽에 쌓아가며 정렬하는 알고리즘이다. 추가 배열 없이 원본 안에서 처리하면서도 최악의 경우까지 O(n log n)을 보장한다는 것이 가장 큰 장점이다.

코딩테스트에서 힙 정렬 자체를 구현할 일은 드물지만, 우선순위 큐가 곧 힙이다. 다익스트라, 최소 비용 문제, "가장 큰 K개" 유형이 전부 힙 위에서 돌아간다. 이 글의 앞부분은 그 우선순위 큐를 이해하는 데 그대로 쓰인다.

힙이란 무엇인가

힙은 두 가지 조건을 만족하는 이진 트리다.

  1. 완전 이진 트리: 위에서 아래로, 왼쪽에서 오른쪽으로 빈틈없이 채워진 모양이다.
  2. 힙 속성: 모든 부모가 자식보다 크거나 같다(최대 힙). 반대면 최소 힙이다.

중요한 건 형제끼리는 아무 관계가 없다는 점이다. 힙은 정렬된 구조가 아니라 "맨 위가 최댓값"만 보장하는 느슨한 구조다. 그래서 삽입과 삭제가 O(log n)으로 싸다.

완전 이진 트리라는 조건 덕분에 힙은 포인터 없이 배열 하나로 표현된다. 이것이 힙 정렬이 추가 공간을 쓰지 않는 이유다.

힙 정렬: 최대 힙 트리와 그것을 그대로 담은 배열이 인덱스로 대응되는 그림
트리를 위에서부터 왼쪽 순서로 읽으면 그대로 배열이 된다. 인덱스 계산만으로 부모와 자식을 오갈 수 있어 포인터가 필요 없다.

힙 만들기 — sift-down

아무 배열이나 힙으로 바꾸는 핵심 연산이 sift-down(내려보내기)이다. 어떤 노드가 자식보다 작아 규칙을 어기고 있으면, 더 큰 자식과 자리를 바꿔 한 칸 내려보낸다. 내려간 자리에서 또 어기면 계속 내려간다. 리프에 닿거나 규칙을 만족하면 멈춘다.

더 작은 자식이 아니라 더 큰 자식과 바꿔야 한다는 점이 중요하다. 작은 쪽과 바꾸면 올라간 값이 형제보다 작아져서 규칙을 또 어긴다.

힙 정렬: 루트의 값 3이 더 큰 자식과 두 번 교환되며 제자리를 찾아 내려가는 세 단계
내려가는 길은 트리의 한 경로뿐이므로 sift-down 한 번은 트리 높이만큼, 즉 O(log n)이다. 빨간 노드가 규칙을 어긴 값, 보라 노드가 교환 상대다.

정렬은 어떻게 되는가

힙이 만들어지면 루트가 최댓값이다. 정렬은 이 성질을 반복해서 써먹는다.

  1. 루트(최댓값)와 배열의 맨 뒤 원소를 교환한다. 최댓값이 제 위치에 확정된다.
  2. 확정된 마지막 칸을 빼고 힙 크기를 하나 줄인다.
  3. 루트가 규칙을 어기게 되었으니 sift-down으로 고친다.
  4. 힙 크기가 1이 될 때까지 반복한다.

매 회차마다 뒤쪽에 정렬된 구간이 한 칸씩 쌓이고, 앞쪽 힙은 한 칸씩 줄어든다. 배열 하나 안에서 두 영역이 자리를 나눠 갖기 때문에 추가 공간이 필요 없다.

시간 복잡도

구분복잡도설명
힙 만들기O(n)아래쪽 노드일수록 내려갈 거리가 짧다
정렬 단계O(n log n)n번 뽑을 때마다 sift-down O(log n)
전체 (최선·평균·최악)O(n log n)입력 상태와 무관
공간O(1)원본 배열 안에서 처리
안정성불안정 정렬멀리 떨어진 원소끼리 교환

힙 만들기가 O(n log n)이 아니라 O(n)이라는 점이 면접에서 자주 나온다. 노드 절반은 리프라 내려갈 거리가 0이고, 위로 갈수록 노드 수가 절반씩 줄어들기 때문이다. 거리가 먼 노드는 개수가 적고 개수가 많은 노드는 거리가 짧아서, 다 더하면 O(n)에 수렴한다.

구현 코드 예시

def heapify(arr, n, i):
    """i를 루트로 하는 서브트리를 최대 힙으로 고친다. 자식 서브트리는 이미 힙이라고 가정."""
    largest = i
    left, right = 2 * i + 1, 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)          # 내려간 자리에서 다시 확인


def heap_sort(arr):
    n = len(arr)

    for i in range(n // 2 - 1, -1, -1):   # 마지막 부모부터 거꾸로 올라오며 힙 구성
        heapify(arr, n, i)

    for end in range(n - 1, 0, -1):
        arr[0], arr[end] = arr[end], arr[0]   # 최댓값을 뒤로 확정
        heapify(arr, end, 0)                  # 남은 앞부분만 다시 힙으로
    return arr

힙 구성 루프가 n // 2 - 1에서 시작하는 이유는 그 뒤가 전부 리프이기 때문이다. 리프는 자식이 없으니 이미 힙 조건을 만족한다. 그리고 거꾸로 올라와야 하는 이유는 sift-down이 "자식 서브트리는 이미 힙"이라는 전제 위에서 동작하기 때문이다. 위에서부터 내려가면 이 전제가 깨진다.

실전에서는 우선순위 큐로 쓴다

파이썬 heapq는 최소 힙만 제공한다. 최대 힙이 필요하면 값에 마이너스를 붙여 넣고 꺼낼 때 다시 뒤집는 것이 관용적인 방법이다.

import heapq

# 최소 힙
pq = []
heapq.heappush(pq, 5)
heapq.heappush(pq, 1)
smallest = heapq.heappop(pq)      # 1

# 최대 힙 흉내
maxpq = []
heapq.heappush(maxpq, -5)
heapq.heappush(maxpq, -1)
largest = -heapq.heappop(maxpq)   # 5

# 튜플을 넣으면 첫 원소 기준으로 정렬된다 — (비용, 노드) 형태가 다익스트라의 기본형
heapq.heappush(pq, (3, "B"))

# 이미 있는 리스트를 O(n)에 힙으로 만들기
nums = [5, 1, 9, 3]
heapq.heapify(nums)

자주 하는 실수

  • 더 작은 자식과 교환한다. 최대 힙에서는 반드시 더 큰 자식과 바꿔야 한다. 작은 쪽과 바꾸면 올라간 값이 형제보다 작아 규칙이 또 깨진다.
  • 자식 인덱스 범위를 확인하지 않는다. left < n, right < n 검사를 빼면 배열 밖을 읽는다. 정렬 단계에서 n이 계속 줄어들기 때문에 특히 위험하다.
  • 힙을 위에서부터 만든다. range(0, n)으로 정방향 순회하면 sift-down의 전제가 깨져 힙이 제대로 만들어지지 않는다.
  • 정렬 단계에서 n을 그대로 넘긴다. heapify(arr, end, 0)의 두 번째 인자는 줄어든 힙 크기여야 한다. n을 넘기면 이미 확정된 뒤쪽 값을 다시 힙에 끌어들인다.
  • heapq가 최대 힙이라고 착각한다. 파이썬은 최소 힙이다. 부호 뒤집기를 잊으면 조용히 반대 결과가 나온다.

면접에서 이렇게 물어본다

  • 힙이 만족해야 하는 두 조건은 무엇인가.
  • 힙을 배열로 표현할 수 있는 이유와 부모·자식 인덱스 공식을 설명해 보라.
  • 힙 만들기가 O(n log n)이 아니라 O(n)인 이유를 설명해 보라.
  • 힙을 구성할 때 왜 마지막 부모부터 거꾸로 올라가야 하는가.
  • 힙 정렬이 불안정 정렬인 이유는 무엇인가.
  • 병합 정렬과 힙 정렬 모두 O(n log n)인데 실무에서 병합 정렬 계열이 더 많이 쓰이는 이유는 무엇인가.
  • 우선순위 큐가 필요한 상황을 예로 들어 보라.
알고리즘 문서 전체 보기

이 개념, 내 코드에서는 어떻게 물어볼까요

데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.

공고 넣고 시작하기