정수 배열에서 K번째 큰 값을 찾는 문제(LeetCode 215)는 top-K 유형의 표준형이다. 이 유형이 보이면 반사적으로 heap 을 꺼내 왔는데, 같은 문제를 Quick Select 로 다시 풀면서 그 반사가 절반만 맞았다는 걸 확인했다. 두 도구는 같은 답을 다른 비용으로 찾는다. 데이터가 스트리밍으로 도착하면 heap 이 사실상 유일한 선택이고, 전체 배열이 이미 메모리에 있다면 Quick Select 가 평균적으로 더 빠르다.

전체 정렬도 물론 답을 낸다. N 이 10⁵ 수준이면 O(N log N) 정렬로도 통과한다. 다만 K번째 하나를 얻으려고 N 개 전부의 순서를 확정하는 건 필요 이상의 일이다.

Heap 크기 K 유지

heap 풀이의 핵심은 max-heap 이 아니라 min-heap 이다. 크기 K 의 min-heap 에 지금까지 본 값 중 큰 것 K 개만 남기면, 루트 heap[0] 은 그 K 개 중 최솟값, 즉 현재까지의 K번째 큰 값 후보가 된다. 처음 K 개는 비교 없이 그대로 채우고, 이후부터 새 값을 루트와 비교한다. 새 값이 루트보다 크면 루트를 버리고 새 값을 넣는다. 순회가 끝나면 루트가 답이다.

max-heap 으로 전체 N 개를 쌓고 K 번 pop 하는 방법도 있다. 답은 같지만 공간이 O(N) 으로 늘어난다. 크기 K 유지 방식은 공간이 O(K) 에 머물고, K 가 N 보다 훨씬 작을수록 이득이 커진다.

flowchart TD
    A["새 값 value"] --> B{"heap 크기 < K"}
    B -->|예| C["heappush"]
    B -->|아니오| D{"value > heap[0]"}
    D -->|예| E["heapreplace"]
    D -->|아니오| F["버린다"]
import heapq


def find_kth_largest(nums, k):
    top_k = []
    for value in nums:
        if len(top_k) < k:
            heapq.heappush(top_k, value)
        elif value > top_k[0]:
            heapq.heapreplace(top_k, value)
    return top_k[0]

heapreplace 는 pop 과 push 를 한 번의 재정렬로 처리한다. heappopheappush 를 따로 부르면 내부 정렬이 두 번 일어난다. 비교 부등호가 > 인 것도 의도적이다. 새 값이 루트와 같으면 교체해도 후보가 바뀌지 않으니 건너뛴다.

이 구조의 강점은 스트리밍이다. 원소가 하나 도착할 때마다 같은 판단을 반복하면 되고, N 을 미리 알 필요가 없다. 실시간 순위표나 로그 스트림처럼 원소가 계속 도착하고 끝을 모르는 상황에서도 같은 코드가 그대로 동작하며, 매 시점 top_k[0] 을 읽으면 그 시점까지의 K번째 큰 값이 나온다. 복잡도는 원소당 O(log K), 전체 O(N log K) 다.

Quick Select

Quick Select 는 발상이 다르다. K번째 큰 값은 오름차순 정렬을 가정하면 인덱스 N - K 위치의 값이다(0-indexed). [3, 2, 1, 5, 6, 4] 에서 2번째 큰 값은 정렬 후 인덱스 4 의 5 다. 전체를 정렬하지 않고 그 위치의 값만 확정할 수 있다면 정렬 비용을 아낄 수 있고, Quick Sort 의 partition 이 그 위치를 확정해 준다.

partition 은 pivot 하나를 골라 그보다 작은 값을 왼쪽으로 모으고, pivot 을 최종 정렬 위치에 놓은 뒤 그 위치를 반환한다. 반환된 위치가 목표 인덱스와 같으면 그 값이 답이다. 다르면 답이 있는 쪽 절반에서만 다시 partition 한다. Quick Sort 가 양쪽을 모두 재귀하는 것과 달리 Quick Select 는 한쪽만 들어가고, 이 차이가 평균 복잡도를 가른다. 탐색 구간이 매번 절반씩 줄어든다고 보면 N + N/2 + N/4 + … 로 2N 을 넘지 않는다. 평균 O(N) 이다.

flowchart TD
    A["partition(lo, hi) → p"] --> B{"p == target"}
    B -->|예| C["답 nums[p]"]
    B -->|"p < target"| D["오른쪽 구간만 다시 partition"]
    B -->|"p > target"| E["왼쪽 구간만 다시 partition"]
    D --> A
    E --> A
import random


def find_kth_largest(nums, k):
    target = len(nums) - k
    lo, hi = 0, len(nums) - 1
    while True:
        pivot_pos = random.randint(lo, hi)
        nums[pivot_pos], nums[hi] = nums[hi], nums[pivot_pos]
        pivot = nums[hi]
        boundary = lo
        for i in range(lo, hi):
            if nums[i] < pivot:
                nums[boundary], nums[i] = nums[i], nums[boundary]
                boundary += 1
        nums[boundary], nums[hi] = nums[hi], nums[boundary]
        if boundary == target:
            return nums[boundary]
        if boundary < target:
            lo = boundary + 1
        else:
            hi = boundary - 1

target 은 K번째 큰 값의 정렬 후 인덱스, boundary 는 pivot 미만 구역의 다음 자리다. pivot 을 무작위로 고르는 첫 줄이 최악을 막는다. 이미 정렬된 입력에서 끝 값을 pivot 으로 고정하면 구간이 매번 한 칸씩만 줄어 O(N²) 가 된다. 무작위화하면 어떤 입력이 와도 평균 O(N) 을 기대할 수 있다.

주의할 점은 in-place 라는 것이다. 입력 배열이 제자리에서 섞인다. 원본 순서를 보존해야 한다면 복사본으로 시작해야 하고, 그러면 공간 이점은 사라진다.

선택 기준

항목heap 크기 K 유지Quick Select
시간 평균O(N log K)O(N)
시간 최악O(N log K)O(N²), 무작위 pivot 으로 회피
공간O(K)O(1), in-place
스트리밍가능불가, 전체 배열 필요

heap 이 적합한 경우:

  • 데이터가 스트리밍으로 도착하거나 N 을 미리 모른다
  • 매 시점의 K번째 값을 반복해서 조회해야 한다
  • 원본 배열을 수정하지 않고 공간을 O(K) 로 제한하고 싶다

Quick Select 가 적합한 경우:

  • 전체 배열이 이미 메모리에 있다
  • K번째 값을 한 번만 찾으면 된다
  • 배열을 제자리에서 수정해도 무방하다

라이브 코딩이라면 구현이 짧고 복잡도가 일정한 heap 으로 먼저 통과시키고, 시간이 남으면 Quick Select 의 평균 O(N) 과 무작위 pivot 을 언급하는 순서가 낫다고 본다.

K번째 큰 값이라는 문제 문장은 같아도 데이터가 주어지는 형태에 따라 도구가 갈린다. 스트리밍으로 도착하는 데이터라면 크기 K 의 heap 을 유지하고, 전체 배열이 주어져 있고 한 번만 찾는다면 partition 으로 목표 인덱스에 수렴한다. 반사적으로 heap 을 꺼내기 전에 데이터 형태를 먼저 확인하는 습관이 이번 재풀이에서 남았다.