LFU Cache em Rust: Implementação com Evição por Frequência em O(1)

Aprenda a implementar um LFU Cache (Least Frequently Used) em Rust com operações O(1). HashMap + buckets de frequência, comparação LRU vs LFU e exemplo prático de cache CDN.

Introdução

O LFU Cache (Least Frequently Used Cache) é uma política de evição que remove o item menos frequentemente acessado quando o cache atinge sua capacidade máxima. Enquanto o LRU (Least Recently Used) se baseia em recência — “quando foi o último acesso?” — o LFU se baseia em frequência — “quantas vezes foi acessado?”.

Essa distinção tem implicações práticas profundas. Em cenários onde certos itens são consistentemente populares (como conteúdo viral em uma CDN, consultas frequentes em banco de dados, ou recursos estáticos de um site), o LFU mantém esses itens no cache mesmo que haja uma rajada de acessos a itens novos. O LRU, por contraste, poderia evictar conteúdo popular em favor de acessos únicos recentes.

O grande desafio do LFU é atingir O(1) para todas as operações. A solução elegante, proposta por Shah, Mitra e Matani em 2010, combina dois HashMaps e uma lista de buckets de frequência para alcançar essa complexidade. Nesta página, vamos implementar essa solução completa em Rust.


Conceito e Teoria

Diferença entre LRU e LFU

LRU vs LFU — Cenário Comparativo:
====================================

Imagine um cache com capacidade 3 e a seguinte sequência de acessos:
  A, A, A, B, B, C, D

=== Estado com LRU (Least Recently Used) ===

  Após A,A,A:     [A]           (A foi o último acessado)
  Após B,B:       [B, A]        (B mais recente)
  Após C:         [C, B, A]     (cheio!)
  Após D:         [D, C, B]     A evicado! (menos recente)

  Problema: A tinha 3 acessos, mas foi evicado!

=== Estado com LFU (Least Frequently Used) ===

  Após A,A,A:     [A(3)]        (A tem frequência 3)
  Após B,B:       [A(3), B(2)]  (B tem frequência 2)
  Após C:         [A(3), B(2), C(1)]  (cheio!)
  Após D:         [A(3), B(2), D(1)]  C evicado! (menor frequência)

  Correto: A permanece no cache por ser o mais acessado!

Estrutura de Dados para O(1)

A chave para O(1) é a estrutura de buckets de frequência: cada frequência tem sua própria lista de itens, e mantemos um ponteiro para a frequência mínima.

Arquitetura do LFU Cache com O(1):
=====================================

  +----------------------------------------------------------+
  |                      LFU Cache                           |
  |                                                          |
  |  freq_minima = 1                                         |
  |                                                          |
  |  HashMap<chave, (valor, frequencia)>  (acesso por chave) |
  |  +--------+--------+-----------+                         |
  |  | chave  | valor  | freq      |                         |
  |  +--------+--------+-----------+                         |
  |  | "img1" | dados  |  5        |                         |
  |  | "img2" | dados  |  3        |                         |
  |  | "css"  | dados  |  1        |                         |
  |  | "js"   | dados  |  3        |                         |
  |  +--------+--------+-----------+                         |
  |                                                          |
  |  HashMap<frequencia, ListaOrdenada<chave>>               |
  |  +------+---------------------------+                    |
  |  | freq | itens (ordem de inserção) |                    |
  |  +------+---------------------------+                    |
  |  |  1   | [css]                     | <-- freq_minima    |
  |  |  3   | [img2, js]               |                    |
  |  |  5   | [img1]                   |                    |
  |  +------+---------------------------+                    |
  |                                                          |
  |  Evição: remove o primeiro item do bucket freq_minima    |
  |  (css seria evicado — frequência 1, mais antigo)         |
  +----------------------------------------------------------+

Fluxo das Operações

Operação GET(chave):
======================

  1. Busca no HashMap principal → (valor, freq_atual)
  2. Remove chave do bucket freq_atual
  3. Se bucket freq_atual ficou vazio E freq_atual == freq_minima:
     → freq_minima += 1
  4. Insere chave no bucket (freq_atual + 1)
  5. Atualiza frequência no HashMap para freq_atual + 1
  6. Retorna valor

  Tudo O(1)!


Operação PUT(chave, valor):
=============================

  Caso 1 — Chave já existe:
    → Atualiza valor, faz mesma lógica do GET (incrementa freq)

  Caso 2 — Chave nova, cache cheio:
    1. Evicta: remove primeiro item do bucket freq_minima
    2. Remove do HashMap principal
    3. Insere nova chave com frequência 1
    4. freq_minima = 1

  Caso 3 — Chave nova, cache com espaço:
    1. Insere no HashMap com frequência 1
    2. Insere no bucket de frequência 1
    3. freq_minima = 1

  Tudo O(1)!

Desempate por Recência

Quando múltiplos itens têm a mesma frequência mínima, o LFU precisa de um critério de desempate. O mais comum é LRU dentro da mesma frequência: entre os itens com frequência mínima, o menos recentemente usado é evicado. Isso combina o melhor dos dois mundos.

Desempate no bucket de frequência mínima:
============================================

  Bucket freq=1: [css, html, svg]
                   ^               ^
                   |               |
              mais antigo     mais recente
              (evicado        (preservado
               primeiro)       por mais tempo)

  Usamos LinkedHashSet ou VecDeque para manter
  a ordem de inserção dentro de cada bucket.

Complexidade

OperaçãoTempoEspaçoObservação
get(chave)O(1)O(1)HashMap lookup + mover entre buckets
put(chave, val)O(1)O(1)HashMap insert + atualizar bucket
eviçãoO(1)O(1)Remove do bucket freq_minima
Espaço totalO(n)n = capacidade do cache

Comparação LRU vs LFU

CritérioLRULFU
Base da decisãoRecência (último acesso)Frequência (total acessos)
Proteção contra rajadasFraca (items quentes saem)Forte (items quentes ficam)
Adaptação a mudançasRápidaLenta (inércia de frequência)
Complexidade de impl.ModeradaAlta
Overhead por item2 ponteiros (lista)freq + ponteiros bucket
Melhor paraPadrões geraisDistribuições Zipf/populares
Pior paraVarreduras sequenciaisMudanças bruscas de padrão

Implementação em Rust

use std::collections::{HashMap, VecDeque};

/// LFU Cache com operações O(1) para get e put.
///
/// Combina dois HashMaps e buckets de frequência com VecDeque
/// para manter a ordem de inserção (desempate LRU).
pub struct LfuCache<K, V>
where
    K: Eq + std::hash::Hash + Clone,
{
    /// Capacidade máxima do cache.
    capacidade: usize,
    /// Frequência mínima atual (para evição O(1)).
    freq_minima: usize,
    /// HashMap principal: chave -> (valor, frequência).
    dados: HashMap<K, (V, usize)>,
    /// Buckets de frequência: frequência -> fila de chaves.
    /// A fila mantém a ordem de inserção para desempate LRU.
    buckets: HashMap<usize, VecDeque<K>>,
}

impl<K, V> LfuCache<K, V>
where
    K: Eq + std::hash::Hash + Clone + std::fmt::Debug,
    V: Clone,
{
    /// Cria um novo LFU Cache com a capacidade especificada.
    pub fn novo(capacidade: usize) -> Self {
        LfuCache {
            capacidade,
            freq_minima: 0,
            dados: HashMap::with_capacity(capacidade),
            buckets: HashMap::new(),
        }
    }

    /// Busca o valor associado à chave.
    /// Incrementa a frequência de acesso do item.
    /// Retorna None se a chave não existir.
    pub fn get(&mut self, chave: &K) -> Option<&V> {
        if !self.dados.contains_key(chave) {
            return None;
        }

        // Obtém a frequência atual
        let freq_atual = self.dados.get(chave).unwrap().1;
        let nova_freq = freq_atual + 1;

        // Remove do bucket de frequência atual
        self.remover_do_bucket(chave, freq_atual);

        // Se o bucket da freq_minima ficou vazio, incrementa freq_minima
        if freq_atual == self.freq_minima
            && self
                .buckets
                .get(&freq_atual)
                .map_or(true, |b| b.is_empty())
        {
            self.freq_minima += 1;
        }

        // Insere no bucket da nova frequência
        self.buckets
            .entry(nova_freq)
            .or_insert_with(VecDeque::new)
            .push_back(chave.clone());

        // Atualiza a frequência no HashMap principal
        self.dados.get_mut(chave).unwrap().1 = nova_freq;

        // Retorna referência ao valor
        Some(&self.dados.get(chave).unwrap().0)
    }

    /// Insere ou atualiza um par chave-valor.
    /// Se a capacidade for excedida, evicta o item com menor frequência
    /// (desempate: o mais antigo entre os de mesma frequência).
    pub fn put(&mut self, chave: K, valor: V) {
        if self.capacidade == 0 {
            return;
        }

        // Caso 1: chave já existe — atualiza valor e incrementa frequência
        if self.dados.contains_key(&chave) {
            // Atualiza o valor
            self.dados.get_mut(&chave).unwrap().0 = valor;
            // Incrementa frequência (reutiliza lógica do get)
            let freq_atual = self.dados.get(&chave).unwrap().1;
            let nova_freq = freq_atual + 1;

            self.remover_do_bucket(&chave, freq_atual);

            if freq_atual == self.freq_minima
                && self
                    .buckets
                    .get(&freq_atual)
                    .map_or(true, |b| b.is_empty())
            {
                self.freq_minima += 1;
            }

            self.buckets
                .entry(nova_freq)
                .or_insert_with(VecDeque::new)
                .push_back(chave.clone());
            self.dados.get_mut(&chave).unwrap().1 = nova_freq;
            return;
        }

        // Caso 2: chave nova — verifica capacidade
        if self.dados.len() >= self.capacidade {
            // Evicta o item com menor frequência (mais antigo no bucket)
            self.evictar();
        }

        // Insere nova entrada com frequência 1
        self.dados.insert(chave.clone(), (valor, 1));
        self.buckets
            .entry(1)
            .or_insert_with(VecDeque::new)
            .push_back(chave);
        self.freq_minima = 1;
    }

    /// Remove o item com menor frequência (evição).
    fn evictar(&mut self) {
        if let Some(bucket) = self.buckets.get_mut(&self.freq_minima) {
            if let Some(chave_evicta) = bucket.pop_front() {
                self.dados.remove(&chave_evicta);
            }
        }
    }

    /// Remove uma chave de um bucket de frequência específico.
    fn remover_do_bucket(&mut self, chave: &K, freq: usize) {
        if let Some(bucket) = self.buckets.get_mut(&freq) {
            if let Some(pos) = bucket.iter().position(|k| k == chave) {
                bucket.remove(pos);
            }
        }
    }

    /// Retorna o número de itens no cache.
    pub fn len(&self) -> usize {
        self.dados.len()
    }

    /// Verifica se o cache está vazio.
    pub fn esta_vazio(&self) -> bool {
        self.dados.is_empty()
    }

    /// Retorna a frequência de acesso de uma chave (sem incrementar).
    pub fn frequencia(&self, chave: &K) -> Option<usize> {
        self.dados.get(chave).map(|(_, freq)| *freq)
    }

    /// Exibe o estado interno do cache para depuração.
    pub fn debug_estado(&self) {
        println!("LFU Cache (cap={}, len={}, freq_min={}):",
            self.capacidade, self.dados.len(), self.freq_minima);
        println!("  Dados:");
        for (chave, (_, freq)) in &self.dados {
            println!("    {:?} -> freq={}", chave, freq);
        }
        println!("  Buckets:");
        let mut freqs: Vec<_> = self.buckets.keys().collect();
        freqs.sort();
        for freq in freqs {
            if let Some(bucket) = self.buckets.get(freq) {
                if !bucket.is_empty() {
                    let marcador = if *freq == self.freq_minima {
                        " <-- freq_minima (evição aqui)"
                    } else {
                        ""
                    };
                    println!("    freq={}: {:?}{}", freq, bucket, marcador);
                }
            }
        }
    }
}

fn main() {
    println!("=== Demonstração do LFU Cache ===\n");

    let mut cache = LfuCache::novo(3);

    // Inserções iniciais
    cache.put("A", "dados_A");
    cache.put("B", "dados_B");
    cache.put("C", "dados_C");

    // Acessos variados para criar frequências diferentes
    // A: 3 acessos, B: 2 acessos, C: 1 acesso
    cache.get(&"A");
    cache.get(&"A");
    cache.get(&"B");

    println!("Estado após acessos variados:");
    cache.debug_estado();

    // Inserir D — deve evictar C (menor frequência)
    println!("\nInserindo D (capacidade excedida)...");
    cache.put("D", "dados_D");

    println!("C evicado (freq=1): {}", cache.get(&"C").is_none());
    println!("A permanece (freq=3): {}", cache.get(&"A").is_some());
    println!("B permanece (freq=2): {}", cache.get(&"B").is_some());

    println!("\nEstado após evição:");
    cache.debug_estado();

    // Demonstração de desempate LRU
    println!("\n--- Desempate por recência ---");
    let mut cache2 = LfuCache::novo(3);
    cache2.put("X", 1);
    cache2.put("Y", 2);
    cache2.put("Z", 3);
    // Todos com frequência 1, X é o mais antigo

    println!("Todos com freq=1:");
    cache2.debug_estado();

    cache2.put("W", 4); // Deve evictar X (mais antigo com freq=1)
    println!("\nApós inserir W:");
    println!("X evicado (mais antigo com freq=1): {}", cache2.get(&"X").is_none());
    cache2.debug_estado();
}

Exemplo Prático

Cache CDN para Conteúdo Web

Uma CDN (Content Delivery Network) precisa manter em cache o conteúdo mais popular para minimizar acessos ao servidor de origem. O LFU é ideal porque conteúdo viral deve permanecer em cache independentemente de acessos a conteúdo novo.

use std::collections::HashMap;
use std::time::Instant;

/// Tipo de conteúdo servido pela CDN.
#[derive(Debug, Clone)]
enum TipoConteudo {
    Imagem,
    JavaScript,
    Css,
    Html,
    Video,
    Api,
}

impl TipoConteudo {
    fn tamanho_medio_kb(&self) -> u64 {
        match self {
            TipoConteudo::Imagem => 200,
            TipoConteudo::JavaScript => 150,
            TipoConteudo::Css => 30,
            TipoConteudo::Html => 15,
            TipoConteudo::Video => 5000,
            TipoConteudo::Api => 5,
        }
    }
}

/// Entrada no cache da CDN.
#[derive(Debug, Clone)]
struct ConteudoCdn {
    url: String,
    tipo: TipoConteudo,
    tamanho_kb: u64,
    corpo: String, // Simulado
}

/// Estatísticas do cache CDN.
#[derive(Debug, Default)]
struct EstatisticasCdn {
    total_requisicoes: u64,
    acertos_cache: u64,
    erros_cache: u64,
    bytes_servidos_cache: u64,
    bytes_servidos_origem: u64,
    evicoes: u64,
}

impl EstatisticasCdn {
    fn taxa_acerto(&self) -> f64 {
        if self.total_requisicoes == 0 {
            return 0.0;
        }
        (self.acertos_cache as f64 / self.total_requisicoes as f64) * 100.0
    }

    fn economia_banda(&self) -> f64 {
        let total = self.bytes_servidos_cache + self.bytes_servidos_origem;
        if total == 0 {
            return 0.0;
        }
        (self.bytes_servidos_cache as f64 / total as f64) * 100.0
    }

    fn exibir(&self) {
        println!("\n  === Estatísticas da CDN ===");
        println!("  Requisições totais:    {}", self.total_requisicoes);
        println!("  Acertos (cache hit):   {}", self.acertos_cache);
        println!("  Erros (cache miss):    {}", self.erros_cache);
        println!("  Taxa de acerto:        {:.1}%", self.taxa_acerto());
        println!("  Economia de banda:     {:.1}%", self.economia_banda());
        println!("  Evições realizadas:    {}", self.evicoes);
        println!(
            "  Bytes servidos cache:  {} KB",
            self.bytes_servidos_cache
        );
        println!(
            "  Bytes servidos origem: {} KB",
            self.bytes_servidos_origem
        );
    }
}

/// Cache CDN usando política LFU.
struct CacheCdn {
    capacidade: usize,
    itens: HashMap<String, (ConteudoCdn, usize)>, // url -> (conteudo, frequencia)
    frequencias: HashMap<usize, Vec<String>>,       // freq -> lista de urls
    freq_minima: usize,
    stats: EstatisticasCdn,
}

impl CacheCdn {
    fn novo(capacidade: usize) -> Self {
        CacheCdn {
            capacidade,
            itens: HashMap::new(),
            frequencias: HashMap::new(),
            freq_minima: 0,
            stats: EstatisticasCdn::default(),
        }
    }

    /// Processa uma requisição HTTP.
    fn requisicao(&mut self, url: &str, tipo: TipoConteudo) -> bool {
        self.stats.total_requisicoes += 1;

        if self.itens.contains_key(url) {
            // Cache HIT
            self.stats.acertos_cache += 1;
            let tamanho = self.itens.get(url).unwrap().0.tamanho_kb;
            self.stats.bytes_servidos_cache += tamanho;

            // Incrementa frequência
            let freq_atual = self.itens.get(url).unwrap().1;
            let nova_freq = freq_atual + 1;

            // Remove do bucket antigo
            if let Some(bucket) = self.frequencias.get_mut(&freq_atual) {
                bucket.retain(|u| u != url);
                if bucket.is_empty() && freq_atual == self.freq_minima {
                    self.freq_minima += 1;
                }
            }

            // Adiciona ao novo bucket
            self.frequencias
                .entry(nova_freq)
                .or_insert_with(Vec::new)
                .push(url.to_string());
            self.itens.get_mut(url).unwrap().1 = nova_freq;

            true
        } else {
            // Cache MISS — busca na origem e armazena
            self.stats.erros_cache += 1;
            let tamanho = tipo.tamanho_medio_kb();
            self.stats.bytes_servidos_origem += tamanho;

            let conteudo = ConteudoCdn {
                url: url.to_string(),
                tipo,
                tamanho_kb: tamanho,
                corpo: format!("[conteúdo de {}]", url),
            };

            // Evicta se necessário
            if self.itens.len() >= self.capacidade {
                self.evictar();
            }

            // Insere com frequência 1
            self.frequencias
                .entry(1)
                .or_insert_with(Vec::new)
                .push(url.to_string());
            self.itens.insert(url.to_string(), (conteudo, 1));
            self.freq_minima = 1;

            false
        }
    }

    fn evictar(&mut self) {
        if let Some(bucket) = self.frequencias.get_mut(&self.freq_minima) {
            if let Some(url_evicta) = bucket.first().cloned() {
                bucket.retain(|u| u != &url_evicta);
                self.itens.remove(&url_evicta);
                self.stats.evicoes += 1;
            }
        }
    }

    fn exibir_cache(&self) {
        println!("\n  Conteúdo em cache ({}/{}):", self.itens.len(), self.capacidade);
        let mut itens: Vec<_> = self.itens.iter().collect();
        itens.sort_by(|a, b| b.1 .1.cmp(&a.1 .1)); // Ordena por frequência
        for (url, (conteudo, freq)) in itens {
            println!(
                "    freq={:3} | {:6} KB | {:?} | {}",
                freq, conteudo.tamanho_kb, conteudo.tipo, url
            );
        }
    }
}

fn main() {
    println!("=== Simulação de Cache CDN com LFU ===\n");

    let mut cdn = CacheCdn::novo(5);

    // Simula padrão de tráfego realista
    // Conteúdo popular (acessado muitas vezes)
    let requisicoes = vec![
        ("/img/logo.png", TipoConteudo::Imagem, 20),     // Popular
        ("/css/main.css", TipoConteudo::Css, 15),         // Popular
        ("/js/app.js", TipoConteudo::JavaScript, 10),     // Moderado
        ("/api/dados", TipoConteudo::Api, 5),             // Pouco popular
        ("/index.html", TipoConteudo::Html, 3),           // Pouco popular
        ("/video/intro.mp4", TipoConteudo::Video, 2),     // Raro
        ("/img/banner.png", TipoConteudo::Imagem, 1),     // Único
        ("/img/promo.png", TipoConteudo::Imagem, 1),      // Único
    ];

    println!("Simulando tráfego web...\n");

    for (url, tipo, repeticoes) in &requisicoes {
        for _ in 0..*repeticoes {
            let acertou = cdn.requisicao(url, tipo.clone());
            // Apenas exibe os primeiros acessos
        }
        let freq = cdn.itens.get(*url).map(|(_, f)| *f).unwrap_or(0);
        println!(
            "  {} -> {} acessos (freq no cache: {})",
            url, repeticoes, freq
        );
    }

    cdn.exibir_cache();
    cdn.stats.exibir();

    // Mostra que conteúdo popular sobrevive a rajadas
    println!("\n--- Rajada de conteúdo novo ---");
    for i in 0..5 {
        let url = format!("/api/temp/{}", i);
        cdn.requisicao(&url, TipoConteudo::Api);
    }

    println!("\nApós rajada de 5 URLs únicos:");
    cdn.exibir_cache();
    println!(
        "\n  /img/logo.png ainda no cache: {}",
        cdn.itens.contains_key("/img/logo.png")
    );
    println!(
        "  /css/main.css ainda no cache: {}",
        cdn.itens.contains_key("/css/main.css")
    );
}

Exercícios

Exercício 1 — LFU Cache com Decaimento de Frequência

O LFU puro tem um problema: itens que foram populares no passado podem monopolizar o cache mesmo depois de pararem de ser acessados (o chamado “cache pollution”). Implemente uma variação com decaimento de frequência: periodicamente (a cada N operações), divida todas as frequências por 2. Isso dá mais peso a acessos recentes.

Dica: Mantenha um contador global de operações. A cada N operações, percorra todos os itens e divida suas frequências por 2. Reconstrua os buckets de frequência.

Exercício 2 — Cache Adaptativo (ARC/LFU+LRU)

Implemente um cache que combine LRU e LFU adaptativamente. Mantenha dois caches internos: um LRU para itens acessados apenas uma vez (“recentes”) e um LFU para itens acessados mais de uma vez (“frequentes”). Quando um item no cache LRU é acessado novamente, ele é promovido para o cache LFU.

Dica: Divida a capacidade entre os dois caches. Ajuste a proporção dinamicamente com base na taxa de acerto de cada um.

Exercício 3 — Benchmark LRU vs LFU

Crie um benchmark que compare LRU e LFU sob diferentes distribuições de acesso:

  1. Uniforme: cada chave tem a mesma probabilidade de acesso.
  2. Zipf (80/20): 20% das chaves recebem 80% dos acessos.
  3. Sequencial com loop: acessos sequenciais que se repetem ciclicamente.

Para cada distribuição, meça a taxa de acerto com N=1000 chaves distintas e cache de tamanho 100.

Resultado esperado: LFU supera LRU na distribuição Zipf; LRU supera em acesso sequencial.


Conclusão

O LFU Cache representa uma abordagem sofisticada para gerenciamento de cache, priorizando frequência sobre recência. Os pontos fundamentais desta estrutura são:

  • O(1) para todas as operações: A combinação de dois HashMaps e buckets de frequência com freq_minima garante tempo constante.
  • Proteção contra rajadas: Conteúdo popular permanece no cache mesmo sob rajadas de acessos únicos — superioridade clara sobre LRU neste cenário.
  • Desempate inteligente: Dentro da mesma frequência, o desempate por recência (LRU) combina o melhor dos dois mundos.
  • Trade-off de adaptabilidade: LFU é lento para se adaptar a mudanças de padrão de acesso (conteúdo que era popular mas deixou de ser). O decaimento de frequência mitiga isso.
  • Complexidade de implementação: Significativamente mais complexo que LRU, mas justificável em cenários de alta performance como CDNs.

Na prática, muitos sistemas usam variantes híbridas como W-TinyLFU (usado no Caffeine, o cache do Java) ou ARC (Adaptive Replacement Cache), que combinam LRU e LFU adaptativamente. Em Rust, a crate moka implementa W-TinyLFU e é uma excelente opção para produção.

A implementação manual que fizemos aqui é fundamental para entender os princípios por trás das políticas de evição e tomar decisões informadas sobre qual cache usar em cada cenário.