Зачем они нужны
Точные ответы на аналитические вопросы дороги при миллиардах событий:
- "Сколько уникальных посетителей за день?" -- точный ответ требует HashSet размером с кардинальность (гигабайты).
- "Как часто встречался товар X в логах?" -- точный счётчик на каждый товар -- миллионы записей.
- "Похожи ли два документа на 90%?" -- Jaccard requires intersection и union, O(n+m).
Вероятностные структуры дают приближённые ответы при sublinear памяти. Ошибка контролируемая (обычно 1-5%), и это приемлемо для analytics/monitoring сценариев.
HyperLogLog: cardinality estimation
Задача: сколько уникальных элементов в потоке (кардинальность)?
Интуиция
Возьмём хеш каждого элемента. Хеш -- равномерно случайная последовательность бит. Посчитаем, сколько ведущих нулей в каждом хеше. Если мы видели хеш с k ведущими нулями -- это событие с вероятностью 1/2^k. Значит, ожидаем увидеть такое после ~2^k разных элементов.
Флажоле (Flajolet) и коллеги предложили HyperLogLog (2007) на основе этой идеи:
- Делим хеш на
bпрефиксных бит (которые задают "бакет") и остальные биты. - В каждом бакете запоминаем максимум ведущих нулей среди хешей, попавших в бакет.
- Кардинальность = harmonic mean × корректирующие константы.
Формула
При m = 2^b бакетов:
$$\hat{n} = \alpha_m \cdot m^2 \cdot \left(\sum_{j=1}^{m} 2^{-M_j}\right)^{-1}$$
где M_j -- max leading zeros в бакете j, α_m -- поправочная константа.
Память
- m = 2^14 = 16384 бакетов × 6 бит = 12 KB.
- Стандартная ошибка ≈ 1.04 / √m ≈ 0.81%.
То есть 12 KB памяти -- на миллиарды уникальных значений с 1% ошибкой. Для сравнения, хеш-сет = O(n) байт на n элементов.
Объединение
Два HLL можно объединить поэлементным максимумом бакетов. Это означает, что можно:
- Поддерживать HLL на каждом сервере, мержить раз в N секунд.
- Считать HLL за час/день и затем суммировать по периодам.
Где используется
- Redis PFADD / PFCOUNT: встроенный HLL, 12 KB на ключ.
- Presto / Trino: функция
approx_distinct()через HLL. - Google BigQuery:
APPROX_COUNT_DISTINCT(). - Amazon Redshift:
APPROX COUNT(DISTINCT ...). - Druid, ClickHouse: HLL для метрик uniques.
Минимальная реализация
<?php
declare(strict_types=1);
/**
* HyperLogLog with 2^b registers.
* Standard error ≈ 1.04 / sqrt(m).
*/
final class HyperLogLog
{
private const B = 14; // 16384 registers
private const M = 1 << self::B; // number of registers
private const ALPHA = 0.7213 / (1 + 1.079 / self::M);
/** @var list<int> */
private array $registers;
public function __construct()
{
$this->registers = array_fill(0, self::M, 0);
}
public function add(string $item): void
{
// 64-bit hash (xxh3 or similar would be better; using fnv1a + seed)
$hash = hexdec(substr(hash('sha256', $item), 0, 16)); // 64 bits
// First B bits → register index
$idx = $hash >> (64 - self::B);
// Remaining bits, count leading zeros + 1
$remaining = $hash & ((1 << (64 - self::B)) - 1);
$w = $this->leadingZeros($remaining, 64 - self::B) + 1;
if ($w > $this->registers[$idx]) {
$this->registers[$idx] = $w;
}
}
public function count(): int
{
$sum = 0.0;
foreach ($this->registers as $r) {
$sum += 2 ** (-$r);
}
$estimate = self::ALPHA * self::M * self::M / $sum;
// Small range correction
if ($estimate <= 2.5 * self::M) {
$zeros = count(array_filter($this->registers, fn(int $r): bool => $r === 0));
if ($zeros > 0) {
$estimate = self::M * log(self::M / $zeros);
}
}
return (int) round($estimate);
}
/** Count leading zeros in a number of $width bits. */
private function leadingZeros(int $n, int $width): int
{
if ($n === 0) {
return $width;
}
$lz = 0;
for ($i = $width - 1; $i >= 0; $i--) {
if (($n & (1 << $i)) !== 0) {
break;
}
$lz++;
}
return $lz;
}
/** Merge another HLL into this one (element-wise max). */
public function merge(self $other): void
{
for ($i = 0; $i < self::M; $i++) {
if ($other->registers[$i] > $this->registers[$i]) {
$this->registers[$i] = $other->registers[$i];
}
}
}
}
Задача: как часто встречался элемент x в потоке? Нужно отвечать на "top-K", "частоты запросов", "heavy hitters".
Идея
- Матрица счётчиков d × w.
- d хеш-функций h_1, ..., h_d, каждая мапит x → [0, w).
add(x): для каждого i увеличитьM[i][h_i(x)]на 1.count(x): вернуть min по i отM[i][h_i(x)].
Min -- потому что коллизии могут только увеличить счётчик, никогда не уменьшить. Берём минимальное значение -- оно ближе всего к истинному.
Ошибка
При d = ⌈ln(1/δ)⌉ и w = ⌈e/ε⌉:
- Ошибка ≤ ε × N с вероятностью 1 - δ, где N -- общее число событий.
- Пример: w = 2720, d = 7 → ε = 0.001, δ = 0.001 → ~20 KB памяти.
Важно: CMS завышает, но не занижает. Он никогда не скажет "элемент встречался меньше, чем на самом деле".
Use cases
- Top-K queries: трекать "самые популярные товары/запросы".
- Heavy hitters detection: network anomaly (IPs с аномальной активностью).
- Frequency-based caching: Caffeine / Redis LFU используют CMS для оценки частоты перед вытеснением.
Минимальная реализация
<?php
declare(strict_types=1);
/**
* Count-Min Sketch: d rows × w columns of counters.
* Queries return an upper bound on true frequency.
*/
final class CountMinSketch
{
/** @var list<list<int>> */
private array $counters;
/** @var list<int> */
private array $seeds;
public function __construct(
public readonly int $d,
public readonly int $w,
) {
$this->counters = array_fill(0, $d, array_fill(0, $w, 0));
$this->seeds = [];
for ($i = 0; $i < $d; $i++) {
$this->seeds[] = (int) hexdec(substr(hash('sha256', "seed:$i"), 0, 8));
}
}
public static function forAccuracy(float $epsilon, float $delta): self
{
$w = (int) ceil(M_E / $epsilon);
$d = (int) ceil(log(1 / $delta));
return new self($d, $w);
}
public function add(string $item, int $count = 1): void
{
for ($i = 0; $i < $this->d; $i++) {
$idx = $this->hash($item, $this->seeds[$i]);
$this->counters[$i][$idx] += $count;
}
}
public function estimate(string $item): int
{
$min = PHP_INT_MAX;
for ($i = 0; $i < $this->d; $i++) {
$idx = $this->hash($item, $this->seeds[$i]);
if ($this->counters[$i][$idx] < $min) {
$min = $this->counters[$i][$idx];
}
}
return $min;
}
private function hash(string $item, int $seed): int
{
$h = hexdec(substr(hash('sha256', $item . ':' . $seed), 0, 15));
return $h % $this->w;
}
}
Задача: оценить Jaccard similarity между двумя большими множествами.
$$J(A, B) = \frac{|A \cap B|}{|A \cup B|}$$
Точное вычисление требует перечисления всех элементов. MinHash даёт оценку за константу memory и O(k) сравнения.
Идея
- Фиксируем k хеш-функций h_1, ..., h_k.
- Для множества S сигнатура sig_i(S) = min_{x ∈ S} h_i(x).
- Jaccard(A, B) ≈ (число i, где sig_i(A) = sig_i(B)) / k.
Почему работает: для случайной перестановки элементов A ∪ B, вероятность того, что min совпадёт, равна |A ∩ B| / |A ∪ B| = J(A, B).
Параметры
- k = 100-200 обычно достаточно для ошибки 5-10%.
- Signature size: k × 8 байт = 800-1600 байт на множество любого размера.
LSH (Locality-Sensitive Hashing)
Для поиска near-duplicates среди миллиардов документов: бьём signature на b bands по r hashes. Документы, у которых хоть один band совпадает полностью, -- кандидаты. Это превращает O(n²) пары в O(n × кандидатов).
Use cases
- Near-duplicate detection: Google crawler находит плагиат в вебе.
- Рекомендации: пользователи с похожим набором просмотренного.
- Genomics: similarity геномов на уровне k-mer'ов.
- Plagiarism: сравнение документов в Turnitin.
Память vs точность (сводная таблица)
| Структура | Задача | Память | Типичная ошибка |
|---|---|---|---|
| HLL | Cardinality | 12 KB | ~1% |
| CMS | Frequency | 20 KB | ~0.1% × N |
| MinHash | Similarity | 1-2 KB / set | ~5-10% |
| Bloom | Set membership | ~1.2 MB / 10⁶ items @ 1% | 1% FP |
| Cuckoo | Set membership + delete | ~1 MB / 10⁶ items | 1% FP |
Практические советы
- Выбирайте хеш тщательно: MurmurHash3 / xxHash / BLAKE3. Не CRC32 (слишком слабый), не SHA-256 (избыточен).
- Комбинируйте: HLL для uniques + CMS для frequency = полноценный monitoring-stack.
- Слияние: все три структуры мержатся за O(size). Это позволяет распределённый подсчёт.
- Redis модули: RedisBloom включает CMS и TopK команды; HLL встроен (PFCOUNT).
- ClickHouse, Druid, Presto: поддерживают HLL/CMS как нативные agg-функции.
Когда НЕ использовать
- Нужен exact count -- считайте точно (hash set, sorted set).
- Нужно перечислять элементы -- вероятностные не хранят их.
- Малое n (< 10⁴) -- оверхед не оправдан.
- Финансовые/биллинг расчёты -- ошибка даже 0.1% недопустима.
Выводы
- HyperLogLog: cardinality в 12 KB при 1% ошибке на миллиарды элементов.
- Count-Min Sketch: приближённая частота в ~20 KB при 0.1% ошибке.
- MinHash: set similarity / Jaccard в 1-2 KB на set; с LSH находит near-duplicates за sublinear time.
- Все три мержатся, что делает их удобными для distributed analytics.
- Используются: Redis (PFCOUNT, BF/CMS модули), Presto, ClickHouse, BigQuery, Druid.
- Всегда идут в ансамбле: HLL для uniques, CMS для heavy hitters, MinHash для similarity.