이진 힙이란?
완전 이진트리(Complete Binary Tree)의 한 형태로 힙 정렬, 또는 우선순위 큐에 사용된다.
이진 힙은 다음 2가지 특성을 가지고 있다.
- 완전 이진 트리와 동일하게 모든 레벨이 완전히 채워져 있다. 마지막 레벨은 왼쪽부터 순서대로 채워진다.
- 모든 부모 노드는 자식 노드보다 큰 값을 가진다. 이 경우, 최대 힙(Max Heap) 이라고 한다.
반대로 부모 노드가 자식 노드보다 작은 값을 가지면 최소 힙(Min Heap)이라고 한다.
이 글에선 최소 힙을 기준으로 설명한다.
힙 선언
class Heap {
private int[] heapArray;
private int tail = 0;
private int capacity;
public Heap(int capacity) {
this.heapArray = new int[capacity];
this.capacity = capacity;
}
// 기타 메소드들 ...
}
이진 힙은 노드로 관리하지 않고, 배열로 관리한다. 이유는 다음과 같다.
메모리 효율성
노드를 클래스 형태로 만들어 관리해야 하면, 각 노드에 대한 별도의 포인터를 가져야 한다.
인덱스만 계산해 접근하면 되는 배열과 달리 비효율적이다.
속도 효율성
객체를 생성, 해제 과정은 오버헤드가 발생한다.
접근 과정 역시 오버헤드가 발생하는데, 객체가 생성될 때는 힙의 빈 공간을 할당받는다.
객체를 연속으로 생성하는 것이 힙 내부에 연속적으로 할당된다는 보장이 없다.
즉, 캐시에서 공간 지역성이 떨어진다.
객체들이 하나의 디스크 블록에 담을 수 없을 정도로 간격이 떨어져 있다면 객체 하나를 가져올 때마다 캐시 미스와 메모리 폴트가 발생한다.
정리하면 객체가 10개 있다면 초기 상태에서 객체를 모두 가져오는데 10번의 I/O 작업이 발생하는 것이다.
인덱스 계산
부모 노드가 $i$이고, 루트 노드의 인덱스를 $0$이라 한다면
왼쪽 자식 노드는 $2 * i + 1 $이 되고, 오른쪽 자식 노드는 $2 * i + 2$ 이 된다.
자식에서 부모 노드로 가고 $(i - 1) / 2$ 을 한다.
어떤 글에서는 루트 노드 인덱스를 $1$로 하는 경우가 있는데
이 경우엔 왼쪽 자식 노드는 $2 * i$, 오른쪽 자식 노드는 $2 * i + 1$로 계산한다.
자식에서 부모 노드로 가고 싶다면 $i / 2$를 한다.
이 글에서는 루트 노드의 인덱스를 $0$으로 했다.
삽입
삽입은 다음 순서로 진행한다.
- 이진트리의 마지막 레벨의 가장 왼쪽에 노드를 추가한다. (배열 내 마지막 원소 다음)
- 삽입된 노드와 부모 노드의 값을 비교한다.
- 최소 힙인 경우 값이 작을 때, 최대 힙인 경우 값이 클 때 값을 스왑 한다. 아니라면 재귀를 끝낸다.
- 부모 노드로 올라가 2번부터 시작한다.
$N$개의 원소를 삽입하면, $N$번의 조정작업이 발생하므로 시간 복잡도는 $O(NlogN)$이 발생한다.
public void insert(int data) {
// 한계에 도달하면 삽입 취소
if (tail >= capacity) {
return;
}
heapArray[tail] = data;
int index = tail;
tail += 1;
upRecursive(getParentIndex(index), index);
}
private void upRecursive(int parent, int child) {
// 자식이 루트 인덱스이거나, 자식이 부모보다 크면 return
if (child <= 0 || heapArray[child] >= heapArray[parent]) {
return;
}
swap(child, parent);
upRecursive(getParentIndex(parent), parent);
}
추출
추출은 우리가 사용하는 우선순위 큐의 pop 과 같은 원리다.
- 배열의 마지막원소를 배열의 $0$번 인덱스에 덮어쓴다. (반환을 할 경우 따로 저장해 놓는다.)
- 배열의 최대 인덱스를 $1$ 감소한다.
- 부모 인덱스(처음엔 루트)와 자식 노드의 값을 비교해 값이 작은 경우(최소 힙) 스왑 한다. 아니라면 재귀를 끝낸다.
- 자식 노드로 내려가 3부터 시작한다.
추출 작업 후 트리의 균형을 조절해야 하므로 $O(logN)$의 시간 복잡도가 발생한다.
// 최솟값 추출
public int extractMin() {
int ret = getMin();
heapArray[0] = heapArray[tail - 1];
heapArray[tail - 1] = 0;
tail -= 1;
downRecursive(0);
return ret;
}
private void downRecursive(int parent) {
int child = parent;
int leftIndex = getLeftChildIndex(parent);
if (leftIndex < tail && heapArray[parent] > heapArray[leftIndex]) {
child = leftIndex;
}
int rightIndex = getRightChildIndex(parent);
if (rightIndex < tail && heapArray[child] > heapArray[rightIndex]) {
child = rightIndex;
}
if (child != parent) {
swap(parent, child);
downRecursive(child);
}
}
정렬
이진트리이기 때문에 힙 정렬을 구현할 수 있다.
추출 과정과 비슷하지만 약간의 차이가 있다.
마지막 원소를 가리키는 인덱스를 $i$라고 할 때
- 배열의 첫 원소와 $i$의 위치를 바꾼다.
- $0$번 인덱스부터 시작해 부모 노드와 자식 노드의 값의 대소를 비교한다.
단, 인덱스가 $i$ 이상이 되면 탐색을 중단한다. - 배열의 마지막 인덱스$(tail - 1)$부터 $0$까지 1~2 과정을 반복
$N$개의 배열에 대해 $N$번 조정작업이 발생하므로 $O(logN)$의 시간 복잡도가 발생한다.
그림으로 표현하긴 너무 노가다여서.. 코드로 대체한다.
public void descend() {
System.out.print("내림차순 정렬: ");
for (int i = tail - 1; i >= 0; i--) {
int temp = heapArray[0];
heapArray[0] = heapArray[i];
heapArray[i] = temp;
descendRecursive(0, i);
}
// 출력
for (int i = 0; i < tail; i++) {
System.out.printf("%d ", heapArray[i]);
}
System.out.println();
}
private void descendRecursive(int parent, int limit) {
int child = parent;
int leftIndex = getLeftChildIndex(parent);
if (leftIndex < limit && heapArray[parent] > heapArray[leftIndex]) {
child = leftIndex;
}
int rightIndex = getRightChildIndex(parent);
if (rightIndex < limit && heapArray[child] > heapArray[rightIndex]) {
child = rightIndex;
}
if (child != parent) {
swap(parent, child);
descendRecursive(child, limit);
}
}
키 변경
상황에 따라선 힙에 있는 값을 바꿀 상황이 있다.
이 경우, 해당 인덱스가 가리키는 값을 바꾼 뒤
삽입 과정과 동일하게 부모 노드와 자식 노드 간 대소 비교를 수행한다.
추출작업과 동일하게 $O(logN)$의 시간 복잡도가 발생한다.
public void updateKey(int key, int newValue) {
if (key >= tail) {
return;
}
heapArray[key] = newValue;
upRecursive(getParentIndex(key), key);
}
// upRecursive 코드 생략
전체 코드는 아래에서 볼 수 있다.
public class HeapTree {
private static Heap heap = new Heap(256);
public static void main(String[] args) {
heap.insert(5);
heap.insert(1);
heap.insert(2);
heap.insert(4);
heap.insert(3);
heap.insert(6);
heap.insert(10);
heap.insert(9);
heap.insert(8);
heap.insert(7);
// descend와 priorityPrint는 따로 실행해야 한다.
heap.descend();
// heap.priorityPrint();
}
}
class Heap {
private int[] heapArray;
private int tail = 0;
private int capacity;
public Heap(int capacity) {
this.heapArray = new int[capacity];
this.capacity = capacity;
}
public void insert(int data) {
// 한계에 도달하면 삽입 취소
if (tail >= capacity) {
return;
}
heapArray[tail] = data;
int index = tail;
tail += 1;
upRecursive(getParentIndex(index), index);
}
private void upRecursive(int parent, int child) {
// 자식이 루트 인덱스이거나, 자식이 부모보다 크면 return
if (child <= 0 || heapArray[child] >= heapArray[parent]) {
return;
}
swap(child, parent);
upRecursive(getParentIndex(parent), parent);
}
private int getParentIndex(int index) {
return (index - 1) / 2;
}
private int getLeftChildIndex(int index) {
return index * 2 + 1;
}
private int getRightChildIndex(int index) {
return index * 2 + 2;
}
private void swap(int childIndex, int parentIndex) {
int temp = heapArray[parentIndex];
heapArray[parentIndex] = heapArray[childIndex];
heapArray[childIndex] = temp;
}
public int getMin() {
return heapArray[0];
}
public int extractMin() {
int ret = getMin();
heapArray[0] = heapArray[tail - 1];
heapArray[tail - 1] = 0;
tail -= 1;
downRecursive(0);
return ret;
}
private void downRecursive(int parent) {
int child = parent;
int leftIndex = getLeftChildIndex(parent);
if (leftIndex < tail && heapArray[parent] > heapArray[leftIndex]) {
child = leftIndex;
}
int rightIndex = getRightChildIndex(parent);
if (rightIndex < tail && heapArray[child] > heapArray[rightIndex]) {
child = rightIndex;
}
if (child != parent) {
swap(parent, child);
downRecursive(child);
}
}
public void updateKey(int key, int newValue) {
if (key >= tail) {
return;
}
heapArray[key] = newValue;
upRecursive(getParentIndex(key), key);
}
public void descend() {
System.out.print("내림차순 정렬: ");
for (int i = tail - 1; i >= 0; i--) {
int temp = heapArray[0];
heapArray[0] = heapArray[i];
heapArray[i] = temp;
descendRecursive(0, i);
}
// 출력
for (int i = 0; i < tail; i++) {
System.out.printf("%d ", heapArray[i]);
}
System.out.println();
}
private void descendRecursive(int parent, int limit) {
int child = parent;
int leftIndex = getLeftChildIndex(parent);
if (leftIndex < limit && heapArray[parent] > heapArray[leftIndex]) {
child = leftIndex;
}
int rightIndex = getRightChildIndex(parent);
if (rightIndex < limit && heapArray[child] > heapArray[rightIndex]) {
child = rightIndex;
}
if (child != parent) {
swap(parent, child);
descendRecursive(child, limit);
}
}
public void priorityPrint() {
System.out.print("오름차순 우선순위 큐: ");
int N = tail;
for (int i = 0; i < N; i++) {
System.out.printf("%d ", extractMin());
}
System.out.println();
}
}
https://github.com/brorica/DataStructure/blob/master/src/HeapTree.java
GitHub - brorica/DataStructure: 수상할 정도로 나무를 좋아하는 레포
수상할 정도로 나무를 좋아하는 레포. Contribute to brorica/DataStructure development by creating an account on GitHub.
github.com
'알고리즘' 카테고리의 다른 글
레드 블랙 트리(red black tree) (0) | 2023.06.25 |
---|---|
AVL 트리 (Adelson-Velsky and Landis Tree) (0) | 2023.06.11 |
이진 탐색 트리 (Binary Search Tree) (0) | 2023.06.10 |
트리, 이진트리 (1) | 2023.06.09 |
2022 KAUPC 2회 출제 후기 (0) | 2022.09.18 |