들어가며
자바에서는 java.util.PriorityQueue
라는 표준 라이브러리에서 우선순위 큐(최소 힙)를 편리하게 제공하고 있다. 그럼에도 불구하고 힙의 동작 원리를 직접 파고들어 구현 방법까지 알아두는 것은 단순히 라이브러리 사용을 넘어, 자료구조에 대한 더 깊은 이해를 돕는다.
힙(Heap)은 우선순위 큐 구현부터 다익스트라, 프림 알고리즘 등 다양한 곳에 활용되는 중요한 자료구조다. 이번 포스트에서는 "힙이란 무엇이고, 어떻게 동작하며, 왜 그렇게 동작하는지" 그리고 "배열을 사용한 직접 구현 과정과 최적화 가능성"까지, 여러 궁금증을 바탕으로 알기 쉽게 설명하고자 한다.
힙(Heap)이란 무엇인가?
힙은 완전 이진 트리의 한 종류이며, 다음과 같은 중요한 특징을 지닌다.
- 힙 속성(Heap Property):
- 최소 힙(Min Heap): 부모 노드의 값이 항상 자식 노드의 값보다 작거나 같다. (가장 작은 값이 루트에 위치)
- 최대 힙(Max Heap): 부모 노드의 값이 항상 자식 노드의 값보다 크거나 같다. (가장 큰 값이 루트에 위치)
- 완전 이진 트리(Complete Binary Tree): 마지막 레벨을 제외하고는 모든 레벨이 완전히 채워져 있으며, 마지막 레벨은 왼쪽부터 채워지는 트리이다. 이 특성 덕분에 힙은 배열(Array)을 사용하여 효율적으로 표현될 수 있다.
힙의 배열 기반 표현과 인덱스
힙은 논리적으로 트리 형태를 가지지만, 실제 메모리에서는 배열 형태로 저장된다. 이 배열 표현의 핵심은 인덱스 계산을 통해 부모-자식 관계를 찾아내는 것이다.
루트 노드를 인덱스 0
에 저장한다고 가정할 때,
- 임의의 노드
i
의 부모 노드 인덱스:(i - 1) / 2
(정수 나눗셈) - 임의의 노드
i
의 왼쪽 자식 노드 인덱스:2 * i + 1
- 임의의 노드
i
의 오른쪽 자식 노드 인덱스:2 * i + 2
🤔 인덱스 계산이 왜 필요할까?
완전 이진 트리는 레벨별로 왼쪽부터 채워지는 특성이 있다. 이 특성을 배열에 그대로 매핑하면, 위와 같은 간단한 수식으로 모든 부모-자식 관계를 빠르고 정확하게 찾아낼 수 있다. 이 계산식은 힙의 핵심 연산(insert
,extractMin
)에서 노드의 위치를 조정할 때 필수적으로 사용된다.
최소 힙 핵심 연산: 삽입과 추출
힙은 insert
와 extractMin
(또는 extractMax
) 연산에서 힙 속성을 유지하기 위해 특별한 재조정 과정을 거친다. 이를 힙 속성 재구성(Heapify)이라고 부른다.
1. insert(item)
: 원소 삽입 (상향식 힙 재구성 - heapifyUp
)
- 새로운 원소를 힙의 가장 마지막 위치(배열의 끝)에 추가한다.
- 새로 추가된 원소의 인덱스에서 시작하여, 부모 노드와 값을 비교한다.
- 만약 현재 원소의 값이 부모보다 작다면(최소 힙), 두 원소의 위치를 교환(swap)한다.
- 이 과정을 현재 원소가 더 이상 부모보다 작지 않거나, 루트(인덱스 0)에 도달할 때까지 반복하며 위로 "버블업"시킨다.
2. extractMin()
: 최소 원소 추출 (하향식 힙 재구성 - heapifyDown
)
- 힙의 가장 작은 원소는 항상 루트(인덱스 0)에 있다. 이 원소를 반환 값으로 저장한다.
- 힙의 가장 마지막 원소를 루트 위치로 옮기고, 마지막 원소는 배열에서 제거한다.
- 새로운 루트 원소의 인덱스에서 시작하여, 자식 노드(왼쪽, 오른쪽) 중 더 작은 값과 비교한다.
- 만약 현재 원소의 값이 자식들 중 더 작은 값보다 크다면, 해당 자식과 위치를 교환(swap)한다.
- 이 과정을 현재 원소가 더 이상 자식들보다 크지 않거나, 리프 노드(자식이 없는 노드)에 도달할 때까지 반복하며 아래로 "버블다운"시킨다.
🤔 힙의 heapify 과정은 마치 버블 정렬 같다!
아주 좋은 비유이다!heapifyUp
과heapifyDown
은 본질적으로 제한된 범위(직계 부모/자식) 내에서 일어나는 버블 정렬과 유사하다. 원소들이 자신의 올바른 위치를 찾아 위아래로 "거품처럼" 이동하는 모습이다.
하지만 일반적인 버블 정렬이 전체 배열을 탐색하며 O(N^2)의 시간 복잡도를 가지는 반면, 힙은 직계 노드와의 비교만을 통해 경로를 따라 이동하기 때문에 훨씬 효율적인 O(log N)의 시간 복잡도를 달성한다. 바로 이 "직계 간 비교" 덕분에 힙이 효율적인 자료구조가 되는 것이다.
자바로 힙 구현하기
자료구조로서의 힙에 대해 알아보았으니, 이제 자바로 힙을 구현해 보자.
1. java.util.PriorityQueue
활용하기
대부분의 경우, 자바의 표준 라이브러리인 java.util.PriorityQueue
를 사용하는 것이 가장 효율적이고 편리하다. 기본적으로는 최소 힙으로 동작하며, 필요 시 최대 힙으로 사용할 수도 있다.
import java.util.PriorityQueue;
import java.util.Collections;
public class MinHeapWithPriorityQueue {
public static void main(String[] args) {
// 최소 힙 초기화
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(10); // 원소 추가
minHeap.add(5);
minHeap.offer(20);
System.out.println("최소 원소 확인: " + minHeap.peek());
System.out.println("최소 원소 추출: " + minHeap.poll());
System.out.println("현재 힙 상태: " + minHeap);
// 최대 힙으로 사용하려면 Comparator.reverseOrder()를 전달
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
maxHeap.add(10);
maxHeap.add(5);
maxHeap.add(20);
System.out.println("최대 힙의 최대 원소: " + maxHeap.peek()); // 20
}
}
2. 최소 힙 직접 구현하기
힙의 동작 원리를 심층적으로 이해하기 위해, 직접 구현해 보면 아래와 같다.
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
public class MyMinHeap {
private List<Integer> heap;
public MyMinHeap() {
this.heap = new ArrayList<>();
}
// 인덱스 계산 메서드 (parent, leftChild, rightChild)는 위에 설명된 공식 활용
// 원소 삽입 (heapifyUp 활용)
public void insert(int item) {
heap.add(item);
int currentIndex = heap.size() - 1;
while (currentIndex > 0 && heap.get(parent(currentIndex)) > heap.get(currentIndex)) {
swap(currentIndex, parent(currentIndex));
currentIndex = parent(currentIndex);
}
}
// 최소 원소 추출 (heapifyDown 활용)
public int extractMin() {
if (heap.isEmpty()) {
throw new NoSuchElementException("Heap is empty");
}
if (heap.size() == 1) {
return heap.remove(0);
}
int root = heap.get(0);
heap.set(0, heap.remove(heap.size() - 1));
heapifyDown(0); // 재귀 호출
return root;
}
// 하향식 힙 재구성 (재귀 구현 예시)
private void heapifyDown(int i) {
int smallest = i;
int left = leftChild(i);
int right = rightChild(i);
int n = heap.size();
if (left < n && heap.get(left) < heap.get(smallest)) {
smallest = left;
}
if (right < n && heap.get(right) < heap.get(smallest)) {
smallest = right;
}
if (smallest != i) {
swap(i, smallest);
heapifyDown(smallest); // 재귀 호출
}
}
// ... (peekMin, size, isEmpty, swap 등 기타 메서드) ...
}
마지막으로
🤔 PriorityQueue
에서 최소 원소 제거 함수는 무엇을 사용해야 하는가?
poll()
과 비슷한 함수로 remove()
가 있다. 둘 다 큐의 헤드(최소 원소)를 제거하고 반환하지만, 큐가 비어있을 때 poll()
은 null
을 반환하는 반면, remove()
는 NoSuchElementException
을 발생시킨다는 차이가 있다. 일반적으로 안전한 사용을 위해 poll()
이 선호된다.
🤔 힙의 heapifyUp
이나 heapifyDown
은 재귀 호출을 활용하는가? 반복문을 활용하는가?
heapifyDown
의 경우, 가장 작은 자식을 찾아 교환하고 "그 자식 위치에서 다시 동일한 과정을 반복한다"는 흐름이 재귀적 사고방식에 더 가깝다. 반면 heapifyUp
은 부모 방향으로의 단일 경로 이동이므로 반복문으로 구현하는 것이 더 직관적일 수 있다. 하지만 어떤 방식이든 힙 속성을 올바르게 복구할 수 있다면 유효한 구현이다.
🤔 반복문이 더 좋아 보이는데, 재귀 호출을 쓰긴 하는가? 꼬리 재귀 최적화가 자바에 없는 이유는 무엇인가?
이론적인 시간 복잡도는 O(log N)로 동일하다. 하지만 실제 성능에서 반복문이 미세하게 더 빠를 수 있고, 스택 오버플로우 위험이 없다는 장점이 있어 충분히 좋게 느껴질 수 있다. 그러나 재귀는 다음과 같은 상황과 의미에서 여전히 중요한 가치를 지닌다.
- 문제 표현의 간결함과 직관성: 어떤 문제들은 그 정의 자체가 재귀적이다. 예를 들어, 트리를 탐색하거나 팩토리얼을 계산하는 문제 등은 재귀로 표현했을 때 훨씬 더 자연스럽고 간결한 코드를 작성할 수 있다.
heapifyDown
처럼 재귀적 사고방식에 부합하는 문제 해결 흐름에서 특히 그러하다. - 수학적 귀납법과의 유사성: 재귀는 특정 조건(기저 사례)이 만족하면 종료되고 그렇지 않으면 더 작은 문제로 분해하여 자기 자신을 호출하는 방식으로 문제를 해결한다. 이러한 패러다임이 특정 알고리즘 설계에 매우 적합하다.
꼬리 재귀 최적화(Tail Call Optimization, TCO)는 함수가 자신을 호출할 때 그 호출이 함수 내에서 마지막으로 수행되는 연산일 경우, 컴파일러가 이를 반복문으로 변환하여 스택 프레임을 재활용하는 최적화 기법이다. 이는 재귀 호출로 인한 스택 오버플로우 위험과 함수 호출 오버헤드를 제거해 준다. 이러한 TCO가 자바에 없는 주된 이유는 다음과 같다.
- JVM의 설계 철학: 자바 가상 머신(JVM)은 디버깅 시 중요한 스택 트레이스 정보를 온전히 보존하는 것을 우선시한다. TCO가 적용되면 스택 프레임이 재활용되어 스택 트레이스가 불완전해질 수 있으며, 이는 디버깅을 어렵게 만들 수 있다.
- 구현의 복잡성과 다른 최적화 기법의 존재: TCO 구현은 컴파일러에게 상당한 복잡성을 더한다. 또한 자바는 JIT(Just-In-Time) 컴파일러와 같은 다른 강력한 최적화 기법들을 이미 보유하고 있으며, 이들이 TCO 없이도 충분히 좋은 성능을 제공한다고 판단한 것으로 보인다.
사실, 힙의 경우 높이가 log N으로 매우 낮기 때문에 heapify
연산에서 재귀로 인한 스택 오버플로우를 걱정할 필요는 거의 없다. 따라서 가독성과 표현의 자연스러움을 고려하여 재귀 또는 반복문 중 더 적절하다고 생각하는 방식을 선택하면 된다.
'MAIN > Java & Spring' 카테고리의 다른 글
Variable Hiding / Shadowing (1) | 2025.07.19 |
---|---|
JPA 프로젝트에서 타임스탬프 자동화를 위한 세 가지 전략 (1) | 2025.06.10 |
JPA 스펙에는 컬럼 순서에 대한 명확한 규정이 없다. (1) | 2025.05.28 |
Spring Boot 드라이버 인식 오류 (driver-class-name) (2) | 2025.05.23 |
Spring Boot에서 JPA로 Docker MySQL 연동하기 (2) | 2025.05.22 |