antonio leandro

estruturas e algoritmos

Probabilistic Counting Algorithms for Data Base Applications

paper · núcleo · Philippe Flajolet, G. Nigel Martin · · ~40 min de leitura do original · densa

a tese

dá para contar quantos elementos distintos há num arquivo gigante sem guardar nenhum deles: os zeros à direita dos hashes já dizem a cardinalidade, com 64 palavras de memória e uma passada

o que fica

  1. Contar distintos de forma exata custa memória proporcional à cardinalidade; a estimativa probabilística custa memória proporcional ao logaritmo dela.
  2. O algoritmo é insensível à repetição por construção, não por aproximação: um registro que aparece um milhão de vezes marca exatamente o mesmo bit que um que aparece uma vez.
  3. O erro padrão cai com a raiz do número de bitmaps — 64 bitmaps dão 9,7%, 256 dão 4,8%, 1.024 dão 2,4% — então cada ganho de precisão custa memória quadrática.
  4. O stochastic averaging troca m funções de hash por uma só: os primeiros bits do hash escolhem o bucket e o resto vira a observação, e o custo por elemento deixa de crescer com m.
  5. Bitmaps produzidos por máquinas diferentes se combinam com um OR lógico, e por isso a estimativa distribuída não perde nada de precisão mesmo se os subarquivos se sobrepuserem.
  6. Uma observação isolada tem desvio padrão de 1,12 — uma ordem binária de grandeza —, e o algoritmo inteiro existe para reduzir essa variância.

o problema

Um otimizador de query escolhe entre planos que custam ordens de grandeza diferentes, e a variável que separa os planos muitas vezes não é o tamanho da relação: é a cardinalidade, quantos valores distintos existem ali. O paper abre com o caso mais banal possível, a interseção de dois arquivos A e B. Ordenar A e buscar cada elemento de B custa O(a log a + b log a); ordenar os dois e fazer um merge custa outra coisa; eliminar duplicatas antes muda a conta de novo. O System R já guardava essa informação em catálogos — tamanho das relações e número de elementos diferentes nos campos-chave — para estimar seletividade e decidir qual algoritmo usar em cada operador relacional.

O jeito exato de obter esse número é construir a lista sem repetição: ordenar ou hashear o arquivo inteiro, com custo de disco e memória proporcional à cardinalidade. Em 1984, isso frequentemente custava mais do que o ganho que a estatística traria ao otimizador — você paga uma varredura completa para decidir como fazer a varredura. Amostrar também não resolve: estimar a cardinalidade de uma amostra e multiplicar pela razão entre os tamanhos depende de como as repetições estão espalhadas, e essa é justamente a informação que você não tem.

a ideia

Pare de olhar os elementos e olhe os bits dos hashes. Se o hash distribui uniformemente sobre L bits, a fração de valores que termina em exatamente k zeros é 2^-k-1: metade termina em 1, um quarto em 01, um oitavo em 001. Encontrar um hash terminado em vinte zeros é evidência de que você viu perto de um milhão de valores distintos. A raridade do padrão é o contador.

O “distintos” sai de graça nesse arranjo. O mesmo registro repetido produz o mesmo hash, marca o mesmo bit e não acrescenta nada. O resultado depende só do conjunto de valores hasheados, nunca da frequência com que aparecem — o oposto de uma amostragem, que é justamente sensível a isso. Os autores rodaram o procedimento no volume 1 do manual do Unix da instalação deles: 26.692 linhas, 16.405 distintas, e o bitmap resultante tinha o primeiro zero na posição 12 e o último um na 15, com 2^14 = 16.384 no meio do caminho.

como funciona

O procedimento base, chamado COUNT, mantém um vetor BITMAP de L bits. Para cada registro, calcula p(hash(x)) — a posição do 1-bit menos significativo, com ranks começando em zero — e acende esse bit. No fim, R é a posição do zero mais à esquerda. O valor esperado de R é log₂(φn), com φ = 0,77351, e o desvio padrão fica em 1,12: uma ordem binária de grandeza de erro, inútil sozinho.

A correção óbvia é usar m funções de hash e tirar a média dos m valores de R, o que reduz o desvio por √m — ao preço de calcular m hashes por registro. O paper substitui isso pelo que chama de stochastic averaging: um hash só, cujos bits de baixa ordem escolhem qual dos m bitmaps atualizar e cujo resto carrega a observação.

for x in M:
    h = hash(x)                 # L bits
    a = h mod m                 # escolhe o bitmap
    i = p(h div m)              # 1-bit menos significativo do resto
    BITMAP[a][i] = 1

S = 0
for a in 0..m-1:
    R = 0
    while BITMAP[a][R] == 1 and R < L: R = R + 1
    S = S + R

Z = m / phi * 2 ** (S / m)      # phi = 0,77351

O algoritmo resultante, PCSA, tem erro padrão de 0,78/√m e viés de 1 + 0,31/m, que se corrige dividindo por esse fator na última linha. São 20 ou 30 instruções de assembly por elemento; a versão em Pascal não otimizada dos autores rodava, num VAX 11/780, duas vezes mais rápido que o sort do sistema.

o que isso custou

Precisão é cara. Com 64 bitmaps o erro padrão é 9,7%; para chegar a 4,8% são 256, e 2,4% exige 1.024. Como o erro cai com a raiz, cada dígito a mais custa cem vezes mais memória — e os autores encerram o paper admitindo que não sabem se esse trade-off entre espaço e acurácia é o melhor possível, nem como provar que não é.

A garantia toda repousa sobre a hipótese de hash pseudo-uniforme, sustentada por evidência empírica (estudos sobre arquivos industriais grandes e as simulações do próprio paper), não por prova. L precisa satisfazer L > log₂(n/m) + 4, senão o vetor trunca e o algoritmo subestima — você precisa da ordem de grandeza antes de medir. As fórmulas são assintóticas e só valem quando n/m passa de 10 ou 20; cardinalidades pequenas exigem correções calculadas à parte. E deleção não existe: para suportar remoções é preciso guardar contadores em vez de bits, com o custo de memória correspondente.

onde isso aparece hoje

A propriedade que mais sobreviveu é a menos comentada no paper: bitmaps de execuções independentes se combinam com um OR lógico. Cada máquina processa seu pedaço, manda um vetor minúsculo para um coordenador, e a estimativa final não perde precisão nem se os pedaços se sobrepuserem. É isso que faz contadores de distintos serem hoje item de prateleira em sistemas analíticos e de streaming, onde count distinct exato é caro justamente por não ser combinável.

O desfecho direto veio do próprio Flajolet duas décadas depois: o HyperLogLog mantém a divisão por hash em m buckets inventada aqui e troca o observável e a média, chegando a muito mais precisão pelo mesmo espaço. Junto com o filtro de Bloom, este paper fundou o hábito de responder perguntas sobre conjuntos enormes guardando hashes em vez de dados.

lido na íntegra por pipeline de llm, revisado por antonio leandro antes de publicar ·