N Log

퀵 정렬 (Quick Sort)

들어가는 말

퀵 정렬은 배열에서 먼저 기준이 되는 값인 피벗(Pivot)을 하나 선택한다.
피벗을 기준으로 값을 재배치해 한쪽에는 피벗보다 작은 값들이, 다른 한쪽에는 피벗보다 큰 값들이 모이게 한다.
이후 나뉜 각 구간에 같은 과정을 반복하면 전체 배열이 정렬된다.
병합 정렬처럼 큰 문제를 작은 문제로 나누어 해결한다는 점에서 퀵 정렬도 분할 정복 알고리즘에 속한다.
평균 시간 복잡도는 O(n log n)이고 별도의 임시 배열이 필요하지 않아 실용적으로 빠르다.
다만 피벗 선택이 계속 불균형하면 최악의 경우 O(n²)까지 느려질 수 있으며, 이를 줄이기 위한 여러 피벗 선택 방법이 있다.

정렬 과정 살펴보기

7 2 8 4 9 1 5 3을 오름차순으로 정렬해 보자.

첫 번째 분할

처음 정렬할 구간은 배열 전체인 7 2 8 4 9 1 5 3이다.
가운데 인덱스 3에 있는 값 4를 피벗으로 선택한다.
왼쪽 포인터는 피벗 이상인 값을 찾고, 오른쪽 포인터는 피벗 이하인 값을 찾아 서로 교환한다.

단계 왼쪽 포인터 왼쪽 포인터의 값 오른쪽 포인터 오른쪽 포인터의 값 배열 상태
초기 상태 0 7 7 3 7 2 8 4 9 1 5 3
73 교환 1 2 6 5 3 2 8 4 9 1 5 7
포인터 이동 2 8 5 1 3 2 8 4 9 1 5 7
81 교환 3 4 4 9 3 2 1 4 9 8 5 7
포인터 이동 3 4 3 4 3 2 1 4 9 8 5 7
분할 완료 3 4 3 4 3 2 1 4 9 8 5 7

분할이 끝나면 피벗 4 이하인 값은 왼쪽 구간에, 피벗 4 이상인 값은 오른쪽 구간에 모인다.

왼쪽 구간 분할

왼쪽 구간 3 2 1 4의 가운데 인덱스 1에 있는 값 2를 피벗으로 선택한다.

단계 왼쪽 포인터 왼쪽 포인터의 값 오른쪽 포인터 오른쪽 포인터의 값 배열 상태
초기 상태 0 3 3 4 3 2 1 4
포인터 이동 0 3 2 1 3 2 1 4
31 교환 1 2 1 2 1 2 3 4
분할 완료 1 2 1 2 1 2 3 4

분할이 끝나면 피벗 2 이하인 값은 왼쪽 구간에, 피벗 2 이상인 값은 오른쪽 구간에 모인다.

사람이라면 지금 정렬이 완료되었다는 것을 알고 멈출 수 있지만, 컴퓨터는 그렇지 않다.
컴퓨터는 각 구간에 원소가 하나만 남을 때까지 피벗을 선택해 분할하는 과정을 반복한다.
따라서 1 23 4 구간에도 같은 과정을 반복한다.

오른쪽 구간 분할

오른쪽 구간 9 8 5 7의 가운데 인덱스 5에 있는 값 8을 피벗으로 선택한다.

단계 왼쪽 포인터 왼쪽 포인터의 값 오른쪽 포인터 오른쪽 포인터의 값 배열 상태
초기 상태 4 9 7 7 9 8 5 7
97 교환 5 8 6 5 7 8 5 9
85 교환 6 8 5 5 7 5 8 9
분할 완료 6 8 5 5 7 5 8 9

분할이 끝나면 피벗 8 이하인 값은 왼쪽 구간에, 피벗 8 이상인 값은 오른쪽 구간에 모인다.
이 구현에서는 분할 과정에서 피벗 값도 다른 값과 교환될 수 있으므로, 피벗의 최종 위치가 처음 선택한 위치와 달라질 수 있다.
이후 7 58 9 구간에도 같은 과정을 반복한다.

분할 과정 요약

순서 호출 구간 피벗 호출 시 배열 상태 분할 후 배열 상태
1 7 2 8 4 9 1 5 3 인덱스 34 7 2 8 4 9 1 5 3 3 2 1 4 9 8 5 7
2 3 2 1 4 인덱스 12 3 2 1 4 9 8 5 7 1 2 3 4 9 8 5 7
3 1 2 인덱스 01 1 2 3 4 9 8 5 7 1 2 3 4 9 8 5 7
4 3 4 인덱스 23 1 2 3 4 9 8 5 7 1 2 3 4 9 8 5 7
5 9 8 5 7 인덱스 58 1 2 3 4 9 8 5 7 1 2 3 4 7 5 8 9
6 7 5 인덱스 47 1 2 3 4 7 5 8 9 1 2 3 4 5 7 8 9
7 8 9 인덱스 68 1 2 3 4 5 7 8 9 1 2 3 4 5 7 8 9

시간 복잡도

퀵 정렬의 시간 복잡도는 피벗이 배열을 얼마나 균등하게 나누는지에 따라 달라진다.
한 번의 분할에서는 현재 구간의 원소를 피벗과 비교하고 재배치하므로 구간의 원소 수에 비례하는 O(n)의 시간이 든다.
피벗이 배열을 비슷한 크기의 두 구간으로 나누면 한쪽 부분 구간의 크기는 n, n / 2, n / 4처럼 줄어들어 분할 단계는 약 log₂ n개가 된다.
따라서 피벗이 배열을 균등하게 나누는 최선의 경우와 평균적으로 한쪽에 치우치지 않는 경우의 시간 복잡도는 O(n log n)이다.

반면 피벗이 계속 최솟값이나 최댓값으로 선택되면 부분 구간의 크기가 n - 1, n - 2, n - 3처럼 줄어든다.
이 경우 분할 단계가 n에 비례하므로 최악의 경우 시간 복잡도는 O(n²)이다.
이처럼 피벗이 한쪽으로 치우치는 문제를 줄이는 방법으로는 무작위 피벗 선택(Random Pivot), 세 원소의 중앙값 선택(Median-of-Three), 중앙값의 중앙값 선택(Median-of-Medians) 등이 있다.

소스 코드

#include <stdio.h>

void swap(int* a, int* b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

int partition(int arr[], int low, int high)
{
    int pivot = arr[(low + high) / 2];
    int left = low;
    int right = high;

    while (1)
    {
        while (arr[left] < pivot)
            left++;

        while (arr[right] > pivot)
            right--;

        if (left >= right)
            return right;

        swap(&arr[left], &arr[right]);
        left++;
        right--;
    }
}

void quick_sort(int arr[], int low, int high)
{
    if (low >= high)
        return;

    int split_index = partition(arr, low, high);

    quick_sort(arr, low, split_index);
    quick_sort(arr, split_index + 1, high);
}

int main(void)
{
    int arr[] = { 7, 2, 8, 4, 9, 1, 5, 3 };
    int size = sizeof(arr) / sizeof(arr[0]);

    quick_sort(arr, 0, size - 1);

    for (int i = 0; i < size; i++)
        printf("%d ", arr[i]); // Result: 1 2 3 4 5 7 8 9

    return 0;
}

구현 설명

quick_sort는 현재 정렬할 구간의 시작 인덱스 low와 마지막 인덱스 high를 매개변수로 받는다.
low >= high라면 구간에 원소가 하나 이하이고 이미 정렬된 상태라서 재귀 호출을 종료한다.
원소가 둘 이상이면 partition을 호출해 분할 경계를 split_index에 저장한다.
그런 다음 low부터 split_index까지의 왼쪽 구간과 split_index + 1부터 high까지의 오른쪽 구간을 각각 재귀적으로 정렬한다.

partition은 현재 구간의 가운데 값인 arr[(low + high) / 2]를 피벗으로 선택한다.
left는 구간의 시작인 low에서, right는 구간의 끝인 high에서 출발한다.
left는 피벗 이상인 값을 만날 때까지 오른쪽으로 이동하고, right는 피벗 이하인 값을 만날 때까지 왼쪽으로 이동한다.

두 포인터가 교차하지 않았고 서로 다른 위치를 가리킨다면, 각 포인터가 찾은 값이 피벗을 기준으로 서로 반대쪽 영역에 있으므로 두 값을 교환한다.
포인터가 교차하거나 같은 위치에서 만나면 분할이 끝나며, right는 두 부분 구간의 경계가 된다.
이 시점에 right 이하에는 피벗 이하인 값만 있고, right + 1 이상에는 피벗 이상인 값만 있다.

partition이 경계인 right를 반환하면, quick_sort는 이를 기준으로 나뉜 왼쪽과 오른쪽 부분 구간에 같은 과정을 재귀적으로 적용한다.
부분 구간의 원소가 하나 이하가 되면 재귀 호출이 종료되며, 이 과정이 모두 끝나면 전체 배열이 정렬된다.