
분류
영단어 힙(heap)은 '무엇인가를 차곡차곡 쌓아올린 더미'라는 뜻을 지니고 있다. 힙은 항상 완전 이진 트리[1]의 형태를 띠어야 하고, 부모의 값은 항상 자식(들)의 값보다 크거나(최대 힙[max heap]), 작아야(최소 힙[min heap])하는 규칙이 있다. (그러므로 사진은 최소 힙[min heap]이다.) 따라서 루트 노드에는 항상 데이터들 중 가장 큰 값(혹은 가장 작은 값)이 저장되어 있기 때문에, 최댓값(혹은 최솟값)을 안에 찾을 수 있다.
아래는 '최댓값 찾기'에 관련된 행위들과, 그 행위들이 걸리는 시간복잡도를 나열한 것이다.
생성 & 읽기 : 무작위 수열에서부터 | 수정 : 수 추가하기 | 삭제 : 다음 최댓값 찾기 | |
정렬하기 | |||
힙 트리 | |||
그냥 순회하며 찾기 |
단순히 최댓/최솟값을 단 한 번만 찾아야 한다면 그냥 전체를 한 번 쓱 보는 것만으로 로 충분하다. 하지만 실제로는 자주 다익스트라 알고리즘처럼 "다음 최댓값도 알고 싶"을 것이다.
이 때 그냥 순회하며 찾기는 쓸 수 없게 된다. 그렇다고 매번 전체를 정렬하자니 정렬 자체에 드는 비용이 너무 크다. 대신 힙은 다음 최댓값을 찾을 때 을 다소 감수하더라도 최초 힙 생성에 밖에 안 쓴다. 즉, "데이터가 뭉텅이로 들어와서 빠르게 구조를 잡아야 하고(), 전체를 정렬할 필요 없이 최댓값/최솟값만 반복적으로 꺼내면 되는" 상황에서 힙은 정렬에 비해 압도적인 효율성을 보여준다.
단순히 최댓값(최솟값)을 안에 찾기 위해서라면 '항상 완전 이진 트리의 형태여야 한다'는 조건을 만족시킬 필요는 없다. [2] 완전 이진 트리를 사용하는 이유는 삽입/삭제의 속도 때문이다. 물론 '힙 트리'는 정의상 완전 이진 트리를 사용하는 트리다. 달리 다른 구조를 사용한다 해도 전혀 얻을 게 없는 최적의 구조이기 때문.
데이터의 삽입과 삭제는 모두 이 소요된다.
heap은 완전 이진 트리의 구조를 가지고 있기 때문에 트리의 레벨이 늘어날수록 노드의 수는 두 배씩 증가한다.
그 말은 레벨이 i라고 가정했을 때 i레벨의 노드 수는 개이다. (단 i는 마지막 레벨은 아니다. 이는 완전 이진 트리의 특성 때문이다.)
그러므로 트리의 높이는 노드의 수를 통해서 알 수 있다.
트리의 높이는 에서 소수점을 버린 값이다.
Heap에서 데이터의 삽입과 삭제는 이 높이와 관련이 있기 때문에 빅오 표기법에 의해 이렇게 표현되는 것이다.
heap은 완전 이진 트리의 구조를 가지고 있기 때문에 트리의 레벨이 늘어날수록 노드의 수는 두 배씩 증가한다.
그 말은 레벨이 i라고 가정했을 때 i레벨의 노드 수는 개이다. (단 i는 마지막 레벨은 아니다. 이는 완전 이진 트리의 특성 때문이다.)
그러므로 트리의 높이는 노드의 수를 통해서 알 수 있다.
트리의 높이는 에서 소수점을 버린 값이다.
Heap에서 데이터의 삽입과 삭제는 이 높이와 관련이 있기 때문에 빅오 표기법에 의해 이렇게 표현되는 것이다.
최댓값 혹은 최솟값이 저장된 루트 노드를 제거할 수 있다.
- 루트 노드를 제거한다.
- 가장 마지막 노드 (L)를 루트 노드로 이동시킨다.[5]
- 노드 L을 자식 노드(들)와 비교한다. 규칙을 만족하면 끝내고, 그렇지 않으면 L을 자식과 교환한다. 최소(최대) 힙에서 과정을 구체적으로 나타내면 다음을 반복하는 것과 같다.
3.1. 자식들의 크기를 비교하여 더 작은(큰) 자식을 찾는다.
3.2. 더 작은(큰) 자식이 L보다 크거나(작거나) 같다면 과정을 끝낸다.
3.3. 더 작은(큰) 자식이 L보다 더 작다면(크다면) 교환한다.[6]
위와 같이 반복적으로 자식과 비교하며 노드를 아래로 내리는 과정을 'sift down' 또는 'percolate down'이라 칭한다.


한편, 유사한 방식으로 임의의 위치에 있는 노드 또한 제거할 수 있다.

한편, 유사한 방식으로 임의의 위치에 있는 노드 또한 제거할 수 있다.
- 노드를 제거한다. 마지막 노드였다면 과정을 끝낸다.
- 가장 마지막 노드 (L)를 빈 노드로 이동시킨다.
- 노드 L을 부모 노드와 비교한다. 규칙을 만족하면 반복을 끝내고, 그렇지 않으면 L을 부모와 교환한다. 최소(최대) 힙에서 과정을 구체적으로 나타내면 다음을 반복하는 것과 같다.
3.1. L이 부모보다 크거나(작거나) 같다면 반복을 끝낸다.
3.2. L이 부모보다 더 작다면(크다면) 교환한다. - 노드 L을 자식 노드(들)와 비교한다. 규칙을 만족하면 끝내고, 그렇지 않으면 L을 자식과 교환한다. 최소(최대) 힙에서 과정을 구체적으로 나타내면 다음을 반복하는 것과 같다.
4.1. 자식들의 크기를 비교하여 더 작은(큰) 자식을 찾는다.
4.2. 더 작은(큰) 자식이 L보다 크거나(작거나) 같다면 과정을 끝낸다.
4.3. 더 작은(큰) 자식이 L보다 더 작다면(크다면) 교환한다.
다만, 임의의 값을 제거하기 위해 해당 값이 들어 있는 노드를 찾는 과정은 매우 비효율적이므로, 노드의 위치를 기록해놓는 자료 구조를 미리 구축해두어야 한다. 삽입과 루트 노드 제거의 시간복잡도가 증가하지 않도록 해당 자료 구조는 배열을 사용한다.

이진 힙은 완전 이진 트리(complete binary tree)로서, 배열로 표현하기 매우 좋은 구조다. 높이 순서대로 순회하면 모든 노드를 배열에 낭비 없이 배치할 수 있기 때문이다. 그림처럼 완전 이진 트리는 배열에 빈틈없이 배치가 가능하며, 대개 트리의 배열 표현의 경우 계산을 편하게 하기 위해 인덱스는 1부터 사용한다.
해당 노드의 인덱스를 알면 깊이가 얼마인지, 부모와 자식 노드가 배열 어디에 위치하는지 바로 알아낼 수 있다. 깊이는 1, 2, 4, 8, ... 순으로 2배씩 증가하며, 인덱스는 1부터 시작했기 때문에 부모/자식 노드의 위치는 각각 부모 , 왼쪽 자식 , 오른쪽 자식 의 간단한 수식으로 계산할 수 있다. 이처럼 해당되는 배열의 인덱스를 금방 찾아낼 수 있다. 물론 꼭 완전 이진 형태가 아니어도 비어있는 위치는 얼마든지 널(null)로 표현할 수 있기 때문에, 사실상 모든 트리는 배열로 표현이 가능하다.
역으로, 배열이 주어지면 이에 대응하는 완전 이진 트리가 존재하게 되는데, 당연하게도 힙의 규칙을 만족하지는 않을 확률이 높다. 이러한 트리를 규칙을 만족하도록 수정하는 과정을 '빌드 힙(build heap)' 또는 'heapify'로 부른다.
- 배열의 모든 원소를 대응되는 트리의 위치에 둔다.
- 자식을 가지는 가장 마지막 노드[7]에 대해 'sift down'을 수행한다.
- 인덱스를 하나씩 줄이며 'sift down'을 수행한다. 'sift down'하여 내려간 노드가 여전히 규칙을 만족하지 않는다면 내려간 노드의 규칙을 먼저 맞춘다.
- 루트 노드까지 과정이 완료되면 종료한다.
원소가 총 개라면, 자식을 한 단계 가지는 개의 노드에 대해서는 1회의 작업, 자식을 두 단계 가지는 개의 노드에 대해서는 2회의 작업, 자식을 세 단계 가지는 개의 노드에 대해서는 3회의 작업 등을 수행한다. 따라서 빌드에 소요되는 시간은 이다.[8]
비어있는 힙에 데이터 삽입을 회 반복하는 방법도 생각할 수 있으나, 이 경우엔 부모를 단계 가지는 개의 노드에 대해서 회 작업, 부모를 단계 가지는 개의 노드에 대해서 회 작업 등을 하므로 빌드에 소요되는 시간이 이다.
비어있는 힙에 데이터 삽입을 회 반복하는 방법도 생각할 수 있으나, 이 경우엔 부모를 단계 가지는 개의 노드에 대해서 회 작업, 부모를 단계 가지는 개의 노드에 대해서 회 작업 등을 하므로 빌드에 소요되는 시간이 이다.
최소 힙 기준으로 짜인 소스이다.
- 삽입
void creheap(int *arr2, int key, int input) {
arr2[key] = input;
while (key > 1) {
if (arr2[key] < arr2[key / 2]) {
swap(arr2[key], arr2[key / 2]);
key /= 2;
}
else break;
}
}- 삭제
루트 노드 삭제 후 힙의 마지막 데이터를 가져온 상태를 가정한다.
void delheap(int *arr3, int key, int heap_size) {
int tmp, nextkey;
while (heap_size >= key * 2) {
if (key * 2 + 1 > heap_size && arr3[key * 2] < arr3[key]) {
swap(arr3[key * 2], arr3[key]);
key = key * 2;
}
else if (key * 2 + 1 > heap_size) break;
else {
if (arr3[key * 2] < arr3[key * 2 + 1]) {
tmp = arr3[key * 2];
nextkey = key * 2;
}
else {
tmp = arr3[key * 2 + 1];
nextkey = key * 2 + 1;
}
if (tmp < arr3[key]) {
swap(arr3[key], arr3[nextkey]);
key=nextkey;
}
else break;
}
}
}[1] 트리의 위부터 아래, 왼쪽부터 오른쪽의 순서로 빠짐없이 가득 차있는 이진 트리.[2] 극단적으로 말해서, 최댓값/최솟값을 항상 헤드에 두고, 나머지 데이터는 비교하든 말든 그냥 뒤에 쭉 이어 붙인 연결 리스트로도 최댓값/최솟값을 상수 시간 내에 찾을 수 있다.[3] 부모 노드는 삽입된 위치의 인덱스 번호에서 /2를 하면 쉽게 구할 수 있다.[4] 최소 힙을 예시로 든다면 형제 노드 > 부모 노드이고, 만약 부모 노드 > 삽입된 노드라면 교환을 수행해도 형제 노드 > 삽입된 노드이므로 규칙이 깨지지 않는다.[5] 이는 수정될 힙에서 중간에 빈 공간이 생기지 않게 하기 위함이다[6] 자식 중 더 작은(큰) 노드를 교환하여 올렸으므로 교환을 수행해도 규칙이 깨지지 않는다.[7] 전체 노드 개수를 2로 나누어 구할 수 있다.[8] 에서 을 빼면 쉽게 보일 수 있다.
![]()
이 저작물은 CC BY-NC-SA 2.0 KR에 따라 이용할 수 있습니다. (단, 라이선스가 명시된 일부 문서 및 삽화 제외)
기여하신 문서의 저작권은 각 기여자에게 있으며, 각 기여자는 기여하신 부분의 저작권을 갖습니다.
나무위키는 백과사전이 아니며 검증되지 않았거나, 편향적이거나, 잘못된 서술이 있을 수 있습니다.
나무위키는 위키위키입니다. 여러분이 직접 문서를 고칠 수 있으며, 다른 사람의 의견을 원할 경우 직접 토론을 발제할 수 있습니다.
