👉🏻 힙 (Heap, '무언인가를 차곡 차곡 쌓아올린 더미') : 힙 자료구조는 여러 개의 값들 중 최댓값 및 최솟값을 빠르게 찾아내기 위해 고안된 완전 이진 트리에 기반한 특별한 이진 트리 데이터 구조입니다. 부모 노드의 값이 자식 노드의 값보다 항상 크거나 작은 이진트리를 말합니다. 이를 위키백과에서는 일종의 반정렬 상태(느슨한 정렬상태)를 유지한다고 표현합니다.
'반정렬', '느슨한'이라는 수식어가 붙는 이유는 부모 노드와 자식 노드의 대소관계만 관계가 있을 뿐, 형제 노드 사이에는 순서나 관계가 없기 때문입니다.
(Max-heap을 기준) 위 그림과 같이 depth 1
의 숫자가 반드시 전체 heap 에서 두번째, 세번째로 큰 숫자라는 보장이 없습니다. 또한 왼쪽과 오른쪽 간의 순서가 존재하지 않습니다. 오로지 부모 노드의 값보다 크거나 작게 정렬이 되면 족합니다. 따라서 완전한 정렬은 아닌 반정렬, 느슨한 정렬 상태라고 합니다.
👉🏻 따라서 완전한 정렬이 아니므로 탐색에는 적합하지 않습니다. 특정 요소를 검색(탐색)하는 경우에는 반드시 전체를 순회해야 해서 O(n)의 시간복잡도를 가지게 됩니다. (완전한 정렬이라면 형제 노드 사이에서도 대소관계가 명확하므로 매 depth마다 특정 요소와 비교해 한 쪽으로만 내려가면서 탐색하므로 O(log n)의 시간복잡도를 가지게 됩니다.)
2n + 1
, 오른쪽 자식 노드는 2n + 2
의 index를 가집니다.Math.floor((idx - 1) / 2)
로 구할 수 있습니다. O(log n)
의 시간복잡도를 가지며, 이 경우 heap의 특징을 유지하도록 재구조화하는 heapify 프로세스가 동작합니다.🌿 시간복잡도 O(log n)
삽입과 삭제 모두 tree의 높이만큼 비교를 하게 되는데, 완전 이진 트리의 높이는 항상 log입니다. 이는 비교를 할 때 부모 노드의 값만 비교를 한다는 뜻입니다. 노드의 갯수는 완전 이진 트리 이므로 높이가 늘어날 수록 노드의 갯수는 2배씩 늘어납니다. 노드의 갯수가 2배가 들어나도 비교횟수는 1번만 늘어난다는 뜻입니다. 따라서 노드의 갯수 n이 늘어나는 것에 비해 비교횟수는 아주 천천히 늘어납니다.
O(n)
이 걸립니다.)O(n log n)
의 시간복잡도를 가지므로, 더 빠른 알고리즘이 필요하다면 적합하지 않을 수 있습니다.주석
으로 과정을 자세하게 설명해놓았습니다.
// 노드를 만들어 그냥 배열에 저장하기만 하면 되므로, 빈 배열을 생성해준다.
class MaxHeap {
constructor() {
this.arr = [];
}
getMax() {
return this.arr[0];
}
insert(element) {
// 언제나 맨 뒤에 값을 삽입하므로 push를 해준다.
this.arr.push(element);
// 이후 bubbleUp이라고 불리는 heapify 과정을 진행해 준다.
// 즉, 추가한 값의 올바른 위치를 찾아가는 과정이다.
// 이 안에서 과정을 정의해도 되지만 따로 method로 정의하는 게 깔끔!✨
this.bubbleUp();
}
bubbleUp() {
// 마지막 요소를 찾는다.
let idx = this.arr.length - 1;
// 마지막 요소의 값을 저장한다.
const element = this.arr[idx];
while(idx > 0){
// 자식노드의 idx를 구하는 식을 거꾸로 해 부모노드의 index를 구할 수 있다.
let parentIdx = Math.floor((idx - 1) / 2);
// 부모노드의 값을 저장한다.
let parent = this.arr[parentIdx];
// 부모노드의 값보다 작거나 같으면 동작을 멈춘다. (올바른 위치이므로)
if (element <= parent) break;
// 아니라면 부모노드 인덱스의 값과, 현재 인덱스의 값을 바꾸어 SWAP!
this.arr[parentIdx] = element;
this.arr[idx] = parent;
// 인덱스를 조정해준다.
idx = parentIdx;
}
}
✔️ bubbleDown()
시에 왼쪽 노드의 값과 오른쪽 노드의 값을 비교해 더 큰 값과 swap 합니다.
delete() {
// 첫번째 노드인 가장 큰 값을 저장해 후에 출력해준다.
const max = this.arr[0];
// 마지막 요소를 제거해 저장한다.
// 참고 : 이때 end가 마지막 요소라면 pop 하고 다시 push로 넣어준다.
const end = this.arr.pop();
// 만약 배열의 길이가 없다면 바로 max를 반환하고, 배열의 길이가 남았다면 end를 root 자리로 올리고 bubbleDown 과정을 진행한다.
if (this.arr.length > 0) {
// 첫번째 요소로 마지막 요소를 넣는다.
this.arr[0] = end;
// heapify 과정으로 이번에는 0번 인덱스에 있는 요소의 위치를 찾아 순회해준다.
// 이 안에서 과정을 정의해도 되지만 따로 method로 정의하는 게 깔끔!✨
this.bubbleDown();
};
// 삭제하는 가장 큰 값(루트 노드의 값)을 반환해준다.
return max;
}
bubbleDown() {
// 첫번쨰 인덱스부터 순회하며 내려갈 예정
let idx = 0;
const length = this.arr.length;
// 현재 인덱스의 요소값을 저장해준다.
const element = this.arr[idx];
while(true) {
// 자식 노드의 인덱스를 구해준다.
let leftChildIdx = 2 * idx + 1;
let rightChildIdx = 2 * idx + 2;
// 바로 value[leftChildIdx]로 값을 넣을 수 없다.
// 구한 자식노드의 인덱스가 범위내인지, 유효한지 확인이 필요하다.
let leftChild, rightChild;
let swap = null;
// 유효성검사
if(leftChildIdx < length) {
// 조건을 통과해 유효범위내의 인덱스라면 값을 저장해주고,
leftChild = this.arr[leftChildIdx];
// 만약 값이 현재값(원래 마지막요소였던 값)보다 크다면 SWAP해주기 위해 인덱스를 저장한다.
if(leftChild > element) {
swap = leftChildIdx;
}
}
// 오른쪽 노드도 유효성을 검사하고, 오른쪽 자식 노드와 자리를 바꿔야 하는 경우인지 확인한다.
if(rightChildIdx < length) {
rightChild = this.arr[rightChildIdx];
// 왼쪽 노드의 값이 더 크지 않았는데 오른쪽 노드의 값이 더 큰 경우나
// 왼쪽 노드의 값이 더 컸음에도 오른쪽 노드의 값이 왼쪽 노드의 값이 더 큰 경우
if((swap === null && rightChild > el) ||
(swap !== null && rightChild > leftChild)) {
// SWAP할 인덱스로 오른쪽 노드의 인덱스를 넣어준다.
swap = rightChildIdx;
}
}
// swap에 아무런 값도 들어있지 않다면, 올바른 위치인 것!!
if (swap === null) break;
// 아니라면 swap 해준다
this.arr[idx] = this.arr[swap];
this.arr[swap] = element;
}
}
}
class MinHeap {
constructor() {
this.arr = [];
}
getMin() {
return this.arr[0];
}
insert(element) {
this.arr.push(element);
// 👉🏻 이번엔 heapify를 내부에서 정의해 봄.
// 마지막 요소의 인덱스를 구한다.
let idx = this.arr.length - 1;
//index가 0보다 큰 동안
while (idx > 0) {
// 부모 노드의 인덱스를 구하고,
let parentIdx = Math.floor((idx - 1) / 2);
// 만약 부모노드의 값이 마지막 요소의 값보다 크면 멈추고
if(this.arr[parentIdx] > this.arr[idx]) brak;
// 아니면 요소들을 SWAP!!
[this.arr[idx], this.arr[parentIdx]] = [this.arr[parentIdx], this.arr[idx]];
idx = parentIdx;
}
}
delete() {
// 요소가 하나라면 pop으로 바로 반환한다.
if (this.arr.length === 1) {
return this.arr.pop();
}
// 가장 작은 루트 노드의 값을 저장하여 반환한다.
let min = this.arr[0];
// 마지막 요소와 첫번째 요소를 바꾼다.
this.arr[0] = this.arr[this.arr.length-1];
// 마지막 요소를 제거하고
this.arr.pop();
// heapify 함수를 부른다.
this.bubbleDown();
return min;
}
bubbleDown() {
let idx = this.arr.length - 1;
let length = this.arr.length;
if (length === 1) return;
let leftChildIdx = 2 * idx + 1;
let rigthChildIdx = 2 * idx + 2;
let min = idx;
if (leftChildIdx < length && this.arr[leftChildIdx] < this.arr[idx])
min = leftChildIdx;
if (rigthChildIdx < length && this.arr[rigthChildIdx] < this.arr[min])
min = rigthChildIdx;
if (min !== idx)
{
[this.arr[idx], this.arr[min]] = [this.arr[min], this.arr[idx]]
this.bubbleDown(min);
}
}
}
👉🏻 우선순위 큐 (priority queue) : 보통의 queue와 비슷하나, 각 요소들은 우선순위를 가지고 우선순위 값에 따라 요소를 정렬하는 queue 유형입니다. 더 높은 우선순위를 가지는 요소가 더 낮은 우선순위를 가진 요소보다 먼저 처리됩니다.
우선순위가 동일한 경우, queue 특징에 따라 순서에 따라 처리됩니다.
배열(Array), 연결 리스트(Linked-List), 힙(Heap), 이진 검색 트리(Binary Search Tree)로도 구현이 가능하고, 각 방법마다 장·단점을 가집니다.
배열 혹은 리스트로 구현하는 경우 삽입 순으로 저장하고 요소를 제거하거나 다음 요소를 반환해야할 때 리스트 전체를 순회하면서 우선순위를 비교합니다. 이는 매우 비효율적입니다. 매번 모든 리스트를 순회하며 전부 비교하므로 O(n)
의 시간복잡도를 가집니다.
힙으로 구현하는 경우가 다른 경우보다 삽입과 삭제에서 효율적이므로 이 글에서는 힙으로 구현하는 우선순위 큐에 대해 설명할 것입니다. 이렇듯 우선순위 큐를 보통 힙을 사용해 구현하는 경우가 많아 둘을 혼동하곤 하지만 우선순위 큐와 힙은 별개의 개념입니다.
대게 숫자가 낮을 수록 우선순위가 높습니다. 즉, 우선순위가 0순위 혹은 1순위일 때 가장 우선됩니다. 따라서 최소 힙으로 구현됩니다.
힙에서 구현한 것과 거의 동일하지만, 요소의 값이 아닌 우선순위로 비교하여 heapify를 진행하도록 수정합니다.
class Node {
constructor(value, priority) {
this.value = value;
this.priority = priority;
}
}
class PriorityQueue {
constructor() {
this.arr = [];
}
enqueue(val, priority) {
//위에서 만든 노드 생성 class를 사용해 요소 자체가 아닌 노드를 삽입합니다.
let newNode = new Node(val, priority);
this.arr.push(newNode);
this.bubbleUp();
}
bubbleUp() {
let idx = this.arr.length - 1;
const element = this.arr[idx];
while(idx > 0){
let parentIdx = Math.floor((idx - 1) / 2);
let parent = this.arr[parentIdx];
// 🌟 요소의 값이 아닌 우선순위로 비교합니다. 🌟
if(element.priority >= parent.priority) break;
this.arr[parentIdx] = element;
this.arr[idx] = parent;
idx = parentIdx;
}
}
dequeue() {
const top = this.arr[0];
const end = this.arr.pop();
if(this.arr.length > 0) {
this.arr[0] = end;
this.bubbleDown();
};
return top;
}
bubbleDown(){
let idx = 0;
const length = this.arr.length;
const element = this.arr[0];
while(true){
let leftChildIdx = 2 * idx + 1;
let rightChildIdx = 2 * idx + 2;
let leftChild,rightChild;
let swap = null;
if(leftChildIdx < length){
leftChild = this.arr[leftChildIdx];
//🌟 요소의 값이 아닌 우선순위로 비교합니다. 🌟
if(leftChild.priority < element.priority) {
swap = leftChildIdx;
}
}
if(rightChildIdx < length){
rightChild = this.arr[rightChildIdx];
if(
(swap === null && rightChild.priority < element.priority) ||
(swap !== null && rightChild.priority < leftChild.priority)
) {
swap = rightChildIdx;
}
}
if(swap === null) break;
this.arr[idx] = this.arr[swap];
this.arr[swap] = element;
idx = swap;
}
}
}