선택 문제 — k번째 원소를 정렬 없이 O(n)에 찾기

quick sort는 최악의 경우 O(n2)O(n^2)까지 느려진다. 만약 pivot으로 항상 중앙값을 고를 수 있다면, 최악에도 O(nlogn)O(n \log n)이 보장된다. 그런데 중앙값을 찾는 것 자체가 문제다. 이것이 바로 선택 문제(selection problem)다. 선택을 O(n)O(n)에 풀면, quick sort의 최악도 O(nlogn)O(n \log n)으로 묶인다.

이 포스트에서 다루는 내용
  • 선택 문제의 정의 — kk번째로 작은 원소 찾기
  • Quickselect — quick sort의 분할을 재활용해 한쪽으로만 재귀하기
  • Approximate median — 정확한 중앙값 대신 “가운데 40%“면 충분하다
  • Median of medians — 5개씩 묶어 approximate median을 O(n)O(n)에 찾고, 최악에도 O(n)O(n) 보장

선택 문제란

nn개의 수 a1,a2,,ana_1, a_2, \ldots, a_n이 주어질 때, kk번째로 작은 수를 찾는 것이 선택 문제다.

  • k=1k = 1이면 최솟값
  • k=nk = n이면 최댓값
  • k=n/2k = \lfloor n/2 \rfloor이면 중앙값(median)

세 가지가 모두 같은 문제의 특수 경우다. 최솟값이나 최댓값은 선형 탐색 O(n)O(n)으로 쉽게 구할 수 있다. 그렇다면 일반적인 kk에 대해서도 정렬 없이 O(n)O(n)에 풀 수 있을까?


정렬보다 쉬운 문제

가장 단순한 접근은 정렬 후 인덱싱이다. 배열을 정렬하고 a[k1]a[k-1]을 반환하면 O(nlogn)O(n \log n)에 풀린다. 하지만 이것은 지나치게 많은 일을 한다.

정렬은 11번째부터 nn번째까지 모든 원소의 순서를 확정한다 — nn개의 선택 문제를 한꺼번에 푸는 셈이다. 우리가 원하는 것은 그중 하나뿐이다.

하한을 먼저 생각해 보자. kk번째 원소를 찾으려면 모든 원소를 최소한 한 번은 봐야 한다. 어떤 원소도 보지 않고 kk번째를 특정할 수는 없으므로, Ω(n)\Omega(n)이 자명한 하한이다. 따라서 목표는 Θ(n)\Theta(n) — 선형 시간이다.

정렬을 다 하면 손해

다른 모든 원소들의 대소관계를 다 알고 있다면, 그것은 이미 정렬 — O(nlogn)O(n \log n)으로 회귀한다. 선택이 정렬보다 쉬운 이유는, 관심 없는 원소들 사이의 순서를 굳이 확정하지 않아도 되기 때문이다.


Quickselect — 한쪽으로만 재귀

quick sort의 분할(partition)을 그대로 가져온다. pivot pp를 하나 잡고 배열을 한 번 훑으면(O(n)O(n)), pivot의 최종 위치 jj가 정해진다. 이때 pivot의 등수j+1j + 1이다.

  • k=j+1k = j + 1이면 a[j]a[j]가 바로 답이다.
  • k<j+1k < j + 1이면 kk번째는 왼쪽 영역에만 있다 — 오른쪽은 볼 필요가 없다.
  • k>j+1k > j + 1이면 kk번째는 오른쪽 영역에만 있다 — 왼쪽은 볼 필요가 없다.

quick sort가 양쪽 모두를 재귀하는 것과 달리, quickselect는 한쪽만 재귀한다. 이것이 핵심이다.

partition 후 pivot 등수와 k를 비교해 관계없는 쪽은 버리고 한쪽만 재귀하는 quickselect 구조
partition 후 pivot 등수와 k를 비교해 관계없는 쪽은 버리고 한쪽만 재귀하는 quickselect 구조
int select(int a[], int n, int k) {  // k번째(1-indexed) 작은 값
    int j = partition(a, n);   // 내부에서 a[0]을 pivot으로 써 제자리로, 반환은 경계 인덱스
    int rank = j + 1;          // pivot의 등수
    if (k == rank) return a[j];
    if (k < rank)  return select(a, j, k);            // 왼쪽만
    else           return select(a + j + 1, n - j - 1, k - rank);  // 오른쪽만
}

최선과 최악

최선은 pivot이 매번 중앙값에 가까울 때다. 분할 후 배열이 절반씩 줄어드는 등비수열이 된다.

S(n)=n+S(n/2)=n+n2+n4+=O(n)S(n) = n + S(n/2) = n + \frac{n}{2} + \frac{n}{4} + \cdots = O(n)

등비수열의 합이 2n2n에 수렴하므로 O(n)O(n)이다.

최악은 pivot이 매번 끝값(최솟값 또는 최댓값)일 때다. 분할 후 한쪽이 n1n-1개로 고작 하나씩만 줄어든다.

S(n)=n+S(n1)=n+(n1)+(n2)+=O(n2)S(n) = n + S(n-1) = n + (n-1) + (n-2) + \cdots = O(n^2)

이 합은 k=1nk=n(n+1)2=O(n2)\sum_{k=1}^{n} k = \frac{n(n+1)}{2} = O(n^2)이다.

평균에 대한 한마디

대부분의 경우 O(n)O(n)이지만 최악이 남는다. 평균이 정말 O(n)O(n)인지는 별도 글에서 기댓값으로 따진다.


Approximate median — 정확할 필요는 없다

최악을 없애려면 pivot이 항상 중앙 근처여야 한다. 그런데 정확한 중앙값을 찾는 것이 문제라면, 어디까지 “어림잡아도” 괜찮을까?

정확한 중앙값이 아니어도 된다. pivot이 전체의 가운데 40%, 즉 전체 중 30번째~70번째 백분위 사이의 값이면 충분하다. 이런 pivot을 approximate median이라 부른다.

정렬된 수직선 위 가운데 40% 띠(approximate median 영역)와 좌우 30%, 최악 시 재귀 크기 0.7n 표시
정렬된 수직선 위 가운데 40% 띠(approximate median 영역)와 좌우 30%, 최악 시 재귀 크기 0.7n 표시

먼저 이런 pivot이 quick sort에 어떤 효과를 주는지 보자. Approximate median을 pivot으로 쓰면 분할 후 두 부분 배열의 크기는 각각 최소 0.3n0.3n, 최대 0.7n0.7n이다(둘의 합은 nn). 핵심은 양쪽 모두 nn의 상수 배수라는 점이다. 가장 큰 가지를 따라가도 0.7dn10.7^d n \le 1이 되는 깊이가 d=O(logn)d = O(\log n)이므로 재귀 트리의 깊이가 O(logn)O(\log n)으로 묶이고, 각 레벨의 작업량 합이 nn 이하이므로 quick sort의 비용 합은 O(nlogn)O(n \log n)이다. 점화식으로 쓰면:

Q(n)=n+Q(0.3n)+Q(0.7n)Q(n) = n + Q(0.3n) + Q(0.7n)

이를 재귀 트리로 그려 보면, 각 레벨의 작업량 합은 항상 nn 이하이고 트리 깊이는 O(logn)O(\log n)이므로 전체 합은 O(nlogn)O(n \log n)이다. 즉, pivot이 가운데 40% 안에만 들어오면 quick sort는 항상 O(nlogn)O(n \log n)을 유지한다.

다만 이 Q(n)Q(n)quick sort의 비용이지 선택의 비용이 아니다. 선택 알고리즘은 양쪽을 모두 푸는 것이 아니라 한쪽만 재귀하므로, 비용은 다음 섹션에서 S(n)S(n)으로 따로 세운다. 우선 남은 문제는 그런 approximate median pivot을 어떻게 빠르게 찾느냐다.


Median of medians — 5개씩 묶기

Approximate median을 O(n)O(n)에 찾는 알고리즘이 median of medians다. 다음 세 단계로 구성된다.

  1. nn개의 원소를 5개씩 묶어 n/5\lceil n/5 \rceil개의 그룹으로 나눈다.
  2. 각 그룹(최대 5개)을 정렬해 그룹 중앙값 XiX_i를 구한다.
  3. XiX_i들(총 n/5\lceil n/5 \rceil개)의 중앙값 XX'를 재귀적으로 구한다. 이 XX'가 approximate median이다.
5개씩 묶어 그룹 중앙값을 구하고, 중앙값들의 중앙값 X'를 찾는 과정
5개씩 묶어 그룹 중앙값을 구하고, 중앙값들의 중앙값 X'를 찾는 과정

왜 X’는 가운데 40% 안에 있는가

XX'n/5\lceil n/5 \rceil개의 그룹 중앙값들의 중앙값이므로, 그룹 중앙값 중 절반(n/10\approx n/10개)이 XX' 이하다.

각 그룹은 5개이고, 그룹 중앙값 XiX_iXX' 이하인 그룹에서는 중앙값 포함 아래 3개(XiX_i 이하 원소)가 XX' 이하다. 따라서 XX' 이하임이 보장되는 원소 수는:

3×n10=0.3n3 \times \frac{n}{10} = 0.3n

대칭 논리로 XX' 이상임이 보장되는 원소도 0.3n0.3n개다.

결론: XX'는 전체에서 대략 30%와 70% 사이에 놓인다. 즉 가운데 40% 안의 값이다. 따라서 XX'를 pivot으로 분할하면 양쪽에 각각 0.3n0.3n쯤이 들어가, 어느 쪽으로 재귀하든 크기가 대략 0.7n0.7n으로 묶인다. Approximate median 조건을 만족한다.

정확히는 0.3n0.3n이 아니다

위 셈은 그룹이 모두 5개로 꽉 차고 그룹 수가 짝수인 경우를 상정한 것이다. 실제로는 세 군데에서 샌다.

  1. 마지막 그룹이 5개가 안 될 수 있다. 원소가 3개인 그룹에서는 중앙값 아래가 2개뿐이라 “그룹마다 3개”라는 셈이 무너진다.
  2. “중앙값의 절반”에 올림이 붙는다. 그룹은 n/5\lceil n/5 \rceil개이고 그중 중앙값이 XX' 이상인 것은 12n/5\left\lceil \frac{1}{2}\lceil n/5 \rceil \right\rceil개다. n/10n/10과 정확히 같지 않다.
  3. XX'가 속한 그룹은 빼야 한다. 그러지 않으면 XX' 자신을 양쪽에서 한 번씩 세게 된다.

5개 미만인 그룹과 XX'의 그룹, 이 둘을 제외하고 다시 세면 XX' 이상인 원소의 하한은

3(12n52)3n1063\left(\left\lceil \frac{1}{2}\left\lceil \frac{n}{5} \right\rceil \right\rceil - 2\right) \ge \frac{3n}{10} - 6

이다. 대칭으로 XX' 이하도 같다. 따라서 재귀하는 쪽의 크기는 0.7n0.7n이 아니라 7n10+6\frac{7n}{10} + 6 이하다.

작은 nn에서는 실제로 깨진다. n=6n = 6일 때 그룹이 {2,3,4,5,6}\{2,3,4,5,6\}{1}\{1\}로 나뉘면 그룹 중앙값은 4411이고, 이 둘의 중앙값 X=1X' = 1이다. XX' 이하인 원소는 자기 자신 하나뿐이라 0.3×6=1.80.3 \times 6 = 1.8에 미치지 못한다. n=7,8,9n = 7, 8, 9에서도 같은 일이 일어난다. 원인은 언제나 5개를 채우지 못한 그룹이다.

결론은 바뀌지 않는다. 선형성을 만드는 것은 15+710=910<1\frac{1}{5} + \frac{7}{10} = \frac{9}{10} < 1이라는 부등호인데, +6+6과 올림은 상수라 이 부등호를 건드리지 못한다. 어떤 상수 n0n_0 아래의 입력은 그냥 정렬해 답을 내면 되므로, 아래 분석은 이 상수항을 흘려보낸 이상화된 형태로 진행한다.

왜 5개인가 (예고)

왜 하필 5개씩일까? 3개로 나누면 왜 이 보장이 깨지는지는 별도 글에서 다룬다.


분석 — 최악에도 O(n)

Median of medians를 pivot 선택에 쓰면 전체 알고리즘의 점화식을 세울 수 있다.

Approximate median을 구하는 비용을 A(n)A(n)이라 하자. A(n)A(n)은 두 부분으로 나뉜다.

  • 5개씩 그룹 정렬: 상수 시간 그룹이 n/5n/5개이므로 O(n)O(n).
  • 그룹 중앙값 n/5n/5개에서 중앙값 재귀: S(0.2n)S(0.2n).
A(n)=n+S(0.2n)A(n) = n + S(0.2n)

Approximate median을 pivot으로 쓰면, 분할 후 재귀하는 쪽 배열 크기가 최대 7n10+6\frac{7n}{10} + 6이다. 올림과 상수항까지 적으면 점화식은 이렇다.

S(n)S ⁣(n5)+S ⁣(7n10+6)+O(n)S(n) \le S\!\left(\left\lceil \frac{n}{5} \right\rceil\right) + S\!\left(\frac{7n}{10} + 6\right) + O(n)

여기서 +6+6과 올림은 두 재귀 크기의 비율 합 15+710=910\frac{1}{5} + \frac{7}{10} = \frac{9}{10}을 바꾸지 못한다. 아래 분석은 상수항을 흘려보낸 이상화된 형태로 진행한다.

S(n)=A(n)+n+S(0.7n)=(n+S(0.2n))+n+S(0.7n)S(n) = A(n) + n + S(0.7n) = \bigl(n + S(0.2n)\bigr) + n + S(0.7n) S(n)=2n+S(0.2n)+S(0.7n)\boxed{S(n) = 2n + S(0.2n) + S(0.7n)}

대입법으로 풀기

S(n)cnS(n) \le cn이라는 선형 상한을 가정하고 점화식에 대입해, 이 가정이 유지되는 상수 cc가 존재함을 보인다. 가정대로라면 S(0.2n)0.2cnS(0.2n) \le 0.2cn, S(0.7n)0.7cnS(0.7n) \le 0.7cn이므로 점화식에 대입하면:

S(n)=2n+S(0.2n)+S(0.7n)2n+0.2cn+0.7cn=2n+0.9cnS(n) = 2n + S(0.2n) + S(0.7n) \le 2n + 0.2cn + 0.7cn = 2n + 0.9cn

이 우변이 다시 cncn 이하가 되려면 2n+0.9cncn2n + 0.9cn \le cn, 즉 20.1c2 \le 0.1c여야 한다. c=20c = 20으로 잡으면 부등식이 성립하므로, S(n)20n=O(n)S(n) \le 20n = O(n)이다.

핵심은 0.2+0.7=0.9<10.2 + 0.7 = 0.9 < 1이라는 점이다. 두 재귀의 크기 합이 nn보다 작기 때문에 0.1c20.1c \ge 2를 만족하는 유한한 cc가 존재하고, 그래서 선형 상한이 닫힌다. 이것이 median of medians 알고리즘이 최악에도 O(n)O(n)인 이유다.


핵심 정리
  • 선택은 정렬보다 쉬운 문제다. 정렬은 nn개 선택 문제를 한꺼번에 푸는 것이고, 선택은 하나만 찾으면 된다. 하한은 Ω(n)\Omega(n).
  • Quickselect는 quick sort의 분할을 재활용해 한쪽으로만 재귀한다. 평균 O(n)O(n)이지만 최악은 O(n2)O(n^2).
  • Approximate median(가운데 40%)을 pivot으로 쓰면 재귀 크기가 최대 0.7n0.7n으로 줄어들어 최악을 통제할 수 있다.
  • Median of medians는 5개씩 묶어 approximate median을 O(n)O(n)에 찾는다. 점화식 S(n)=2n+S(0.2n)+S(0.7n)S(n) = 2n + S(0.2n) + S(0.7n)S(n)cnS(n) \le cn 대입법으로 풀면 c=20c = 20, 즉 최악에도 O(n)O(n).
  • 양쪽 0.3n0.3n 보장은 정확한 값이 아니라 근사다. 5개를 못 채운 그룹과 올림 때문에 실제 하한은 3n106\frac{3n}{10} - 6이고, n=6,7,8,9n = 6, 7, 8, 9에서는 0.3n0.3n이 실제로 깨진다. 선형성을 만드는 것은 비율 합 910<1\frac{9}{10} < 1이라 상수항은 결론을 바꾸지 않는다.
  • 단, 상수 20과 메모리 오버헤드가 커서 실전에서는 정렬 후 인덱싱이나 std::nth_element가 더 빠를 때가 많다. Median of medians는 이론적 최악 보장이 중요한 상황에서 쓴다.
이어지는 글

Quickselect의 평균이 정말 O(n)O(n)인지 기댓값으로 유도하려면 quickselect 평균 O(n) 유도를 보자. 왜 3개가 아닌 5개로 묶어야 하는지는 왜 5개로 나누는가에서 다룬다. 이 글의 바탕이 된 분할 알고리즘은 quick sort에, 점화식을 푸는 일반 도구는 분할 정복에서 다룬다.

© 2026 XsQuare01. Powered by GitHub Pages. · 방문자