고정 크기 Min-Heap 기반 Top-K 선택
수천만 개의 요청 중 가장 느린 100개만 필요하다면 전체를 정렬하는 것은 요구사항보다 훨씬 많은 일을 수행한다. 필요한 것은 상위 K개에 포함될 후보만 유지하는 것뿐이다. 이때 크기 K의 min-heap을 두고 현재 Top-K 가운데 가장 약한 원소를 root에 유지하면, 새 원소 하나를 볼 때 root보다 강한지만 판단하면 되는 것이다.
· 연습 메모 · 43 min read · Medium
수천만 개의 요청 중 가장 느린 100개만 필요하다면 전체를 정렬하는 것은 요구사항보다 훨씬 많은 일을 수행한다.
필요한 것은 상위 K개에 포함될 후보만 유지하는 것뿐이다. 이때 크기 K의 min-heap을 두고 현재 Top-K 가운데 가장 약한 원소를 root에 유지하면, 새 원소 하나를 볼 때 root보다 강한지만 판단하면 되는 것이다.
Heap의 삽입·제거 비용은 heap 크기에 대해 O(log K)이므로 전체 입력 N을 한 번 순회하는 비용은 O(N log K), 유지 메모리는 O(K)가 된다. C++ 표준 초안은 heap의 원소 추가·제거가 O(log N) 시간에 가능하다고 설명하며, 개별 push_heap과 pop_heap의 Complexity 절에서는 비교 횟수를 각각 log N, 2 log N 이하로 제한한다. 1
O(N log K)는 상한이라는 점을 인지해두어야한다. heap이 K개로 찬 뒤에는 후보 대부분이 root와의 비교 한 번에 탈락하고 heap을 건드리지 않는다.
아래의 구현처럼 처음 K개를 하나씩 heap에 넣는 경우, 무작위 입력의 기대 비용은 대략 O(K + N + K(H_N - H_K) log K)로 쓸 수 있다.
여기서 H_n은 조화수다.
첫 항이 K log K가 아니라 K인 이유를 짚어 둔다. 무작위 순열을 heap에 하나씩 push할 때 sift-up 거리는 평균 O(1)이라 build 전체가 선형인데, Hayward와 McDiarmid의 분석은 Williams의 repeated insertion에 대해 평균 비교 횟수를 대략 2.28K 수준으로 준다. 최악은 여전히 K log K이지만 기대 비용을 말할 때는 선형이라고 할 수 있다.
K << N인 이 글의 대상 workload에서는 전체를 O(N + K log K log(N/K))로 단순화할 수 있다. 예를 들어서, N이 5천만이고 K가 100이면 기대 갱신 횟수는 약 1,312번이고, 나머지 대부분의 입력은 root와의 비교 한 번으로 탈락한다.
N=1,000,000 K=100: 실측 931회 공식 921회
N=5,000,000 K=100: 실측 1,077회 공식 1,081회(본인이 실측해본 결과)
반대로 입력이 이미 오름차순이면 매 원소가 갱신을 일으켜 상한에 도달한다. 벤치마크 결과를 해석할 때 이 차이를 알고 있어야 한다.
헷갈리기 쉬운 지점은 min-heap의 root가 "가장 좋은 값"이 아니라 현재 Top-K의 탈락 1순위라는 점이다. 가장 느린 요청을 찾는다면 Top-K 안에서 latency가 가장 작은 요청을 root에 두는데, 새 요청이 root보다 느리면 root를 제거하고 새 요청을 넣고, 더 빠르면 버린다. Heap 크기는 K를 넘지 않는다.
개인적인 메모: 보통 초보자들이 가장 많이 헷갈리는게 Min/Max Heap의 방향성이다. 가장 큰 K 갯수 찾기 위해 Min-Heap을 써야하는 이유는 컷오프(방어선,CutOff)때문이다.
하지만 latency만 comparator에 넣으면 production 결과가 비결정적일 수 있다. 같은 latency를 가진 요청이 여러 개라면 어느 요청이 Top-K에 남는지 container의 안정성에 의존하게 된다.
Python heapq 문서도 heap 정렬은 stable하지 않으며 동순위 작업에는 별도 tie-breaker가 필요할 수 있다고 설명한다. .NET PriorityQueue 역시 같은 priority에 대해 FIFO를 보장하지 않는다고 되어있다.
따라서 이번 예제는 (latencyMicros DESC, requestId ASC)를 완전한 랭킹 계약(ranking contract)으로 정의한다. ([Python documentation]2)
이 계약에는 전제가 있는데, requestId가 유일해야 total order가 된다. ID가 중복되면 comparator가 양방향으로 false를 반환해 두 원소가 동치가 되고 어느 쪽이 남는지가 다시 구현에 의존한다. at-least-once 전달이나 재시도나 클라이언트가 만든 ID를 쓰는 시스템이라면 수신 시퀀스 같은 세 번째 키를 넣어야 한다.
이 패턴은 특히 스트리밍 텔레메트리(streaming telemetry)[한국어로 스트리밍 텔레메트리 번역어가 뭔지 찾을 수 없었다], 로그 분석, leaderboard 후보 추출, 이상 랭킹(anomaly ranking)처럼 K << N이고 입력 전체를 보관할 필요가 없는 경우에 적합하다. 반대로 K가 N에 가까우면 heap 유지 비용이 장점이 되지 않을 수 있다. Python 공식 문서 역시 nlargest/nsmallest는 작은 n에서 유리하며 큰 n에서는 전체 정렬이 더 적합할 수 있다고 안내한다.
Top-K heap은 입력 규모와 K의 비율이 그 선택을 정당화할 때 쓰는 것이다 (2)
운영에서는 heap에 무엇을 저장하는지도 중요한데, 느린 요청 100개를 찾는 데 request body, response body, tracing tree까지 전부 heap에 넣으면 K가 작아도 거대한 객체 그래프가 메모리에 붙잡힐 수 있다. Ranking에 필요한 immutable key와 최종 출력에 필요한 최소 summary만 heap에 유지하는 편이 안전하다.
간단한 요약
Ranking:
A가 B보다 좋다
=
A.latencyMicros > B.latencyMicros
또는 latency가 같고
A.requestId < B.requestId
Heap:
현재 Top-K만 저장
root
=
현재 Top-K 중 가장 나쁜 원소
새 candidate:
heap.size < K
-> 삽입
candidate > root
-> root 제거
-> candidate 삽입
candidate <= root
-> 폐기
복잡도:
Scan:
O(N log K)
Memory:
O(K)
최종 정렬:
O(K log K)1. 문제 상황
API gateway가 하루 동안 5천만 개 요청의 latency를 관측한다고 하자.
각 요청에서 필요한 정보는 다음뿐이다.
LatencySample
- requestId
- latencyMicros목표:
가장 느린 요청 100개입력:
requestId latencyMicros
101 1,250
102 82
103 8,400
104 910
105 8,400
...Ranking contract:
1순위:
latencyMicros가 클수록 우선
2순위:
latency가 같다면
requestId가 작을수록 우선따라서:
103 / 8400
105 / 8400
101 / 1250
104 / 910
102 / 82순이다.
K = 3이면 유지할 값은:
103 / 8400
105 / 8400
101 / 1250이다.
하지만 heap 내부에서는 다음 원소가 root다.
101 / 1250왜냐하면 101이 현재 Top-3에서 가장 먼저 탈락해야 하는 원소이기 때문이다.
새 요청:
106 / 500은 root보다도 약하다.
500 < 1250
-> 즉시 폐기반대로:
107 / 2200이면:
2200 > 1250
-> 101 제거
-> 107 삽입이 된다.
개인적인 메모:DDOS 테스트 후에 방어 Workload 테스트에서 전체 비용을 체크할때 쓴다, 공격 후보의 위험도 값을 스트리밍으로 관측하면서 상위 K개만 min-heap에 유지하므로, 임계값이 형성된 이후에는 대부분의 후보가 heap root와의 비교 한 번으로 탈락한다. 실제로 요긴하게 써먹었다.
2. 핵심 표현
C++23
C++에서는 작은 값 객체를 generic bounded heap에 넣는다. Comparator 하나가 최종 정렬 순서와 heap cutoff 의미를 동시에 정의한다.
#include <algorithm>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <queue>
#include <ranges>
#include <utility>
#include <vector>
class LatencySample final
{
public:
constexpr LatencySample(
std::uint64_t requestId,
std::uint32_t latencyMicros) noexcept
: requestId_(requestId),
latencyMicros_(latencyMicros)
{
}
[[nodiscard]]
constexpr std::uint64_t getRequestId() const noexcept
{
return requestId_;
}
[[nodiscard]]
constexpr std::uint32_t getLatencyMicros() const noexcept
{
return latencyMicros_;
}
private:
std::uint64_t requestId_;
std::uint32_t latencyMicros_;
};
struct BetterLatencyRank final
{
[[nodiscard]]
constexpr bool operator()(
const LatencySample& left,
const LatencySample& right) const noexcept
{
if (left.getLatencyMicros()
!= right.getLatencyMicros())
{
return left.getLatencyMicros()
> right.getLatencyMicros();
}
return left.getRequestId()
< right.getRequestId();
}
};
template<
std::copy_constructible T,
typename Better>
requires std::strict_weak_order<
Better,
const T&,
const T&>
class BoundedTopK final
{
public:
BoundedTopK(
std::size_t limit,
Better better)
: limit_(limit),
better_(std::move(better)),
heap_(better_)
{
}
void add(T candidate)
{
if (limit_ == 0)
{
return;
}
if (heap_.size() < limit_)
{
heap_.push(std::move(candidate));
return;
}
replaceWorstIfBetter(
std::move(candidate));
}
[[nodiscard]]
std::vector<T> finish() &&
{
std::vector<T> result;
result.reserve(heap_.size());
while (!heap_.empty())
{
result.push_back(
heap_.top());
heap_.pop();
}
std::ranges::sort(
result,
better_);
return result;
}
private:
std::size_t limit_;
Better better_;
std::priority_queue<
T,
std::vector<T>,
Better> heap_;
void replaceWorstIfBetter(
T candidate)
{
if (!better_(
candidate,
heap_.top()))
{
return;
}
heap_.pop();
heap_.push(
std::move(candidate));
}
};사용:
BoundedTopK<
LatencySample,
BetterLatencyRank> topK{
100,
BetterLatencyRank{}};
for (const LatencySample& sample : samples)
{
topK.add(sample);
}
std::vector<LatencySample> slowest =
std::move(topK).finish();finish() &&는 lvalue에서의 호출을 막지만 객체를 선형 타입처럼 소비시키지는 않는다. std::move(topK).finish()를 다시 호출하는 코드는 컴파일된다.
첫 호출이 heap을 비우므로 두 번째 결과가 빈 vector가 될 뿐이다.
finish()가 원소를 복사한다는 점도 알아 둘 필요가 있다. std::priority_queue::top()은 const T&를 반환하므로result.push_back(heap_.top())에서 move가 일어나지 않는다.
이 글이 전제하는 compact value struct라면 K번 복사는 무시할 수 있지만, 5절에서 경고하는 대로 무거운 객체를 heap에 넣었다면 여기서 그 대가를 한 번 더 치른다. top()을 const_cast로 벗겨 move하는 우회는 표준이 보장하지 않으므로 쓰지 않는다. 필요하면 std::vector를 직접 underlying container로 잡고 std::pop_heap / pop_back을 손으로 호출하는 편이 낫다고 생각한다.
std::priority_queue에서 comparator 방향은 처음 보면 반대로 느껴질 수 있다. 이번 BetterLatencyRank는 "더 좋은 원소가 먼저 온다"는 ordering을 표현하고 그 ordering을 priority queue에 주면 top()에는 그 ordering상 가장 뒤쪽인 가장 나쁜 retained value가 위치한다.
복잡도 근거는 한 단계를 거쳐 온다. 표준의 [priority.queue] 절에는 push와 pop에 대한 별도의 Complexity 조항이 없다. 대신 push를 "As if by: c.push_back(x); push_heap(c.begin(), c.end(), comp)"로, pop을 "As if by: pop_heap(c.begin(), c.end(), comp); c.pop_back()"으로 규정한다. 따라서 O(log K)는 priority_queue 자체가 보장하는 값이 아니라 이 as-if 규정을 통해 [alg.heap.operations]의 비교 횟수 상한에서 상속된다.
그 절이 push_heap을 log(last - first) 이하, pop_heap을 2 log(last - first) 이하로 제한한다. 실제 전체 시간은 비교 함수와 underlying container의 비용에도 영향을 받는다. (3, 1)
Python
Python heapq는 기본적으로 min-heap이며 heap[0]이 가장 작은 원소다. heapreplace()는 최소 원소를 제거하고 새 값을 넣는 fixed-size heap에 적합한 연산으로 공식 문서에 설명돼 있다. ([Python documentation]2)
from __future__ import annotations
from dataclasses import dataclass, field
from heapq import heapreplace, heappush
@dataclass(frozen=True, slots=True)
class LatencySample:
request_id: int
latency_micros: int
@dataclass(order=True, slots=True, frozen=True)
class _HeapItem:
latency_micros: int
inverted_request_id: int
sample: LatencySample = field(
compare=False
)
class BoundedTopKLatency:
def __init__(
self,
limit: int,
) -> None:
if limit < 0:
raise ValueError(
"limit은 0 이상이어야 합니다."
)
self._limit = limit
self._heap: list[_HeapItem] = []
def add(
self,
sample: LatencySample,
) -> None:
if self._limit == 0:
return
if len(self._heap) < self._limit:
heappush(
self._heap,
_to_heap_item(sample),
)
return
self._replace_if_better(
sample
)
def finish(
self,
) -> tuple[LatencySample, ...]:
result = [
item.sample
for item in self._heap
]
result.sort(
key=lambda sample: (
-sample.latency_micros,
sample.request_id,
)
)
return tuple(result)
def _replace_if_better(
self,
sample: LatencySample,
) -> None:
root = self._heap[0]
root_request_id = -root.inverted_request_id
if (
sample.latency_micros < root.latency_micros
or (
sample.latency_micros
== root.latency_micros
and sample.request_id >= root_request_id
)
):
return
heapreplace(
self._heap,
_to_heap_item(sample),
)
def _to_heap_item(
sample: LatencySample,
) -> _HeapItem:
return _HeapItem(
latency_micros=(
sample.latency_micros
),
inverted_request_id=(
-sample.request_id
),
sample=sample,
)사용:
top_k = BoundedTopKLatency(
limit=100
)
for sample in samples:
top_k.add(sample)
slowest = top_k.finish()inverted_request_id가 필요한 이유는 min-heap root를 "최악의 retained record"로 만들기 위해서다.
_HeapItem을 frozen=True로 둔 것은 의도적으로 둔 것이다. 5절에서 다루듯 heap에 들어간 ranking field를 나중에 바꾸면 root가 최소라는 보장이 깨진다. heap 바깥의 LatencySample만 immutable하고 정작 heap에 들어가는 쪽이 mutable이면 그 규칙이 코드로 강제되지 않는다. heapq는 항목 자체를 수정하지 않고 list slot만 교환하므로 frozen으로 두어도 동작에는 영향이 없다.
heap이 아직 덜 찼을 때는 _HeapItem을 만들어 저장한다. 반대로 heap이 가득 찬 뒤에는 먼저 LatencySample의 두 필드와 root를 비교하고, 실제로 교체할 후보일 때만 _HeapItem을 만든다. 그래서 탈락하는 입력마다 임시 객체를 하나씩 만들지 않는다. 보관하는 원소 수는 여전히 O(K)이고, heap이 찬 뒤의 _HeapItem 생성 횟수는 교체 횟수에 비례한다.
동일 latency에서:
requestId가 작음
-> 더 좋음
requestId가 큼
-> 더 나쁨
-> heap root 쪽으로 가야 함정리하면:
heap key:
(
latency,
-requestId
)를 사용한다.
C#
.NET PriorityQueue<TElement,TPriority>는 우선순위가 가장 낮은 원소를 먼저 제거하는 min-priority queue이며 현재 구현은 배열 기반 quaternary min-heap이다. 또한 같은 priority에 대해 FIFO를 보장하지 않는다. 이번 코드는 그래서 requestId까지 priority 안에 포함한다. ([Microsoft Learn]4)
using System;
using System.Collections.Generic;
public readonly struct LatencySample
{
private readonly ulong requestId;
private readonly uint latencyMicros;
public LatencySample(
ulong requestId,
uint latencyMicros)
{
this.requestId = requestId;
this.latencyMicros = latencyMicros;
}
public ulong GetRequestId()
{
return this.requestId;
}
public uint GetLatencyMicros()
{
return this.latencyMicros;
}
}
internal readonly struct RankKey
: IComparable<RankKey>
{
private readonly uint latencyMicros;
private readonly ulong requestId;
private RankKey(
uint latencyMicros,
ulong requestId)
{
this.latencyMicros = latencyMicros;
this.requestId = requestId;
}
public static RankKey From(
in LatencySample sample)
{
return new RankKey(
sample.GetLatencyMicros(),
sample.GetRequestId());
}
public int CompareTo(
RankKey other)
{
int latencyOrder =
this.latencyMicros.CompareTo(
other.latencyMicros);
if (latencyOrder != 0)
{
return latencyOrder;
}
return other.requestId.CompareTo(
this.requestId);
}
}
public sealed class BoundedTopKLatency
{
private readonly int limit;
private readonly PriorityQueue<
LatencySample,
RankKey> heap;
public BoundedTopKLatency(
int limit)
{
ArgumentOutOfRangeException.ThrowIfNegative(
limit);
this.limit = limit;
this.heap =
new PriorityQueue<
LatencySample,
RankKey>(
Math.Max(1, limit));
}
public void Add(
in LatencySample sample)
{
if (this.limit == 0)
{
return;
}
RankKey rank =
RankKey.From(
in sample);
if (this.heap.Count < this.limit)
{
this.heap.Enqueue(
sample,
rank);
return;
}
this.heap.EnqueueDequeue(
sample,
rank);
}
public LatencySample[] Finish()
{
LatencySample[] result =
new LatencySample[
this.heap.Count];
for (
int index = result.Length - 1;
index >= 0;
index -= 1)
{
result[index] =
this.heap.Dequeue();
}
return result;
}
}사용:
BoundedTopKLatency topK =
new(limit: 100);
foreach (LatencySample sample in samples)
{
topK.Add(in sample);
}
LatencySample[] slowest =
topK.Finish();EnqueueDequeue()는 새 원소를 넣고 최소 원소를 즉시 제거하는 결합 연산이며 Microsoft는 별도의 enqueue + dequeue보다 일반적으로 효율적인 heap 연산이라고 문서화하고 있다. 후보가 기존 Top-K보다 나쁘면 방금 넣은 후보 자신이 최소 원소가 되어 제거되고, 후보가 더 좋으면 기존 cutoff가 제거된다. ([Microsoft Learn]5)
TypeScript
TypeScript에서는 ranking contract를 별도 함수로 두고 작은 binary min-heap을 사용한다.
비교자(Comparator) 규칙:
< 0
-> left가 더 나쁨
> 0
-> left가 더 좋음이다.
export type LatencySample =
Readonly<{
requestId: bigint;
latencyMicros: number;
}>;
type Comparator<T> =
(
left: T,
right: T,
) => number;
class MinHeap<T>
{
readonly #values: T[] = [];
readonly #compare: Comparator<T>;
public constructor(
compare: Comparator<T>,
)
{
this.#compare = compare;
}
public getSize(): number
{
return this.#values.length;
}
public peek(): T | undefined
{
return this.#values[0];
}
public push(
value: T,
): void
{
this.#values.push(value);
this.#siftUp(
this.#values.length - 1,
);
}
public replaceRoot(
value: T,
): T
{
const previous =
this.#values[0];
this.#values[0] = value;
this.#siftDown(0);
return previous;
}
public copyValues(): T[]
{
return [...this.#values];
}
#siftUp(
startIndex: number,
): void
{
let index = startIndex;
while (index > 0) {
const parent =
Math.floor(
(index - 1) / 2,
);
if (!this.#isLess(
index,
parent,
)) {
return;
}
this.#swap(
index,
parent,
);
index = parent;
}
}
#siftDown(
startIndex: number,
): void
{
let index = startIndex;
while (true) {
const child =
this.#findSmallerChild(
index,
);
if (child === undefined
|| !this.#isLess(
child,
index,
)) {
return;
}
this.#swap(
index,
child,
);
index = child;
}
}
#findSmallerChild(
parent: number,
): number | undefined
{
const left =
parent * 2 + 1;
if (left >= this.#values.length) {
return undefined;
}
const right = left + 1;
if (right >= this.#values.length) {
return left;
}
return this.#isLess(
right,
left,
)
? right
: left;
}
#isLess(
left: number,
right: number,
): boolean
{
return this.#compare(
this.#values[left],
this.#values[right],
) < 0;
}
#swap(
left: number,
right: number,
): void
{
[
this.#values[left],
this.#values[right],
] = [
this.#values[right],
this.#values[left],
];
}
}
export class BoundedTopKLatency
{
readonly #limit: number;
readonly #heap =
new MinHeap<LatencySample>(
compareLatencyRank,
);
public constructor(
limit: number,
)
{
if (!Number.isSafeInteger(limit)
|| limit < 0) {
throw new RangeError(
"limit은 0 이상의 안전한 정수여야 합니다.",
);
}
this.#limit = limit;
}
public add(
sample: LatencySample,
): void
{
if (this.#limit === 0) {
return;
}
if (this.#heap.getSize()
< this.#limit) {
this.#heap.push(sample);
return;
}
this.#replaceIfBetter(
sample,
);
}
public finish():
readonly LatencySample[]
{
const result =
this.#heap.copyValues();
result.sort(
(left, right) =>
-compareLatencyRank(
left,
right,
),
);
return result;
}
#replaceIfBetter(
sample: LatencySample,
): void
{
const worst =
this.#heap.peek();
if (worst === undefined
|| compareLatencyRank(
sample,
worst,
) <= 0) {
return;
}
this.#heap.replaceRoot(
sample,
);
}
}
export function compareLatencyRank(
left: LatencySample,
right: LatencySample,
): number
{
if (
left.latencyMicros
!== right.latencyMicros
) {
return (
left.latencyMicros
- right.latencyMicros
);
}
if (left.requestId
< right.requestId) {
return 1;
}
if (left.requestId
> right.requestId) {
return -1;
}
return 0;
}사용:
const topK =
new BoundedTopKLatency(
100,
);
for (const sample of samples) {
topK.add(sample);
}
const slowest =
topK.finish();3. 호출부
실제 시스템에서는 network DTO 전체를 heap에 넣지 않는다.
Network Event
-> Validation
-> Compact Ranking Record
-> Top-K Heap예를 들어 transport event가 다음처럼 훨씬 크다고 하자.
export type LatencyEvent =
Readonly<{
requestId: bigint;
durationNanos: bigint;
requestBody?: Uint8Array;
responseBody?: Uint8Array;
headers:
Readonly<Record<string, string>>;
}>;Top-K에 필요한 값은 두 개뿐이다.
export type LatencySource =
Readonly<{
readChunkAsync: (
signal?: AbortSignal,
) => Promise<
readonly LatencyEvent[]
| undefined
>;
}>;
export async function collectSlowestAsync(
source: LatencySource,
limit: number,
signal?: AbortSignal,
): Promise<
readonly LatencySample[]
>
{
const topK =
new BoundedTopKLatency(
limit,
);
while (true) {
const chunk =
await source.readChunkAsync(
signal,
);
if (chunk === undefined) {
return topK.finish();
}
addChunk(
topK,
chunk,
);
}
}
function addChunk(
topK: BoundedTopKLatency,
events: readonly LatencyEvent[],
): void
{
for (const event of events) {
const sample =
normalizeLatency(event);
topK.add(sample);
}
}
function normalizeLatency(
event: LatencyEvent,
): LatencySample
{
if (event.durationNanos < 0n) {
throw new RangeError(
"durationNanos는 음수일 수 없습니다.",
);
}
const micros =
event.durationNanos / 1_000n;
if (micros > 0xffff_ffffn) {
throw new RangeError(
"latencyMicros 범위를 초과했습니다.",
);
}
return {
requestId:
event.requestId,
latencyMicros:
Number(micros),
};
}단위 변환에 주의할 점이 있는데, durationNanos / 1_000n은 BigInt 정수 나눗셈이라 절사된다. 1999ns와 1000ns가 같은 1μs가 된다.
나노초 해상도로 관측한 값을 마이크로초로 줄이면 서로 다른 요청이 같은 순위 칸에 들어가고 그만큼 tie-breaker인 requestId가 실질적인 정렬 키 역할을 하게 된다. 특히 cutoff와 같은 마이크로초 bucket 안에서는 requestId가 어떤 원소를 남길지 결정한다. 해상도가 중요하면 나노초를 그대로 두고 상한 검사를 uint64로 올린다.
책임은 다음과 같이 분리한다.
LatencySource
= network/file I/O
= cancellation
= chunk lifetime
Boundary Validation
= ID 유효성
= 음수 duration 차단
= 단위 변환
= 정수 범위 검증
LatencySample
= Top-K에 필요한 최소 값만 소유
BoundedTopK
= ranking
= cutoff 유지
= O(K) 메모리
Caller
= I/O와 ranking orchestration
Reporting Layer
= 최종 K개에 대해서만
상세 trace 재조회 가능특히 다음 구조는피해야한다
Top-K heap
-> requestBody 5 MiB
-> responseBody 20 MiB
-> header map
-> tracing graphK가 100이어도 수 GiB의 객체 그래프를 붙잡을 수 있다.
더 좋은 구조의 경우에는 아래와 같을 것이다.
Heap:
requestId
latencyMicros
최종 Top-K 확정 후:
requestId
-> 필요하면 상세 trace 조회4. 읽는 순서
1.
"더 좋은 값"의 ranking이 정확히 무엇인가
↓
2.
동점까지 포함해 total order가 있는가
↓
3.
Heap root가 현재 Top-K의
최악 원소인가
↓
4.
Heap 크기가 K를 절대 넘지 않는가
↓
5.
Candidate가 root보다 좋을 때만
교체하는가
↓
6.
Heap 내부 배열을
정렬된 결과라고 오해하지 않는가
↓
7.
최종 정렬은 K개에 대해서만 하는가
↓
8.
Heap이 무거운 원본 객체를
붙잡고 있지 않은가
↓
9.
Ranking에 사용되는 값이
삽입 후 변경되지 않는가
↓
10.
K와 N의 비율이
heap 선택을 실제로 정당화하는가언제 써야할지에 대한 질문은 아래와 같다(알고리즘의 본질이라고 할 수 있다)
5. 경계와 오해
Heap은 정렬된 collection이 아니다
Min-heap이 보장하는 것은 root가 최소라는 heap invariant다. Python 공식 문서도 heap[0]이 가장 작은 원소임을 명시한다. Heap 내부 배열 전체가 정렬돼 있다는 보장은 없다. ([Python documentation]2)
따라서:
heap 내부 iteration
-> 최종 ranking 출력으로 사용하면 안 된다.
최종 출력이 순서까지 필요하다면:
N개 전체 정렬이 아니라:
K개만 최종 정렬한다.
Comparator 방향이 가장 흔한 실수다
목표:
가장 큰 K개인데 max-heap을 사용하면 root가 가장 좋은 값이 된다.
그러면 새 candidate가 들어올 때 누구를 버려야 하는지 즉시 알 수 없다.
Top-K largest에서는:
min-heap이 자연스럽다.
반대로 Bottom-K smallest를 원하면 max-heap이 자연스럽다.
동점 규칙을 container 안정성에 맡기지 않는다
이번 ranking:
latency DESC
requestId ASC는 의도적이다.
Python 문서는 heap-based sort가 stable하지 않다고 설명하고 동일 priority 항목을 다룰 때 별도 entry count를 tie-breaker로 넣는 방법도 소개한다. .NET PriorityQueue 역시 equal priority에 대한 FIFO ordering을 보장하지 않는다. ([Python documentation]2)
production comparator는 가능한 한:
primary score
secondary immutable ID까지 포함한다.
Floating-point score에 NaN을 그대로 넣지 않는다
다음 comparator:
left.score > right.score에서 score = NaN이면 ordering 관계가 정상적인 strict weak ordering을 만족하지 않을 수 있다.
이번 예제는 latency를:
uint32 microseconds로 정규화한 뒤 ranking한다.
실수 score가 필요한 모델이라면 ingestion boundary에서:
finite
not NaN을 강제해야 한다.
개인적인 메모: 현재는 중요 강조 블록을 코드 블록으로 사용하고 있는데, 가시성으로 이게 맞는지 모르겠다. 그렇다고 이 강조에 대해서 다른 방식을 써야하는 것도 좀 어렵고. 요즘은 MAC OS의 UI에 따온 형식의 위젯을 많이 쓰는데, 나도 그걸 삽입해야할지 고민이다. 하지만 현재 UI 시안이 맞지 않아서, 독자 가독성을 어떻게 잡아야할지 모르겠다.
Heap에 넣은 ranking field를 변경하지 않는다
다음의 경우에는 잘못될 수 있다.
heap.add(sample)
이후:
sample.latency = ...Heap은 삽입 당시 comparator 결과를 기준으로 tree invariant를 만든다.
원소의 priority를 외부에서 바꾸면 root가 더 이상 실제 최소값이라는 보장이 없다.
heap element는:
immutable value이거나 heap 자체가 update operation을 관리해야 한다.
이 구조는 append-only observation에 특히 적합하다
다음 workload:
새 sample 계속 추가
-> 마지막에 Top-K에는 매우 잘 맞는다.
반면:
기존 item score 변경
기존 item 삭제
임의 item priority 갱신이 빈번하다면 단순 bounded heap은 적합하지 않다.
그 경우:
indexed heap
balanced tree
ordered set
재계산등을 검토한다.
K가 N에 가까우면 heap이 당연한 정답이 아니다
예:
N = 1,000,000
K = 900,000이면 거의 모든 값을 heap에 넣고 log K 유지 비용까지 지불한다.
Python 공식 문서도 nlargest/nsmallest가 작은 n에서 가장 잘 동작하고 큰 n에는 sorted()가 더 효율적일 수 있다고 명시한다. ([Python documentation]2)
실전에서는:
K << N
-> bounded heap 우선 검토
K ≈ N
-> sort / partition selection benchmark가 맞다.
K = 1이면 heap조차 필요 없다
가장 느린 요청 하나만 필요하다면:
running max하나면 충분하다.
O(N)
memory O(1)이다.
Python 문서 역시 n == 1이면 min()/max() 같은 직접 연산이 더 적합하다고 안내한다. ([Python documentation]2)
K를 나중에 늘릴 수 없다
처음부터:
K = 100으로 stream을 처리하고 나면 101등 이하 데이터는 버려졌다.
나중에:
K = 1000으로 바꾸자고 해도 복구할 수 없다.
필요한 선택:
source replay
더 큰 K로 처음부터 유지
raw archive 보존중 하나다.
Bounded selection은 의도적으로 정보를 버리는 알고리즘이다.
Heap은 deduplication을 해 주지 않는다
동일 이벤트가 두 번 들어오면:
request 42
request 42가 둘 다 Top-K에 들어갈 수 있다.
Top-K와 dedup은 별개의 문제다.
Top-K
= ranking selection
Dedup
= identity uniqueness둘을 heap 코드 안에서 섞지 않는다.
무거운 object를 heap에 넣으면 K의 의미가 달라진다
메모리 복잡도를 흔히:
O(K)라고 쓰지만 실제 byte 비용은:
O(
K × retainedObjectGraphSize
)다.
C++에서 shared_ptr<Request>를 heap에 넣으면 request body와 downstream graph가 계속 살아 있을 수 있다.
Python/C#/TypeScript에서도 object reference를 heap에 저장하면 해당 object graph가 GC 대상에서 제외될 수 있다.
그래서 Top-K에는 가능한 한:
compact immutable summary를 넣는다.
managed 언어에서는 여기에 allocation 압박이 하나 더 붙는다. 수천만 개의 stream 원소마다 heap용 wrapper 객체를 새로 만들면 대부분 즉시 버려지더라도 GC가 그 쓰레기를 계속 훑는다. 이 글의 Python 구현이 heap이 찬 뒤에는 root와 raw field를 먼저 비교하고 교체가 확정될 때만 _HeapItem을 만드는 이유가 그것이고, C# 구현이 class가 아니라 readonly struct를 쓰는 이유도 같다.
ranking key가 충분히 작으면 wrapper 자체를 없앨 수도 있다. latencyMicros가 uint32이고 requestId가 uint32에 들어간다면:
packed = (uint64(latencyMicros) << 32) | uint32(requestId)로 묶어 정수 하나만 heap에 넣는 방법이 있다. 다만 이 트릭은 tie-break 방향이 ID 오름차순일 때 그대로 쓸 수 없다. 이 글의 계약은 latency DESC + requestId ASC이므로 하위 32비트에는 requestId가 아니라 ~requestId를 넣어야 min-heap root가 계약상 최악 원소가 된다. 비트 패킹은 각 필드의 폭과 정렬 방향을 모두 고정할 수 있을 때만 쓰고, 그 전제가 문서화되지 않으면 나중에 필드 하나가 늘어나는 순간 조용히 깨진다.
공유 heap에 lock을 걸어 parallelization하지 않는다
다수 worker가 sample마다 같은 heap을 잠그면:
Worker A ─┐
Worker B ─┼-> lock -> global heap
Worker C ─┘ranking보다 lock contention이 더 큰 비용이 될 수 있다.
Top-K에는 더 좋은 성질이 있다.
각 partition에서 Local Top-K
-> Local Top-K들의 union에서
다시 Top-K
-> Global Top-K가 정확한 결과를 낸다.
parallel/distributed 환경에서는:
local heap
-> merge가 자연스럽다.
Local Top-K merge는 근사가 아니다
다만 여기에는 전제가 있다. 각 partition은 global K개까지 유지해야 하며, partition의 원소 수가 global K보다 작다면 그 partition의 모든 원소를 유지하면 된다.로컬 K를 global K보다 작게 줄이면 결과가 틀린다. global K보다 크게 잡는 것은 정확성을 깨뜨리지 않지만, coordinator로 보내는 후보 수가 늘어난다.
Partition P에서 어떤 원소 x가 local Top-K에 들지 못했다고 하자.
그렇다면 P 안에 이미:
x보다 좋은 원소가 최소 K개존재한다.
그 K개는 global input에도 그대로 존재한다.
따라서 x는 global Top-K에도 들어갈 수 없다.
즉:
TopK(
전체 입력
)
=
TopK(
각 partition의 TopK를 모두 합친 것
)이다.
이 성질은 distributed aggregation에 매우 유용하다.
완전한 ranking이 없다면 distributed merge 결과도 흔들린다
Latency만 비교하고 동점 처리가 없다면:
Partition A
Partition B에서 같은 score 후보가 어떤 쪽에서 살아남는지가 heap implementation이나 arrival order에 따라 달라질 수 있다.
distributed Top-K일수록:
(score, uniqueId)형태의 total rank가 더 중요하다.
언제 실패하는가?
- Top-K largest인데 max-heap을 씀
- Heap root를 최고 원소라고 생각함
- Heap 배열 자체가 정렬됐다고 생각함
- latency 동점을 정의하지 않음
- container stability에 tie 처리를 맡김
- NaN이 들어가는 float comparator를 그대로 사용함
- 삽입 후 ranking field를 mutation함
- K=100인데 request body까지 heap에 보관함
- K가 N에 가까운데 무조건 heap을 사용함
- K=1인데 priority queue를 만듦
- 나중에 K를 늘릴 수 있다고 생각함
- Top-K heap이 duplicate도 제거한다고 생각함
- 여러 worker가 sample마다 global heap lock을 잡음6. 잘못된 예제
가장 단순한 TypeScript 구현을 보자.
export function slowestRequestsBad(
samples: LatencySample[],
limit: number,
): LatencySample[]
{
samples.sort(
(left, right) =>
right.latencyMicros
- left.latencyMicros,
);
return samples.slice(
0,
limit,
);
}이 코드는 항상 나쁜 코드는 아니다.
입력이 몇백 개라면 오히려 이 방식이 더 단순하고 충분히 빠를 수 있다.
문제는:
N = 50,000,000
K = 100같은 요구사항에 그대로 사용하는 것이다.
첫 번째 문제:
전체 N개를 정렬
O(N log N)한다.
실제로 필요한 것은:
100개 후보 유지뿐이다.
두 번째 문제는 입력 배열을 직접 mutation한다.
samples.sort(...)때문에 호출자가 기대하던 원래 입력 순서가 사라진다.
세 번째 문제는 tie-breaker가 없다.
request 10 / 8400
request 20 / 8400중 어느 쪽이 먼저 오는지 domain contract가 정의되지 않았다.
네 번째 문제는 입력 전체를 이미 메모리에 가지고 있어야 한다.
Streaming source:
network
file
generator
database cursor에서 Top-K만 필요하다면 전체 materialization이 불필요하다.
올바른 선택은 workload에 따라 다르다.
작은 N
-> sort가 더 단순
K ≈ N
-> sort/partition 검토
거대한 N
AND
작은 K
AND
streaming
-> bounded Top-K heap세 번째 선택지: partition selection
앞에서 두 번 꺼낸 partition selection을 여기서 풀어 보자. 배열을 partition해서 K번째 경계만 찾고, 필요 없는 쪽은 더 이상 처리하지 않는 선택 알고리즘이다. C++에서는 std::nth_element가 이 연산을 제공하지만, 표준은 introselect 같은 특정 구현 알고리즘을 요구하지 않는다. non-parallel overload의 평균 비교 복잡도는 선형이지만, 일반적인 최악 복잡도는 표준이 고정하지 않는다.
full sort
O(N log N)
bounded heap
O(N log K) 상한
기대 O(N + K log K log(N/K))
nth_element
평균 O(N) / 평균 linear
일반적인 최악 복잡도는 표준이 규정하지 않음선택 기준은 이렇다.
상황 | 후보 |
|---|---|
스트리밍, 입력을 붙들 수 없음 | bounded heap |
전량 메모리, K가 아주 작음 | heap 또는 nth_element |
전량 메모리, K가 중간 | nth_element가 유리한 경우가 많음 |
K가 N에 가까움 | full sort |
- 상황
스트리밍, 입력을 붙들 수 없음
- 후보
bounded heap
- 상황
전량 메모리, K가 아주 작음
- 후보
heap 또는 nth_element
- 상황
전량 메모리, K가 중간
- 후보
nth_element가 유리한 경우가 많음
- 상황
K가 N에 가까움
- 후보
full sort
대가가 있다. nth_element는 입력 배열을 재배치하므로 6절 도입부에서 지적한 mutation 문제를 그대로 갖는다. 결과가 정렬돼 있지도 않아서 K개를 따로 정렬해야 한다. 안정적이지도 않다. 그래도 선택지에서 빼면 안 된다. heap과 full sort의 이분법으로만 보면 실무에서 가장 자주 이기는 쪽을 놓친다.
7. 프로덕션 확장
여러 worker로 넓히면 Partition-local Top-K -> Global Top-K 병합 구조가 자연스럽다고 할 수 있다.
Worker 8개가 각각 천만 개 요청을 처리한다고 하자.
나쁜 구조:
모든 8천만 sample
-> 중앙 서버
-> Global Heap좋은 구조:
Worker 1:
10,000,000
-> Top 100
Worker 2:
10,000,000
-> Top 100
...
Worker 8:
10,000,000
-> Top 100
Coordinator:
800개
-> 다시 Top 100중앙 서버는 8천만 개가 아니라 800개만 봐야하는 것이다.
TypeScript:
export function mergePartitionTopK(
partitions:
readonly (
readonly LatencySample[]
)[],
limit: number,
): readonly LatencySample[]
{
const global =
new BoundedTopKLatency(
limit,
);
for (const partition of partitions) {
addPartition(
global,
partition,
);
}
return global.finish();
}
function addPartition(
global: BoundedTopKLatency,
partition:
readonly LatencySample[],
): void
{
for (const sample of partition) {
global.add(sample);
}
}각 worker도 같은 구현을 사용한다.
export function selectPartitionTopK(
samples:
readonly LatencySample[],
limit: number,
): readonly LatencySample[]
{
const topK =
new BoundedTopKLatency(
limit,
);
for (const sample of samples) {
topK.add(sample);
}
return topK.finish();
}모델 기반 테스트에서는 bounded heap 결과를 느리지만 단순한 full-sort 기준 구현과 비교한다.
import assert from "node:assert/strict";
import test from "node:test";
test(
"Bounded Top-K는 full sort 기준 모델과 같다",
() =>
{
const random =
createDeterministicRandom(
0x5eed,
);
for (
let iteration = 0;
iteration < 1_000;
iteration += 1
) {
const sampleCount =
random() % 2_000;
const limit =
random() % 100;
const samples =
createSamples(
sampleCount,
random,
);
const actual =
selectPartitionTopK(
samples,
limit,
);
const expected =
referenceTopK(
samples,
limit,
);
assert.deepEqual(
actual,
expected,
);
}
},
);
test(
"Partition Top-K 병합은 전체 Top-K와 같다",
() =>
{
const random =
createDeterministicRandom(
1234,
);
const samples =
createSamples(
20_000,
random,
);
const partitions =
partition(
samples,
8,
);
const local =
partitions.map(
(values) =>
selectPartitionTopK(
values,
100,
),
);
const merged =
mergePartitionTopK(
local,
100,
);
const expected =
referenceTopK(
samples,
100,
);
assert.deepEqual(
merged,
expected,
);
},
);
test(
"Latency 동점은 requestId로 결정된다",
() =>
{
const samples:
readonly LatencySample[] =
[
{
requestId: 30n,
latencyMicros: 5_000,
},
{
requestId: 10n,
latencyMicros: 5_000,
},
{
requestId: 20n,
latencyMicros: 5_000,
},
];
assert.deepEqual(
selectPartitionTopK(
samples,
2,
),
[
{
requestId: 10n,
latencyMicros: 5_000,
},
{
requestId: 20n,
latencyMicros: 5_000,
},
],
);
},
);
function referenceTopK(
samples:
readonly LatencySample[],
limit: number,
): readonly LatencySample[]
{
return [...samples]
.sort(
(left, right) =>
-compareLatencyRank(
left,
right,
),
)
.slice(
0,
limit,
);
}
function createSamples(
count: number,
random: () => number,
): readonly LatencySample[]
{
return Array.from(
{ length: count },
(_, index) => ({
requestId:
BigInt(index + 1),
latencyMicros:
random() % 100_000,
}),
);
}
function partition<T>(
values: readonly T[],
count: number,
): readonly (
readonly T[]
)[]
{
const result:
T[][] =
Array.from(
{ length: count },
() => [],
);
for (
let index = 0;
index < values.length;
index += 1
) {
result[
index % count
].push(
values[index],
);
}
return result;
}
function createDeterministicRandom(
seed: number,
): () => number
{
let state =
seed >>> 0;
return () =>
{
state = (
Math.imul(
state,
1_664_525,
)
+ 1_013_904_223
) >>> 0;
return state;
};
}Benchmark에서는 단순히 "heap이 빠른가?"를 묻지 않는다.
비교:
1. full sort
2. bounded min-heap
3. partition local Top-K + merge
4. 언어별 표준 Top-K helper
변수:
N
K
K/N
partition 수
tie 비율
element 크기
ranking 계산 비용
입력 source가 streaming인지 여부
관측:
전체 실행 시간
peak memory
allocation 수
retained bytes
comparison 수
merge 비용Python 문서가 nlargest()에 대해 작은 n에서 특히 적합하다고 명시하는 것처럼, Top-K 구현 선택은 결국 N, K, element 비용을 포함한 실제 workload로 검증해 볼 일이다. ([Python documentation]2)
개인적인 메모: 프로덕션 확장 설명은 어디까지나, 케이스 스터디다. 실제 파이썬 코드는 실전을 거쳤지만, 확장은 어디까지나 이론상으로 이상적인 케이스다.
8. C++ / Python / C# / TypeScript 비교 메모
언어 | Heap 표현 | Root | Element 저장 | 주의점 |
|---|---|---|---|---|
C++ |
| comparator상 cutoff | 값 타입 | comparator 방향 |
Python |
| 최소 item | Python object | tie와 객체 overhead |
C# |
| 최소 priority | element + priority | equal priority 비안정 |
TypeScript | 직접 binary heap | comparator 최소 | 객체 reference | ownership·구현 검증 |
- 언어
C++
- Heap 표현
std::priority_queue- Root
comparator상 cutoff
- Element 저장
값 타입
- 주의점
comparator 방향
- 언어
Python
- Heap 표현
heapq+list- Root
최소 item
- Element 저장
Python object
- 주의점
tie와 객체 overhead
- 언어
C#
- Heap 표현
PriorityQueue<T,P>- Root
최소 priority
- Element 저장
element + priority
- 주의점
equal priority 비안정
- 언어
TypeScript
- Heap 표현
직접 binary heap
- Root
comparator 최소
- Element 저장
객체 reference
- 주의점
ownership·구현 검증
C++
C++ 표준 heap 알고리즘은 random-access range 위에서 push_heap과 pop_heap의 비교 횟수를 heap 크기의 로그에 비례하도록 제한한다. std::priority_queue는 이런 heap 연산을 사용하는 container adaptor다. 정확한 실행 시간은 비교 함수와 underlying container의 비용까지 포함해서 봐야 한다. (1)
작은 value struct:
requestId
latencyMicros를 직접 저장하면 object allocation 없이 contiguous underlying container를 활용하기 쉽다.
반대로:
std::shared_ptr<FullRequest>를 넣으면 algorithmic memory는 O(K)여도 실제 retained memory는 전혀 작지 않을 수 있다.
C++에서는 comparator가 strict weak ordering을 만족해야 하므로 mutable key나 NaN-like unordered score를 특히 피해야 한다.
Python
heapq는 Python list를 min-heap으로 관리하며 heappush, heappop, heapreplace, heappushpop, nlargest 등을 제공한다. 공식 문서는 heapreplace()를 fixed-size heap에 적합한 연산으로 설명하며 heap[0]에서 최소 원소를 조회할 수 있다고 명시한다. ([Python documentation]2)
다만 Python object를 수천만 번 loop하는 비용이 heap arithmetic보다 더 커질 수 있다.
데이터가 NumPy/PyArrow column으로 이미 존재한다면 Python-level heap loop와 native selection kernel을 반드시 비교해야 한다.
C#
.NET PriorityQueue<TElement,TPriority>는 최소 priority가 먼저 제거되는 quaternary min-heap이다. 동일 priority에 대한 FIFO를 보장하지 않기 때문에 이번 예제처럼 deterministic tie-break를 TPriority 자체에 포함하는 편이 안전하다. ([Microsoft Learn]4)
TElement와 TPriority를 분리할 수 있다는 점도 유용하다.
Element:
LatencySample
Priority:
RankKey로 두면 업무 데이터와 heap ordering semantics를 분리할 수 있다.
EnqueueDequeue()는 insert-then-extract를 하나의 heap operation으로 제공하며 별도의 enqueue/dequeue 순서보다 일반적으로 효율적이라고 Microsoft가 설명한다. ([Microsoft Learn]5)
TypeScript
이번 구현에서는 binary heap을 직접 작성했지만 production에서는 핵심이 heap class 자체가 아니라 comparator contract다.
negative
-> 더 나쁨
positive
-> 더 좋음이라는 규칙을 한 곳에 고정했다.
TypeScript에서는 객체가 reference이므로 heap에 들어간 LatencySample의 ranking field를 외부에서 mutation할 수 없도록 readonly API와 ownership 규칙을 유지하는 것이 중요하다.
또한 requestId는 ranking tie-break에서 정확한 정수 비교가 필요하므로 이번 예제에서는 bigint를 사용했다.
이 글이 binary heap을 직접 작성한 것은 자료구조를 설명하기 위해서다. 실무 선택은 다르다. JavaScript와 TypeScript에는 표준 라이브러리 heap이 없어서 Node.js 프로젝트는 보통 heap-js나 fastpriorityqueue 같은 검증된 패키지를 쓴다. 직접 구현하면 sift 로직의 off-by-one이나 #findSmallerChild의 경계 처리 같은 곳에서 조용한 버그가 나기 쉽고, 그 버그는 Top-K 결과가 "그럴듯하게" 틀리는 형태로 나타나 발견이 늦다. 직접 짜기로 했다면 최소한 이 글 7절처럼 full-sort 기준 구현과 대조하는 model-based test를 함께 둔다.
Comparator 표현 차이
C++:
bool better(left,right)형식이다.
Python:
HeapItem의 자연 ordering으로 mapping했다.
C#:
RankKey.CompareTo()에서 낮은 priority가 root가 되게 했다.
TypeScript:
compare(left,right)
< 0 -> worse
> 0 -> better를 사용했다.
문법은 다르지만 공통 의미는 하나다.
Heap Root
=
현재 retained Top-K에서
가장 나쁜 원소메모리 차이
C++의 작은 value object와 C# value struct는 heap 내부 storage에 직접 저장할 수 있다.
Python과 TypeScript에서는 heap이 일반적으로 object reference를 유지한다.
managed 언어에서는 다음 차이가 더 중요해질 수 있다.
작은 summary object
vs
거대한 request objectTop-K는 자료구조가 K개만 보관한다는 사실보다 그 K개가 무엇을 소유하고 있는가가 중요하다.
공통 계약
Rank:
(latency DESC, requestId ASC)
Heap:
size <= K
Root:
현재 Top-K 중 worst
Candidate:
better(candidate, root)
-> replace
Final Output:
heap 내부 순서 사용 금지
-> K개만 정렬
Ownership:
compact immutable summary
Parallel:
local Top-K
-> global Top-K
Local K:
global K 이상
finish() 재호출:
C++ 파괴적, `&&` 한정
lvalue 호출은 금지되지만 `std::move(...)`로 다시 호출할 수 있음
첫 호출 뒤 두 번째 결과는 empty
C# 파괴적 (Dequeue로 비움)
Python·TypeScript 비파괴적 (복사)9. 추가로 생각해보기
K = 100,N = 1억일 때 heap에는 요청 summary만 둘 것인가, 최종 보고서 생성에 필요한 trace metadata 일부까지 미리 유지할 것인가?Local worker 1,000개가 각각 Top-100을 보낸다면 coordinator가 10만 개를 다시 heap 처리하는 방식으로 충분한가, 계층형 Top-K reduction tree를 둘 가치가 있는가?
동일 latency가 빈번한 시스템에서
requestId를 tie-breaker로 쓰는 것이 업무 의미와 맞는가, timestamp·sequence 같은 별도 deterministic key가 필요한가?K가 runtime에 따라 10에서 백만까지 크게 변한다면 하나의 heap 구현을 계속 사용할 것인가,
K/N에 따라 full sort·partition·heap을 선택하는 policy가 필요한가?Retained request가 이후 수정될 수 있는 mutable object라면 snapshot summary를 복사할 것인가, immutable event ID만 저장하고 결과 확정 후 다시 조회할 것인가?
Top-K를 최근 5분 window로 유지해야 한다면 append-only bounded heap을 계속 사용할 수 있는가, 아니면 만료되는 원소를 제거할 수 있는 다른 자료구조가 필요한가?
개인적인 메모: 마지막 질문에는 방향을 하나 적어 둔다. 고정 크기 min-heap은 시간 기반 만료에 구조적으로 약하다. 이유는 5절에서 이미 나왔다. heap이 저렴한 것은 root 하나만 알면 되기 때문인데, window에서 빠져나가는 원소는 heap 안 어디에나 있을 수 있고 일반적인 priority queue나 heap 인터페이스는 임의 원소의 위치를 추적하지 않으므로, identity만 주어진 원소를 O(log K)에 삭제할 수 없다.
원소 위치를 따로 추적하는 indexed heap을 쓰거나, 아예 balanced BST나 ordered set으로 옮겨 삭제와 순위 조회를 함께 지원받아야 한다. 만료가 항상 삽입 순서대로 일어나는 sliding window maximum 같은 특수한 경우라면 monotonic deque 계열의 two-queue 알고리즘이
O(1)상각으로 더 낫다. 요구사항이 append-only에서 window로 바뀌는 순간 자료구조 선택을 다시 하는 것이 맞다. 이 글의 heap을 억지로 늘리는 것이 아니라.
10. 요약
K << N일 때 전체 정렬 대신 크기 K의 min-heap을 유지하면 정확한 Top-K를O(N log K)시간과O(K)메모리로 구할 수 있다. C++ 표준은 heap push/pop의 비교 횟수를 heap 크기의 로그로 제한한다. 다만 이건 상한이며 실제 기대 비용은 아래 항목을 참조한다. 1Top-K largest에서 heap root는 최고 원소가 아니라 현재 Top-K 중 가장 나쁜 원소여야 한다.
동점은 container 안정성에 맡기지 말고 immutable ID 같은 tie-breaker를 ranking contract에 포함한다. Python heap과 .NET PriorityQueue 모두 동순위 안정성을 자동으로 제공한다고 가정해서는 안 된다. ([Python documentation]2)
Heap 내부는 정렬된 결과가 아니므로 마지막에 K개만 별도로 정렬한다.
K가 N에 가까우면 heap이 자동으로 최선이 아니며 full sort나 partition selection을 benchmark할 대상이다. Python 공식 문서도 큰
n에는 정렬이 더 적합할 수 있음을 명시한다. ([Python documentation]2)분산 환경에서는 각 partition의 Local Top-K만 모아 다시 Top-K를 수행해도 정확한 Global Top-K를 얻을 수 있으므로 shared global heap보다 local reduction이 자연스럽다.
쉽게 암기하기:
상위 K개만 필요하다면
전체 N개를 정렬하지 마라.
현재 승자 K개만 유지하고,
그중 가장 약한 원소를 root에 둬라.
새 후보가 root보다 강할 때만 교체하라.
Top-K의 핵심은
"최고를 빨리 찾는 것"이 아니라
"탈락시킬 최저 승자를 항상 알고 있는 것"이다.참고 문헌
각주
- [[alg.heap.operations]](https://eel.is/c%2B%2Bdraft/alg.heap.operations) ↩
- heapq — 힙 큐 알고리즘 — Python 문서 ↩
- [[priority.queue]](https://eel.is/c%2B%2Bdraft/priority.queue) ↩
- PriorityQueue<TElement,TPriority> 클래스 | Microsoft Learn ↩
- * PriorityQueue<TElement,TPriority>.EnqueueDequeue Method | Microsoft Learn ↩