JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수

2026.09.10·수정 2026.09.13·약 18분·작성: 해비·블로그 소개

최소 힙: 가장 작은 값이 위에 있도록 자리만 고칩니다

작업 우선순위 예제로 최소 힙을 배웁니다. 배열과 트리 위치를 대응시키고 값이 위아래로 이동하는 과정을 단계별로 확인합니다.

먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

최소 힙의 핵심 구조를 표현한 개념 표지
구조의 특징을 표현한 개념 이미지입니다. 정확한 동작은 아래 코드와 추적 표를 함께 확인하세요.

가장 급한 작업 하나만 계속 꺼내고 싶습니다

작업마다 작은 숫자일수록 급하다고 정해 보겠습니다. 작업이 들어올 때마다 전체를 정렬하는 대신, 다음에 꺼낼 가장 작은 값만 맨 앞에 유지할 수 있습니다. 최소 힙은 이 일을 하는 구조입니다.

힙은 트리 모양이지만 배열에 차례로 저장할 수 있습니다. 아래는 값 2, 5, 7, 9가 들어 있는 상태입니다.

배열 인덱스 0 1 2 3
2 5 7 9
트리에서의 자리 맨 위 2의 왼쪽 2의 오른쪽 5의 왼쪽

부모는 자식보다 작거나 같습니다. 다만 배열 전체가 정렬된 것은 아닙니다. 예를 들어 5와 7 중 어느 것이 먼저 배열에 놓이는지는 전체 정렬 규칙이 아닙니다. 확실한 것은 0번의 2가 가장 작다는 점입니다.

인덱스로 부모 위치를 찾습니다

자식 인덱스 부모 인덱스
1, 2 0
3, 4 1
5, 6 2
const childIndex = 3;
const parentIndex = Math.floor((childIndex - 1) / 2);

console.log(parentIndex);
1

3번 칸의 부모는 1번 칸입니다. Math.floor는 소수점 아래를 버립니다. 새 값은 배열 끝에 들어오므로 이 계산으로 부모를 찾아 비교할 수 있습니다. 부모 위치를 index / 2로만 계산하면 소수가 생겨 올바른 배열 칸을 읽지 못합니다.

새 값은 끝에서 시작해 위로 올라갑니다

[2, 5, 7, 9]에 새 값 1을 넣는 과정을 손으로 따라가겠습니다.

단계 배열 비교
끝에 1 추가 [2, 5, 7, 9, 1] 1과 부모 5
자리 교환 [2, 1, 7, 9, 5] 1과 부모 2
한 번 더 교환 [1, 2, 7, 9, 5] 1이 맨 위
const heap = [2, 5, 7, 9, 1];
[heap[1], heap[4]] = [heap[4], heap[1]];
[heap[0], heap[1]] = [heap[1], heap[0]];

console.log(heap.join(', '));
1, 2, 7, 9, 5

[heap[1], heap[4]] = [heap[4], heap[1]]은 두 칸의 값을 서로 바꾸는 JavaScript 문법입니다. 오른쪽에서 기존 두 값을 먼저 묶고, 왼쪽의 반대 칸에 다시 넣습니다. 이 짧은 코드는 방금 표의 두 교환만 재현합니다. 완성 구현은 새 값이 부모보다 앞설 동안 이 과정을 반복합니다. 첫 비교 뒤 바로 멈추면 1이 2 아래에 남아 맨 위가 최솟값이라는 규칙이 깨집니다.

맨 위를 꺼내면 마지막 값으로 빈칸을 채웁니다

[1, 2, 7, 9, 5]에서 1을 꺼낸 뒤 맨 위를 그냥 비워 둘 수는 없습니다. 마지막 값 5를 맨 위로 옮기고 아래의 두 자식과 비교합니다.

단계 배열 판단
1 보관, 마지막 5를 위로 [5, 2, 7, 9] 자식 2와 7 중 2 선택
5와 2 교환 [2, 5, 7, 9] 5는 자식 9보다 작음
완료 [2, 5, 7, 9] 다음 최솟값은 2
const afterPop = [5, 2, 7, 9];
[afterPop[0], afterPop[1]] = [afterPop[1], afterPop[0]];

console.log(afterPop.join(', '));
2, 5, 7, 9

아래로 내릴 때는 두 자식 중 더 작은 쪽을 먼저 골라야 합니다. 5를 오른쪽 자식 7과 먼저 바꾸면 맨 위가 7이 되고, 그 아래에 더 작은 2가 남아 규칙이 깨집니다.

확인 문제: [8, 3, 4]의 맨 위 8은 어느 자식과 먼저 바꿔야 할까요?

3과 먼저 바꿔야 합니다. 두 자식 3과 4 중 작은 값이 3이기 때문입니다. 결과는 [3, 8, 4]가 됩니다.

우선순위가 같을 때 순서는 저절로 정해지지 않습니다

const a = { priority: 1, order: 0 };
const b = { priority: 1, order: 1 };
const compare = (x, y) => x.priority - y.priority || x.order - y.order;

console.log(compare(a, b) < 0);
true

두 작업의 priority는 같습니다. 이때 먼저 들어온 순서인 order를 두 번째 기준으로 비교합니다. priority만 비교하면 동률 작업의 처리 순서는 구현 중 교환 결과에 따라 달라질 수 있습니다. 먼저 온 동률 작업을 먼저 처리해야 한다면 이 규칙을 명시해야 합니다.

이제 기존 전체 구현을 읽는 순서

먼저 배열 인덱스로 부모와 두 자식을 계산하는 부분을 찾습니다. push에서는 끝에 넣고 부모와 비교하며 위로 가는 반복을, pop에서는 루트를 보관하고 마지막 값을 위에 둔 뒤 더 작은 자식과 비교하는 반복을 위 표와 맞춰 보세요. 마지막으로 빈 힙과 원소 하나인 힙의 처리를 확인합니다.

위 교환 코드는 특정 상태의 한 장면만 보여 주는 축소 예제입니다. 아래 전체 구현은 어떤 길이에서도 반복하도록 만들고, 비교 함수를 받아 숫자뿐 아니라 작업 객체의 우선순위와 동률 순서도 처리합니다.

관련 코딩테스트 문제로 이어서 연습합니다

이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.

전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기

여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.

가장 작은 항목만 필요할 때 전체 정렬을 유지해야 할까요

우선순위 큐는 먼저 넣은 항목이 아니라 우선순위가 가장 높은 항목을 먼저 꺼냅니다. 여기서는 비교 값이 작은 항목을 우선하는 최소 힙을 구현합니다. 작업이 계속 들어오는 동안 다음 작업 하나만 선택한다면 매번 전체 배열을 정렬하는 것보다 갱신 범위를 좁힐 수 있습니다.

힙은 배열 전체를 정렬해 두지 않습니다. 부모가 자식보다 우선한다는 지역 조건만 유지하면 루트가 전체 최솟값이라는 결론을 얻습니다. 따라서 data[1]과 data[2]의 대소 순서나 형제 간 순서를 가정하면 안 됩니다. 임의 값 찾기와 범위 검색은 BST처럼 빠르지 않습니다.

완전 이진 트리를 배열 인덱스로 표현합니다

0번을 루트로 두면 i의 부모는 floor((i-1)/2), 왼쪽 자식은 2i+1, 오른쪽 자식은 2i+2입니다. 배열 마지막에만 추가·삭제하여 중간 빈자리가 없는 완전 이진 트리 모양을 유지합니다. 이 모양 덕분에 루트에서 마지막 층까지의 이동은 O(log n)단계에 그칩니다.

유일하게 순서를 정하는 것은 compare(a,b)입니다. 음수이면 a가 앞, 0이면 동률, 양수이면 b가 앞입니다. 모든 저장 값에 대해 일관된 순서를 만들고 NaN이 아닌 유한한 수를 반환해야 합니다. 기본 비교 함수 a-b는 유한한 숫자용이며 이 글의 예시는 작은 정수만 사용합니다.

compare가 외부 상태에 따라 바뀌거나 저장한 객체의 priority를 나중에 수정하면 기존 부모-자식 조건이 깨질 수 있습니다. 코드가 모든 비교법의 수학적 성질을 검사하지는 않습니다. 저장 중인 우선순위를 바꾸려면 제거·재삽입이나 인덱스 추적을 갖춘 별도 갱신 API가 필요합니다.

빈 peek/pop은 undefined를 반환하므로 undefined 저장은 금지합니다. push는 값을 추가하며 반환값을 사용하지 않고, size는 현재 항목 수입니다. 동률 항목의 삽입 순서는 일반 힙이 보장하지 않습니다. 안정적인 동률 처리가 필요하면 입력 순번을 비교의 두 번째 기준으로 포함해야 합니다.

전체 코드와 실행

heap.cjs에 아래 전체를 저장한 뒤 Node.js에서 node ./heap.cjs를 실행합니다. CommonJS 한 파일에 구현·export·직접 실행 예제가 모두 있습니다. 내부 배열의 모양이 아니라 pop을 반복한 결과가 정렬 순서인지 확인하는 예제입니다.

'use strict';

class MinHeap {
  constructor(compare = (a, b) => a - b) {
    if (typeof compare !== 'function') throw new TypeError('compare must be a function');
    this.compare = compare;
    this.data = [];
  }

  get size() {
    return this.data.length;
  }

  peek() {
    return this.data[0];
  }

  push(value) {
    if (value === undefined) throw new TypeError('undefined is reserved for an empty heap');
    let hole = this.data.length;
    this.data.push(value);
    while (hole > 0) {
      const parent = Math.floor((hole - 1) / 2);
      if (this.compare(this.data[parent], value) <= 0) break;
      this.data[hole] = this.data[parent];
      hole = parent;
    }
    this.data[hole] = value;
  }

  pop() {
    if (this.data.length === 0) return undefined;
    const minimum = this.data[0];
    const last = this.data.pop();
    if (this.data.length === 0) return minimum;

    let hole = 0;
    while (hole * 2 + 1 < this.data.length) {
      let child = hole * 2 + 1;
      const right = child + 1;
      if (right < this.data.length &&
          this.compare(this.data[right], this.data[child]) < 0) {
        child = right;
      }
      if (this.compare(last, this.data[child]) <= 0) break;
      this.data[hole] = this.data[child];
      hole = child;
    }
    this.data[hole] = last;
    return minimum;
  }
}

module.exports = { MinHeap };

if (require.main === module) {
  const heap = new MinHeap();
  for (const value of [8, 3, 5, 1, 3]) heap.push(value);
  const smallest = heap.peek();
  const sorted = [];
  while (heap.size > 0) sorted.push(heap.pop());
  console.log(JSON.stringify({ smallest, sorted, empty: heap.pop() === undefined }));
}

실행 출력

{"smallest":1,"sorted":[1,3,3,5,8],"empty":true}

push: 새 값 하나만 위로 올라갑니다

새 값을 배열 끝에 놓으면 완전 이진 트리 모양은 유지됩니다. 기존 노드끼리의 순서도 그대로여서 새 값과 조상 사이만 검사하면 됩니다. hole은 새 값이 들어갈 위치를 뜻하며, 부모가 새 값보다 크면 부모를 hole로 내려 보내고 hole을 부모 자리로 올립니다.

이 구현은 매 단계 두 값을 교환하지 않고 새 값을 지역 변수 value에 보관합니다. 마지막 위치가 결정된 뒤 한 번만 value를 씁니다. 이동 중 같은 부모 값이 잠시 두 칸에 보여도 메서드가 끝날 때 hole을 채우면 완전한 힙이 됩니다. 중간 상태를 외부에서 읽지 않는 동기 메서드 계약입니다.

pop: 두 자식 중 작은 쪽을 먼저 선택합니다

최솟값은 루트에 있으므로 먼저 따로 저장합니다. 배열의 마지막 원소를 pop하면 트리 모양이 유지되지만 루트 자리가 비었다고 볼 수 있습니다. 원소가 하나뿐이었다면 마지막 원소가 곧 최솟값이어서 더 복구하지 않고 반환합니다.

빈 루트에 마지막 값을 무조건 넣으면 자식보다 클 수 있습니다. 두 자식 중 작은 쪽을 고르고, 그 자식이 last보다 앞서면 자식을 hole로 올립니다. 큰 자식을 올리면 작은 자식이 부모보다 작아지는 위반이 남으므로 자식 선택 순서가 핵심입니다.

last가 선택한 자식보다 앞서거나 같으면 그 아래로 내려갈 필요가 없습니다. 해당 자식은 자기 자식보다 앞서므로 현재 자리의 last도 모든 아래 값보다 앞섭니다. 자식이 없는 곳까지 내려간 경우에도 hole에 last를 넣으면 복구가 끝납니다.

단계 배열 또는 빈칸 이동 이유
[3,8,5]에 1 추가 마지막 위치 3에서 시작 모양은 완전 이진 트리
push의 첫 이동 부모 8을 위치 3으로, hole=1 1이 8보다 우선
push의 두 번째 이동 부모 3을 위치 1로, hole=0 1이 3보다 우선
push 종료 [1,3,5,8] 루트에 1 기록
pop 시작 최소 1 저장, last=8, 배열 길이 3 마지막 슬롯 제거
pop 복구 자식 3을 루트로 올리고 위치 1에 8 [3,8,5]로 복구

최소 힙은 안정 정렬 자료구조가 아닙니다

동률을 compare=0으로만 표현하면 나중에 들어온 항목이 먼저 나올 수 있습니다. 예를 들어 우선순위가 같은 작업 세 개는 pop할 때 마지막 작업이 루트로 올라가는 과정에서 순서가 바뀔 수 있습니다. 작업의 입력 순번 order를 저장하고 priority 차이 다음 order 차이를 비교하면 원하는 FIFO 동률 계약을 만들 수 있습니다.

모든 데이터를 한꺼번에 받아 전체 정렬 결과만 반환한다면 내장 sort가 더 간단할 수 있습니다. 힙의 장점은 입력과 추출이 섞이거나 상위 K개만 유지하는 상황입니다. 이 구현에는 임의 항목 삭제, decrease-key, 선형 시간 heapify가 없으므로 그런 연산이 필요한 알고리즘에 그대로 있다고 가정하면 안 됩니다.

복잡도와 메모리

작업 시간 공간
size / peek O(1) O(1)
push / pop의 힙 복구 최악 O(log n)번 비교 지역 변수 O(1)
배열 저장 n개 항목 참조 O(n)
n개를 push 후 모두 pop O(n log n) 출력 포함 O(n)

표는 compare 한 번과 배열 인덱스 접근을 O(1)로 보는 모델입니다. 비교 함수가 긴 문자열을 비교하면 그 비용을 곱해야 합니다. JavaScript 동적 배열의 저장 공간 확장까지 포함하면 특정 push에서 재할당 비용이 생길 수 있어 “상수 시간 배열 append”는 통상 상환 모델로 해석합니다. 힙의 위아래 이동 자체는 로그 단계로 제한됩니다.

경계 확인과 연습

빈 힙, 한 항목, 같은 우선순위 여러 개, 음수 값, 오른쪽 자식이 더 작은 경우를 확인하세요. 각 child에 대해 compare(parent,child) ≤ 0을 검사하는 불변식 테스트와, 정렬 배열에서 하나씩 꺼내는 독립 기준을 함께 쓰면 눈에 보이지 않는 내부 위반도 잡을 수 있습니다.

JavaScript 최소 힙 연습: 동률 순서를 지키는 작업 스케줄러에서 직접 구현을 적용해 보세요.

함께 연습하고 확인할 자료

Kth Largest Element in a Stream: 스트림에서 K번째 큰 값을 유지하는 외부 연습입니다. 이 글의 모든 작업 출력 문제와는 목표가 다릅니다.

관련 개념 참고: Princeton Algorithms — Priority Queues 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.

직접 실습: 연산 비용으로 구조를 설명합니다

실습 주제: JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수

  1. 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
  2. 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
  3. 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
  4. 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과

메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.

테스트 체크리스트

  • 빈 구조에 대한 조회·삭제 처리
  • 첫 원소와 마지막 원소 변경
  • 중복값 또는 동일 우선순위 처리
  • 입력 크기가 커졌을 때 예상 복잡도 유지

이 글이 도움이 되었나요?

조회 중

자료구조 학습 순서

필수 18개 · 전체 18개

읽음 기록 관리

전체 과정 목차 (18개)
  1. 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
  2. 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
  3. 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
  4. 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
  5. 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
  6. 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
  7. 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
  8. 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
  9. 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
  10. 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
  11. 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수 현재 글
  12. 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
  13. 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
  14. 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
  15. 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
  16. 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
  17. 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
  18. 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기