O código mais rápido que existe é aquele que não precisa ser executado.
Considere, por exemplo, que esteja implementando uma funcionalidade para recuperação de senhas. Nesse caso, é um completo desperdício de recursos fazer uma requisição ao servidor apenas para constatar que o e-mail informado não está cadastrado. Entretanto, como fazer para determinar se um e-mail “existe na base”, sem consultá-la?
Em outro exemplo, considere que esteja implementando a funcionalidade para cadastro de um novo usuário que precisará ter um “nome-de-usuário” único. Seria um total desperdício de recursos fazer requests ao servidor para constatar que o “nome-de-usuário” informado já está cadastrado, associado a outro usuário. Entretanto, como saber se um “nome-de-usuário” já foi utilizado sem consultar o servidor?
Infelizmente, é comum que requisições “inúteis”, como as indicadas nos parágrafos anteriores, ocorram, gerando desperdício de recursos e, muitas vezes, prejuízos de performance. O triste, é que elas poderiam ser evitadas com uso inteligente de estruturas de dados como o Bloom Filter.
Bloom Filter
Um Bloom Filter é uma estrutura de dados probabilística, que demanda pouquíssimo espaço, concebida por Burton Howard Bloom em 1970. Ela é utilizada para testar se um elemento está presente em um conjunto sem necessitar consultar a lista completa de elementos presente nesse conjunto.
Eventualmente, a estrutura pode apontar um falso positivo (indicando que um elemento está em um conjunto quando, na verdade, não está). Entretanto, jamais gera um falso negativo.
Quanto mais elementos são adicionados a uma Bloom filter, maiores são as chances de um falso positivo.
PS: O paper que apresenta a estrutura de dados está disponível aqui.
Utilizando Bloom Filter, com apenas poucos bits é possível gerar filtros eficientes para milhares de elementos. Um milhão de strings, por exemplo, poderiam ser controladas com apenas 1Mb e 1% de taxa de erro (consultas que geram falsos positivos).
Ou seja, é possível enviar para o “lado cliente” de um software informação suficiente para reduzir dramaticamente requests inócuos e, por consequência, trafego na rede, acessos a “disco” e alocações desnecessárias em memória. Afinal, antes de fazer uma requisição ao servidor para verificar a “presença” de um dado em um conjunto, bastaria o “lado cliente” consultar, antes, ao filtro, podendo prover feedback muito mais rápido sem onerar o backend.
O segredo para a eficiência dessa estrutura de dados é a utilização de hashes de qualidade que são utilizados para ligar ou desligar bits em um mapa. Sempre que um dado é “incluído” no filtro, os bits correspondentes do hash são ligados.
Para verificar se um dado está no filtro, basta verificar se todos os bits correspondentes ao hash estão ligados. Caso estejam, o dado poderá estar no conjunto. Caso contrário, o dado certamente não estará.
A implementação que compartilhamos aqui é baseada em uma versão antiga compartilhada pela Microsoft no Codeplex.
public delegate int HashFunction<T>(T input); public class BloomFilter<T> { private readonly HashFunction<T> getHashPrimary; private readonly HashFunction<T> getHashSecondary; private readonly int hashFunctionCount; private readonly BitArray hashBits; public BloomFilter( int capacity, float errorRate, HashFunction<T> primaryHashFunction, HashFunction<T> secondaryHashFunction ) { var m = BestM(capacity, errorRate); var k = BestK(capacity, errorRate); Debug.WriteLine($"New BloomFilter (k = {k}; m = {m})"); hashBits = new BitArray(m); hashFunctionCount = k; getHashPrimary = primaryHashFunction ?? throw new ArgumentNullException(nameof(secondaryHashFunction)); getHashSecondary = secondaryHashFunction ?? throw new ArgumentNullException(nameof(secondaryHashFunction)); } public void Add(T item) { // start flipping bits for each hash of item int primaryHash = item.GetHashCode(); int secondaryHash = getHashSecondary(item); for (int i = 0; i < hashFunctionCount; i++) { int hash = ComputeHashUsingDillingerManoliosMethod(primaryHash, secondaryHash, i); hashBits[hash] = true; } } public bool MaybeContains(T item) { int primaryHash = getHashPrimary(item); int secondaryHash = getHashSecondary(item); for (int i = 0; i < hashFunctionCount; i++) { int hash = ComputeHashUsingDillingerManoliosMethod(primaryHash, secondaryHash, i); if (hashBits[hash] == false) return false; } return true; } private int ComputeHashUsingDillingerManoliosMethod(int primaryHash, int secondaryHash, int i) { var resultingHash = (primaryHash + i * secondaryHash) % hashBits.Count; return Math.Abs(resultingHash); } private static int BestM(int capacity, float errorRate) => (int)Math.Ceiling(capacity * Math.Log(errorRate, 1.0 / Math.Pow(2, Math.Log(2.0)))); private static int BestK(int capacity, float errorRate) => (int)Math.Round(Math.Log(2.0) * BestM(capacity, errorRate) / capacity); }
O código, aqui escrito em C#, é fácil de transcrever para qualquer linguagem, incluindo Javascript.
A utilidade da Bloom filter também está associada a capacidade de gerar boas chaves hash para os objetos que se quer controlar. Abaixo, implementações para string e int32.
public static class HashFunctions { public static HashFunction<T> FromType<T>() => (obj) => obj.GetHashCode(); public static int ForStrings(string input) { var hash = 0; for (var i = 0; i < input.Length; i++) { hash += input[i]; hash += (hash << 10); hash ^= (hash >> 6); } hash += (hash << 3); hash ^= (hash >> 11); hash += (hash << 15); return hash; } public static int ForInt32(int input) { uint x = (uint)input; unchecked { x = ~x + (x << 15); x = x ^ (x >> 12); x = x + (x << 2); x = x ^ (x >> 4); x = x * 2057; x = x ^ (x >> 16); return (int)x; } } }
[tweet]Embora existam “acrobacias técnicas” que geram grandes ganhos de desempenho, a forma mais consistente e efetiva de escrever software com performance excelente é fazendo bom uso de algoritmos e estruturas de dados.[/tweet]
Excelente artigo.