Сколько чисел в массиве
Небольшая предыстория. Этот пост я написал для двух целей. Во-первых, обкатать конвертор разметки Markdown + в хабрачитаемый вид. Во-вторых, рассказать об интересной задаче из data streaming. К концу написания, я обнаружил пост про LogLog четырехлетней давности. На мою удачу автор предыдущего поста делал упор на реализацию. Я же, полагаясь на
, расскажу больше о математике.
Давайте представим, что у нас есть роутер. Через роутер проходит много пакетов по разным адресам. Нам интересно получить статистику, как много адресов задействовано в коммуникации. Есть пара проблем.
- Пакетов так много, что запомнить их все нельзя. Сказать ушедшему пакету «Вернись! Я все прощу,» — тоже.
- Всех возможных адресов
. Столько памяти на роутере нет.
Задача. Есть последовательность целых чисел , все числа принимают значения от
до
. Требуется в один проход посчитать количество различных чисел, используя
памяти.
Я расскажу вероятностный приближенный алгоритм Флажолета-Мартина. ТТХ алгоритма:
- использует
памяти!
- работает на любом входе;
- находит ответ, который отличается от точного не более чем в 3 раза с вероятностью
:
вероятность берется по случайным битам алгоритма.
В конце поста я расскажу, почему точные детерминированные алгоритмы требуют памяти.
Алгоритм Флажолета-Мартина
Представим, что у нас есть отрезок действительных чисел . На отрезок мы независимо случайно кидаем
точек согласно равномерному распределению. Какое будет расстояние между крайней левой точкой и нулем?
Можно предположить, что точки разделят отрезок на меньших подотрезков примерно одинаковой длины. Если аккуратно записать матожидание расстояния и взять интеграл, мы получим
Пусть кто-то кинул случайно несколько точек на отрезок, и — расстояние от нуля до крайней левой точки. Можно прикинуть, что всего точек порядка
.
Идея алгоритма Флажолета-Мартина — случайно бросить все числа последовательности на отрезок
, а затем найти расстояние от нуля до крайней левой точки. Если одинаковые числа будут всегда отображаться в одну точку, а разные независимо распределяться по отрезку, мы сможем прикинуть ответ.
2-независимые хэш-функции
Бросать числа на отрезок мы будем с помощью случайной хэш-функции.
Определение. Семейство хэш-функций называется 2-независимым, если для любых
и
Смысл определения в следующем. Зафиксируем два каких угодно ключа и
.
Ключи — различные. Посмотрим на случайные величины и
. Случайность задается выбором функции
. Тогда, по определению, величины
и
будут вести себя как независимые.
Как следствие, если взять всего один ключ , то величина
будет равномерна распределена по
.
Для примера возьмем простое число . Пусть
.
— это семейство всех линейных отображений по модулю
:
для . Тогда
Поскольку , система имеет ровно одно решение среди
возможных:
Поймем два важных момента. Во-первых, хранение такой функции обходится в памяти, что очень немного. Во-вторых, если внимательно приглядеться, то можно понять, что вычисления проходят в поле
, и могут быть обобщены для любого конечного поля.
В тестовом коде я буду использовать поле Галуа . В описании далее можно считать, что у нас есть семейство хэш-функций
, где
— степень двойки. Хранение одной функции занимает
памяти.
Алгоритм
Пусть — степень двойки.
Перед стартом, алгоритм случайно выбирает хэш-функцию из 2-независимого семейства.
Будем бросать элементы последовательности на отрезок . Берем очередное значение
и записываем: ноль, точка,
в двоичном виде. Например, если
, то получится число
.
Обозначим через число лидирующих нулей в двоичной записи
. Пусть
. Мы знаем, что минимальное значение лежит между
и
.
Ответ алгоритма: .
def init():
h = H.sample()
z = 0
def process(a):
z = max(z, zero(h(a))
def answer():
return 2**(z + 0.5)
Анализ
Я планирую показать, что ответ алгоритма будет в 3 раза больше верного с вероятностью меньшей . Аналогично, алгоритм вернет ответ в 3 раза меньше верного вероятностью меньшей
. Если вы не планируете углубляться в математику, смело переходите к следующей части.
Обозначим через множество всех различных чисел последовательности
, а
— их количество.
Для анализа алгоритма, нам потребуются два набора случайных величин.
Заметим, что вероятность : величина
равномерно распределена по отрезку
;
— степень двойки; есть всего
чисел с
лидирующими нулями.
Значит, матожидание . Ограничим сверху дисперсию
Заметим, что дисперсия по величинам линейна. Для любых двух
и
Поскольку и
независимы, то
. Значит,
Более того, , поскольку величины
— 2-независимы.
Теперь рассмотрим величину .
по линейности матожидания.
по линейности дисперсии для 2-независимых величин.
Пусть — минимальное число такое, что
. Событие «алгоритм выдал ответ в 3 раза больше нужного» равносильно событию
и равносильно событию
. Применяя неравенство Маркова, ограничим вероятность
Пусть — максимальное число такое, что
. Аналогично, событие «алгоритм выдал ответ в 3 раза меньше нужного» равносильно событию
и равносильно событию
. Применяя неравенство Чебышёва, получим
Финальный аккорд: медиана
Осталось понять как понизить ошибку. Возьмем случай, когда алгоритм выдает слишком большой ответ. Запустим алгоритм параллельно раз и вернем медиану среди ответов. Я утверждаю, что если
, то алгоритм ошибется с вероятностью не больше
. Аналогично, ограничив ошибку в другую сторону, получим
Почему медиана так работает? По неравенству Чернова. Заведем случайную величину . Величина
равна единице, если ответ алгоритма на
запуске меньше
. Вероятность этого события не меньше 0.52.
Если медиана запусков алгоритма больше
, то это значит, что алгоритм хотя бы половину раз выдал ответ больший
и
. Тогда по неравенству Хефдинга-Чернова
где — некоторая константа. Другой случай показывается аналогично.
Нижняя оценка для точного алгоритма
Давайте представим, что кто-то действительно придумал детерминированный алгоритм, который в один проход находит точное число различных элементов в один проход. Мы покажем, что такой алгоритм должен использовать памяти.
Возьмем множество размера
и положим его в качестве начала последовательности. Скормим эту часть алгоритму и посмотрим на его память.
Из одной только памяти алгоритма можно извлечь все множество . Если скормить в текущем состоянии число
, ответ алгоритма не изменится; если
, то увеличится на 1. Значит, каждому множеству
должно соответствовать свое уникальное состояние памяти.
Различных подмножеств из размера
примерно
. Если мы хотим каждому множеству сопоставить битовую строку, нам потребуется
Что почитать
- «Probabilistic Counting Algorithms for Data Base Applications», Flajolet, Martin, 1983, link.
- «The space complexity of approximating the frequency moments», Alon, Matias, Szegedy, 1999, link.