우선순위 큐

‘우선순위 큐(Priority Queue)’는 집합 S를 구현하는 자료 구조이다. 각 요소들은 우선순위를 뜻하는 키(key)를 가지고 있다. 우선순위 큐에서는 다음과 같은 작업들을 할 수 있다.

작업
insert(S, x) 요소 x를 집합 S에 삽입
max(S) 최대 키인 S의 요소를 반환
extract_max(S) 최대 키인 S의 요소를 반환하고 집합 S에서 제거
increase_key(S, x, k) 요소 x의 키를 새로운 값 k로 증가시킴.(k가 현재 값만큼 크다고 가정)


힙은 우선순위 큐를 완전 이진 트리로 시각화한 것이다.


위와 같은 우선순위 큐를 아래와 같은 트리로 표현한 것을 힙이라고 한다. 각 노드들에 번호(인덱스)가 있으며 그에 맞는 키를 노드에 할당한다.

힙을 조금 더 엄밀하게 정의하면 다음과 같다.

  • 트리의 루트(root): 배열의 첫 요소(i=1)
  • parent(i) = i/2: 부모 노드의 인덱스는 자식의 절반
  • left(i) = 2i: 왼쪽 자식 노드의 인덱스는 부모 인덱스의 두 배
  • right(i) = 2i + 1: 오른쪽 자식 노드의 인덱스는 (부모 인덱스의 두 배 + 1)

최대 힙은 노드의 키가 자식 값들의 키보다 큰 것을 말한다. 위 힙을 살펴보면 자식 노드들의 키가 부모 노드보다 항상 작은 것을 볼 수 있다. 예를 들어 4번 노드를 보면, 2와 4는 8보다 작다.

힙의 높이는 $\log{n}$ 이다. 나중에 유용하게 사용될 특징이다.

힙에서는 다음과 같은 연산을 할 수 있다.

작업
build_max_heap 정렬되지 않은 배열로부터 최대 힙 만들기
max_heapify(힙, 인덱스) 최대 힙화. 인덱스의 서브 트리에서 힙 특성을 위반한 것을 한 가지 고친다.


max_heapity

말 그대로 최대 힙을 만족하도록 만드는 것을 최대 힙화(max heapify)라고 한다.

최대 힙화에는 몇 가지 가정과 전제 조건이 붙는다. 먼저, 왼쪽과 오른쪽 서브 트리들이 모두 최대 힙이라고 가정한다. 또한, 만약 요소 A[i]가 최대 힙 특성을 위반한다면, 요소 A[i]를 트리 아래로 보내면서(강의 노트에서는 ‘흘러 내려가도록’이라고 표현했다) i의 서브 트리들을 최대 힙으로 만든다.

최대 힙화의 예시를 살펴보자. 아래와 같은 힙이 있다.

힙의 사이즈는 10이다(나중에는 힙의 크기도 중요하게 사용할 것이다).

max_heapify(A, 2)를 적용하면 어떻게 될까? A[2]와 A[4]를 교환할 것이다. 이제 A[2] 노드는 그 자체만으로는 최대 힙을 만족한다.

그러나 A[4]는 최대 힙을 위반하고 있다. 그래서 우리는 max_heapify(A, 4)를 할 것이다.

A[4]와 A[9]를 맞교환 했다. 이제 최대 힙 조건을 만족하는 힙이 되었다.

최대 힙화의 의사코드는 다음과 같다.

l = left(i) #왼쪽 자식의 인덱스
r = right(i)  #오른쪽 자식의 인덱스

if l <= heap-size(A) and A[l] > A[i]:
    largest = l 
else:
    largest = i

if r <= heap-size(A) and A[r] > A[largest]:
    largest = r

if largest != i:
    exchange A[i] and A[largest]
    max_heapity(A, largest) #서브 트리로 계속 내려간다

최대 힙화의 시간은 어떻게 될까? 일단 힙은 완전 이진 트리로 높이가 $\log{n}$ 이다. 그리고 앞서 살펴본 전제 조건에 의해, 우리는 모든 노드를 살펴볼 필요 없이(만약 그랬다면 $O(n)$ 이었겠지만) 조건을 만족하지 않는 노드만 본다. 따라서 시간은 $O(\log{n})$ 이다.


build_max_heap

‘build_max_heap’은 A[1, …, n]을 최대 힙으로 변환하는 것이다. 다음과 같이 의사코드로 표현할 수 있다.

Build_max_heap(A):
  for i = n/2 downto 1:
    do max_heapify(A, i)

왜 n/2부터 시작할까? 트리 가장 아래에 있는 단말 노드들은 자식들이 없기 때문에 살펴볼 필요가 없기 때문이다.

build_max_heap은 시간이 얼마나 걸릴까? 간단하게 생각해보면 $O(n\log{n})$ 라고 할 수 있다. 그러나 이는 정확하지 않다! 그럼 정확하게 분석을 해보자.

먼저 단계에 따른 시간을 생각해본다. 단말 노드 바로 위에 있는 노드(1단계 노드)들에서는 max_heapify에 O(1)의 시간이 걸린다. 그럼 $l$단계 노드들은 어떨까? $O(l)$ 의 시간이 걸린다.

노드의 개수도 생각해볼까? 1단계에는 n/4 개의 노드, 2단계에서는 n/8 개의 노드, 점점 줄어들어 마지막에를 트리의 루트 1개만 남을 것이다.

전체 단계는 몇 단계인가? 앞서 보았듯이 $\log{n}$ 단계이다.

그럼 build_max_heap에서 하는 전체 일의 양은 다음과 같이 계산할 수 있을 것이다. (c는 상수)

\[n/4(1 \quad c) + n/8(2 \quad c) + n/16(3 \quad c) + ... + 1(\log{n} \quad c)\]

n/4를 $2^k$ 라고 가정한다면 식이 다음과 같이 바뀐다.

\[c \; 2^k(1/2^0 + 2/2^1 + 3/2^2 + ... + (k+1)/2^k)\]

괄호 안의 식은 결국 상수로 한정(수렴)된다. $2^k$를 다시 n/4로 바꾸어 보자. 결론적으로, build_max_heap의 복잡도는 O(n)이다.

build_max_heap의 예시를 살펴보자. 다음과 같은 힙이 있다.

n/2부터 시작하므로, max_heapify(A, 5)를 할 것이다. 5번 노드를 보면 최대 힙을 잘 만족하므로 별 문제 없이 넘어간다.

다음으로 max_heapify(A, 4)를 한다. 최대 힙을 만족하지 않으므로, A[4]와 A[8]을 바꾼다.

다음으로 max_heapify(A, 3)를 한다. A[3]과 A[7]이 바뀌게 될 것이다.

max_heapify(A, 2)는 어떨까? 먼저 A[2]와 A[5]가 바뀐다. 그리고 A[5]와 A[10]도 바뀌게 될 것이다.

최종적으로 정리된 힙은 다음과 같다.


힙 정렬

힙을 이용하면 정렬을 쉽게 수행할 수 있다. 전략은 다음과 같다.

  1. 정렬된 배열에서 최대 힙을 만든다.
  2. 최대 요소 A[1]을 찾는다.
  3. 요소 A[n]와 A[1]을 스왑한다. 최대 요소가 배열 끝(단말 노드)으로 갈 것이다.
  4. 힙에서 노드 n을 제거한다.
  5. 새로운 루트는 최대 힙 특성을 위반할 것이므로, max_heapify로 해결한다.
  6. 힙이 비어있지 않다면 2단계로 돌아간다.

예시로 이해해보자.

3번 단계에 따라 A[10]과 A[1]을 스왑한다. 그리고 4단계에 따라 노드 10을 제거한다.

최대 힙을 만족하지 않으므로 max_heapify(A, 1)을 한다. 그 다음에는 또 A[9]과 A[1]을 스왑하고 노드 9를 제거한다.

이를 계속 반복해서 최종적으로 힙이 비게 되면, 우리는 정렬된 배열을 얻게 된다.

힙 정렬의 실행 시간은 어떻게 될까? n번 반복한 후 힙이 비게 되며, 각 단계는 max_heapify를 포함하므로 $O(\log{n})$ 이 걸린다. 즉, 힙 정렬은 $O(n\log{n})$ 의 시간이 걸린다.



별도의 출처 표시가 있는 이미지를 제외한 모든 이미지는 강의자료에서 발췌하였음을 밝힙니다.

댓글남기기