에듀테크 프로젝트 대시보드
퀵 셀렉트 (Quickselect)
"시험 성적 300개 중 중앙값만 알고 싶다" — 이럴 때 전체를 정렬(O(n log n))하는 건 낭비다. 퀵 셀렉트는 퀵 정렬의 절반만 써서 평균 O(n)에 k번째로 작은 값을 찾는다.
std::nth_element, NumPy np.partition의 내부 구현.
k번째로 작은 값 하나만 필요한데 전체를 정렬하면, 나머지 n-1개의 순서까지 전부 확정해 버린다. 필요 없는 정보다.
퀵 정렬을 떠올려 보자. 피벗 하나를 골라 배열을 "피벗보다 작은 것들 | 피벗 | 피벗보다 큰 것들"로 나누면(파티션), 피벗은 자기 최종 자리에 놓인다. 그 자리가 인덱스 p라면:
p == k-1 → 피벗이 바로 답이다. 끝.p > k-1 → 답은 왼쪽 조각에 있다. 오른쪽은 통째로 버린다.p < k-1 → 답은 오른쪽 조각에 있다. 왼쪽을 버린다.퀵 정렬은 양쪽을 모두 재귀하지만, 퀵 셀렉트는 한쪽만 파고든다. 이 차이가 O(n log n)을 O(n)으로 바꾼다.
「한 단계 →」를 누르며 진행해 보자. 보라 막대가 피벗, 주황 테두리가 지금 비교 중인 원소, 초록 눈금이 우리가 찾는 위치 k-1이다. 흐려진 막대는 이미 버려진 구간이다.
비교 횟수 옆의 회색 숫자는 "전체 정렬이라면 대략 필요했을 비교 수(n·log₂n)"입니다.
def quickselect(a, k): # k: 1-based 순위 lo, hi, target = 0, len(a) - 1, k - 1 while lo < hi: p = partition(a, lo, hi) # 피벗을 제자리에, 그 인덱스 반환 if p == target: return a[p] elif p < target: lo = p + 1 # 오른쪽 조각만 남긴다 else: hi = p - 1 # 왼쪽 조각만 남긴다 return a[lo] def partition(a, lo, hi): # Lomuto 방식, 피벗 = 마지막 원소 pivot = a[hi] i = lo for j in range(lo, hi): if a[j] < pivot: a[i], a[j] = a[j], a[i] i += 1 a[i], a[hi] = a[hi], a[i] # 피벗을 경계(i)로 return i
재귀 대신 while 루프로 lo/hi만 좁혀 가므로 추가 스택도 필요 없다(O(1) 공간).
피벗이 운 좋게 매번 구간을 절반으로 나눈다고 하면, 훑는 양이 n + n/2 + n/4 + n/8 + … 이다. 이 무한합은 2n 으로 수렴한다.
n + n/2 + n/4 + … → 2n ⇒ O(n)
퀵 정렬이 O(n log n)인 건 각 단계에서 양쪽 모두를 처리해 매 레벨이 다시 n이 되고, 레벨이 log n개이기 때문이다. 퀵 셀렉트는 레벨마다 일이 절반으로 줄어 총합이 상수배 n에 그친다.
이미 정렬된 배열에서 항상 끝 원소를 피벗으로 쓰면, 구간이 1씩만 줄어 n + (n-1) + (n-2) + … = O(n²). 해결책:
O(n). (실무 표준)O(n). 상수가 커서 보통은 무작위 피벗을 쓴다.O(n log k) 선택으로 전환. std::nth_element가 이 방식.sorted(a)[k-1]. O(n log n). 구현은 한 줄이고, 여러 개의 k를 물어볼 거라면 오히려 이득.
최대 힙에 k개를 유지하며 스캔. O(n log k), O(k) 공간. 스트리밍(전체를 메모리에 못 올릴 때)에 적합.
| 방법 | 평균 시간 | 최악 시간 | 추가 공간 | 비고 |
|---|---|---|---|---|
| 전체 정렬 | O(n log n) | O(n log n) | O(1)~O(n) | 여러 k 질의에 유리 |
| 크기 k 힙 | O(n log k) | O(n log k) | O(k) | 스트리밍·상위 K |
| 퀵 셀렉트 (무작위) | O(n) | O(n²) | O(1) | 단일 k, 제자리 |
| median-of-medians | O(n) | O(n) | O(log n) | 최악 보장, 상수 큼 |