Reservoir sampling: случайный элемент из потока за один проход
Резервуарная выборка простыми словами: алгоритм R, доказательство равновероятности, выборка k элементов, взвешенный A-Res и задачи Linked List Random Node и Random Pick Index. Код на Python.
Коротко
| Вопрос | Ответ |
|---|---|
| Задача | Выбрать случайный элемент (или элементов) из потока, длина которого заранее неизвестна, за один проход |
| Главная идея | -й элемент становится кандидатом с вероятностью (для элементов — ) |
| Сложность | времени, памяти для одного элемента и для |
| Гарантия | Каждый элемент попадает в выборку с вероятностью ровно (или ), хотя мы не знали |
| Задачи | LeetCode 382 Linked List Random Node, LeetCode 398 Random Pick Index, выборка из логов и стримов |
Reservoir sampling, или резервуарная выборка, — короткий алгоритм с красивым доказательством. Его любят на собеседованиях по двум причинам: код занимает пять строк, а объяснить, почему он работает, может не каждый. Именно объяснение и проверяют.
Постановка задачи
Есть поток элементов: строки огромного лога, записи из сети, узлы связного списка. Мы видим элементы по одному и не знаем, сколько их будет. Нужно в конце вернуть один элемент так, чтобы каждый был выбран с одинаковой вероятностью.
Ограничения, которые делают задачу интересной:
- поток проходим один раз, вернуться назад нельзя;
- сохранить всё нельзя: данных может быть больше, чем памяти;
- длина становится известна только в конце.
Если бы было известно, решение тривиально: сгенерировать индекс random.randint(0, n - 1) и дойти до него. Если бы можно было сохранить всё — сложить в массив и выбрать. Резервуарная выборка нужна, когда нельзя ни то, ни другое.
Наивные решения и почему они не подходят
Два прохода. Первый проход считает длину, второй идёт до случайного индекса. Для связного списка это работает, но для потока — нет: второго прохода не будет.
Сохранить всё. Память . Для списка из узлов не страшно, для терабайтного лога — невозможно.
Брать каждый элемент с вероятностью 1/2. Тогда последний элемент выигрывает с вероятностью 1/2, а первый — с . Распределение неравномерное.
Нужна вероятность, которая зависит от номера элемента. Ровно это и делает алгоритм R.
Алгоритм R для одного элемента
Идём по потоку и нумеруем элементы с единицы. Встретив -й элемент, с вероятностью делаем его текущим ответом, иначе оставляем прежний.
import random def reservoir_one(stream): chosen = None for i, x in enumerate(stream, start=1): if random.randint(1, i) == 1: # вероятность 1/i chosen = x return chosen
Первый элемент берётся всегда (). Второй заменяет его с вероятностью . Третий — с вероятностью . И так далее.
Проверим эмпирически: 100 000 запусков на потоке range(5).
from collections import Counter random.seed(1) print(sorted(Counter(reservoir_one(range(5)) for _ in range(100_000)).items())) # [(0, 20181), (1, 19925), (2, 19870), (3, 19901), (4, 20123)]
Каждое число выпало около 20 000 раз, то есть с частотой . Теперь докажем, что это не совпадение.
Доказательство равновероятности
Посчитаем вероятность, что в конце выбран элемент с номером . Для этого должны произойти два события:
- На шаге элемент был взят: вероятность .
- На каждом следующем шаге его не заменили: вероятность .
Решения на разных шагах независимы, поэтому вероятности перемножаются:
Произведение телескопическое: числитель каждой дроби сокращается со знаменателем предыдущей, и остаётся . Это верно для любого , значит, все элементы равновероятны.
Доказательство по индукции
Интервьюеры иногда просят другую формулировку, через инвариант. Утверждение: после обработки элементов каждый из них является текущим ответом с вероятностью .
- База, : первый элемент взят с вероятностью 1.
- Переход : новый элемент становится ответом с вероятностью . Любой из старых был ответом с вероятностью и остался им с вероятностью . Итого .
Инвариант выполняется на каждом шаге, поэтому поток можно оборвать в любой момент, и текущий ответ будет равномерно случайным среди уже прочитанного. Это важное свойство для бесконечных стримов: выборка корректна «здесь и сейчас».
Выборка k элементов
Обобщение: нужно выбрать различных элементов (без повторов) так, чтобы каждое подмножество размера было равновероятным. Резервуар — это массив на мест.
- Первые элементов кладём в резервуар как есть.
- Для элемента с индексом (нумерация с нуля) генерируем
jот0доiвключительно. - Если
j < k, заменяемreservoir[j]новым элементом.
def reservoir_k(stream, k): reservoir = [] for i, x in enumerate(stream): if i < k: reservoir.append(x) else: j = random.randint(0, i) # 0..i включительно, всего i+1 вариантов if j < k: reservoir[j] = x return reservoir
Новый элемент попадает в резервуар с вероятностью , а выбрасывает случайный из старых, потому что j при условии j < k равномерно распределён на 0..k-1.
Почему вероятность равна k/n
Снова считаем для элемента с номером (с единицы, ). Он попал в резервуар с вероятностью . На шаге его вытесняют, если новый элемент принят () и выбрал именно его место (): вероятность . Значит, он выживает с вероятностью , и снова получается телескоп:
Для первых элементов всё то же самое, только первый множитель равен 1, а произведение идёт от : .
Эксперимент на range(6) при : каждое число должно попасть в выборку с частотой .
random.seed(1) c = Counter() for _ in range(100_000): c.update(reservoir_k(range(6), 2)) print(sorted(c.items()))
каждое число около 33 300 раз из 100 000
Можно проверить и сильное утверждение, что равновероятны именно пары. Для `range(4)` и $k = 2$ все шесть пар выпадают примерно по 16 700 раз из 100 000, то есть по $1/6$.
### Что если k больше длины потока
Тогда резервуар просто не заполнится, и функция вернёт все элементы. Обычно это правильное поведение, но в интервью стоит проговорить его вслух.
## Типичные ошибки
### Ошибка на единицу в диапазоне
Самая частая и самая коварная ошибка — написать `random.randint(0, i - 1)` вместо `random.randint(0, i)`. Код выглядит правдоподобно и не падает, но распределение ломается. Тот же эксперимент на `range(6)`, $k = 2$:
```text
правильно: 0: 33 267 1: 33 225 2: 33 217 3: 33 315 4: 33 628 5: 33 348
ошибка: 0: 20 076 1: 19 954 2: 39 671 3: 40 227 4: 40 235 5: 39 837
На элементе с индексом 2 «ошибочный» код выбирает j из {0, 1}, условие j < 2 выполняется всегда, и элемент попадает в резервуар со стопроцентной вероятностью. Ранние элементы вытесняются слишком часто.
Помните: в Python random.randint(a, b) включает обе границы, а random.randrange(a, b) — нет. В Java rand.nextInt(n) даёт 0..n-1, в JavaScript Math.floor(Math.random() * n) — тоже 0..n-1. Для элемента с индексом i с нуля нужно вариантов.
Нумерация с нуля в версии для одного элемента
if random.randint(0, i) == 0 при enumerate(stream) с нуля — правильно. if random.randint(1, i) == 1 при нумерации с нуля упадёт на первом же элементе: randint(1, 0) бросает исключение. Выберите одну нумерацию и держитесь её.
Проверка на «выглядит случайно»
Запустить функцию три раза и посмотреть на разные ответы — не проверка. Нужен счётчик частот на десятках тысяч запусков, как выше. На собеседовании достаточно сказать, что вы бы так проверили.
Задачи с LeetCode
Linked List Random Node (LeetCode 382)
Дан односвязный список. Метод getRandom() должен возвращать значение случайного узла, каждый узел с вероятностью . Follow-up из условия: что если список огромный, длина неизвестна, а дополнительной памяти нет?
Ответ на follow-up — резервуар из одного элемента, где поток — это обход списка.
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next class Solution: def __init__(self, head): self.head = head def getRandom(self): result, node, i = None, self.head, 0 while node: i += 1 if random.randint(1, i) == 1: result = node.val node = node.next return result s = Solution(ListNode(1, ListNode(2, ListNode(3)))) print(s.getRandom()) # 1, 2 или 3, каждое с вероятностью 1/3
Каждый вызов — времени и памяти. Альтернатива — при создании скопировать значения в массив и отвечать за , тратя памяти. Стоит назвать оба варианта и сказать, когда какой лучше: много вызовов на небольшом списке — массив; один-два вызова на гигантском — резервуар.
Random Pick Index (LeetCode 398)
Дан массив с повторами. Метод pick(target) возвращает случайный индекс, где стоит target, каждый такой индекс равновероятно.
Здесь «поток» — это только элементы, равные target. Счётчик увеличиваем лишь на них.
class Solution: def __init__(self, nums): self.nums = nums def pick(self, target): result, count = -1, 0 for i, x in enumerate(self.nums): if x == target: count += 1 if random.randint(1, count) == 1: result = i return result s = Solution([1, 2, 3, 3, 3]) print(s.pick(3)) # 2, 3 или 4, каждое с вероятностью 1/3 print(s.pick(1)) # 0
Частая ошибка: использовать в вероятности индекс массива i, а не число найденных совпадений count. Тогда вероятность зависит от позиции в массиве, и равномерность пропадает.
Решение через хеш-таблицу «значение → список индексов» отвечает за , но хранит индексов. На LeetCode оно проходит, однако задача задумана именно под резервуар, и интервьюер почти наверняка спросит про вариант без дополнительной памяти.
Взвешенная выборка: A-Res
Иногда элементы неравноценны: у каждого есть вес , и вероятность выбора должна от него зависеть. Например, выбрать запросы для отладки пропорционально их длительности.
Алгоритм A-Res (Efraimidis и Spirakis, 2006) делает это одним проходом:
- Для каждого элемента генерируем равномерно на и считаем ключ .
- Оставляем элементов с наибольшими ключами.
Хранить « наибольших» удобно в куче с минимумом наверху: новый элемент вытесняет корень, если его ключ больше. Это та же схема, что в задаче Top K Frequent Elements.
import heapq def weighted_reservoir(stream, k): heap = [] # (ключ, элемент), минимальный ключ наверху for x, w in stream: if w <= 0: continue key = random.random() ** (1 / w) if len(heap) < k: heapq.heappush(heap, (key, x)) elif key > heap[0][0]: heapq.heapreplace(heap, (key, x)) return [x for _, x in heap] random.seed(1) items = [("a", 1), ("b", 2), ("c", 7)] print(sorted(Counter(weighted_reservoir(items, 1)[0] for _ in range(100_000)).items())) # [('a', 9966), ('b', 19996), ('c', 70038)]
При вероятность выбора ровно : веса 1, 2, 7 дали частоты 10%, 20%, 70%. При это выборка без возвращения: сначала с такой вероятностью выбирается первый элемент, потом среди оставшихся — второй, и итоговая вероятность попасть в выборку уже не строго пропорциональна весу. Об этом стоит сказать, если интервьюер спросит про точность.
Время , память . Почему ключ именно : чем больше вес, тем ближе к нулю и тем ближе ключ к единице, то есть у тяжёлого элемента больше шансов оказаться наверху. Строгое доказательство есть в оригинальной статье, на собеседовании его обычно не требуют.
Как ускорить: идея алгоритма L
Алгоритм R вызывает генератор случайных чисел на каждом элементе. Когда намного больше , почти все вызовы заканчиваются отказом. Алгоритм L (Li, 1994) вместо этого сразу вычисляет, сколько элементов пропустить до следующей замены, и генерирует случайное число только на заменах. Ожидаемое число таких событий .
Для собеседования достаточно знать, что такая оптимизация существует, и уметь объяснить, откуда берётся выигрыш. Писать её по памяти не просят.
Где резервуарная выборка встречается в жизни
- Логи и мониторинг. Сохранить 1000 случайных запросов за сутки из миллиардов, не зная заранее их количества.
- Базы данных. Оценка статистики по таблице,
TABLESAMPLEи сбор выборки для планировщика запросов. - Машинное обучение. Буфер воспроизведения в обучении с подкреплением и потоковые датасеты, которые не помещаются в память.
- Распределённые системы. Каждый узел ведёт свой резервуар, а потом выборки объединяют с учётом размеров потоков.
Рядом стоят другие потоковые алгоритмы с похожей философией «приближённо, но в постоянной памяти»: фильтр Блума для проверки принадлежности и голосование Бойера — Мура для поиска элемента большинства за один проход.
Как отвечать на собеседовании
- Уточните условия: известна ли длина, можно ли хранить данные, сколько будет вызовов.
- Назовите тривиальные решения (два прохода, массив) и их цену.
- Предложите алгоритм R: «-й элемент беру с вероятностью ».
- Докажите через телескопическое произведение или по индукции. Этот шаг важнее кода.
- Обобщите на элементов и назовите сложность: времени, памяти.
- Скажите, как проверили бы: счётчик частот на большом числе запусков.
Если после этого спросят про веса, упомяните A-Res с ключом и кучей на элементов. Для смежной темы о случайности в алгоритмах посмотрите Quickselect: там случайный выбор опорного элемента даёт ожидаемое линейное время.