Loading [MathJax]/jax/element/mml/optable/MathOperators.js
 

정렬 알고리즘의 시간 복잡도 증명 - 삽입, 퀵, 머지, 힙 정렬

안녕하세요. 이번 글에서는 여러 정렬 알고리즘의 시간 복잡도를 증명해보겠습니다.

 

혹시 자료구조를 배우시는 분들이 계시다면, 시험볼 때 참고하시면 좋을 것 같습니다.

 

이 글은 LATEX 문법이 잔뜩 적용되어 있어서, MathJax 스킨이 적용되어야 편하게 보인답니다.

혹시 Tistory 모바일 앱이나, 혹은 모바일 페이지 주소(nx006.tistory.com/m/67)로 접속이 되었을 경우, 인터넷 브라우저(Tistory 앱 X)에서 여기 주소로 다시 접속해주세요: https://nx006.tistory.com/67

정렬 알고리즘의 종류

이번 글은 정렬 알고리즘의 종류에 대해서 다루는 글은 아닙니다. 여기서는 간략하게 소개만 하고 넘어가겠습니다.

 

가장 대중적으로 많이 사용되는 정렬 알고리즘은 시간 복잡도에 따라서 크게 두 가지로 나뉩니다.

  • 구현이 간단하나 시간 복잡도가 O(n2)인 알고리즘
  • 구현이 복잡하나 시간 복잡도가 O(nlogn)인 알고리즘

O(n2) 알고리즘의 경우 대표적으로 삽입 정렬이 있고, O(nlogn)인 알고리즘의 경우 대표적으로 힙 정렬(Heap Sort), 합병 정렬(Merge Sort), 퀵 정렬(Quick Sort)이 있습니다.

  • 힙 정렬은 힙(Heap) 자료구조를 이용하는 정렬 알고리즘입니다.
  • 합병 정렬은 파티셔닝 기술을 이용해서 정렬하고자 하는 대상을 분할한 후, 이를 다시 합치는 과정을 통해서 정렬을 수행합니다.
  • 퀵 정렬은 피벗(Pivot)을 이용하는 알고리즘으로, 정렬의 기준점이 되는 피벗을 이리저리 옮겨가는 과정을 통해서 정렬을 수행합니다.

알고리즘 별 시간 복잡도 증명하기

글의 핵심인 알고리즘 별 시간 복잡도를 증명해보겠습니다.

 

증명해보고자 하는 주요한 알고리즘은 삽입 정렬, 퀵 정렬, 합병 정렬, 힙 정렬입니다.

 

이중에서 가장 중요한 알고리즘은 퀵 정렬입니다. 컴퓨터공학과 학생이고 자료구조 기말 시험을 치르러 간다면, 다른 정렬 알고리즘은 놓쳐도 퀵 정렬은 전부 외우고 가는 것이 좋습니다. 그렇기에 이 글에서도 퀵 정렬에 대해서만 자세히 다루고, 나머지 정렬 알고리즘은 간단하게만 다루고 넘어가겠습니다.

삽입 정렬(Insertion Sort)

우선 가장 쉬운 삽입 정렬(Insertion Sort)의 시간 복잡도에 대해서 증명해보겠습니다.

 

삽입 정렬은 구현이 단순하지만 시간 복잡도가 O(n2)인 알고리즘입니다.

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and key < arr[j]:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key

삽입 정렬의 과정
삽입 정렬의 과정

우선 시간 복잡도를 증명할 때 먼저 세 가지 시나리오로 상황을 가정하고 각각 증명할 수 있어야 합니다.

  1. 최악의 경우(Worst Case): 알고리즘이 수행될 때, 시간이 가장 오래 걸리는 최악의 시나리오를 가정합니다. 흔히 시간 복잡도를 말한다면 최악의 경우 시간 복잡도(Worst Case Time Complexity)를 의미합니다.
  2. 최선의 경우(Best Case): 알고리즘이 수행될 때 가장 이상적인 시나리오를 가정합니다. 일반적으로 시간 복잡도를 이야기할 때 최선의 경우는 고려하지 않습니다.
  3. 평균적인 경우(Average Case): 일반적인 경우에 알고리즘이 수행될 때, 평균적으로 어느 정도의 시간이 걸릴 지를 고려합니다.

또한, 시간 복잡도를 계산할 때 가장 중요하게 생각해보아야 하는 것은 ‘비교’와 ‘이동’ 연산입니다.

최악의 경우

삽입 정렬은 배열의 각 요소를 이미 정렬된 부분 배열에 올바른 위치에 '삽입'하는 방식으로 작동합니다.

 

삽입 정렬에서의 최악의 경우는 배열이 역순으로 정렬되어 있을 때입니다. 배열이 역순으로 정렬되어 있을 때, 개별 요소를 이미 정렬된 앞 단의 부분까지 이동시켜야 합니다. 즉 i 번째 반복에서 i 번의 비교와 이동 연산이 수행됩니다.

 

따라서 최악의 경우 삽입 정렬은 n1i=1i=1+2++(n1)=n(n1)2 의 이동 연산을 수행하게 됩니다. 따라서 최악의 경우 시간 복잡도는 O(n2) 입니다.

최선의 경우

최선의 경우는 배열이 이미 정렬이 된 상태입니다. 이 경우에는 n1 번의 비교 연산만 선형적으로 일어나게 됩니다. 이동은 필요 없습니다. 따라서 최선의 경우 시간 복잡도는 O(n)입니다.

평균적인 경우

평균적인 경우에 각 새로운 원소가 배열의 정렬된 부분의 중앙으로 가게 될 것입니다. 이 경우에도 O(n2)의 시간이 필요하게 됩니다.

평균적이 경우의 시간 복잡도를 정확히 증명하려면 확률과 통계의 수학적인 기술이 필요해서, 일반적으로 1, 2학년 수준의 자료구조와 프로그래밍 과목에서는 다루지 않습니다. 그러나 필요하다면 아래의 흐름대로 평균적인 경우를 증명할 수 있습니다.

  1. 비교 횟수의 기댓값 구하기
  2. 각 원소가 n개의 배열에서 고르게 분포되어 있을 때(evenly distributed), 평균적으로는 중간 어딘가에 위치합니다. 각 i 번째 원소를 정렬된 배열에 새로 삽입할 때 i2 번의 비교 연산이 필요함을 보일 수 있습니다.
  3. 전체 비교 횟수의 합12+22++n2=12(1+2++n)=n(n+1)4
  4. 비교 횟수의 기대값을 이용하면, 전체 비교 횟수의 합은 다음과 같이 됩니다:
  5. 위 식을 Big-O notation으로 나타내면 O(n2)이 됩니다.

엄밀한 수학적 증명이 필요하다면 따로 찾아보는 게 좋을 것 같습니다.

퀵 정렬(Quick Sort)

가장 중요한 알고리즘입니다. 시험을 본다면, 다른 알고리즘은 몰라도 퀵 정렬은 외우고 가시는 게 좋습니다.

 

퀵 정렬은 시간 복잡도가 O(nlogn)입니다. 분할 정복(Divide and Conquer) 방법을 사용해서 정렬을 수행합니다. 정렬의 기준이 되는 피벗을 옮겨가면서 정렬을 수행합니다.

def quicksort(arr, low, high):
    if low < high:
        pivot_index = partition(arr, low, high)
        quicksort(arr, low, pivot_index)
        quicksort(arr, pivot_index + 1, high)

def partition(arr, low, high):
    pivot = arr[low]
    left = low + 1
    right = high
    done = False
    while not done:
        while left <= right and arr[left] <= pivot:
            left = left + 1
        while arr[right] >= pivot and right >=left:
            right = right -1
        if right < left:
            done= True
        else:
            arr[left], arr[right] = arr[right], arr[left]
    arr[low], arr[right] = arr[right], arr[low]
    return right

참고로 피벗을 정할 때 어떻게 정하느냐에 따라서 퀵 정렬 알고리즘의 성능 역시 크게 달라지게 됩니다. 그냥 단순히 피벗을 서브 리스트의 맨 왼쪽 원소로 선택하는 경우 최악의 경우 O(n2)의 시간 복잡도가 나오게 되는데, 선택 과정에 변형을 주어 Median 방식을 선택할 경우 최악의 경우의 시간 복잡도를 방지할 수 있습니다. 그뿐만 아니라 실제 성능 역시 올라가게 됩니다.

 

그러나 이번 글에서는 이 과정에 대해서는 다루지 않겠습니다.

 

참고로 퀵 정렬에서만 시간 복잡도를 이야기할 때 최악의 경우가 아닌 평균적인 경우를 이야기합니다. 최악의 경우가 발생될 확률은 인위적으로 만들지 않는 이상 거의 0에 수렴하기에, 최악의 경우를 따지는 건 큰 의미가 없습니다.

 

퀵 정렬의 핵심은 Divide and Conquer, 즉 어떻게 원래의 배열을 잘게 쪼개서 서브 리스트로 만들 것이냐입니다.

평균적인 경우

가장 기본적인 방식은, quicksort가 재귀 함수로 이루어져 있으므로, 다음 식을 유도해나가면 됩니다.

T(n)cn+2T(n2)(for some constant c)cn+2(cn2+2T(n4))2cn+4T(n4)cnlog2n+nT(1)=O(nlog2n)

W.T.S(What to Show): Tavg(n)을 퀵 정렬이 원소를 정렬하는데 걸리는 예상 시간(expected time, 혹은 기댓값)이라 합니다. n2에 대해서 Tavg(n)knlnn 을 만족하는 k가 존재함을 보이시오.

증명:

quicksort(arr, 1, n) 호출 시, 피벗이 j 위치에 놓이게 된다고 가정합니다. 이때 크기가 각각 j1,nj인 두 서브 리스트를 정렬하는 문제로 나눌 수 있습니다. 각각의 서브리스트에 대해서 정렬 expected time은 각각 Tavg(j1), Tavg(nj) 입니다.

함수의 나머지는 어떤 상수 c에 대해서 최대 cn의 시간이 걸립니다. j1에서부터 n까지의 범위에서 똑같은 확률로 임의의 값을 취하기 때문에, n2 에 대해서 다음 식을 얻습니다.

eq. 1:

Tavg(n)cn+1nnj=1(Tavg(j1)+Tavg(nj))=cn+2nn1j=0Tavg(j)

임의의 상수 b에 대해서 Tavg(0)bTavg(1)b 라고 가정한다면 Tavg(0)+Tavg(1)2b 입니다.

이제 우리는 n2 이고 k=2(b+c)일 때 Tavg(n)knlnn 을 보일 것입니다.

이 과정에서 우리는 n에 대해서 수학적 귀납법을 사용합니다.

  • Induction base: n=2 에 대해서 eq. 1에 의해서 Tavg(2)2c+2bknloge2
  • Induction hypothesis: 1n<m에 대해 Tavg(n)knlnn 이라고 가정
  • Induction step:

eq. 1과 induction hypothesis(귀납 가정)에 의해서, 아래 식을 얻을 수 있습니다.

eq. 2:

Tavg(m)cm+4bm+2mm1j=2Tavg(j)cm+4bm+2kmm1j=2jlnj

이때 jlnjj에 대해서 증가 함수이기 때문에, eq. 2에서 아래 식을 유도할 수 있습니다.

Tavg(m)cm+4bm+2kmm2xlnxdx=cm+4bm+2kmuse partial integral(m2lnm2m24)=cm+4bm+kmlnmkm2kmlnm, for m2

따라서 Tavg(n)knlnn 이므로 O(nlogn)으로 나타낼 수 있습니다.

 

위의 유도 과정에서 m2xlnxdx의 적분이 왜 m2lnm2m24 로 유도되는 지 의문이 드신다면, 이는 Partial Integral(부분 적분)을 사용한 것입니다. 고등학교, 혹은 대학수학(1) 등의 과목에서 Partial을 배우지 않았다면, 찾아보시기 바랍니다(도표 적분이라고도 합니다).

최선의 경우

가정 : 배열 안의 모든 데이터는 무작위로 배치되어 있다고 가정합니다. 즉, n개의 데이터를 대상으로 각 partition이 일어날 때 선택된 pivot에 대해 pivot보다 작은 데이터가 0개, 1개, …, n1개 있을 확률이 모두 1n이라 합니다.

 

n개의 원소인 배열을 정렬할 때 걸리는 수행 시간을 T(n)이라 합니다.

 

재귀 호출이 진행되기 전에 비교 연산을 수행할 때 걸리는 시간을 S(n)이라 합니다. 단, Quick Sort는 S(n)=n임이 성립합니다.

quicksort에서는 다음이 성립합니다. 식(1):

T(n)=2T(0)+...+T(n1)n+(n1)

(맨 마지막에 S(n)=n이 아닌 (n1)이 오는 이유는, 원소가 한 개 있는 경우는 비교 연산이 의미가 없으므로 하나를 제외했기 때문입니다. 큰 신경 안 써도 됩니다.)

 

한편, n=0, n=1일 때는 정렬할 필요가 없으므로, T(0)=T(1)=0 입니다.

 

위 식을 정리하면 식(2):

nT(n)=2T(0)+...+T(n2)+T(n1)+n(n1)

한편 n-1에 대해 식(3):

(n1)T(n1)=2T(0)+...+T(n2)+(n1)(n2)

식(2)와 식(3)을 빼면 식(4):

nT(n)(n1)T(n1)=2T(n1)+2(n1)

위를 nT(n)에 관해 정리하면((n1)T(n1)을 우항으로 넘기면)

nT(n)=(n+1)T(n1)+2(n1)

n(n+1)으로 위 식을 나눕시다.

T(n)n+1=T(n1)n+2(n1)n(n+1)=T(n1)n+2(n1)(1n1n+1)=T(n1)n+2(n1)n2(n1)n+1,(

이때 \cfrac{B-A}{AB} = \cfrac{1}{A}-\cfrac{1}{B} 를 이용해서 식을 전개한 것입니다. 헷갈리지 않으시길 바랍니다.

같은 방식으로, \cfrac{T(n-1)}{n} 을 다음과 같이 표현할 수 있습니다:

\cfrac{T(n-1)}{n}=\cfrac{T(n-2)}{n-1}+\cfrac{2(n-2)}{n-1}-\cfrac{2(n-2)}{n}

그러면 위의 수식을 다음과 같이 바꿔 쓸 수 있습니다:

\cfrac{T(n)}{n+1}=\cfrac{T(n-2)}{n-1}+\cfrac{2(n-2)}{n-1}-\cfrac{2(n-2)}{n}+\cfrac{2(n-1)}{n}-\cfrac{2(n-1)}{n+1}

한편 또다시 \cfrac{T(n-2)}{n-1}=\cfrac{T(n-3)}{n-2}+\cfrac{2(n-3)}{n-2}-\cfrac{2(n-3)}{n-1}에 대해 위 식을 다시 정리하면

\cfrac{T(n)}{n+1}=\cfrac{T(n-3)}{n-2}+\cfrac{2(n-3)}{n-2}-\cfrac{2(n-3)}{n-1}+\cfrac{2(n-2)}{n-1}-\cfrac{2(n-2)}{n}+\cfrac{2(n-1)}{n}-\cfrac{2(n-1)}{n+1}

이 됩니다.

계속해서 정리하면:

\cfrac{T(n)}{n+1}=\cfrac{T(n-k)}{n-k+1}+2\left(\cfrac{1}{n-k+1}+...+\cfrac{1}{n}\right)-\cfrac{2(n-1)}{n+1}, (k\in\mathbb{N}\cap[1,n-1])

k=n-1 일 때:

\cfrac{T(n)}{n+1} = \cancelto{0}{\cfrac{T(1)}{2}} + \cfrac{2}{2} + \cfrac{2}{3} + \cdots + \cfrac{2}{n} - \cfrac{2(n-1)}{n+1} = \cancelto{0}{\cfrac{T(1)}{2}} + 2\left(\cfrac{1}{2} + \cdots + \cfrac{1}{n}\right) - \cancelto{c=2}{\cfrac{2(n-1)}{n+1}}

\because\lim_{n\to\infty}\cfrac{2(n-1)}{(n+1)}=2, T(1)=0\Longrightarrow\therefore \cfrac{T(n)}{n+1}\approx 2\left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)

한편 2\left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)에 대해 y=\cfrac{1}{x}(x\in\mathbb{R})을 그래프로 표현하면 아래 그림과 같이 표현할 수 있습니다.

y=1/x의 그래프
y=1/x의 그래프

각각 직사각형이 (n, f(n)), (n, f(n+1))인 경우를 따로 따로 나누어서 분석해봅시다 (구분구적법 쓰는 거 맞습니다).

  • 높이가 f(n)인 직사각형

높이가 f(n)일 때
높이가 f(n)일 때

위 그림에서 직사각형들의 넓이의 합은 \left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)입니다. 이때 직사각형 넓이의 합이 x=2부터 x=n+1까지 y의 적분값보다 크므로 아래 식을 구할 수 있답니다:

\left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)=\sum_{k=2}^{n+1}{\cfrac{1}{k}}>\int_{2}^{n+1}{\frac{1}{x}dx}=\ln{(n+1)}-\ln{2}

  • 높이가 f(n+1)인 직사각형

높이가 f(n+1)일 때
높이가 f(n+1)일 때

곧 직사각형 넓이의 합이 x=1부터 x=n까지 함수 y의 적분값보다 작으므로 아래 식이 성립합니다:

\left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)<\int_{1}^{n}{\frac{1}{x}dx}=\ln{(n)}-\cancelto{0}{\ln{1}}

따라서 압축 정리(Squeeze theorem, 혹은 샌드위치 정리)에 의해서 다음이 성립한답니다:

\ln{(n+1)}-\ln{2}<\left(\cfrac{1}{2}+...+\cfrac{1}{n}\right)<\ln{(n)}

\sum_{k=2}^{n+1}{\cfrac{1}{k}}=\ln{n}

\cfrac{T(n)}{n+1}2\ln{n} 꼴이므로

\begin{aligned} T(n) &= 2(n+1)(\ln n) \\ & = \cfrac{2}{\log_2 e} (n+1) \log_2 n \\ & \Longleftrightarrow T(n) = O(n \log_2 n) \end{aligned}

이렇게 해서 최선의 경우 시간 복잡도를 구할 수 있습니다.

최악의 경우

최악의 경우의 시간 복잡도는 다음 가정을 해서 구합니다.

 

가정 : n개의 데이터에 대해 매번 pivot이 선택될 때마다, 해당 pivot의 우선순위가 가장 높다고 합니다.

 

위 가정은 Pivot이 매번 맨 왼쪽에 가있어서, 서브 리스트로 Divide and conquer가 일어나지 않도록 막는 역할을 합니다.

최악의 경우 시나리오
최악의 경우 시나리오

이 경우 partition이 일어나지 않으므로, 매 n회의 데이터에 대해 n회 partition이 일어나고, 각 partition마다 S(n)=n회의 비교연산이 일어나므로

 

\begin{aligned} T(n) &= n+(n-1)+(n-2)+(n-3)+\ldots+2 \\ & = \sum_{k=1}^{n}{k}-1 \\ & = \cfrac{n(n+1)}{2}-1 \end{aligned}

 

\therefore T(n)=O(n^2)

이렇게 보일 수 있습니다.

합병 정렬(Merge Sort)

Merge Sort는 Quick Sort와 다르게 최선, 최악, 평균의 시간 복잡도가 모두 동일합니다.

 

합병 정렬은 다음 순서대로 진행됩니다.

  1. 분할: 리스트를 두 서브 리스트로 분할합니다. 이때는 단순 분할이므로 상수 시간인 O(1)의 시간이 소요됩니다.
  2. 정복: 두 개의 서브 리스트를 정복합니다. 이때 시간 복잡도는 재귀적으로 계산되며, 각 서브 리스트에 대해서의 합병 정렬 시간 복잡도의 SUM입니다.
  3. 합병: 두 개의 서브 리스트를 다시 합칩니다. 이때는 N개 원소에 대해서 단순히 순회하는 과정을 거치므로 O(n)입니다.

n개 원소를 정렬하는데 걸리는 시간을 T(n)이라 해봅시다. T(n)을 다음과 같이 나타낼 수 있습니다.

 

\begin{aligned} T(n)&=O(1)+2T\left(\cfrac{1}{2}\right)+O(n) +O(1) \\ &= 2T\left(\cfrac{1}{2}\right)+O(n) \end{aligned}

이제, 각 부분 배열을 또다시 두 개의 서브 리스트로 나누어 봅시다. 이때 나누어진 서브 리스트의 크기는 1보다는 커야 합니다.

 

\begin{aligned} \forall c\in\mathbb{R}, T(n) & =2\left(2T\left(\cfrac{n}{4}\right)+\cfrac{cn}{2}\right)+cn \\ & = 4T\left(\cfrac{n}{4}\right)+2cn \end{aligned}

이제 여기서 n=2^k 라 해봅시다. 그러면 k=\log_2 n 으로 나타낼 수 있습니다.

 

그리고 위의 식에 n과 k를 대입합니다. 그러면 T(n)=2^k\left(T\left(\cfrac{n}{2^k}\right)\right)+kcn 이 나오게 됩니다.

 

따라서 다시 n에 대해서 나타낸다면(k=\log_2 n 대입),

T(n) = \cancelto{n}{2^k} \left( T \left( \cfrac{n}{\cancelto{n}{2^k}} \right) \right) + cn\log_2 n

입니다. 곧 T(n)=nT(1)+n\log n=O(n\log n) 으로 나타낼 수 있습니다.

힙 정렬(Heap Sort)

힙 정렬은 말 그대로 힙(Heap) 자료 구조를 이용해서 정렬을 수행하는 알고리즘입니다. 힙에 넣고 빼면서 정렬을 수행합니다. 따라서 힙을 구성하고 빼내는 시간 복잡도가 곧 힙 정렬의 시간 복잡도입니다.

 

힙 정렬의 과정은 아래와 같습니다.

  1. 힙 구성: n개 원소에 대해서 힙을 구성합니다. 이때 시간은 O(n)입니다.
  2. 힙 정렬: 힙에서 루트 원소를 꺼내서 배열의 마지막 원소와 교환한 후에, n-1 개 원소에 대해서 다시 최대 힙을 만듭니다. 이 과정이 n번 이루어지고, 각각 최대 힙을 만드는 작업이 O(\log n) 번 이루어집니다.

증명:

k 레벨 깊이의 트리에 대해서 원소가 n개이면, 2k^{-1}\leq n \leq 2^k 입니다. 그리고 i 번째 원소의 깊이는 2^{i-1} 입니다. 첫 번째 for 루프에서, 힙 정렬 함수는 각각 자식 노드를 가진 모든 노드에 대해서 재귀적으로 adjust 함수를 호출합니다. 이때 adjust 함수는 힙을 구성하거나 복원할 때 사용되는 함수입니다. adjust 함수의 시간 복잡도는 O(\log n) 입니다.

 

따라서 이 루프에서 소비되는 시간은 각 레벨에서의 노드 수와, 각 노드가 이동할 수 있는 최대 거리를 곱한 값이 됩니다.

\sum\limits_{1\leq i \leq k}{2^{i-1}(k-1)} = \sum\limits_{1\leq i \leq k-1}{2^{k-i-1}i} \leq n = \sum\limits_{1\leq i \leq k-1}{\cfrac{1}{2^i}} < 2n = O(n)

따라서 힙을 구성하는데 O(n), 그리고 힙을 꺼내어 정렬하는데 O(\log n)이 소요된다면, 총 시간 복잡도는 O(n\log n)이 됩니다.

📚 참고 자료

  • Horowitz, Ellis, Sahni Sartaj, and Mehta Dinesh. Fundamentals in data structures in C++. 2nd ed. Silicon Press, 2007.
  • 윤성우. 윤성우의 열혈 자료구조. n.p.: 오렌지미디어. 2012.

잘못된 정보가 있다면 지적해주시면 감사하겠습니다.