"힙(heap)"은 이진 트리를 기반으로 한 특수한 트리 기반 데이터 구조다. 데이터에서 최댓값과 최솟값을 빠르게 찾기 위해 고안된 완전 이진 트리(Complete Binary Tree)이며, 힙에는 두 가지 주요한 속성이 있다.
힙은 우선순위 큐를 구현하는 데 사용되며, 우선순위 큐는 여러 곳에서 사용된다. 예를 들어, CPU 스케줄링, 그래프 알고리즘(다익스트라 알고리즘, 프림 알고리즘 등), 데이터 압축 알고리즘 등에서 사용된다.
배열에 데이터를 넣고, 최댓값과 최솟값을 찾으려면 O(n)이 걸리지만, 힙의 삽입 및 삭제 연산은 대략적으로 O()의 시간 복잡도를 가진다. 이는 모든 레벨에서 요소를 삽입하거나 제거하는 경우를 기준으로 한다. 이러한 특성 때문에 힙은 효율적인 우선순위 큐를 구현하는 데 매우 유용하다.
힙과 이진 탐색 트리의 차이점 : 힙과 이진 탐색 트리(Binary Search Tree, BST)는 모두 트리 기반의 자료구조다. 하지만, 차이점이 있는데, BST에서는 노드의 왼쪽 하위 트리에는 노드의 값보다 작은 값들이 오고, 오른쪽 하위 트리에는 노드의 값보다 큰 값들이 위치하는 반면, 힙은 노드의 값이 자식 노드들의 값보다 크거나 같은(최대 힙의 경우) 또는 작거나 같은(최소 힙의 경우) 구조를 가지고 있다.
데이터가 어떻게 힙에 삽입되고 삭제되는지 알아본다.
삽입 작업과 삭제 작업을 설명하면 다음과 같다.
이러한 과정에서 힙은 두 가지 주요 속성, 즉 "완전 이진 트리"의 형태를 유지하고 "부모가 자식보다 크거나 작다"는 (최대 힙 또는 최소 힙에 따라 다름) 힙 속성을 유지한다.
힙은 이러한 동작을 통해 효과적으로 최대값 (또는 최소값)을 추적하며, 이를 통해 우선순위 큐와 같은 고급 데이터 구조를 구현하는 데 사용될 수 있다.
class Heap:
def __init__(self, data):
self.heap_array = list()
self.heap_array.append(None)
self.heap_array.append(data)
heap = Heap(1)
heap.heap_array # [None, 1]
이 코드는 Heap이라는 클래스를 구현하는 부분이다. Heap 클래스는 힙을 구현하기 위한 기본적인 구조를 가지고 있다.
__init__
메서드는 Heap 클래스의 인스턴스를 초기화하는 역할을 한다. data라는 매개변수를 받아, 이를 힙에 첫 번째 데이터로 추가한다.
self.heap_array는 힙의 모든 요소를 저장하는 배열이다. 배열의 첫 번째 요소는 None으로 설정하여, 인덱스 1부터 실제 데이터가 저장되도록 한다. 이렇게 하는 이유는 배열 기반의 힙에서 첫 번째 요소(인덱스 0)는 무시하고 루트 노드는 인덱스 1에 위치시키는 것이 일반적이기 때문이다.
self.heap_array.append(data) 라인은 인스턴스를 초기화할 때 주어진 데이터를 heap_array에 추가한다. 이로써 첫 번째 데이터가 힙에 저장된다.
마지막에 보여주는 heap = Heap(1)과 heap.heap_array는 Heap 클래스의 인스턴스를 생성하고, 그 인스턴스의 heap_array를 출력하는 예시다. 이 경우, heap_array는 [None, 1]이 출력된다.
인덱스 번호는 1번부터 시작하도록 변경
class Heap:
def __init__(self, data):
self.heap_array = list()
self.heap_array.append(None)
self.heap_array.append(data)
def insert(self, data):
if len(self.heap_array) == 0:
self.heap_array.append(None)
self.heap_array.append(data)
return True
self.heap_array.append(data)
return True
이 코드는 Heap 클래스에 새로운 데이터를 삽입하는 insert 메소드를 추가한 것이다.
이러한 방식으로 새로운 데이터를 힙에 삽입하는 기본적인 기능을 구현했다. 이 코드는 아직 힙의 속성(최대 힙의 경우 부모 노드는 자식 노드보다 크거나 같다는 속성 등)을 유지하는 로직은 없다. 즉, 단순히 데이터를 배열에 추가하는 동작만을 수행하고 있다. 힙의 속성을 유지하려면 추가적인 로직이 필요하다.
heap = Heap(15)
heap.insert(10)
heap.insert(8)
heap.insert(5)
heap.insert(4)
heap.insert(20)
heap.heap_array # [None, 20, 10, 15, 5, 4, 8]
class Heap:
def __init__(self, data):
self.heap_array = list()
self.heap_array.append(None)
self.heap_array.append(data)
def move_up(self, inserted_idx):
if inserted_idx <= 1:
return False
parent_idx = inserted_idx // 2
if self.heap_array[inserted_idx] > self.heap_array[parent_idx]:
return True
else:
return False
def insert(self, data):
if len(self.heap_array) == 0:
self.heap_array.append(None)
self.heap_array.append(data)
return True
self.heap_array.append(data)
inserted_idx = len(self.heap_array) - 1
while self.move_up(inserted_idx):
parent_idx = inserted_idx // 2
self.heap_array[inserted_idx], self.heap_array[parent_idx] = self.heap_array[parent_idx], self.heap_array[inserted_idx]
inserted_idx = parent_idx
return True
이 코드는 최대 힙의 속성을 유지하도록 새로운 데이터를 힙에 삽입하는 로직을 추가한 것이다.
이렇게 함으로써 새로운 데이터를 힙에 삽입하면서도 힙의 속성 (즉, 모든 노드는 자식 노드들보다 크거나 같다는 속성)을 유지하도록 보장한다.
class Heap:
def __init__(self, data):
self.heap_array = list()
self.heap_array.append(None)
self.heap_array.append(data)
def pop(self):
if len(self.heap_array) <= 1:
return None
returned_data = self.heap_array[1]
return returned_data
이 코드는 힙에서 가장 큰 값을 반환하는 기본적인 동작을 구현한 것이다. 힙의 구조 상 가장 큰 값은 루트 노드에 위치하므로, pop 메서드를 호출하면 루트 노드의 값을 반환한다.
그러나 현재 코드는 데이터를 삭제하는 동작은 포함하지 않았으며, 오직 루트 노드의 값을 반환만 한다. 그리고 힙 배열의 길이가 1 이하일 경우, 즉 힙에 데이터가 없을 경우에는 None을 반환한다.
힙에서 데이터를 제대로 삭제하려면 루트 노드를 삭제한 뒤에 힙의 속성을 유지하도록 다시 조정하는 작업이 필요하다. 이 작업은 일반적으로 마지막 노드를 루트 노드로 이동시킨 뒤, 루트 노드에서부터 아래로 내려가며 자식 노드들과 비교하여 필요한 경우 위치를 교환하는 방식으로 이루어진다. 이러한 동작은 아직 구현되지 않았다.
heap = Heap(15)
heap.insert(10)
heap.insert(8)
heap.insert(5)
heap.insert(4)
heap.insert(20)
heap.heap_array # [None, 20, 10, 15, 5, 4, 8]
heap.pop() # 20
heap.heap_array # [None, 15, 10, 8, 5, 4]
class Heap:
def __init__(self, data):
self.heap_array = list()
self.heap_array.append(None)
self.heap_array.append(data)
def move_down(self, popped_idx):
left_child_popped_idx = popped_idx * 2
right_child_popped_idx = popped_idx * 2 + 1
# case1: 왼쪽 자식 노드도 없을 때
if left_child_popped_idx >= len(self.heap_array):
return False
# case2: 오른쪽 자식 노드만 없을 때
elif right_child_popped_idx >= len(self.heap_array):
if self.heap_array[popped_idx] < self.heap_array[left_child_popped_idx]:
return True
else:
return False
# case3: 왼쪽, 오른쪽 자식 노드 모두 있을 때
else:
if self.heap_array[left_child_popped_idx] > self.heap_array[right_child_popped_idx]:
if self.heap_array[popped_idx] < self.heap_array[left_child_popped_idx]:
return True
else:
return False
else:
if self.heap_array[popped_idx] < self.heap_array[right_child_popped_idx]:
return True
else:
return False
def pop(self):
if len(self.heap_array) <= 1:
return None
returned_data = self.heap_array[1]
self.heap_array[1] = self.heap_array[-1]
del self.heap_array[-1]
popped_idx = 1
while self.move_down(popped_idx):
left_child_popped_idx = popped_idx * 2
right_child_popped_idx = popped_idx * 2 + 1
# case2: 오른쪽 자식 노드만 없을 때
if right_child_popped_idx >= len(self.heap_array):
if self.heap_array[popped_idx] < self.heap_array[left_child_popped_idx]:
self.heap_array[popped_idx], self.heap_array[left_child_popped_idx] = self.heap_array[left_child_popped_idx], self.heap_array[popped_idx]
popped_idx = left_child_popped_idx
# case3: 왼쪽, 오른쪽 자식 노드 모두 있을 때
else:
if self.heap_array[left_child_popped_idx] > self.heap_array[right_child_popped_idx]:
if self.heap_array[popped_idx] < self.heap_array[left_child_popped_idx]:
self.heap_array[popped_idx], self.heap_array[left_child_popped_idx] = self.heap_array[left_child_popped_idx], self.heap_array[popped_idx]
popped_idx = left_child_popped_idx
else:
if self.heap_array[popped_idx] < self.heap_array[right_child_popped_idx]:
self.heap_array[popped_idx], self.heap_array[right_child_popped_idx] = self.heap_array[right_child_popped_idx], self.heap_array[popped_idx]
popped_idx = right_child_popped_idx
return returned_data
def move_up(self, inserted_idx):
if inserted_idx <= 1:
return False
parent_idx = inserted_idx // 2
if self.heap_array[inserted_idx] > self.heap_array[parent_idx]:
return True
else:
return False
def insert(self, data):
if len(self.heap_array) == 1:
self.heap_array.append(data)
return True
self.heap_array.append(data)
inserted_idx = len(self.heap_array) - 1
while self.move_up(inserted_idx):
parent_idx = inserted_idx // 2
self.heap_array[inserted_idx], self.heap_array[parent_idx] = self.heap_array[parent_idx], self.heap_array[inserted_idx]
inserted_idx = parent_idx
return True
이 코드는 힙 자료구조에서 데이터를 삭제하는 로직을 구현한 것이다. 데이터 삭제는 일반적으로 루트 노드(가장 큰 값 혹은 가장 작은 값)를 삭제하는 것이며, 삭제 후에는 힙의 속성이 유지되도록 재정렬이 이루어져야 한다.
구체적으로 pop 메서드를 보면, 먼저 가장 마지막에 추가한 노드(배열의 마지막 요소)를 루트 노드로 이동시키고, 원래의 루트 노드(배열의 첫 번째 요소)는 삭제합니다. 그런 다음, 루트 노드부터 아래로 내려가면서 루트 노드가 자식 노드보다 작을 경우, 두 노드의 위치를 바꿉니다. 이를 move_down 메서드를 통해 수행한다.
move_down 메서드는 삭제된 노드의 인덱스를 매개변수로 받아, 해당 노드의 왼쪽 자식 노드와 오른쪽 자식 노드의 인덱스를 구합니다. 그런 다음, 세 가지 경우를 검사한다.
이렇게 move_down 메서드를 통해 루트 노드부터 아래로 내려가면서 힙의 속성이 유지되도록 노드를 교환한다. 이 과정을 통해 삭제 작업이 완료되고, 삭제된 노드의 값을 반환한다.
마지막으로, insert 메서드는 데이터를 힙에 삽입하는 로직을 구현한 것으로, 새 데이터를 배열의 마지막에 추가하고 move_up 메서드를 통해 힙의 속성이 유지되도록 노드를 교환한다. 이 과정을 통해 삽입 작업이 완료된다.