10억 개의 데이터에서 상위 100개만 뽑으려면, 전부 정렬해야 할까요? 힙 하나면 충분합니다.

Top-K 문제란

n개의 데이터에서 가장 큰(또는 작은) k개의 원소 를 찾는 문제입니다.

  • 인기 검색어 Top 10
  • 매출 상위 100개 상품
  • CPU 사용률이 높은 프로세스 Top 5

접근법 비교

방법시간 복잡도공간 복잡도특징
전체 정렬O(n log n)O(n)단순하지만 비효율
Min-HeapO(n log k)O(k)k가 작을 때 효율적
Quick SelectO(n) 평균O(1)순서 보장 X
Partial SortO(n log k)O(1)일부 언어 지원

방법 1: Min-Heap (가장 실용적)

크기 k의 min-heap을 유지합니다. 새 원소가 heap의 최솟값보다 크면 교체합니다.

JAVA
public static int[] topK(int[] arr, int k) {
    // 크기 k의 min-heap
    PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);

    for (int num : arr) {
        if (minHeap.size() < k) {
            minHeap.offer(num);
        } else if (num > minHeap.peek()) {
            minHeap.poll();   // 가장 작은 원소 제거
            minHeap.offer(num); // 새 원소 추가
        }
    }

    // 힙을 배열로 변환
    int[] result = new int[k];
    for (int i = k - 1; i >= 0; i--) {
        result[i] = minHeap.poll();
    }
    return result;
}

왜 max-heap이 아닌 min-heap인가?

max-heap을 사용하면 크기가 n이 되어야 합니다. min-heap 크기 k 를 유지하면:

  • 힙의 루트(최솟값)가 "현재까지의 Top-K 중 가장 작은 값"
  • 새 원소가 이 값보다 크면 교체 → 항상 Top-K 유지
  • 공간: O(k), 삽입: O(log k)

객체에 적용

JAVA
// 매출 상위 K개 상품 찾기
public static List<Product> topKProducts(List<Product> products, int k) {
    PriorityQueue<Product> minHeap = new PriorityQueue<>(
        Comparator.comparingLong(Product::getRevenue) // 매출 기준 min-heap
    );

    for (Product p : products) {
        if (minHeap.size() < k) {
            minHeap.offer(p);
        } else if (p.getRevenue() > minHeap.peek().getRevenue()) {
            minHeap.poll();
            minHeap.offer(p);
        }
    }

    List<Product> result = new ArrayList<>(minHeap);
    result.sort(Comparator.comparingLong(Product::getRevenue).reversed());
    return result;
}

방법 2: Quick Select

Quick Sort의 파티셔닝을 한쪽만 수행하여 k번째 원소를 O(n) 평균에 찾습니다.

JAVA
public static int quickSelect(int[] arr, int left, int right, int k) {
    if (left == right) return arr[left];

    int pivotIdx = partition(arr, left, right);

    if (k == pivotIdx) {
        return arr[k];
    } else if (k < pivotIdx) {
        return quickSelect(arr, left, pivotIdx - 1, k);
    } else {
        return quickSelect(arr, pivotIdx + 1, right, k);
    }
}

private static int partition(int[] arr, int left, int right) {
    // 랜덤 피벗 선택 (최악 O(n²) 방지)
    int randomIdx = left + new Random().nextInt(right - left + 1);
    swap(arr, randomIdx, right);

    int pivot = arr[right];
    int i = left;

    for (int j = left; j < right; j++) {
        if (arr[j] <= pivot) {
            swap(arr, i, j);
            i++;
        }
    }
    swap(arr, i, right);
    return i;
}

k번째로 큰 원소 찾기

JAVA
public static int findKthLargest(int[] arr, int k) {
    // k번째로 큰 = (n-k)번째로 작은
    return quickSelect(arr, 0, arr.length - 1, arr.length - k);
}

Quick Select의 특징

  • 평균: O(n), 최악: O(n²) (랜덤 피벗으로 회피)
  • 원본 배열을 수정함 (in-place)
  • k번째 원소를 기준으로 좌/우가 나뉘지만, 각 쪽의 순서는 보장 안 됨

스트리밍 Top-K

데이터가 한 번에 주어지는 게 아니라 ** 실시간으로 들어올 때** Top-K를 유지하는 방법입니다.

JAVA
public class StreamingTopK {
    private final PriorityQueue<Integer> minHeap;
    private final int k;

    public StreamingTopK(int k) {
        this.k = k;
        this.minHeap = new PriorityQueue<>(k);
    }

    // 새 데이터가 들어올 때마다 호출
    public void add(int value) {
        if (minHeap.size() < k) {
            minHeap.offer(value);
        } else if (value > minHeap.peek()) {
            minHeap.poll();
            minHeap.offer(value);
        }
    }

    // 현재 Top-K 반환
    public List<Integer> getTopK() {
        List<Integer> result = new ArrayList<>(minHeap);
        result.sort(Collections.reverseOrder());
        return result;
    }
}

Top-K 빈도 문제

"가장 자주 나타나는 K개 원소"를 찾는 변형입니다.

JAVA
public static List<Integer> topKFrequent(int[] nums, int k) {
    // 1. 빈도 계산
    Map<Integer, Integer> freq = new HashMap<>();
    for (int num : nums) {
        freq.merge(num, 1, Integer::sum);
    }

    // 2. 빈도 기준 min-heap (크기 k)
    PriorityQueue<Map.Entry<Integer, Integer>> minHeap =
        new PriorityQueue<>(Comparator.comparingInt(Map.Entry::getValue));

    for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
        minHeap.offer(entry);
        if (minHeap.size() > k) {
            minHeap.poll();
        }
    }

    // 3. 결과 추출
    List<Integer> result = new ArrayList<>();
    while (!minHeap.isEmpty()) {
        result.add(minHeap.poll().getKey());
    }
    Collections.reverse(result);
    return result;
}

Bucket Sort 활용 (O(n))

빈도가 최대 n이므로, 빈도를 인덱스로 하는 버킷을 만들면 O(n)에 해결됩니다.

JAVA
public static List<Integer> topKFrequentBucket(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int num : nums) freq.merge(num, 1, Integer::sum);

    // 빈도를 인덱스로 하는 버킷
    List<List<Integer>> buckets = new ArrayList<>();
    for (int i = 0; i <= nums.length; i++) {
        buckets.add(new ArrayList<>());
    }

    for (var entry : freq.entrySet()) {
        buckets.get(entry.getValue()).add(entry.getKey());
    }

    // 높은 빈도부터 k개 수집
    List<Integer> result = new ArrayList<>();
    for (int i = buckets.size() - 1; i >= 0 && result.size() < k; i--) {
        result.addAll(buckets.get(i));
    }
    return result.subList(0, k);
}

백엔드/실무에서의 연결

인기 검색어 (Real-time Trending)

JAVA
// Redis Sorted Set으로 인기 검색어 관리
// ZINCRBY search:trending "spring boot" 1    → 검색할 때마다 점수 증가
// ZREVRANGE search:trending 0 9              → 상위 10개 조회

Redis의 Sorted Set은 내부적으로 skiplist를 사용하며, Top-K 조회가 O(log n + k)입니다.

로드 밸런서의 핫스팟 감지

가장 많은 요청을 받는 서버 Top-K를 실시간으로 추적하여 트래픽을 재분배합니다.

모니터링 대시보드

  • CPU 사용률 Top 10 프로세스
  • 응답 시간이 가장 느린 API Top 5
  • 에러율이 높은 서비스 Top 3

이런 지표들은 모두 스트리밍 Top-K 문제입니다.

추천 시스템

"이 상품을 본 사람이 가장 많이 본 다른 상품 Top-10"은 빈도 기반 Top-K입니다.

대용량 파일에서의 Top-K

메모리에 다 올릴 수 없는 경우:

  1. 파일을 청크로 나눠서 각 청크의 Top-K를 구합니다.
  2. 각 청크의 Top-K를 합쳐서 최종 Top-K를 구합니다.
  3. 이것이 MapReduce의 "로컬 Top-K → 글로벌 Top-K" 패턴입니다.
JAVA
// MapReduce Top-K 패턴 (개념)
// Map: 각 파티션에서 로컬 Top-K 추출
// Reduce: 모든 로컬 Top-K를 모아서 글로벌 Top-K 추출

주의할 점

전체를 정렬한 뒤 K개를 뽑는 비효율

전체 정렬은 O(n log n)이지만, min-heap을 사용하면 O(n log K)에 Top-K를 구할 수 있습니다. K가 n보다 훨씬 작을 때 차이가 큽니다.

min-heap과 max-heap을 혼동하는 실수

상위 K개를 구할 때는 ** 크기 K의 min-heap**을 사용합니다. min-heap의 루트가 "현재 Top-K 중 가장 작은 값"이므로, 새 원소가 루트보다 크면 교체합니다. max-heap을 사용하면 상위 K개가 아닌 하위 K개가 유지됩니다.


정리

방법시간공간적합한 상황
전체 정렬O(n log n)O(n)k ≈ n
Min-HeapO(n log k)O(k)k << n, 스트리밍
Quick SelectO(n) 평균O(1)k번째 원소만 필요
Bucket (빈도)O(n)O(n)빈도 기반 Top-K

기억할 포인트:

  • Top-K는 min-heap 크기 k 를 유지하는 것이 가장 실용적입니다.
  • 스트리밍 데이터에서도 min-heap으로 O(log k)에 갱신할 수 있습니다.
  • Quick Select는 평균 O(n)이지만, 원본 배열을 수정하고 순서를 보장하지 않습니다.
  • 인기 검색어, 모니터링, 추천 시스템 등 실무에서 매우 자주 등장하는 패턴입니다.
댓글 로딩 중...