⚠️ 이 글은 경북대학교 컴퓨터학부 이시형 교수님의 '알고리즘2' 수업을 수강하고 배운 점들을 복습하며 기록한 글입니다. 본문 내용을 비롯하여 사진 자료와 코드 또한 이시형 교수님의 수업자료에서 발췌하였습니다. 또한 모든 코드는 python을 사용합니다.
스택, 큐와 더불어 자주 쓰이는 자료구조인 우선순위 큐(Priority queue
) 에 대해서 알아본다.
우선순위 큐는 큐와 유사하게 데이터를 추가하고 삭제하는 기능이 있는데, 큐에서는 가장 먼저 들어온 원소를 먼저 삭제(First in, First out, FIFO)인 반면 우선순위 큐는 가장 우선순위가 높은 원소를 먼저 삭제한다.
'우선순위' 란 사용자의 편의에 맞게 다양하게 정의할 수 있다. 가장 큰 원소일 수도 있고 가장 작은 원소일 수도 있고, 혹은 가중치가 가장 큰 원소일 수도 있다. 이 글에서는 가장 작은 원소를 먼저 제거하는 min priority queue
를 다룬다.
따지고 보면 우선순위 큐는 큐와 스택을 모두 포괄할 수 있는 더 일반적인 개념이라고도 할 수 있다.
우선순위 큐를 구현하는 방법 중 첫 번째는 unordered list, 즉 정렬되지 않은 상태로 저장하는 방식이다.
일반 큐와 마찬가지로, 단순히 큐의 맨 뒤에 붙이면 된다. 정렬되지 않은 상태로 저장하기 때문에, 위치를 고려할 필요가 없다.
뒤에 갖다붙이기만 하면 되므로 필요한 시간은 ~1
이다.
큐의 원소들 중 가장 작은 원소를 찾기 위해 전체 큐를 탐색해야 한다. 찾은 최솟값을 반환한다.
필요한 시간은 전체 큐를 탐색하므로 ~N
이다.
def insert(self, key):
self.pq.append(key)
def delMin(self):
minId, min = None, None
for idx, item in enumerate(self.pq):
if minId == None or item < min:
minId, min = idx, item
del self.pq[minId]
return min
두 번째 방법은 큐 안의 원소들을 ordered, 정렬된 순서로 저장하는 방식이다.
이진 탐색 (binary search)를 이용하여 추가할 위치를 찾는 것은 logN
시간 만에 가능하지만, 그 뒤의 원소들을 한 칸씩 뒤로 밀어 주어야 하는데 그 비용이 N
에 비례한다.
즉 필요한 시간은 ~N
이다.
가장 작은 원소는 큐의 맨 첫 번째 원소이다. 그냥 삭제하면 된다.
뒤에 있는 원소들을 한 칸씩 당길 필요는 없다.
필요한 시간은 ~1
이다.
정리하자면, ordered list와 unordered list는 각각 장단점이 명확히 존재한다. ordered list는 삽입은 느린 반면 삭제가 빠르고, unordered list는 삽입은 빠른 반면 삭제가 느리다.
insert
와 delMin
두 작업의 비용은 1
과 N
인데, 이 두 작업의 비용을 모두 logN
으로 낮춘 것이 바로 이어서 살펴볼 heap
구조이다.
binary heap은 complete binary tree
이다. 다음의 두 가지 성질을 동시에 가진다.
binary tree
: 모든 노드의 자식이 2개complete tree
: 가장 아래 level을 제외한 모든 level이 빈 곳 없이 가득 차 있으며 가장 아래 level은 왼쪽부터 차례로 채워짐complete binary tree는 노드 수에 따라 유일한 모양을 가진다.
왜 tree를 사용하는 걸까??
각 level에 1개, 2개, 4개... n^k개의 노드가 존재하며, 그에 따라 전체 트리의 깊이는 logN
(N: 노드의 개수) 이다.
그리고 complete binary tree의 경우, 왼쪽부터 차곡차곡 쌓이는 구조이므로 트리가 불균형하게 한 쪽으로 치우치거나 하는 일이 없어 깊이를 안정적으로 logN
으로 유지할 수 있다.
complete binary tree의 또 하나의 장점은, 트리를 구성함에 있어 linked list가 아닌 1차원 배열에 담을 수 있어서 편리하다
는 것이다.
배열에 담으면 부모-자식 간의 링크를 만들지 않아도 index만으로 찾아갈 수 있다.
node i의
i//2
i * 2
i * 2 + 1
그리고 index 접근의 편의를 위해 첫 노드(root노드)를 index 0이 아닌 index 1에 저장한다. 0번부터 저장하면 식이 더 복잡해진다.
가장 큰 원소를 우선 삭제하는 max PQ라고 가정하면, 부모 key >= 자식 key 관계를 유지함으로써 항상 가장 큰 원소가 index 1에 위치하도록 만들 수 있다.
heap order를 항상 만족하도록 저장하면 insert
작업과 delMax
작업을 모두 logN
시간 만에 수행 가능하다.
heap에서의 insert는 다음의 세 가지 조건을 만족해야 한다.
logN
시간만에insert 하는 새 값을 k라고 할 때, 다음과 같은 과정을 거친다.
swap
하는 것을 반복하며 부모 노드를 따라 계속 올라간다.(swim up
) 만약 k보다 큰 부모를 만나면 그 위치가 k의 올바른 위치이다.먼저 S를 트리 맨 끝에 붙이고, 부모와 비교를 시작한다. S는 H보다 크므로 H와 S 자리를 swap 해 주고, 다시 S와 그의 부모인 P를 비교한다. S는 P보다도 크므로 한 번 더 swap 해 준다. 즉 최종 트리의 구조는 오른쪽과 같다.
def insert(self, key):
self.pq.append(key) # 맨 뒤에 append
idx = len(self.pq) - 1 # append 한 key의 index
while idx > 1 and self.pq[idx//2] < key: # pq[idx//2] (자신의 부모)와 비교해서 더 크면 swap
self.pq[idx], self.pq[idx//2] = self.pq[idx//2], self.pq[idx] # swap
idx = idx // 2 # 그리고 부모의 index로 수정(//2)함
logN
으로 제한된다.heap에서의 delMax 또한 다음 세 가지 조건을 만족해야 한다.
logN
시간만에 삭제트리의 최댓값을 삭제하는 delMax는 다음과 같이 구현한다.
자식 노드들 중 큰 쪽
과 비교하여 heap-order를 만족할 때까지 swap 하는 것 반복 (sink
)즉 heap order를 만족할 때까지 자식 노드들 중 큰 쪽을 따라서 계속해서 내려간다.
root를 삭제한 후에 자식 중 더 큰 쪽을 swim up 시키면 되지 않는가??
-> 그러면 complete binary tree 모양이 깨질 수 있다.
더 큰 자식을 찾는 이유는??
-> 둘 중에 더 작은 자식을 올리게 되면, 부모보다 자식이 더 커지게 되어 heap order가 깨지게 된다.
sink
해가는 과정에서 두 자식 중 큰 값과 swap 하므로 나머지 자식과의 대소 관계가 깨지지 않는다. 즉 전체 트리의 heap order도 깨지지 않않고 유지된다.logN
으로 제한되므로 비용(시간) 역시 logN
으로 제한된다.입력이 실시간으로 들어오는 상황이라면 정렬 대신 PQ를 사용하는 것이 더 효과적이다. PQ는 어떤 값을 추가할 때 logN
의 시간이 필요하지만 정렬의 경우 그 원소 이후의 원소들을 한 칸씩 밀어주어야 하므로 N
의 시간이 걸리기 때문이다.
반대로 정렬을 사용해도 되는 상황에는 PQ를 사용해도 큰 문제는 없다.
정렬하는 데 NlogN
시간이 걸린다면. PQ를 사용해도 logN
작업을 N
회 수행하므로 시간 소요는 비슷하다.
어떤 목표를 향해 시행착오를 거치며 탐색한다고 할 때, 목표 상태와 비교적 가까운 상태가 있을 것이다. 그러한 상태는 목표 상태에 도달할 가능성이 더 높다고 보고 우선적으로 계속하여 탐색한다.
이 때 각 상태를 PQ에 넣어두고 그때그때 가장 탐색할 가치가 있는
상태를 PQ에서 꺼내 탐색한다면 보다 효과적으로 목표 상태에 도달할 수 있다.