Content

  • Insert sort
  • Merge sort
  • Quick sort
  • Heap sort

Insert Sort

insert sort는 일반적으로 적은 수의 요소들을 정렬할 때 유리하다.

insert sort는 배열이 주어졌을 때, 순차적으로 순회하면서 각 요소의 올바른 위치로 정렬한다. 만약, i 번째까지 순회하였다면, 0 ~ i번째의 배열은 0 ~ i번째 배열의 성분 기준으로 모두 알맞은 위치에 정렬된 상태이다. i + 1번째 요소는 알맞게 정렬된 0 ~ i번째 배열기준으로 알맞은 위치로 삽입된다.

이를 의사코드로 나타내면 다음과 같다.

for i in range(1, len(A)):
    key = A[i] # 알맞은 위치로 가야하는 성분
    # Insert into the sorted sequence A[:i]
    j = i - 1
    while j > 0 and A[j] > key:
        A[j + 1] = A[j]
        j -= 1
    A[j + 1] = key
        

이해를 돕기 위해서 아래의 이미지를 첨부하였다.

Loop invariants and the correctness of insertion sort

위의 의사코드에서 i index는 현재 정렬해야 되는 성분을 의미하며, A[:i]는 0 ~ i - 1의 성분들을 의미한다. 이 때 A[:i ]는 정렬되기전의 A[:i]와 구성요소는 모두 같으면서 정렬된 배열을 의미한다. 이러한 성질을 CS에서는 loop invariants라고 한다. 그리고 이런 성질은 correcteness를 증명하는데 사용된다.

loop invariants는 3가지 조건을 충족해야한다.

  • Initialization: 첫 번째 순회에서 참이어야 한다.
  • Maintenance: 순회를 돌기전에 참이라면, 다음순회를 시작하기 전까지 참이어야 한다.
  • Termination: 전체 순회가 끝날때, loop invariant가 해당 알고리즘이 정확하다는 것을 알려줘야 한다.

예시를 들어보겠다.

A = [0, 3, 8, 2, 1, 4]

  • Initialization

    index번호가 1부터 시작한다.(A[i] = 3) 따라서 A[:i] = [0]이다. 그리고 이는 항상 참이다.\

  • Maintenance

    순회를 하는 동안 i는 1, 2, 3, 4 …의 값을 가질 것이다. 정렬된 subarray A[:i]는 기존의 정렬되지 않은 A[:i]와 구성요소는 같다. (Loop invariant)

  • Termination

    전체 순회가 종료되었을 때를 생각해보자. (i = len(A) - 1)

    순회를 할때마다, i는 1씩 증가하게 되므로, 알고리즘이 종료될 때는 i=len(A) - 1이 된다. 이때 subarray는 A[:i] 이므로 0 ~ len(A) - 2의 범위를 가지게 된다. (정렬된 상태이다.) 그러므로 마지막 성분 A[i]만 올바른 위치에 정렬하면 된다.

Analyzing algorithms

  • input size: array size
  • running time: 주어진 input에 대해서 주된 연산의 call 수

  • best case: O(N)/O(1)
    • 모두 정렬되어 있을 경우 $c_5 \sum_{j=2}^n t_j + c_6 \sum_{j=2}^n (t_j - 1) + c_7 \sum_{j=2}^n (t_j - 1)$ 은 무시해도 좋다.
  • worst case: O(N^2)/O(1)
    • 모두 거꾸로 정렬되어 있을 경우
  • order of growth: O(N^2)/O(1)
    • 해당 알고리즘의 upper bound

Merge sort: devide-and-conquer

devide-and-conquer 방법론은 문제를 본래의 문제와 유사한 작은 문제로 나누어 해결한다.

  • devide: problem -> subproblem, subproblem의 성질은 기존의 problem과 동일하다.
  • conquer: subproblem의 크기가 충분히 작다면, 해를 도출한다.
  • combine: subproblem을 해를 고려하여 상위의 problem의 문제를 해결한다.

merge sort는 전형적인 devide-and-conquer 방법론이다.

  • devide: array를 2개의 작은 array로 나눈다.
  • conquer: 두개의 작은 array를 재귀적으로 정렬한다.
  • combine: 두개의 정렬된 작은 array를 합치면서, 정렬된 array를 도출한다.

merge sort는 두 가지 방법으로 구현할 수 있다. 아래는 python으로 제작한 의사코드이다.

Top-Down approach: recursive


def merge(A, p, q, r):
    """
    A[p:q+1]와 A[q+1:r+1]을 합친다. 
    A: 합쳐지기 전 array, A[p:q+1], A[q+1:r+1]은 각각 정렬된 상태
    """
    arr, i = [0] * (r - p + 1), 0
    while p < q and q < r:
       	if A[p] < A[q]:
            arr[i] = A[p]
            p += 1
        else:
            arr[i] = A[q]
            q += 1
    
    if p < q - p - 1:
        arr[i+1:] = A[p:q+1]
    if q < r - q - 2:
        arr[i+1: ] = A[q+1:r+1]
    A[p:r+1] = arr


def merge_sort(A, p, r):
    '''
    A: 정렬되지 않은 array
    p: left index
    r: right index
    '''
    if p < r:
        q =  (p + r) // 2
        merge_sort(A, p, q)
        merge_sort(A, q + 1, r)
        merge(A, p, q, r)
    

Bottom-up approach: iterative

def merge(A, p, q, r):
    """
    A[p:q+1]와 A[q+1:r+1]을 합친다. 
    A: 정렬되지 않은 array
    """
    arr, i = [0] * (r - p + 1), 0
    while p < q and q < r:
       	if A[p] < A[q]:
            arr[i] = A[p]
            p += 1
        else:
            arr[i] = A[q]
            q += 1
    
    if p < q - p - 1:
        arr[i+1:] = A[p:q+1]
    if q < r - q - 2:
        arr[i+1: ] = A[q+1:r+1]
    A[p:r+1] = arr


group_size = 1
while group_size < len(A):
   	i = 0
    while i < len(A):
        merge(A, i, i + group_size, i + 2*group_size + 1)
        i += group_size * 2 + 2
    group_size *= 2
        
    
    

Loop invariant

insert sort와 마찬가지로 loop invariant 성질을 이용하여 corrteness를 증명할 수 있다.

  • Initialization: 초기에는 subarray가 비워진 상태로 시작한다.
  • Maintainance: 위의 의사코드에서 알 수 있듯이 subarray의 성분의 index는 바뀌지만 전체 성분은 변하지 않는다.
  • Termination: 마지막 두 개의 subarray를 합칠 때를 고려해보자. 각각의 subarray는 정렬되어 있는 상태이다. 따라서 merge 알고리즘을 그대로 적용한다면, 결과는 모두 정렬되어 나올 것이다.

Analyzing divide-and-conquer algorithms

recursive한 알고리즘을 사용했다면, running time을 recurrence equation을 활용해서 표현할 수 있다. 아래는 예시이다.

  • subarray가 충분히 작다면: $\mbox{if } n > c$
  • $T(n/b)$: $n/b$ subarray의 크기, $a$ 는 subarray의 개수
  • $D(n)$: array를 subarray로 나누는데 걸리는 시간
  • $C(n)$: combine

이제 merge sort를 분석해보자.

  • divide: $D(n) = O(1)$, subarray의 중간까지만 나눈다.
  • conquer: $2T(N/2)$
  • combine: $C(n) = O(n)$

  • time complexity: O(nlogn)
  • space complexity: O(nlogn)

Quick Sort

  • worst case: O(n ^ 2)

  • expected: O(nlogn) constant factor가 다른 기법에 비해서 작은 편이다.

quick sort 역시 divide-and-conquer의 한 방법이다.

  • Divide: Array를 두개의 subarray로 나눈다. $A[p…r]$을 $A[p..q-1]$,$A[q + 1.. r]$

    로 나누는 것이다. $A[p..q-1]$은 $A[q]$보다 작거나 같다. 또한 $A[q + 1.. r]$은 $A[q]$ 보다 크거나 같다. 나중에 언급하지만 $A[q]$는 pivot이다.

  • Conquer: $A[p..q-1]$,$A[q + 1.. r]$를 재귀적으로 정렬한다.

  • Combine: subarray가 모두 정렬되어 있기 때문에, 따로 결합할 필요가 없다.

quick sort의 의사코드는 아래와 같다.

def quick_sort(A, p, r):
    if p < r:
        q = partition(A, p, r)
        quick_sort(A, p, q - 1)
        quick_sort(A, q + 1, r)
        
def partition(A, p, r):
    x = A[r] # pivot value
    i = p - 1
    for j in range(p, r-1):
        if A[j] <= x: # pivot보다 왼쪽에 위치해야한다.
            i += 1
            A[i], A[j] = A[j], A[i]
   	A[i+1], A[r] = A[r], A[i + 1]
    return i + 1

partition이 이해가 안되다면 아래의 그림을 참고해보면 좋다. 참고로 i index는 pivot보다 작거나 같은 값을 가졌다는 것을 알려주며, j index는 탐색하는 의도로 구성되었다.

Loop invariant

  • Initialization

    i = p - 1 이고 j = p 이다. p와 i 사이에는 value가 없다. 그리고 i + 1과 j- 1사이에도 아무것도 없다. 따라서 loop invariant 조건을 충족한다. (swap이 일어나지 않는다.)

  • Maintainance

    위의 그림에서 볼 수 있듯이, pivot value에 따라서 성분의 배치가 달라질 수 있다. 하지만, 배치만 달라질뿐 전체 성분은 변하지 않는다.

  • Termination

    알고리즘이 종료될 때는 3가지 set이 주어진다. pivot보다 작은 set, pivot, pivot보다 큰 set 이렇게 주어지게 되는데, 이는 내부적으로 모두 정렬된 상태이다.

Performance of quicksort

  • worst-case

    partitioning이 1: n-1식으로 계속해서 나눠지면 최악의 성능 O(N^2)이다.

  • balanced partioning

    partitioning이 모두 고루게 나눠지면 성능은 O(NlogN)이다.