에듀테크 프로젝트 대시보드
저수지 샘플링 (Reservoir Sampling)
전체 크기를 모르는 데이터 스트림을 딱 한 번만 지나가면서, 모든 원소가 정확히 같은 확률로 뽑히는 표본 k개를 O(k) 메모리로 유지하는 알고리즘.
n을 모르는데, 어떻게 "균일 무작위"를 보장할까?
표본 1개(k = 1)를 뽑는 경우부터 보자. 규칙은 딱 한 줄이다.
i번째로 도착한 원소를 확률 1/i 로 현재 표본과 교체한다.
1/1 로 무조건 표본에 넣는다.1/2 로 교체 → 두 원소 모두 최종 확률 1/2.1/3 로 교체. 기존 원소가 살아남을 확률은 (1/2)·(2/3) = 1/3.n개 모두 정확히 1/n.표본이 k개인 일반형은 이렇게 확장된다: 처음 k개는 그대로 채우고, i > k인 원소는 확률 k/i 로 받아들여 저수지 안의 무작위 슬롯 하나를 덮어쓴다.
# 스트림 stream 에서 표본 k개를 균일 무작위로 뽑는다 import random def reservoir_sample(stream, k): reservoir = [] for i, item in enumerate(stream): # i = 0, 1, 2, ... if i < k: reservoir.append(item) # 저수지 채우기 else: j = random.randint(0, i) # 0 ~ i 중 하나 if j < k: # 확률 k/(i+1) reservoir[j] = item # 무작위 슬롯 교체 return reservoir
메모리 O(k), 시간 O(n), 스트림 1회 통과. n을 미리 알 필요가 전혀 없다.
아래에서 스트림이 하나씩 도착한다. 각 원소마다 주사위를 굴려 저수지에 넣을지 결정한다. 저수지에 들어간(살아있는) 원소는 파란색이다.
같은 스트림에서 표본 뽑기를 수천 번 반복하면, 모든 원소가 뽑힌 횟수가 거의 같아야 한다.
이론값은 각 원소마다 k / n. 아래 버튼으로 직접 확인해 보자.
k = 1 기준으로, 스트림 길이가 n일 때 첫 번째 원소가 최종적으로 살아남을 확률을 따져 보자.
| 단계 | 일어나야 하는 일 | 확률 |
|---|---|---|
| 2번째 도착 | 교체 안 됨 | 1 − 1/2 = 1/2 |
| 3번째 도착 | 교체 안 됨 | 1 − 1/3 = 2/3 |
| 4번째 도착 | 교체 안 됨 | 1 − 1/4 = 3/4 |
| … | … | … |
n번째 도착 | 교체 안 됨 | (n−1)/n |
이 확률들을 모두 곱하면 예쁘게 약분된다 (망원 곱, telescoping):
P(1번 원소 생존) = 1/2 · 2/3 · 3/4 · … · (n−1)/n = 1/n
중간에 도착한 m번째 원소도 똑같다: 들어올 확률 1/m × 이후 m+1 … n에서 안 밀려날 확률 m/(m+1) · … · (n−1)/n = 1/n. 모든 원소가 대칭적으로 1/n이다. 귀납법의 핵심은 "i개까지 균일하다면 i+1개째를 확률 k/(i+1)로 받아도 여전히 균일하다" 이다.
전체를 저장하지 않고 대표 표본만 뽑아 통계·A/B 분석. Apache Spark의 sample, 로그 수집기의 샘플링 등.
Kafka·센서처럼 끝없이 들어오는 데이터에서 최근 구간의 균일 표본 유지 (분산 추적의 trace sampling).
조건을 만족하는 오브젝트가 몇 개인지 모를 때, 한 번 순회하며 무작위 하나 선택 (랜덤 스폰 지점 고르기).
길이를 모르는 연결 리스트에서 임의의 노드 하나를 O(1) 메모리로 뽑는 고전 면접 문제.
i에 키 random()^(1/wᵢ)를 부여하고 키가 큰 k개를 최소 힙으로 유지한다. (→ 힙 & 우선순위 큐 참고)