quick sort — 최악 O(n²)인데 평균은 왜 O(n log n)인가
quick sort는 최악의 경우 까지 느려진다. 그런데도 현장에서 가장 널리 쓰이는 정렬이다. 모순처럼 보이는 이 사실의 답은 “평균”에 있다. 그 평균이 정말 인지, 기댓값 점화식을 세워 끝까지 풀어 본다.
- 두 포인터 , 로 한 배열 안에서 분할하는 과정
- 배열을 2개 쓰는 더 단순한 분할과 그 비용
- pivot이 어디에 놓이느냐로 갈리는 최선 · 최악
- 평균 분석의 핵심. 기댓값 점화식 을 세워 직접 풀어 을 유도
정렬 알고리즘 포스트에서 quick sort의 분할·올바름 증명·복잡도의 개요를 다뤘다. 그 글은 “평균은 왜 인가”를 “한쪽으로 너무 쏠리지만 않으면 깊이가 로그” 라는 직관으로만 짚고 넘어갔다. 이 글은 그 빈칸을 채운다. 평균이 임을 수식으로 유도하는 것이 목표다.
분할 정복이지만, 절반을 보장하지 않는다
quick sort는 분할 정복이다. 하지만 merge sort가 배열을 정확히 절반으로 자르는 것과 달리, quick sort는 pivot이라 부르는 한 원소를 기준으로 “작은 것은 왼쪽, 큰 것은 오른쪽”으로 나눈다. 이 분할이 절반에 가까울지, 한쪽으로 쏠릴지는 입력과 pivot 선택에 달려 있다. 바로 이 점이 quick sort의 복잡도가 입력마다 달라지는 이유이고, 평균을 따져야만 하는 이유다.
먼저 분할이 어떻게 일어나는지부터 본다.
두 포인터로 한 배열 안에서 분할하기
가장 왼쪽 값 을 pivot 로 잡고, 두 포인터로 배열을 한 번 훑으며 제자리에서 분할한다.
void sort(int a[], int n) {
if (n <= 1) return; // 기저 사례: 크기 0·1은 이미 정렬됨
int p = a[0]; // pivot: 맨 왼쪽 값
int i = 1; // 왼쪽 → 오른쪽으로 다가가는 포인터
int j = n - 1; // 오른쪽 → 왼쪽으로 다가가는 포인터
while (i <= j) {
while (i <= j && a[i] < p) i++; // p보다 작은 동안 전진 → p 이상에서 멈춤
while (i <= j && a[j] >= p) j--; // p 이상인 동안 후진 → p 미만에서 멈춤
if (i < j) swap(a[i], a[j]); // 엇갈리기 전이면 두 값을 교환
}
swap(a[0], a[j]); // pivot을 경계 j(항상 a[j] < p, 또는 j==0)로 → 최종 위치
sort(a, j); // 왼쪽 영역 재귀 (크기 j)
sort(a + j + 1, n - j - 1); // 오른쪽 영역 재귀 (크기 n−j−1)
}
과정
- 맨 왼쪽의 값을 (pivot)으로 잡는다.
- 왼쪽에서 오른쪽으로 다가가는 포인터를 , 오른쪽에서 왼쪽으로 다가가는 포인터를 라 하자.
- 는 이상인 값()을 만날 때까지 전진한다. 는 보다 작은 값()을 만날 때까지 후진한다.
- 이고 이므로, 와 를 맞바꾸면 둘 다 제 영역으로 들어간다. 그러면 와 는 다시 전진·후진을 이어 갈 수 있다.
- 와 가 서로를 가로질러 엇갈리면 배열은 이렇게 정리되어 있다. 경계의 왼쪽에는 보다 작은 값들만, 오른쪽에는 이상인 값들만 남는다. pivot과 같은 값은 오른쪽에 모인다.
- 따라서 마지막에 (즉 )를 경계 자리 와 바꾸면, 를 기준으로 왼쪽엔 작은 값, 오른쪽엔 이상인 값만 놓인다. 이 순간 의 자리는 정렬이 끝난 뒤의 자리와 같다. 즉 최종 위치다.
- 남은 일은 왼쪽 영역과 오른쪽 영역을 각각 재귀로 정렬하는 것뿐이다.
두 내부 조건이 한쪽은 a[i] < p, 다른 쪽은 a[j] >= p로 비대칭인 점에 주목하자. pivot과 같은 값()이 여러 개 있어도, 가 그런 값을 건너뛰며 후진하므로 포인터가 항상 한 칸 이상 진행한다. 덕분에 [5, 5]처럼 같은 값만 있는 입력에서도 멈추지 않고 정확히 정렬된다. 다만 정확히 정렬된다는 것과 빠르다는 것은 다른 이야기다. 같은 값만 있는 입력의 비용은 아래 평균 분석에서 다시 본다.
정렬 알고리즘 포스트의 분할은 경계 인덱스 하나(
s)로 작은 값을 앞으로 모으는 방식이었다. 여기 두 포인터 방식은 같은 결과(작은 값 왼쪽 · 이상 오른쪽 · pivot은 최종 위치)를 양 끝에서 좁혀 오며 만든다. 분할의 결과는 같고 경로가 다를 뿐이다.
배열 2개를 쓰는 더 단순한 방법
위 방법은 배열 하나 안에서 끝내려다 보니 두 포인터를 엇갈리게 다루는 과정이 다소 복잡해 보인다. 배열을 두 개 쓰면 , 를 둘 필요가 없다.
원본을 앞에서부터 훑으며, 보다 작은 값은 새 배열의 왼쪽 끝부터 차곡차곡 채우고, 큰 값은 오른쪽 끝부터 채운다. 다 훑고 나면 가운데 빈 한 칸에 를 넣으면 된다. 포인터를 엇갈리게 관리할 필요 없이, 한 번의 패스로 분할이 끝난다.
대신 길이 짜리 배열을 하나 더 쓰므로 추가 메모리 이 든다. 이는 merge sort가 떠안는 메모리 비용과 같은 종류의 트레이드오프다. quick sort가 실측에서 빠른 큰 이유 중 하나가 제자리(in-place) 분할이라는 점을 생각하면, 두 배열 방식은 이해를 돕는 설명용에 가깝다.
시간 문제: pivot이 어디에 놓이는가
분할 자체는 배열을 한 번 훑으므로 이다. 전체 복잡도는 pivot이 분할 후 어디에 놓이느냐, 즉 양쪽 영역의 크기가 어떻게 갈리느냐에 전적으로 달려 있다.
- 정확히 반으로 나뉠 때(최선). pivot이 매번 중앙값이면 이 되어, merge sort와 같은 이다.
- 한쪽으로 완전히 쏠릴 때(최악). pivot이 매번 최솟값이나 최댓값이면 한쪽은 개, 다른 쪽은 개가 된다. 이 되어 로 추락한다. 이미 정렬된(또는 역순으로 정렬된) 배열에 항상 을 pivot으로 잡으면 정확히 이 최악 경우다.
과 의 간극은 거대하다. 그렇다면 “보통” 입력에서는 어느 쪽에 가까울까? 이 질문에 답하려면 평균을 정의해야 한다.
평균은 왜 인가
평균을 말하려면 확률을 가정해야 한다
“평균”은 입력의 확률분포가 주어져야만 말할 수 있는 개념이다. 분포가 없으면 평균도 없다. 아래 분석이 세우는 모형은 이것이다.
입력은 서로 다른 개의 키이고, 그 배열 순서는 가지 순열 중 하나가 균등한 확률로 뽑힌 것이다.
이 모형에서 pivot인 이 정렬 후 몇 등인지는 등부터 등까지 모두 같은 확률 이다. 그리고 분할이 끝난 뒤 왼쪽 개와 오른쪽 개의 내부 순서도 다시 균등 무작위 순열이므로, 부분문제의 비용에 과 를 그대로 넣을 수 있다. 점화식이 성립하는 것은 이 두 사실 덕분이다.
입력 분포를 가정하고 싶지 않다면 뒤집어도 된다. pivot을 배열의 첫 원소가 아니라 균등 무작위로 고르면 입력이 무엇이든 등수가 로 흩어진다. 아래 점화식은 두 모형 어느 쪽에서도 같은 모양으로 선다.
분석의 무기는 기댓값의 선형성이다. 어떤 두 양 , 에 대해서도 다음이 성립한다.
이 성질 덕분에, 복잡하게 얽힌 비용도 조각조각 나눠 기댓값을 더할 수 있다.
기댓값 점화식 세우기
개를 정렬하는 데 드는 시간의 기댓값을 이라 하자. pivot의 ‘등수’를 라 하면, 분할 후 왼쪽에는 개, 오른쪽에는 개가 남는다.
| pivot 등수 | 왼쪽 크기 | 오른쪽 크기 |
|---|---|---|
각 등수 가 확률 로 나오고, 그때 두 부분문제의 비용은 과 다. 여기에 분할 자체의 비용 (, 가 배열 전체를 훑는 시간)을 더하면, 기댓값의 선형성으로 다음 점화식이 선다.
점화식을 풀어 나가기
합을 펼쳐 쓰면, 가 에서 까지 돌 때 이 각각 두 번씩 나타난다.
같은 항이 두 번씩이므로 묶으면,
양변에 을 곱해 합 기호를 다루기 쉽게 만든다.
이제 같은 식을 대신 에 대해 써 보자.
두 식을 빼면 합 기호가 한 항만 남기고 사라진다.
을 우변으로 넘겨 정리하면, 깔끔한 1차 점화식이 된다.
텔레스코핑으로 닫힌 식에 다가가기
양변을 로 나누면, 과 이 같은 꼴로 나란히 놓인다.
마지막 등호는 을 부분분수로 쪼갠 것이다(). 이제 로 두면 식은 한 칸씩 더해 나가는 모양이 된다.
을 다시 로, 그 다음을 또 그 앞으로 바꾸어 바닥까지 펼치면(텔레스코핑) 추가항들이 전부 더해진다.
같은 분모끼리 다시 묶으면 정리된다(유한합이라 실제로는 꼬리항 과 초기값 가 남지만, 둘 다 상수·저차항이라 점근 분석에서 무시한다. 그래서 아래 등호는 지배항만 남긴 근사다).
여기서 괄호 안은 조화수(harmonic number) 이고, 잘 알려진 대로 이다(자연로그가 나오는 이유는 의 합이 에 대응하기 때문이다). 따라서 추가항들의 총합은 약 이고, 텔레스코핑의 결과로 자체가 이 값에 닿는다.
이 마지막 식은 누적된 총합이다. 한 칸짜리 점화식 과 혼동해 우변에 을 그대로 남겨 두면 같은 양을 두 번 세게 된다. 텔레스코핑은 그 을 끝까지 펼쳐 위의 급수로 대체하는 과정이다.
결론: 평균은
이므로 양변에 을 곱하면 이다. 자연로그 과 임의 밑의 은 상수배만큼만 다르므로(), 점근적으로는 다음과 같이 쓴다.
앞 절의 분할은 중복 키가 있어도 올바르게 동작한다. 하지만 위 모형은 키가 서로 다르다고 두었고, 그 가정이 빠지면 등수가 로 흩어진다는 전제부터 성립하지 않는다.
극단을 보면 분명하다. 모든 원소가 같은 값인 배열에서는 가 첫 칸에서 곧바로 멈추고 만 끝까지 내려온다. 경계가 항상 이 되어 왼쪽 개, 오른쪽 개로 갈린다. 매 단계가 최악의 쏠린 분할이므로 비교 횟수는 정확히 다. 이면 79,800번으로, 같은 크기의 서로 다른 무작위 입력에서 관측되는 3,700번 남짓과 스무 배 넘게 벌어진다.
이 입력은 위 모형의 표본 공간에 들어 있지 않으므로 이라는 결론과 모순되지 않는다. 실무에서 중복이 많은 데이터를 다룰 때 세 갈래 분할(작다·같다·크다)을 쓰는 이유가 여기에 있다. 같은 값을 가운데로 몰아 재귀에서 아예 빼면 이 최악이 사라진다.
최악이 인 알고리즘의 평균이 이라는 결론이다. 직관으로도 들어맞는다. 과 의 간극이 워낙 크기 때문에, 최악의 쏠린 분할이 매 단계 반복되어야만 평균이 쪽으로 끌려간다. 그런데 무작위 입력에서 그런 극단이 매번 일어날 확률은 지극히 작아, 기대 깊이는 로그 규모에 머문다. 실험적으로 측정해 봐도 평균은 으로 나타난다.
- quick sort는 pivot 하나로 “작은 값 왼쪽 · 이상 오른쪽”으로 가르는 분할 정복이다. 분할은 이고, 두 포인터로 제자리에서 끝낼 수 있다.
- 복잡도는 pivot의 위치로 갈린다. 절반에 가까우면 최선 , 한쪽으로 완전히 쏠리면 최악 .
- 평균 분석: 서로 다른 개 키의 균등 무작위 순열(또는 무작위 pivot)을 두면 모든 등수가 확률 로 같아져 . 이를 1차 점화식 로 바꾸고, 을 텔레스코핑하면 조화수 이 나타나 .
- 결국 quick sort는 최악을 피할 수만 있다면(무작위 pivot 등) 평균적으로 최적에 가까운 정렬이며, 제자리 분할의 캐시 이점까지 더해 실측에서 자주 가장 빠르다.
평균 복잡도의 정확한 유도는 조화수의 점근, 부분분수, 텔레스코핑이 한데 얽혀 까다롭다. 위 전개는 상수항을 근사로 흘려보낸 곳이 있지만, 결론인 은 변하지 않는다. 더 엄밀한 분석은 무작위 pivot을 쓰는 무작위화 quick sort의 기대 시간 분석에서 같은 결론에 더 단단하게 도달한다.