가장 유명한 정렬 알고리즘중 하나인 퀵정렬에 대해 알아보자
분할(Divide): 입력 배열을 피벗을 기준으로 비균등하게 2개의 부분 배열(피벗을 중심으로 왼쪽: 피벗보다 작은 요소들, 오른쪽: 피벗보다 큰 요소들)로 분할한다.
정복(Conquer): 부분 배열을 정렬한다. 부분 배열의 크기가 충분히 작지 않으면 순환 호출 을 이용하여 다시 분할 정복 방법을 적용한다.
순환 호출이 한번 진행될 때마다 최소한 하나의 원소(피벗)는 최종적으로 위치가 정해지므로, 이 알고리즘은 반드시 끝난다는 것을 보장할 수 있다.
-> 시간복잡도가 준수하여 코테에서도 종종 응용한다. 재귀 함수 형태로 직접 구현하는게 중요!
pivot을 중심으로 계속 데이터를 2개의 집합으로 나누면서 정렬하는 것
<pivot 정하는 법>
pivot == K : K번째 수를 찾은 것이므로 알고리즘 종료
pivot > K : pivot의 왼쪽 부분에 K가 있으므로 왼쪽(S~pivot-1)만 정렬 수행
pivot < K : pivot의 오른쪽 부분에 K가 있으므로 오른쪽(pivot+1 ~ E)만 정렬 수행
[6, 5, 1, 4, 7, 2, 3]
이 있다고 가정하자.
피봇(pivot)이라고 불리는 임의의 기준값을 사용합니다.
pivot 값을 선택하는데는 여러가지 방법이 있지만 여기서는 간단한 설명을 위해 정 중앙에 위치한 4을 pivot으로 정하겠습니다.
그리고 다음과 같이 이 pivot 값을 기준으로 pivot보다 작은 값의 그룹과 pivot보다 큰 값의 그룹으로 나눕니다.
[3, 2, 1] < 4 < [7, 5, 6]
위와 같이 pivot 값보다 작은 값들은 모두 왼편으로 몰고, 큰 값들은 모두 오른편으로 몰면 기준값은 정확히 정렬된 위치에 놓이게 됩니다.
또한 이런 방식으로 분할을 해놓으면 앞으로 더 이상 왼편에 있는 값들과 오른편에 있는 값들 간에는 비교를 할 필요가 없습니다.
따라서 반대편은 전혀 신경쓰지 않고 왼편이든 오른편이든 같은편 내의 값들 끼리만 비교 후 정렬을 할 수 있게 됩니다.
먼저 왼편을 동일한 방식으로 정렬해보도록 하겠습니다. 왼편의 정 가운데에 위치한 pivot 값인 2 보다 작은 값인 1인 왼쪽에 큰 값인 3은 오른쪽에 위치시켰습니다. 이제 양쪽 모두 값이 하나씩 밖에 없기 때문에 이로써 왼편의 정렬 작업은 완료되었습니다.
[1] < 2 < [3]
오른편도 동일한 방식으로 정렬해보겠습니다.
오른편의 pivot 값인 5 보다 작은 값은 없으므로 7과 6을 모두 오른편에 위치시켰습니다.
[] < 5 < [7, 6]
5의 오른편에는 값이 2개가 있기 때문에 추가 정렬이 필요합니다.
[6] < 7 < []
마지막으로 지금까지 좌우로 분할했던 값들을 모두 합치보면 다음과 같이 정렬된 배열을 얻을 수 있습니다.
[1, 2, 3, 4, 5, 6, 7]
지금까지 살펴본 것과 같이 퀵 정렬은 배열을 pivot 값 기준으로 더 작은 값과 큰 값으로 반복적으로 분할하여 정렬해나가는 방식을 취하고 있습니
<이상적인 경우>
pivot 값을 기준으로 동일한 개수의 작은 값들과 큰 값들이 분할되어 O(nlog(n))의 시간 복잡도
순환 호출의 깊이
레코드의 개수 n이 2의 거듭제곱이라고 가정(n=2^k)했을 때, n=2^3의 경우, 2^3 -> 2^2 -> 2^1 -> 2^0 순으로 줄어들어 순환 호출의 깊이가 3임을 알 수 있다. 이것을 일반화하면 n=2^k의 경우, k(k=log₂n)임을 알 수 있다.
각 순환 호출 단계의 비교 연산
각 순환 호출에서는 전체 리스트의 대부분의 레코드를 비교해야 하므로 평균 n번 정도의 비교가 이루어진다.
순환 호출의 깊이 * 각 순환 호출 단계의 비교 연산 = nlog₂n
이동 횟수
비교횟수보다 적으므로 무시할 수 있다.
<최악의 경우>
O(n^2)의 시간 복잡도
리스트가 계속 불균형하게 나누어지는 경우 (특히, 이미 정렬된 리스트에 대하여 퀵 정렬을 실행하는 경우)
순환 호출의 깊이
레코드의 개수 n이 2의 거듭제곱이라고 가정(n=2^k)했을 때, 순환 호출의 깊이는 n임을 알 수 있다.
각 순환 호출 단계의 비교 연산
각 순환 호출에서는 전체 리스트의 대부분의 레코드를 비교해야 하므로 평균 n번 정도의 비교가 이루어진다.
순환 호출의 깊이 * 각 순환 호출 단계의 비교 연산 = n^2
이동 횟수
비교 횟수보다 적으므로 무시할 수 있다.
따라서 상용 코드에서는 중앙값(median)에 가까운 pivot 값을 선택할 수 있는 섬세한 전략이 요구되며, 배열의 첫값과 중앙값 그리고 마지막값 중에 크기가 중간인 값을 사용하는 방법이 많이 사용됩니다.
퀵 정렬은 공간 복잡도는 구현 방법에 따라 달라질 수 있는데, 입력 배열이 차지하는 메모리만을 사용하는 in-place sorting 방식으로 구현을 사용할 경우, O(log(n))의 공간 복잡도를 가진 코드의 구현이 가능합니다.
(순환 호출의 깊이를 말하는것같다.)
-> 코테에서 STL을 사용할 수 없으면, 최악의 경우가 있으므로 퀵정렬은 절대 사용하면 안된다!!! 비슷한 시간복잡도 O(NlogN)을 가지는 병합정렬이나 힙정렬을 사용하도록하자!
O(1) : 비교시 사용하는 포인터에 필요한 공간
참고사이트
https://gmlwjd9405.github.io/2018/05/10/algorithm-quick-sort.html