Top-K 문제 — 힙 하나로 대량 데이터에서 상위 K개 뽑기
10억 개의 데이터에서 상위 100개만 뽑으려면, 전부 정렬해야 할까요? 힙 하나면 충분합니다.
Top-K 문제란
n개의 데이터에서 가장 큰(또는 작은) k개의 원소 를 찾는 문제입니다.
- 인기 검색어 Top 10
- 매출 상위 100개 상품
- CPU 사용률이 높은 프로세스 Top 5
접근법 비교
| 방법 | 시간 복잡도 | 공간 복잡도 | 특징 |
|---|---|---|---|
| 전체 정렬 | O(n log n) | O(n) | 단순하지만 비효율 |
| Min-Heap | O(n log k) | O(k) | k가 작을 때 효율적 |
| Quick Select | O(n) 평균 | O(1) | 순서 보장 X |
| Partial Sort | O(n log k) | O(1) | 일부 언어 지원 |
방법 1: Min-Heap (가장 실용적)
크기 k의 min-heap을 유지합니다. 새 원소가 heap의 최솟값보다 크면 교체합니다.
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)
객체에 적용
// 매출 상위 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) 평균에 찾습니다.
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번째로 큰 원소 찾기
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를 유지하는 방법입니다.
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개 원소"를 찾는 변형입니다.
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)에 해결됩니다.
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)
// 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
메모리에 다 올릴 수 없는 경우:
- 파일을 청크로 나눠서 각 청크의 Top-K를 구합니다.
- 각 청크의 Top-K를 합쳐서 최종 Top-K를 구합니다.
- 이것이 MapReduce의 "로컬 Top-K → 글로벌 Top-K" 패턴입니다.
// 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-Heap | O(n log k) | O(k) | k << n, 스트리밍 |
| Quick Select | O(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)이지만, 원본 배열을 수정하고 순서를 보장하지 않습니다.
- 인기 검색어, 모니터링, 추천 시스템 등 실무에서 매우 자주 등장하는 패턴입니다.