Не люблю хэш-таблицы
Не люблю я хэш-таблицы. Какой бы областью я не занимался — они везде просто “достаточно хорошее” решение. Где нужны объёмы — масштабируется линейно. Где нужна точность — даёт вероятность (высокую, но вероятность всё-таки).
Задача
Есть класс задач, где удобно заранее узнать включение паттерна в потоке. Например, 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-фильтра он тоже есть, и я ниже покажу у кого при одинаковом размере их больше
). А 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. Это вторая статья за сегодня. В первой я показал новый прикольный универсальный код
Ставьте лайки, колокольчик, подписывайтесь на канал!
-Источник