평균 시간복잡도가 O(nlogn)을 자랑하는 ‘퀵 정렬’을 알아 봅시다!
퀵 정렬의 아이디어 자체는 간단합니다.
아래 세 가지만 기억하면 됩니다!
- 리스트에서 원소 하나를 고른다. 이것을 ‘피봇’이라고 한다.
- ‘피봇’보다 작은 것과 큰 것으로 분할한다. (작은 것은 왼쪽, 큰 것은 오른쪽)
- 분할된 두 개의 작은 리스트는 재귀적으로 정렬한다.
위 로직을 이용해서 알고리즘을 소개하겠습니다!
피봇을 선정한 후 분할하는 알고리즘: Partition
임의로 피봇을 선정했을 때, 배열의 중간 값(Median)이 선정된다면 O(nlogn)을 만족하지만 한 번에 Median이 선정된다는 보장은 없습니다.
그렇다고 Median을 구하는 알고리즘을 추가하면 O(nlogn)을 만족하지 않으니까
‘피봇’은 그냥 배열의 마지막 원소로 선정합니다.
이제 아래 알고리즘대로 실행하면 ‘피봇’보다 작은 원소들은 왼쪽, 큰 원소들은 오른쪽에 위치하게 됩니다!
p는 피봇의 인덱스이고 i,b는 피봇과 값을 비교하기 위한 인덱스 변수(초기값 0)입니다.
(i = p 일 때까지 반복한다) {
If) list[i] <= list[p]
-> list[i]와 list[b]를 교환한다.
-> i와 b에 1을 더한다.
if) list[i] > list [p]
-> i에 1을 더한다.
if) i = p
-> list[b]와 list[p]를 교환힌다.
-> p를 b값으로 업데이트한다.
}
배열 [4,5,6,1,2,3]에서 피봇 3을 선정 후 Partition을 하는 모습입니다!
재귀적으로 정렬하는 알고리즘: quicksort
위의 Partition 예시의 결과는 한 번에 정렬이 끝난 모습이지만
대부분 오른쪽 리스트와 왼쪽 리스트는 정렬돼있지 않습니다.
따라서 오른쪽 리스트와 왼쪽 리스트를 Partition함수를 이용해서 다시 정렬해야 됩니다.
다시 정렬하는 과정을 반복하다 보니 순환 구조가 적합합니다!
quicksort(list,start,end){
if(end-start <1) // 베이스 케이스이자 종료 조건
return ;
p= partition(list,start,end)
quicksort(list,start,p-1) // 오른쪽 부분 정렬
quicksort(list,p+1,end) // 왼쪽 부분 정렬
}
start와 end는 각각 0, length-1이 됩니다. (length:부분 리스트의 크기)
만약 list의 길이가 1이면 정렬할 필요가 없습니다. 이 때는 end-start가 0이 되서 퀵 정렬 함수를 마칩니다.
이렇게 문제가 충분히 작아서 바로 풀 수 있는 케이스를 ‘베이스 케이스’라고 합니다.
이러한 ‘베이스 케이스’가 있어서 재귀 함수를 종료할 수 있습니다!
지금까지 설명한 내용을 C언어로 나타내겠습니다.
퀵 정렬 및 테스트 코드 (C언어)
#include <stdio.h> // 배열의 두 요소의 위치를 바꿔주는 함수 void swap_elements(int* list, int index1, int index2) { int temp = list[index1]; list[index1] = list[index2]; list[index2] = temp; } // 퀵 정렬에서 사용되는 partition 함수 int partition(int* list, int start, int end) { // 인덱스로 사용해서 리스트 값 확인, 이 변수들은 피봇과 값을 비교하기 위해 필요함 int i, b; i = b = start; // 피봇의 인덱스 int p=end; // -> 피봇은 배열의 마지막 요소의 값 // 범위 안의 모든 값을 볼 때까지 반복 while (i < p) { // list[i]가 피봇보다 작거나 같으면 list[i]와 list[b]바꾸고 i와 b는 + 1 if (list[i] <= list[p]) { swap_elements(list, i, b); b += 1; } // 그렇지 않으면 i만 + 1 i += 1; } // i = p가 되서 위 내용을 모두 마침 swap_elements(list, b, p); p = b; //피봇의 최종 인덱스는 리턴 return p; } // 퀵 정렬 void quicksort(int* list, int start, int end) { /* < 베이스 케이스: 문제가 충분히 작아서 바로 풀 수 있을 때 > - 원소 한 개 들어와서 start = end->정렬할 필요 x */ if (end - start < 1) return ; int p = partition(list, start, end); quicksort(list, start, p - 1); // 피봇의 왼쪽 부분 정렬 quicksort(list, p + 1, end); // 피봇의 오른쪽 부분 정렬 } int main() { int list[16] = { 2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4 }; for (int i = 0; i < 16; i++) // 정렬 전 printf("%d ", list[i]); printf("\n"); quicksort(list, 0, 15); // 정렬 for (int i = 0; i < 16; i++) // 정렬 후 printf("%d ", list[i]); return 0; }
마지막으로 퀵 정렬을 이용해서 간단하게 시간 복잡도를 알아 봅시다!
중앙값이 피봇으로 선정되면 T(n)=2*T(n/2)+O(n)형태가 된다. -> O(nlogn)이 된다. <평균의 경우>
[오른쪽 Part와 왼쪽 Part를 나눠서 처리하는 분할정복법]
최솟값이나 최댓값이 피봇으로 선정되면 T(n)=T(n-1)+o(n)형태가 된다. -> O(n^2)이 된다. <최악의 경우>
[오른쪽 Part 혹은 왼쪽 Part만 처리]