Не люблю хэш-таблицы
Не люблю я хэш-таблицы. Какой бы областью я не занимался — они везде просто “достаточно хорошее” решение. Где нужны объёмы — масштабируется линейно. Где нужна точность — даёт вероятность (высокую, но вероятность всё-таки).
Задача
Есть класс задач, где удобно заранее узнать включение паттерна в потоке. Например, AB есть в DDDABEEE. И узнавать надо часто. Наивный подход — линейный скан на каждый запрос. Медленно.
Как работает Bloom-фильтр
Ребята придумали Bloom-фильтр. У вас массив нулей фиксированного размера. Входная строка проходит через K хэш-функций (по сути мясорубку), и по получившимся хэшам вы сыпете единицами в массив:
Вставка "CAT": hash1("CAT") = 3 hash2("CAT") = 7 hash3("CAT") = 1Массив: 0 1 0 1 0 0 0 1 0 0индекс: [0][1][2][3][4][5][6][7][8][9] ↑ ↑ ↑ h3 h1 h2
Проверка: прогоняем запрос через те же K функций. Если все K позиций = 1, ответ “вероятно есть”. Если хоть одна позиция = 0, ответ “точно нет”:
Поиск "DOG": hash1("DOG") = 3 → массив[3] = 1 ✓ hash2("DOG") = 5 → массив[5] = 0 ✗ → ТОЧНО НЕТПоиск "FOX": hash1("FOX") = 3 → массив[3] = 1 ✓ hash2("FOX") = 7 → массив[7] = 1 ✓ hash3("FOX") = 1 → массив[1] = 1 ✓ → ВЕРОЯТНО ЕСТЬ (но мы FOX не вставляли!)
Работает за константное время, но есть минусы:
-
Размер массива надо подбирать эмпирически
-
Со временем массив захламляется единицами
-
False Positives неизбежны (чем больше данных — тем больше)
Зачем K функций а не одна?
Одна функция ставит 1 бит. Проверка: “этот бит = 1?” — но куча других элементов тоже его поставили. С одной функцией FPR ≈ заполненность массива.
K функций ставят K бит. Проверка: “ВСЕ K бит = 1?” Вероятность что K случайных позиций все заняты чужими элементами = (заполненность)^K:
Массив заполнен на 50%: K = 1 → FPR ≈ 50% (каждый второй запрос врёт) K = 3 → FPR ≈ 12.5% (уже терпимо) K = 7 → FPR ≈ 0.8% (почти идеально) K = 10 → FPR ≈ 0.1% (но вставка стала в 10 раз дороже)
Моя идея: LZ77 без ссылок
Я подумал: а что будет если взять LZ77 (классический алгоритм сжатия), но вместо ссылок просто удалять дубликат? Тогда у меня останется минимальное ядро — скелет потока без повторов, по которому можно быстро искать вхождение.
Пример
У нас есть поток DDDBBBEEEAAABBB и поисковая строка AAABBB.
Построение скелета: жадно ищем дубликаты с правого конца. BBB уже встречался → удаляем:
Исходный поток: D D D B B B E E E A A A B B B ^^^^^ дубликат BBB — удаляем!Скелет: D D D B B B E E E A A A (12 байт вместо 15)
Поиск AAABBB: в скелете DDDBBBEEEAAA такой подстроки целиком нет. Но мы применяем тот же алгоритм коллапса к поисковой строке — ищем в скелете максимальное совпадение:
Запрос: A A A B B B ^^^^^ BBB есть в скелете → удаляем из запросаОстаток: A A A ^^^ AAA есть в скелете → удаляемОстаток: пусто → НАЙДЕНО!
Включение доказано! Все куски запроса нашлись в скелете.
При таком алгоритме есть риск False Positives (у Bloom-фильтра он тоже есть, и я ниже покажу у кого при одинаковом размере их больше :D). А False Negatives невозможны — ведь мы не уменьшаем энтропию, а только удаляем точные дубликаты. Оригинал каждого куска всегда остаётся в скелете.
Бенчмарки
Поток: 1MB избыточных данных (100 уникальных блоков по 50 байт, повторённых случайно). Скелет сжал весь мегабайт до 4.88 KB — оставил только уникальные блоки.
Bloom-фильтру выделяем ровно столько же памяти — 4.88 KB. Честное сравнение.
Результат (длина паттерна P = 16 байт)
|
Фильтр |
Размер |
False Positive Rate |
False Negative Rate |
|---|---|---|---|
|
Скелет |
4.88 KB |
0% |
0% |
|
Bloom |
4.88 KB |
96.4% |
0% |
При одинаковом бюджете памяти Bloom-фильтр бесполезен (96% ложных срабатываний), а скелет — идеален.
А если памяти мало?
Допустим нам разрешено потратить на фильтр только 2, 5 или 10 KB. Тот же сжимаемый поток (1MB → скелет 4.88KB), ищем паттерны длиной 16 байт. Оба фильтра получают одинаковый бюджет:
|
Бюджет памяти |
Скелет FPR |
Bloom FPR |
Скелет FNR |
Bloom FNR |
|---|---|---|---|---|
|
2 KB |
0% |
100% |
0% |
0% |
|
5 KB |
0% |
96% |
0% |
0% |
|
10 KB |
0% |
80% |
0% |
0% |
Скелет влез в 4.88 KB и отвечает без ошибок. Bloom при тех же килобайтах — захлебнулся.
А на случайных данных?
На полном рандоме дубликатов нет при любом уровне агрессивности — скелет не может сжаться и честно говорит: “эти данные несжимаемы, мне нужен весь мегабайт”. Bloom можно запихнуть в любой бюджет, но он предсказуемо ломается — при 50KB на миллион записей даёт 92% FPR. Оба фильтра бесполезны на рандоме при маленьком бюджете, только по разным причинам.
Итог
Bloom-фильтр — универсальный, работает на любых данных, но всегда с ошибками. Скелет — специализированный: на сжимаемых данных (а реальные данные почти всегда сжимаемы) он даёт идеальный результат при в разы меньшей памяти.
|
|
Bloom |
Скелет |
|---|---|---|
|
FPR |
Есть всегда |
0% на сжимаемых данных |
|
FNR |
0% (гарантия) |
0% на сжимаемых данных |
|
Сжимаемые данные |
Переполняется |
Идеально |
|
Рандом |
Переполняется по-своему |
Честно: “не могу сжать” |
|
Сложность |
O(K) хэшей |
O§ поиск подстроки |
Код и бенчмарки на GitHub.
P.S. Это вторая статья за сегодня. В первой я показал новый прикольный универсальный код
Ставьте лайки, колокольчик, подписывайтесь на канал! 😀
ссылка на оригинал статьи https://habr.com/ru/articles/1037072/