JUNSEOK
01 · 수학과 자료구조·18분·4개 레슨

스택·큐·힙

LIFO, FIFO, 우선순위 큐가 설명하는 알고리즘의 반.

LIFO, FIFO, 우선순위. 세 개의 "줄 서기" 방식이 알고리즘의 절반을 설명한다. MinHeap을 배열로 직접 구현할 수 있어야 통과.


0. 세 자료구조를 한 그림으로 — 초심자용

0-1. 공통점과 차이

셋 다 "데이터를 하나씩 넣고 하나씩 꺼내는" 자료구조다. 어떤 순서로 꺼내느냐가 다르다.

이름꺼내는 규칙일상 비유
스택(Stack)가장 최근에 넣은 것부터책상 위 책 쌓기. 위에 올린 책부터 꺼냄
큐(Queue)가장 먼저 넣은 것부터줄 서기. 먼저 온 사람부터 서빙
힙(Heap)가장 우선순위 높은 것부터응급실 순서. 먼저 왔어도 중증 환자가 먼저

0-2. 두 줄 약어

  • LIFO (Last In, First Out) = 스택
  • FIFO (First In, First Out) = 큐
  • 은 약어가 아니고 "우선순위 큐(Priority Queue)"라고도 불린다

0-3. 이게 왜 중요한가

프론트 개발자가 매일 쓰는 것들이 사실 이 자료구조다 (안 보일 뿐).

  • 스택: 브라우저 뒤로가기(history), 함수 호출의 Call Stack, undo 기능
  • : 이벤트 루프의 Task Queue (2-6), 작업 대기열
  • : 메모리 할당(Heap 메모리), 타이머 관리, 최상위 N개 뽑기

0-4. 이 장의 도달점

셋을 직접 코드로 구현할 수 있어야 한다. 특히 은 라이브러리 없이는 JS에 없어서, 코딩 테스트에서 거의 반드시 나온다.


1. 스택 (Stack, LIFO)

1-1. 정의

후입선출 (Last In, First Out). 책상 위의 책 쌓기. 가장 나중에 올린 것이 가장 먼저 내려간다.

1-2. JS로 스택

배열의 push/pop이 둘 다 O(1)이므로 JS에선 배열이 곧 스택이다.

const stack = [];
stack.push(1);     // [1]
stack.push(2);     // [1, 2]
stack.pop();       // 2, stack = [1]
stack[stack.length - 1];  // peek: 1

1-3. 실전 1: 괄호 검증

function isValidParens(s) {
  const pair = { ')': '(', ']': '[', '}': '{' };
  const stack = [];
  for (const ch of s) {
    if (ch === '(' || ch === '[' || ch === '{') stack.push(ch);
    else if (pair[ch]) {
      if (stack.pop() !== pair[ch]) return false;
    }
  }
  return stack.length === 0;
}
isValidParens("({[]})");   // true
isValidParens("([)]");     // false

1-4. 실전 2: Undo/Redo

두 개의 스택으로 구현.

class History {
  constructor() { this.undo = []; this.redo = []; }

  execute(command) {
    this.undo.push(command);
    this.redo = [];   // 새 액션 후 redo 스택 비움
  }

  undoLast() {
    const c = this.undo.pop();
    if (c) this.redo.push(c);
    return c;
  }

  redoLast() {
    const c = this.redo.pop();
    if (c) this.undo.push(c);
    return c;
  }
}

1-5. 실전 3: 콜 스택

JS 엔진 자체가 함수 호출 스택을 관리한다. 재귀가 깊어지면 Maximum call stack size exceeded가 뜨는 이유. Chrome V8에서 대략 10000 ~ 15000 프레임.

1-6. 실전 4: 브라우저 히스토리

history.back()두 개의 스택 구조. 현재 페이지 스택 + forward 스택.


2. 큐 (Queue, FIFO)

2-1. 정의

선입선출 (First In, First Out). 대기줄. 먼저 온 사람이 먼저 나간다.

2-2. ⚠️ JS 배열로 큐를 만들면 O(n)

const queue = [];
queue.push(1);
queue.shift();   // O(n)! 뒤의 원소 전부 앞으로 밀림

수만 개 이상을 처리하면 shift가 병목이 된다.

2-3. 해결책 1: 인덱스 큐

shift 대신 head 포인터를 증가시킨다.

class Queue {
  constructor() { this.items = []; this.head = 0; }
  enqueue(v) { this.items.push(v); }
  dequeue() {
    if (this.head >= this.items.length) return undefined;
    const v = this.items[this.head++];
    if (this.head > 50 && this.head * 2 > this.items.length) {
      this.items = this.items.slice(this.head);
      this.head = 0;
    }
    return v;
  }
  get size() { return this.items.length - this.head; }
}

주기적으로 배열을 압축하지 않으면 메모리가 계속 커진다.

2-4. 해결책 2: 연결 리스트

앞/뒤 포인터를 유지하면 enqueue/dequeue 모두 O(1).

2-5. 실전: BFS의 기본 자료구조

function bfs(start, graph) {
  const queue = new Queue();
  const visited = new Set();
  queue.enqueue(start);
  visited.add(start);
  while (queue.size > 0) {
    const node = queue.dequeue();
    for (const next of graph[node] ?? []) {
      if (!visited.has(next)) {
        visited.add(next);
        queue.enqueue(next);
      }
    }
  }
}

2-6. 실전: 이벤트 루프

JS 런타임은 태스크 큐마이크로태스크 큐를 유지한다. setTimeout은 태스크 큐에, Promise.then은 마이크로태스크 큐에 들어간다. (2단계에서 심화)


3. 덱 (Deque, Double-Ended Queue)

3-1. 정의

양 끝에서 삽입/삭제 모두 O(1).

3-2. 실전: 슬라이딩 윈도우 최댓값

크기 k 윈도우 안의 최댓값을 매 위치마다 구한다. 덱에 인덱스를 저장, 새 원소보다 작은 꼬리 원소들을 제거해 단조 감소 유지.

function slidingMax(arr, k) {
  const deque = [];   // 인덱스 저장, 값은 arr[deque[0]]
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    // 1. 윈도우 벗어난 인덱스 제거
    while (deque.length && deque[0] <= i - k) deque.shift();
    // 2. 새로 온 값보다 작은 꼬리 제거
    while (deque.length && arr[deque[deque.length - 1]] < arr[i]) deque.pop();
    deque.push(i);
    // 3. 윈도우 완성되면 답에 추가
    if (i >= k - 1) result.push(arr[deque[0]]);
  }
  return result;
}
slidingMax([1, 3, -1, -3, 5, 3, 6, 7], 3);  // [3, 3, 5, 5, 6, 7]

각 원소는 덱에 한 번 들어가고 한 번 나온다 → O(n).


4. 힙 (Heap)과 우선순위 큐

4-1. 정의

완전 이진 트리 + 힙 속성.

  • 완전 이진 트리: 마지막 레벨 제외 전부 채워짐, 마지막 레벨은 왼쪽부터 채움
  • MinHeap: 부모 ≤ 자식 (루트가 최솟값)
  • MaxHeap: 부모 ≥ 자식 (루트가 최댓값)

4-2. 배열 기반 표현

완전 이진 트리는 배열에 순서대로 담으면 낭비 없이 표현된다.

        1         인덱스 0
       / \
      3   2       1, 2
     / \ /
    5  4 8        3, 4, 5

부모-자식 관계 공식:

  • 부모: (i - 1) >> 1
  • 왼쪽 자식: 2i + 1
  • 오른쪽 자식: 2i + 2

4-3. MinHeap 구현

class MinHeap {
  constructor() { this.heap = []; }

  push(val) {
    this.heap.push(val);
    this._bubbleUp(this.heap.length - 1);
  }

  pop() {
    if (this.heap.length === 0) return undefined;
    const top = this.heap[0];
    const last = this.heap.pop();
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this._sinkDown(0);
    }
    return top;
  }

  peek() { return this.heap[0]; }
  get size() { return this.heap.length; }

  _bubbleUp(i) {
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.heap[parent] <= this.heap[i]) break;
      [this.heap[parent], this.heap[i]] = [this.heap[i], this.heap[parent]];
      i = parent;
    }
  }

  _sinkDown(i) {
    const n = this.heap.length;
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let smallest = i;
      if (l < n && this.heap[l] < this.heap[smallest]) smallest = l;
      if (r < n && this.heap[r] < this.heap[smallest]) smallest = r;
      if (smallest === i) break;
      [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]];
      i = smallest;
    }
  }
}

4-4. 동작 시각화

push(5):  [5]
push(3):  [3, 5]          ← 3이 5보다 작아 위로
push(8):  [3, 5, 8]
push(1):  [1, 3, 8, 5]    ← 1이 5와 스왑, 다시 3과 스왑
push(6):  [1, 3, 8, 5, 6]

pop():    1 반환
  마지막 6을 루트로: [6, 3, 8, 5]
  6 > 3이므로 3과 스왑: [3, 6, 8, 5]
  6 > 5이므로 스왑: [3, 5, 8, 6]
  ← 최종

4-5. 복잡도

연산복잡도
pushO(log n)
popO(log n)
peekO(1)
heapify (배열 → 힙)O(n)

heapify가 O(n)인 이유: 리프 노드부터 거꾸로 sinkDown을 실행하면, 위로 갈수록 노드 수가 절반씩 줄어든다. 수학적으로 n × (1/2 × 1 + 1/4 × 2 + ...) ≈ 2n.

4-6. 실전: Top-K

상위 K개를 구할 때 전체 정렬(O(n log n))보다 크기 K인 MinHeap 유지가 빠르다.

function topK(arr, k) {
  const heap = new MinHeap();
  for (const v of arr) {
    heap.push(v);
    if (heap.size > k) heap.pop();
  }
  return [...heap.heap].sort((a, b) => b - a);
}

복잡도: O(n log k). n=10⁶, k=10이면 대략 2 × 10⁷. 전체 정렬은 2 × 10⁷. 비슷하지만 메모리를 O(k)만 쓴다는 장점이 크다.

4-7. 실전: 다익스트라 / A*

최단 경로 알고리즘의 핵심. "지금까지 가장 거리 짧은 노드"를 꺼내는 연산을 O(log V)에 수행. (1-7 챕터에서 심화)

4-8. 실전: 이벤트 스케줄러

브라우저 setTimeouttimer queue를 유지하는데, 만료 시각 기준 MinHeap을 쓰는 엔진이 많다 (Node.js libuv 포함). 매 틱마다 "가장 먼저 만료될 타이머"를 O(log n)에 꺼낼 수 있기 때문.


5. JS에 내장된 힙이 없다

Java의 PriorityQueue, Python의 heapq에 해당하는 내장이 JS엔 없다. 항상 직접 구현하거나 라이브러리를 써야 한다 (@js-sdsl/priority-queue, heap-js).

면접과 코테에서는 배울 내용 중 하나로 간주된다. 위 클래스는 외워두는 것이 좋다.


연습 문제

  1. 스택으로 "3 + 4 * 2" (중위식)을 "3 4 2 * +" (후위식)으로 변환하라.
  2. JS 배열로 큐를 만들면 왜 O(n)인지, head 포인터 버전이 왜 O(1)인지 설명하라.
  3. 덱을 써서 크기 K 슬라이딩 윈도우의 최솟값을 O(n)에 구하라.
  4. MinHeap을 직접 구현하고, [7, 2, 5, 1, 8, 3]을 push 순서대로 넣었을 때 pop 4번의 결과와 배열 상태를 추적하라.
  5. Top-K 문제를 MinHeap 버전과 정렬 버전 양쪽으로 구현하고 복잡도를 비교하라.
  6. 다익스트라에서 왜 MaxHeap이 아닌 MinHeap을 쓰는지 설명하라.

연습 문제 정답

1. 중위 → 후위

function infixToPostfix(expr) {
  const precedence = { '+': 1, '-': 1, '*': 2, '/': 2 };
  const output = [];
  const ops = [];
  for (const token of expr.split(/\s+/)) {
    if (/\d+/.test(token)) output.push(token);
    else {
      while (ops.length && precedence[ops[ops.length - 1]] >= precedence[token]) {
        output.push(ops.pop());
      }
      ops.push(token);
    }
  }
  while (ops.length) output.push(ops.pop());
  return output.join(' ');
}
infixToPostfix("3 + 4 * 2");  // "3 4 2 * +"

2. 큐 O(n) → O(1)

Array.shift는 모든 뒤 원소를 한 칸씩 앞으로 이동시켜 O(n). head 포인터 방식은 인덱스만 증가시키므로 O(1). 대신 주기적으로 배열을 압축해야 메모리 누수가 없다.

3. 슬라이딩 윈도우 최솟값

function slidingMin(arr, k) {
  const deque = [];
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    while (deque.length && deque[0] <= i - k) deque.shift();
    while (deque.length && arr[deque[deque.length - 1]] > arr[i]) deque.pop();
    deque.push(i);
    if (i >= k - 1) result.push(arr[deque[0]]);
  }
  return result;
}

단조 감소 대신 단조 증가를 유지하면 된다.

4. MinHeap 추적

push(7): [7]
push(2): [2, 7]
push(5): [2, 7, 5]
push(1): [1, 2, 5, 7]
push(8): [1, 2, 5, 7, 8]
push(3): [1, 2, 3, 7, 8, 5]

pop(): 1 → [2, 7, 3, 5, 8] (5를 루트에 놓고 내려보냄)
pop(): 2 → [3, 5, 8, 7]
pop(): 3 → [5, 7, 8]
pop(): 5 → [7, 8]

5. Top-K

  • MinHeap 버전: O(n log k), 메모리 O(k)
  • 정렬 버전: O(n log n), 메모리 O(n)

k가 n보다 훨씬 작을 때 MinHeap이 유리하다.

6. 다익스트라에서 MinHeap

매 단계마다 "지금까지 발견된 최단 거리가 가장 작은 노드"를 확장해야 한다. 최솟값 추출이 반복되므로 MinHeap.

MaxHeap은 "가장 큰 것부터" — 다익스트라의 요구와 반대다.


체크리스트

  • 스택의 LIFO 특성과 FE 활용 예시 3개 이상을 안다
  • JS 배열의 push/pop이 O(1)인 이유를 안다
  • Array.shift가 O(n)인 이유를 메모리 배치로 설명할 수 있다
  • 큐를 O(1)로 구현하는 2가지 방법을 안다 (head 포인터, 연결 리스트)
  • 덱의 용도와 슬라이딩 윈도우 최댓값 문제를 안다
  • 힙의 배열 기반 부모/자식 인덱스 공식을 안다
  • MinHeap을 배열로 직접 구현할 수 있다
  • push/pop의 복잡도가 왜 O(log n)인지 설명할 수 있다
  • heapify가 O(n)인 이유를 안다
  • Top-K 문제에서 MinHeap이 정렬보다 유리한 조건을 안다
  • 다익스트라/A*가 우선순위 큐를 쓰는 이유를 안다

이전: 1-4. 배열·문자열·해시 | 다음: 1-6. 연결 리스트·트리·그래프

진도 체크시작 전
NEXT · 1-6

연결 리스트·트리·그래프

DOM, Virtual DOM, 번들러 의존성 그래프의 뿌리.

이어서 학습하기 →