惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Troy Hunt's Blog
T
The Blog of Author Tim Ferriss
WordPress大学
WordPress大学
人人都是产品经理
人人都是产品经理
博客园 - 【当耐特】
D
DataBreaches.Net
Vercel News
Vercel News
IT之家
IT之家
博客园_首页
aimingoo的专栏
aimingoo的专栏
GbyAI
GbyAI
罗磊的独立博客
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
爱范儿
爱范儿
The Cloudflare Blog
V
Visual Studio Blog
大猫的无限游戏
大猫的无限游戏
Last Week in AI
Last Week in AI
小众软件
小众软件
V
V2EX
SecWiki News
SecWiki News
L
LINUX DO - 最新话题
MyScale Blog
MyScale Blog
S
Secure Thoughts
The Last Watchdog
The Last Watchdog
A
About on SuperTechFans
博客园 - 司徒正美
雷峰网
雷峰网
Cloudbric
Cloudbric
Engineering at Meta
Engineering at Meta
Apple Machine Learning Research
Apple Machine Learning Research
T
The Exploit Database - CXSecurity.com
Google DeepMind News
Google DeepMind News
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Cisco Talos Blog
Cisco Talos Blog
Blog — PlanetScale
Blog — PlanetScale
Google DeepMind News
Google DeepMind News
PCI Perspectives
PCI Perspectives
V
Vulnerabilities – Threatpost
Security Latest
Security Latest
P
Privacy & Cybersecurity Law Blog
The Register - Security
The Register - Security
N
Netflix TechBlog - Medium
C
Cyber Attacks, Cyber Crime and Cyber Security
U
Unit 42
The GitHub Blog
The GitHub Blog
P
Privacy International News Feed
博客园 - 三生石上(FineUI控件)
A
Arctic Wolf
Hugging Face - Blog
Hugging Face - Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Структуры данных на практике. Глава 16: Фильтры Блума и вероятностные структуры данных
PatientZero · 2026-06-28 · via Все публикации подряд на Хабре

12 мин

7

«Преждевременная оптимизация — корень всех зол», — Дональд Кнут

Оглавление

Глава 1: Разрыв в производительности

Глава 2: Иерархия памяти

Глава 3: Бенчмаркинг и профилирование

Глава 4: Массивы и локальность кэша

Глава 5: Связанные списки — убийцы кэша

Глава 6: Стеки и очереди

Глава 7: Хэш-таблицы и конфликты кэша

Глава 8: Динамические массивы и управление памятью

Глава 9: Двоичные деревья поиска

Глава 10: B-деревья и деревья, эффективно использующие кэш

Глава 11: Префиксные деревья и базисные деревья

Глава 12: Кучи и очереди с приоритетом

Глава 13: Структуры данных без блокировок

Глава 14: Обработка строк и эффективность использования кэша

Глава 15: Графы и их обход с эффективным использованием кэша

Глава 16: Фильтры Блума и вероятностные структуры данных

Кризис памяти

Наш веб-краулер потреблял 128 МБ ОЗУ только на отслеживание посещённых URL. На встраиваемом устройстве с 256 МБ это была половина всей памяти.

Задача краулера была простой: отслеживать посещённые URL, чтобы не краулить одну и ту же страницу дважды. После обработки 1 миллиона URL (средняя длина 80 байт) хэш-таблица, в которой хранились эти URL, разрослась до 96 МБ плюс оверхед.

«Можем ли мы обменять точность на память? Нас вполне устроит несколько дублированных операций, если это позволит сэкономить большой объём памяти», — сказал мне мой менеджер во время ревью кода.

Этот вопрос изменил всё. На самом деле, идеальная точность не требуется. Если мы случайно обработаем одну страницу дважды, то впустую потратим часть пропускной способности, но ничего не поломаем. Главным ограничением была память.

В нашем исходном решении использовалась простая хэш-таблица:

hash_table_t *visited_urls;  // Хранит полные URL

bool is_visited(const char *url) {
    return hash_table_contains(visited_urls, url);
}

void mark_visited(const char *url) {
    hash_table_insert(visited_urls, url, NULL);
}

После краулинга 1 миллиона URL средней длиной 80 байт хэш-таблица заняла следующий объём:

$ ./crawler_hashtable
Memory usage: 128 MB
  Hash table: 96 MB (1M URLs × 80 bytes + overhead)
  Other: 32 MB
  
Lookup time: 150 ns/lookup (including cache misses)

128 МБ на одно лишь отслеживание посещённых URL! На встраиваемом устройстве с 256 МБ ОЗУ это было неприемлемо.

Я реализовал фильтр Блума. Результаты получились такими:

$ ./crawler_bloom
Memory usage: 18 MB
  Bloom filter: 1.2 MB (10 bits per URL)
  Other: 16.8 MB
  
Lookup time: 45 ns/lookup
False positive rate: 0.8% (8,000 false positives out of 1M)

Memory reduction: 10.7× (128 MB → 12 MB)
Speedup: 3.3× (150 ns → 45 ns)

В 10,7 раза меньше памяти и в 3,3 раза быстрее, при этом всего 0,8% ложноположительных результатов (то есть повторного краулинга нескольких страниц).

В этой главе мы поговорим о вероятностных структурах данных, которые позволяют в обмен на снижение точности сэкономить огромные объёмы памяти.


Что нам рассказывают в учебниках

Фильтр Блума — это эффективно использующая пространство вероятностная структура данных, проверяющая, находится ли элемент в множестве.

Свойства:

  • Отсутствие ложноотрицательных результатов: если он говорит «нет в множестве», то элемента точно нет

  • Возможные ложноположительные результаты: если он говорит «есть в множестве», то может ошибаться

  • Эффективное использование пространства: использует биты вместо хранения полных элементов

  • Высокая скорость: O(k), где k — количество хэш-функций (обычно 3-10)

Что нам говорят учебники: «Используйте фильтры Блума, когда вас устраивает наличие ложноположительных результатов и есть необходимость экономии памяти».


Проверка реальностью: как работают фильтры Блума

Базовая структура

Фильтр Блума — это битовый массив размера m и k хэш-функций:

typedef struct {
    uint64_t *bits;   // Битовый массив
    size_t m;         // Количество бит
    int k;            // Количество хэш-функций
} bloom_filter_t;

bloom_filter_t *bloom_create(size_t m, int k) {
    bloom_filter_t *bf = malloc(sizeof(bloom_filter_t));
    bf->m = m;
    bf->k = k;
    bf->bits = calloc((m + 63) / 64, sizeof(uint64_t));
    return bf;
}

Операция вставки

Хэшируем элемент k раз, задаём k бит:

void bloom_insert(bloom_filter_t *bf, const char *element) {
    for (int i = 0; i < bf->k; i++) {
        uint64_t hash = hash_function(element, i);
        size_t bit_pos = hash % bf->m;
        
        size_t word = bit_pos / 64;
        size_t bit = bit_pos % 64;
        bf->bits[word] |= (1UL << bit);
    }
}

Операция поиска

Хэшируем элемент k раз, проверяем, все ли k бит заданы:

bool bloom_contains(bloom_filter_t *bf, const char *element) {
    for (int i = 0; i < bf->k; i++) {
        uint64_t hash = hash_function(element, i);
        size_t bit_pos = hash % bf->m;
        
        size_t word = bit_pos / 64;
        size_t bit = bit_pos % 64;
        
        if (!(bf->bits[word] & (1UL << bit))) {
            return false;  // Точно нет в множестве
        }
    }
    return true;  // Вероятно, находится в множестве (может быть ложноположительным результатом)
}

Почему возникают ложноположительные результаты

После вставки большого количества элементов многие биты имеют значение 1. Поиск случайно может обнаружить, что заданы все k бит, даже если элемент не вставлялся.

Пример:

Вставка "foo": задаёт биты 5, 12, 23
Вставка "bar": задаёт биты 12, 18, 30
Вставка "baz": задаёт биты 5, 18, 42

Поиск "xyz": хэшируется в биты 5, 12, 18
  Все три бита заданы (другими элементами)!
  Ложноположительный результат!

Выбор параметров: m и k

Частота ложноположительных результатов зависит от:

  • m: количества бит

  • k: количества хэш-функций

  • n: количества вставленных элементов

Оптимальное k: k = (m/n) × ln(2) ≈ 0,693 × (m/n)

Частота ложноположительных результатов: p ≈ (1 - e^(-kn/m))^k

Пример расчёта

Для 1 миллиона URL с 1% ложноположительных результатов:

Целевые показатели: p = 0,01, n = 1000000

Вычисляем m:
  m = -n × ln(p) / (ln(2))^2
  m = -1000000 × ln(0,01) / 0,48
  m ≈ 9585058 бит ≈ 1,2 МБ

Оптимальное k:
  k = (m/n) × ln(2)
  k = 9,6 × 0,693
  k ≈ 7 хэш-функций

То есть для 1 миллиона URL с 1% ложноположительных результатов нужно всего 1,2 МБ и 7 хэш-функций.

Сравните это с хэш-таблицей: 96 МБ (в 80 раз больше памяти!).


Реализация фильтра Блума с оптимальным использованием кэша

Наивная реализация обладает плохим поведением при работе с кэшем: k хэш-функций получают доступ к k произвольным адресам памяти.

Проблема: произвольный доступ к памяти

// Наивное решение: k произвольных операций доступа
for (int i = 0; i < k; i++) {
    size_t bit_pos = hash(element, i) % m;
    // Каждый bit_pos случаен → промах кэша!
}

При k=7 получается 7 промахов кэша на одну операцию поиска!

Решение: блочный фильтр Блума

Разбиваем битовый массив на блоки, используем по k бит в одном блоке:

#define BLOCK_SIZE 512  // 512 бит = 64 байт = 1 линия кэша

typedef struct {
    uint64_t *bits;
    size_t num_blocks;
    int k;
} blocked_bloom_t;

bool blocked_bloom_contains(blocked_bloom_t *bf, const char *element) {
    uint64_t hash = hash_function(element, 0);
    size_t block = hash % bf->num_blocks;
    
    // Все k бит находятся в одном блоке (одна линия кэша!)
    uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)];
    
    for (int i = 0; i < bf->k; i++) {
        uint64_t h = hash_function(element, i);
        size_t bit_pos = h % BLOCK_SIZE;
        size_t word = bit_pos / 64;
        size_t bit = bit_pos % 64;
        
        if (!(block_ptr[word] & (1UL << bit))) {
            return false;
        }
    }
    return true;
}

Почему это помогает:

  • Все k бит находятся в одной линии кэша

  • 1 промах кэша вместо k промахов

  • Для k=7 промахов кэша становится в 7 раз меньше

Бенчмарк

Тест: 1 миллион операций поиска в фильтре Блума (k=7, m=10 миллионов бит)

Наивный фильтр Блума:
  Промахи кэша: 7 миллионов (7 на одну операцию)
  Такты: 450 миллионов
  Время: 375 мс

Блочный фильтр Блума (512-битные блоки):
  Промахи кэша: 1 миллион (1 на одну операцию)
  Такты: 85 миллионов
  Время: 71 мс
  Ускорение: 5,3×

Ускорение в 5,3 раза благодаря одному лишь повышению локальности кэша!


Продвинутый уровень: фильтр Блума с подсчётом

Стандартные фильтры Блума не могут удалять элементы (нельзя сбросить бит — он может быть общим с другими элементами).

В фильтрах Блума с подсчётом вместо битов используются счётчики:

typedef struct {
    uint8_t *counters;  // 4-битные счётчики (0-15)
    size_t m;
    int k;
} counting_bloom_t;

void counting_bloom_insert(counting_bloom_t *bf, const char *element) {
    for (int i = 0; i < bf->k; i++) {
        size_t pos = hash(element, i) % bf->m;
        if (bf->counters[pos] < 15) {  // Защита от переполнения
            bf->counters[pos]++;
        }
    }
}

void counting_bloom_delete(counting_bloom_t *bf, const char *element) {
    for (int i = 0; i < bf->k; i++) {
        size_t pos = hash(element, i) % bf->m;
        if (bf->counters[pos] > 0) {
            bf->counters[pos]--;
        }
    }
}

Компромисс: использует в 4 раза больше памяти (4 бита на счётчик вместо 1 бита), но поддерживает удаление.


Пример из реального мира: безопасный браузинг Google Chrome

Перед отправкой URL на серверы Google браузер Chrome проверяет его на потенциальную зловредность при помощи фильтра Блума.

Проблема

Chrome необходимо сверить миллионы URL с чёрным списком зловредных сайтов:

  • В чёрном списке около миллиона элементов

  • Отправлять каждый URL в Google нельзя (конфиденциальность + задержки)

  • Ограниченная память на клиенте

Решение

Двухэтапная проверка:

  1. Локальный фильтр Блума (быстрый, занимает мало памяти):

    • 1 миллион элементов, 1% ложноположительных результатов

    • Память: 1,2 МБ

    • Поиск: <1 мкс

    • Если фильтр Блума сообщает «элемента нет в множестве» → безопасно, не обращаемся к серверу

  2. Проверка на сервере (медленная и точная):

    • Если фильтр Блума сообщает «элемент может быть в множестве» → обращаемся на сервер за подтверждением

    • Проверки сервером требует всего 1% URL (ложноположительные результаты)

Результат:

  • 99% URL проверяется локально (сетевые задержки отсутствуют)

  • 1,2 МБ памяти (против 80 МБ для полной хэш-таблицы)

  • Сохранение конфиденциальности (на сервер отправляются только подозрительные URL)


Другие вероятностные структуры данных

1. Count-Min Sketch

Оценивает частоту элементов в потоке.

typedef struct {
    int **counters;  // Массив счётчиков d × w
    int d;           // Количество хэш-функций
    int w;           // Ширина каждой строки
} count_min_sketch_t;

void cms_increment(count_min_sketch_t *cms, const char *element) {
    for (int i = 0; i < cms->d; i++) {
        int pos = hash(element, i) % cms->w;
        cms->counters[i][pos]++;
    }
}

int cms_estimate(count_min_sketch_t *cms, const char *element) {
    int min_count = INT_MAX;
    for (int i = 0; i < cms->d; i++) {
        int pos = hash(element, i) % cms->w;
        if (cms->counters[i][pos] < min_count) {
            min_count = cms->counters[i][pos];
        }
    }
    return min_count;  // Оценка (всегда ≥ истинного количества)
}

Сценарий использования: анализ сетевого трафика (подсчёт частоты пакетов без хранения всех пакетов).

Память: O(d × w) вместо O(n) в случае точного подсчёта.

2. HyperLogLog

Оценивает кардинальность (количество уникальных элементов) в потоке.

typedef struct {
    uint8_t *registers;  // m регистров
    int m;               // Количество регистров (степень двойки)
} hyperloglog_t;

void hll_add(hyperloglog_t *hll, const char *element) {
    uint64_t hash = hash_function(element);
    int j = hash & (hll->m - 1);  // Первые log2(m) бит
    int w = __builtin_clzll(hash >> __builtin_ctz(hll->m)) + 1;  // Нули в начале

    if (w > hll->registers[j]) {
        hll->registers[j] = w;
    }
}

size_t hll_estimate(hyperloglog_t *hll) {
    double sum = 0;
    for (int i = 0; i < hll->m; i++) {
        sum += 1.0 / (1 << hll->registers[i]);
    }
    double alpha = 0.7213 / (1 + 1.079 / hll->m);  // Коррекция перекоса
    return (size_t)(alpha * hll->m * hll->m / sum);
}

Сценарий использования: подсчёт уникальных посетителей веб-сайта без хранения всех IP-адресов.

Память: 1,5 КБ в обмен на погрешность в 2% при миллиардах элементов!

Пример:

Точный подсчёт (хэш-таблица): 10 ГБ для 1 миллиарда уникальных IP-адресов
HyperLogLog: 1,5 КБ с погрешностью в 2%
Уменьшение объёма памяти: 6666667×

3. Кукушкин фильтр

Похож на фильтр Блума, но поддерживает удаление и имеет более высокую скорость поиска.

#define BUCKET_SIZE 4

typedef struct {
    uint8_t fingerprint[BUCKET_SIZE];
} bucket_t;

typedef struct {
    bucket_t *buckets;
    size_t num_buckets;
} cuckoo_filter_t;

bool cuckoo_insert(cuckoo_filter_t *cf, const char *element) {
    uint64_t hash = hash_function(element);
    uint8_t fp = fingerprint(hash);  // 8-битный фингерпринт
    size_t i1 = hash % cf->num_buckets;
    size_t i2 = (i1 ^ hash_function(&fp, 0)) % cf->num_buckets;

    // Пытаемся вставить в бакет i1
    for (int j = 0; j < BUCKET_SIZE; j++) {
        if (cf->buckets[i1].fingerprint[j] == 0) {
            cf->buckets[i1].fingerprint[j] = fp;
            return true;
        }
    }

    // Пытаемся вставить в бакет i2
    for (int j = 0; j < BUCKET_SIZE; j++) {
        if (cf->buckets[i2].fingerprint[j] == 0) {
            cf->buckets[i2].fingerprint[j] = fp;
            return true;
        }
    }

    // Оба бакета заполнены, нужен перенос (кукушкино хэширование)
    // ... (сложная логика переноса)

    return false;  // Фильтр заполнен
}

bool cuckoo_contains(cuckoo_filter_t *cf, const char *element) {
    uint64_t hash = hash_function(element);
    uint8_t fp = fingerprint(hash);
    size_t i1 = hash % cf->num_buckets;
    size_t i2 = (i1 ^ hash_function(&fp, 0)) % cf->num_buckets;

    // Проверяем бакет i1
    for (int j = 0; j < BUCKET_SIZE; j++) {
        if (cf->buckets[i1].fingerprint[j] == fp) {
            return true;
        }
    }

    // Проверяем бакет i2
    for (int j = 0; j < BUCKET_SIZE; j++) {
        if (cf->buckets[i2].fingerprint[j] == fp) {
            return true;
        }
    }

    return false;
}

Преимущества перед фильтром Блума:

  • Поддерживает удаление

  • Улучшенная локальность кэша (всего два проверяемых бакета против k случайных позиций)

  • Чуть более оптимальное использование пространства

Бенчмарк:

Тест: 1 миллион элементов, 1% ложноположительных результатов

Фильтр Блума:
  Память: 1,2 МБ
  Поиск: 7 промахов кэша
  Время: 150 нс

Кукушкин фильтр:
  Память: 1,1 МБ (чуть лучше)
  Поиск: 2 промаха кэша (2 бакета)
  Время: 65 нс
  Ускорение: 2,3×

Когда использовать вероятностные структуры данных

Реализовав множество разных вероятностных структур данных, я понял, когда их стоит применять.

Фильтры Блума следует использовать, когда:

  1. Ограничена память: экономия памяти в 10-100 раз по сравнению с хэш-таблицами

  2. Приемлемы ложноположительные результаты: допустимы возникающие время от времени ошибки

  3. Часты негативные запросы: «посещён ли этот URL», когда большинство URL новые

Пример: устранение дубликатов URL в веб-краулере

  • 1 миллион URL: 1,2 МБ (фильтр Блума) против 96 МБ (хэш-таблица)

  • 0,8% ложноположительных результатов → небольшое количество страниц придётся краулить дважды (приемлемо)

Count-Min Sketch следует использовать при:

  1. Подсчёте частотности в потоках: точное количество не требуется

  2. Ограниченной памяти: все элементы хранить невозможно

Пример: анализ сетевого трафика

  • Подсчёт типов пакетов без хранения всех пакетов

  • 100 КБ (sketch) против 10 ГБ (точный подсчёт)

HyperLogLog следует использовать при:

  1. Определении кардинальности: «Сколько есть уникальных пользователей?»

  2. Миллиардах элементов: подсчитывать точно непрактично

Пример: аналитика веб-сайта

  • 1,5 КБ в обмен на погрешность в 2% для 1 миллиарда уникальных IP-адресов

  • Против 10 ГБ в случае точного подсчёта

Кукушкин фильтр следует использовать, когда:

  1. Требуется удаление: фильтры Блума не могут удалять

  2. Требуется повышенная производительность поиска: 2 промаха кэша против 7

Пример: политика занесения в кэш

  • Отслеживаем недавно встреченные элементы

  • Удаляем старые элементы, когда они очищаются из кэша

НЕ используйте вероятностные структуры данных, когда:

  1. Неприемлемы ложноположительные результаты: решения критичны для безопасности

  2. Память избыточна: просто выберите хэш-таблицу

  3. Нужны точные ответы: вероятностный = неточный


Соединяем всё вместе: оптимизированный веб-краулер

Вот окончательный оптимизированный краулер, в котором используется блочный фильтр Блума:

#define BLOCK_SIZE 512
#define BITS_PER_URL 10
#define NUM_HASH 7

typedef struct {
    uint64_t *bits;
    size_t num_blocks;
    int k;
} crawler_bloom_t;

crawler_bloom_t *crawler_bloom_create(size_t max_urls) {
    crawler_bloom_t *bf = malloc(sizeof(crawler_bloom_t));
    size_t total_bits = max_urls * BITS_PER_URL;
    bf->num_blocks = (total_bits + BLOCK_SIZE - 1) / BLOCK_SIZE;
    bf->bits = calloc(bf->num_blocks * (BLOCK_SIZE / 64), sizeof(uint64_t));
    bf->k = NUM_HASH;
    return bf;
}

bool crawler_is_visited(crawler_bloom_t *bf, const char *url) {
    uint64_t hash = hash_function(url, 0);
    size_t block = hash % bf->num_blocks;
    uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)];

    for (int i = 0; i < bf->k; i++) {
        uint64_t h = hash_function(url, i);
        size_t bit_pos = h % BLOCK_SIZE;
        size_t word = bit_pos / 64;
        size_t bit = bit_pos % 64;

        if (!(block_ptr[word] & (1UL << bit))) {
            return false;  // Точно не посещён
        }
    }
    return true;  // Вероятно посещён (может быть ложноположительным результатом)
}

void crawler_mark_visited(crawler_bloom_t *bf, const char *url) {
    uint64_t hash = hash_function(url, 0);
    size_t block = hash % bf->num_blocks;
    uint64_t *block_ptr = &bf->bits[block * (BLOCK_SIZE / 64)];

    for (int i = 0; i < bf->k; i++) {
        uint64_t h = hash_function(url, i);
        size_t bit_pos = h % BLOCK_SIZE;
        size_t word = bit_pos / 64;
        size_t bit = bit_pos % 64;

        block_ptr[word] |= (1UL << bit);
    }
}

Окончательный бенчмарк

Тест: краулинг 1 миллиона URL (средняя длина: 80 байт)

Хэш-таблица:
  Память: 128 МБ
  Поиск: 150 нс (с промахами кэша)
  Ложноположительные результаты: 0%

Наивный фильтр Блума:
  Память: 1,2 МБ (в 107 раз меньше)
  Поиск: 375 нс (7 промахов кэша)
  Ложноположительные результаты: 0,8%

Блочный фильтр Блума:
  Память: 1,2 МБ (в 107 раз меньше)
  Поиск: 45 нс (1 промах кэша)
  Ложноположительные результаты: 0,8%

Ускорение: 3,3× (150 нс → 45 нс)
Снижение объёма занимаемой памяти: 107×

Подведём итог

Кризис памяти был устранён. Объём потребляемой веб-краулером памяти снизился с 128 МБ до 1,2 МБ (в 107 раз). Время поиска уменьшилось с 150 нс до 45 нс (в 3,3 раза быстрее) со всего 0,8% ложноположительных результатов. Возникающая время от времени необходимость повторного краулинга — это маля цена за освобождение половины ОЗУ устройства.

Ключевые выводы:

  1. Фильтры Блума позволяют обменять ухудшение точности на снижение объёма памяти. Экономия памяти в 10-100 раз с <1% частотой ложноположительных результатов. Идеально для запросов «встречал ли я это раньше?».

  2. Блочные фильтры Блума удобны для кэша. Размещение всех k бит в одной линии кэша снижает количество промахов кэша с k до 1. В случае k=7 скорость увеличивается в 5,3 раза.

  3. Важны оптимальные параметры. Для получения целевой частоты ложноположительных результатов p используйте k = 0.693 × (m/n) хэш-функций и m = -n × ln(p) / (ln(2))² бит.

  4. Кукушкины фильтры лучше справляются с поиском, чем фильтры Блума. Только 2 промаха кэша против k промахов кэша. В 2,3 раза быстрее при сравнимом объёме памяти.

  5. HyperLogLog — волшебное решение при определении кардинальности. Проверка миллиардов уникальных элементов с 1,5 КБ и погрешностью 2%. Экономия памяти в 6 миллионов раз по сравнению с точным подсчётом.

Показатели веб-краулера:

  • Блочный фильтр Блума: в 107 раз меньше памяти, чем при хэш-таблице

  • Поиск: быстрее в 3,3 раза (45 нс против 150 нс)

  • Ложноположительные результаты: 0,8% (8000 из 1 миллиона)

  • Промахи кэша: меньше в 7 раз (1 против 7 на одну операцию поиска)

Вероятностные структуры данных — мощный инструмент в случае допустимости небольших погрешностей. Они позволяют создавать приложения, которые были бы невозможны при использовании точных структур данных.

В следующей главе: мы исследуем реальные случаи применения этих техник к загрузчикам.