이번에는 평균적으로 매우 빠른 수행 속도를 보장하는 정렬 방법인 퀵 정렬에 대해 알아보겠다.
퀵 정렬은 평균적으로 매우 빠른 수행 속도를 자랑하는 정렬 방법이다. 이 정렬 방법은 분할-정복법(divide and conquer)을 사용하고, 합병 정렬과 달리 리스트를 균등하지 않게 분할한다. 여기서 분할-정복법이란 문제를 작은 문제들로 분리하여 각각 해결하고 결과를 모아서 문제를 해결하는 전략이다. 그리고 분할-정복법은 대게 순환 호출을 이용하여 구현한다.
이제 퀵 정렬이 어떻게 이뤄지는지 살펴보자. 먼저 리스트 안에 있는 한 요소를 피벗(pivot)으로 선택한다. 여기서는 리스트의 첫 번째 요소를 피벗으로 하자. 피벗보다 작은 요소들은 모두 피벗의 왼쪽으로 옮기고 피벗보다 큰 요소들은 모두 피벗의 오른쪽으로 옮긴다. 결과적으로 피벗을 중심으로 왼쪽은 피벗보다 작은 요소들로 구성되고, 오른쪽은 피벗보다 큰 요소들로 구성된다. 이 상태에서 피벗을 제외한 왼쪽 리스트와 오른쪽 리스트를 다시 정렬하게 되면 전체 리스트가 정렬된다.
그러면 퀵 정렬은 어떻게 피벗을 기준으로 나누어진 왼쪽 부분 리스트와 오른쪽 부분 리스트를 정렬할까? 여기서 순활이 사용된다. 부분 리스트에서 다시 피벗을 정하고 피벗을 기준으로 2개의 부분 리스트로 나누는 과정이 되풀이 된다. 이 과정은 부분 리스트를 더 이상 분할할 수 없을 때까지 반복된다.
퀵 정렬은 다음의 단계들로 이루어진다.
배열에 5, 3, 8, 4, 9, 1, 6, 2, 7이 저장되어 있다고 가정하고 자료를 오름차순으로 정렬해 보자.
#include <iostream>
using namespace std;
void printArray(int* a, int n)
{
for (int i = 0; i < n; i++)
cout << a[i] << " ";
cout << endl;
}
void swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int partition(int* a, int left, int right)
{
int low = left;
int high = right+1;
int pivot = a[left]; // 피벗 설정
do { // 왼쪽 리스트에서 피벗보다 큰 레코드 선택
do {
low++;
} while (low <= right && a[low] < pivot);
do { // 오른쪽 리스트에서 피벗보다 작은 레코드 선택
high--;
} while (high >= left && a[high] > pivot);
if (low < high) // 선택된 두 레코드 교환
swap(a[low], a[high]);
} while (low < high); // 인덱스 low와 high가 엇갈리지 않는한 반복
swap(a[left], a[high]); // 인덱스 high가 가리키는 레코드와 피벗값을 교환
return high;
}
// 퀵정렬 알고리즘을 이용해 배열의 left ~ right 항복들을 오름차순으로 정렬하는 함수
void quickSort(int* a, int left, int right)
{
if (left < right) // 정렬 범위가 2개 이상인 경우
{
int q = partition(a, left, right); // 좌우로 분할
quickSort(a, left, q - 1); // 왼쪽 부분리스트를 퀵 정렬
quickSort(a, q + 1, right); // 오른쪽 부분리스트를 퀵 정렬
}
}
int main()
{
int a[] = { 7,33,35,9,44,23 };
int len = sizeof(a) / sizeof(int);
printArray(a, len);
quickSort(a, 0, len-1);
printArray(a, len);
return 0;
}
레코드의 개수 n이 2의 거듭제곱이라고 가정하고 퀵 정렬에서의 리스트 분할이 항상 리스트의 가운데에서 이루어진다고 가정하면 합병 정렬의 복잡도 분석과 마찬가지로 n개의 레코드를 가지는 리스트는 n/2, n/4, n/8,...,n/2^k의 크기로 나누어질 것이다. 크기가 1이될 때까지 나누어지므로 n/2^k=1일 때까지 나누어질 것이고 따라서 k=log_2n개의 패스가 필요하게 된다. 각각의 패스에서는 전체 리스트의 대부분의 레코드를 비교해야 하므로 평균 n번 정도의 비교가 이루어지므로 퀵 정렬은 비교 연산을 총 nlog_2n번 실행해야 하여 O(nlog_2n) 알고리즘이 된다.
최악의 경우는 리스트가 계속 불균형하게 나누어지는 것이다. 이미 정렬된 리스트에 대하여 퀵 정렬을 실행하는 경우를 생각해보자. 이 경우 리스트의 첫 번째 레코드를 피벗으로 설정하면, 왼편 리스트가 텅 비게 되는 불균형 분할이 연속해서 이루어진다.
이 경우 레코드의 수만큼 총 n번의 패스가 실행되고, 각 패스에서 n번의 비교가 이루어지게 되므로 비교 연산을 n^2번 실행하게 된다. 따라서 최악의 경우 퀵 정렬은 O(n^2)의 시간 복잡도를 가지게 된다.
퀵 정렬의 평균적인 경으의 시간 복잡도는 O(nlog_2n)으로 나타난다. 특히 다른 O(nlog_2n)의 정렬 알고리즘과 비교하였을 때도 가장 빠른 것으로 나타났다. 이는 퀵 정렬이 불필요한 데이터의 이동을 줄이고 먼 거리의 데이터를 교환할 뿐 아니라, 한번 결정된 피벗들이 추후 연산에서 제외되는 특성들 때문이다.
퀵 정렬은 속도가 빠르고 추가 메모리 공간을 필요로 하지 않는 등의 장점이 있는 반면에 정렬된 리스트에 대해서는 오히려 수행 시간이 더 많이 걸리는 등의 단점도 가진다. 이러한 불균형 분할을 방지하기 위하여 피벗을 선택할 때 크기 순의 중간 값을 선택하는 방법이 많이 사용된다.
이진 탐색 (0) | 2021.01.31 |
---|---|
합병 정렬(Merge sort) (0) | 2019.08.25 |
삽입 정렬(Insertion sort) (0) | 2019.08.23 |
선택 정렬(selection sort) (0) | 2019.08.21 |
버블 정렬(Bubble sort) (0) | 2019.07.22 |
댓글 영역