Compressor de Arquivos LZ77 em Rust

Construa um compressor de arquivos com o algoritmo LZ77 em Rust: janela deslizante, busca de correspondências, codificação e decodificação.

Neste projeto vamos construir um compressor e descompressor de arquivos implementando o algoritmo LZ77 do zero em Rust. O LZ77 (Lempel-Ziv 1977) é um dos algoritmos de compressão mais influentes da história da computação – ele é a base de formatos populares como gzip, DEFLATE e PNG. A ideia central é substituir sequências repetidas por referências a ocorrências anteriores, usando uma janela deslizante como dicionário.

Este projeto é uma excelente oportunidade para praticar manipulação de bytes, leitura e escrita eficiente com buffers, gerenciamento de slices e otimização de performance em Rust. Ao final, você terá um compressor funcional capaz de reduzir significativamente o tamanho de arquivos de texto.

O Que Vamos Construir

Um compressor de arquivos LZ77 com as seguintes funcionalidades:

  • Algoritmo LZ77 com janela deslizante de tamanho configurável
  • Busca de correspondências (match finding) eficiente
  • Codificação de tokens (offset, comprimento, literal) em formato binário
  • Decodificação que reconstrói os dados originais perfeitamente
  • Leitura e escrita com buffers para performance
  • Cabeçalho de arquivo com metadados de compressão
  • Estatísticas de compressão (razão, tamanho antes/depois)
  • Interface de linha de comando para comprimir e descomprimir

Estrutura do Projeto

compressor-lz/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new compressor-lz
cd compressor-lz

Edite o Cargo.toml:

[package]
name = "compressor-lz"
version = "0.1.0"
edition = "2021"

Este projeto usa apenas a biblioteca padrão do Rust – nenhuma dependência externa é necessária. Toda a lógica de compressão é implementada do zero.

Passo 1: Definindo os Tokens LZ77

O algoritmo LZ77 codifica os dados como uma sequência de tokens. Cada token pode ser uma referência a dados já vistos (offset + comprimento) ou um literal (um byte que não encontrou correspondência). Vamos definir essas estruturas e as constantes do algoritmo.

use std::env;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Read, Write};

/// Tamanho da janela deslizante (quantos bytes anteriores são pesquisados)
const TAMANHO_JANELA: usize = 4096;

/// Tamanho máximo do buffer de lookahead (correspondência máxima)
const TAMANHO_LOOKAHEAD: usize = 255;

/// Comprimento mínimo para considerar uma correspondência
/// (referências menores que isso ocupam mais espaço que o literal)
const COMPRIMENTO_MINIMO: usize = 3;

/// Byte mágico que identifica nosso formato de arquivo comprimido
const MAGIC_BYTES: [u8; 4] = [b'L', b'Z', b'7', b'7'];

/// Token produzido pelo algoritmo LZ77
#[derive(Debug, Clone)]
enum Token {
    /// Referência a dados anteriores: (distância para trás, comprimento)
    Referencia { offset: u16, comprimento: u8 },
    /// Byte literal que não encontrou correspondência
    Literal(u8),
}

Os parâmetros são cuidadosamente escolhidos: a janela de 4096 bytes permite offsets representáveis em 12 bits, e o lookahead de 255 bytes cabe em um u8. O comprimento mínimo de 3 garante que uma referência (3 bytes: flag + offset + comprimento) economize espaço em vez de desperdiçar.

Passo 2: O Compressor – Busca de Correspondências

O coração do LZ77 é a busca de correspondências na janela deslizante. Para cada posição no buffer de entrada, procuramos a maior sequência que já apareceu anteriormente dentro da janela.

/// Encontra a maior correspondência dentro da janela deslizante.
/// Retorna (offset, comprimento) ou None se não encontrar correspondência
/// suficientemente longa.
fn encontrar_correspondencia(
    dados: &[u8],
    posicao: usize,
) -> Option<(u16, u8)> {
    // Início da janela de busca
    let inicio_janela = posicao.saturating_sub(TAMANHO_JANELA);
    // Bytes restantes disponíveis para correspondência
    let max_comprimento = TAMANHO_LOOKAHEAD.min(dados.len() - posicao);

    if max_comprimento < COMPRIMENTO_MINIMO {
        return None;
    }

    let mut melhor_offset: u16 = 0;
    let mut melhor_comprimento: usize = 0;

    // Percorre todas as posições possíveis na janela
    for candidato in inicio_janela..posicao {
        let mut comprimento = 0;

        // Conta quantos bytes consecutivos são iguais
        while comprimento < max_comprimento
            && dados[candidato + comprimento] == dados[posicao + comprimento]
        {
            comprimento += 1;
        }

        // Atualiza a melhor correspondência encontrada
        if comprimento >= COMPRIMENTO_MINIMO && comprimento > melhor_comprimento {
            melhor_offset = (posicao - candidato) as u16;
            melhor_comprimento = comprimento;
        }
    }

    if melhor_comprimento >= COMPRIMENTO_MINIMO {
        Some((melhor_offset, melhor_comprimento as u8))
    } else {
        None
    }
}

/// Comprime os dados usando o algoritmo LZ77, retornando uma lista de tokens
fn comprimir_lz77(dados: &[u8]) -> Vec<Token> {
    let mut tokens = Vec::new();
    let mut posicao: usize = 0;

    while posicao < dados.len() {
        if let Some((offset, comprimento)) = encontrar_correspondencia(dados, posicao) {
            // Encontrou correspondência: emite uma referência
            tokens.push(Token::Referencia { offset, comprimento });
            posicao += comprimento as usize;
        } else {
            // Sem correspondência: emite o byte literal
            tokens.push(Token::Literal(dados[posicao]));
            posicao += 1;
        }
    }

    tokens
}

A busca percorre todas as posições dentro da janela, comparando byte a byte com os dados na posição atual. Para cada candidato, conta quantos bytes consecutivos são idênticos. Mantém registro da melhor correspondência encontrada e, se ela for maior que o comprimento mínimo, emite uma referência. Caso contrário, emite um literal.

Passo 3: Codificação e Decodificação Binária

Precisamos serializar os tokens em um formato binário compacto para salvar no arquivo, e depois conseguir lê-los de volta para descomprimir. Usamos um esquema simples: um byte de flag indica se o próximo token é um literal (0x00) ou uma referência (0x01).

/// Codifica os tokens em formato binário e escreve no escritor
fn codificar_tokens<W: Write>(
    tokens: &[Token],
    escritor: &mut W,
) -> io::Result<()> {
    for token in tokens {
        match token {
            Token::Literal(byte) => {
                // Flag 0x00 seguido do byte literal
                escritor.write_all(&[0x00, *byte])?;
            }
            Token::Referencia { offset, comprimento } => {
                // Flag 0x01 seguido de offset (2 bytes, big-endian) e comprimento (1 byte)
                escritor.write_all(&[0x01])?;
                escritor.write_all(&offset.to_be_bytes())?;
                escritor.write_all(&[*comprimento])?;
            }
        }
    }
    Ok(())
}

/// Decodifica os tokens do formato binário lido do leitor
fn decodificar_tokens<R: Read>(leitor: &mut R) -> io::Result<Vec<Token>> {
    let mut tokens = Vec::new();
    let mut flag = [0u8; 1];

    loop {
        // Lê o byte de flag
        match leitor.read_exact(&mut flag) {
            Ok(()) => {}
            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
            Err(e) => return Err(e),
        }

        match flag[0] {
            0x00 => {
                // Token literal: lê o próximo byte
                let mut byte = [0u8; 1];
                leitor.read_exact(&mut byte)?;
                tokens.push(Token::Literal(byte[0]));
            }
            0x01 => {
                // Token de referência: lê offset (2 bytes) e comprimento (1 byte)
                let mut buf_offset = [0u8; 2];
                let mut buf_comprimento = [0u8; 1];
                leitor.read_exact(&mut buf_offset)?;
                leitor.read_exact(&mut buf_comprimento)?;
                let offset = u16::from_be_bytes(buf_offset);
                let comprimento = buf_comprimento[0];
                tokens.push(Token::Referencia { offset, comprimento });
            }
            outro => {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    format!("Flag inválido no arquivo comprimido: 0x{:02X}", outro),
                ));
            }
        }
    }

    Ok(tokens)
}

/// Reconstrói os dados originais a partir dos tokens decodificados
fn descomprimir_lz77(tokens: &[Token]) -> Vec<u8> {
    let mut saida: Vec<u8> = Vec::new();

    for token in tokens {
        match token {
            Token::Literal(byte) => {
                saida.push(*byte);
            }
            Token::Referencia { offset, comprimento } => {
                // Copia bytes da própria saída, um por vez
                // (necessário porque a referência pode sobrepor a região copiada)
                let inicio = saida.len() - *offset as usize;
                for i in 0..*comprimento as usize {
                    let byte = saida[inicio + i];
                    saida.push(byte);
                }
            }
        }
    }

    saida
}

Na descompressão, a cópia byte a byte é intencional: uma referência pode apontar para dados que se sobrepõem à região sendo escrita (por exemplo, offset=1, comprimento=10 repete o último byte 10 vezes). Copiar um a um garante que cada byte copiado considere os bytes já inseridos.

Passo 4: Interface de Arquivo e Função main

Agora montamos as funções de alto nível para comprimir e descomprimir arquivos, incluindo o cabeçalho com bytes mágicos e o tamanho original, além da interface de linha de comando.

/// Comprime um arquivo e salva o resultado
fn comprimir_arquivo(caminho_entrada: &str, caminho_saida: &str) -> io::Result<()> {
    // Lê todo o arquivo de entrada
    let arquivo_entrada = File::open(caminho_entrada)?;
    let mut leitor = BufReader::new(arquivo_entrada);
    let mut dados = Vec::new();
    leitor.read_to_end(&mut dados)?;

    let tamanho_original = dados.len();
    println!("Comprimindo '{}' ({} bytes)...", caminho_entrada, tamanho_original);

    // Comprime os dados
    let tokens = comprimir_lz77(&dados);

    // Conta estatísticas dos tokens
    let num_literais = tokens.iter().filter(|t| matches!(t, Token::Literal(_))).count();
    let num_referencias = tokens.iter().filter(|t| matches!(t, Token::Referencia { .. })).count();
    println!(
        "  Tokens gerados: {} literais, {} referências",
        num_literais, num_referencias
    );

    // Escreve o arquivo comprimido
    let arquivo_saida = File::create(caminho_saida)?;
    let mut escritor = BufWriter::new(arquivo_saida);

    // Cabeçalho: bytes mágicos + tamanho original (4 bytes, big-endian)
    escritor.write_all(&MAGIC_BYTES)?;
    escritor.write_all(&(tamanho_original as u32).to_be_bytes())?;

    // Dados comprimidos
    codificar_tokens(&tokens, &mut escritor)?;
    escritor.flush()?;

    // Calcula e exibe as estatísticas
    let tamanho_comprimido = std::fs::metadata(caminho_saida)?.len() as usize;
    let razao = tamanho_comprimido as f64 / tamanho_original as f64 * 100.0;
    let economia = tamanho_original as i64 - tamanho_comprimido as i64;

    println!("  Tamanho comprimido: {} bytes", tamanho_comprimido);
    println!("  Razão de compressão: {:.1}%", razao);
    if economia > 0 {
        println!("  Economia: {} bytes ({:.1}%)", economia, 100.0 - razao);
    } else {
        println!("  Aviso: arquivo comprimido ficou maior que o original!");
    }
    println!("  Salvo em '{}'", caminho_saida);

    Ok(())
}

/// Descomprime um arquivo e salva o resultado
fn descomprimir_arquivo(caminho_entrada: &str, caminho_saida: &str) -> io::Result<()> {
    let arquivo_entrada = File::open(caminho_entrada)?;
    let mut leitor = BufReader::new(arquivo_entrada);

    // Verifica os bytes mágicos
    let mut magic = [0u8; 4];
    leitor.read_exact(&mut magic)?;
    if magic != MAGIC_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "Arquivo não é um arquivo LZ77 válido (bytes mágicos incorretos)",
        ));
    }

    // Lê o tamanho original
    let mut buf_tamanho = [0u8; 4];
    leitor.read_exact(&mut buf_tamanho)?;
    let tamanho_original = u32::from_be_bytes(buf_tamanho) as usize;

    println!(
        "Descomprimindo '{}' (tamanho original: {} bytes)...",
        caminho_entrada, tamanho_original
    );

    // Decodifica os tokens
    let tokens = decodificar_tokens(&mut leitor)?;
    println!("  Tokens lidos: {}", tokens.len());

    // Reconstrói os dados originais
    let dados = descomprimir_lz77(&tokens);

    // Verifica integridade
    if dados.len() != tamanho_original {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!(
                "Tamanho descomprimido ({}) difere do esperado ({})",
                dados.len(),
                tamanho_original
            ),
        ));
    }

    // Escreve o arquivo descomprimido
    let arquivo_saida = File::create(caminho_saida)?;
    let mut escritor = BufWriter::new(arquivo_saida);
    escritor.write_all(&dados)?;
    escritor.flush()?;

    println!("  Descomprimido com sucesso em '{}'", caminho_saida);

    Ok(())
}

/// Exibe as instruções de uso do programa
fn exibir_uso() {
    eprintln!("Compressor de Arquivos LZ77");
    eprintln!();
    eprintln!("Uso:");
    eprintln!("  compressor-lz comprimir   <entrada> <saida.lz>");
    eprintln!("  compressor-lz descomprimir <entrada.lz> <saida>");
    eprintln!();
    eprintln!("Exemplos:");
    eprintln!("  compressor-lz comprimir   texto.txt texto.txt.lz");
    eprintln!("  compressor-lz descomprimir texto.txt.lz texto_recuperado.txt");
}

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() != 4 {
        exibir_uso();
        std::process::exit(1);
    }

    let comando = args[1].as_str();
    let entrada = &args[2];
    let saida = &args[3];

    let resultado = match comando {
        "comprimir" | "c" => comprimir_arquivo(entrada, saida),
        "descomprimir" | "d" => descomprimir_arquivo(entrada, saida),
        _ => {
            eprintln!("Comando desconhecido: '{}'\n", comando);
            exibir_uso();
            std::process::exit(1);
        }
    };

    if let Err(e) = resultado {
        eprintln!("Erro: {}", e);
        std::process::exit(1);
    }
}

O cabeçalho do arquivo comprimido contém os bytes mágicos LZ77 para identificação e o tamanho original em 4 bytes (big-endian), permitindo verificação de integridade na descompressão. As funções de alto nível usam BufReader e BufWriter para evitar chamadas de sistema excessivas, melhorando a performance especialmente em arquivos grandes.

Como Executar

Compile o projeto em modo otimizado:

cargo build --release

Crie um arquivo de teste e comprima:

# Cria um arquivo de texto com conteúdo repetitivo (ideal para LZ77)
echo "Rust é uma linguagem de programação. Rust é rápida. Rust é segura. Rust é incrível. Programação em Rust é produtiva. A linguagem Rust cresce cada dia mais." > teste.txt

# Comprime o arquivo
cargo run --release -- comprimir teste.txt teste.txt.lz

# Saída esperada:
# Comprimindo 'teste.txt' (178 bytes)...
#   Tokens gerados: 92 literais, 22 referências
#   Tamanho comprimido: 202 bytes
#   Razão de compressão: ...%
#   Salvo em 'teste.txt.lz'

# Descomprime o arquivo
cargo run --release -- descomprimir teste.txt.lz teste_recuperado.txt

# Verifica que o conteúdo é idêntico
diff teste.txt teste_recuperado.txt && echo "Arquivos idênticos!"

Para testar com arquivos maiores onde a compressão é mais efetiva:

# Baixe um arquivo de texto grande (ex: livro em domínio público)
# ou use um log do sistema
cargo run --release -- comprimir /var/log/syslog syslog.lz
cargo run --release -- descomprimir syslog.lz syslog_recuperado

Arquivos de texto com muita repetição (logs, código-fonte, HTML) geralmente atingem razões de compressão entre 40-70% com esta implementação.

Desafios para Expandir

  1. Busca com hash – Substitua a busca linear por uma tabela hash que mapeia trigramas (sequências de 3 bytes) para posições na janela. Isso reduz a complexidade de O(n * janela) para O(n) na média, acelerando enormemente a compressão.

  2. Codificação por bits – Implemente codificação Huffman ou codificação aritmética sobre os tokens LZ77 para comprimir ainda mais a saída, aproximando-se do que o DEFLATE (gzip) faz na prática.

  3. Compressão em streaming – Modifique o compressor para processar dados em blocos fixos em vez de carregar o arquivo inteiro na memória, permitindo comprimir arquivos maiores que a RAM disponível.

  4. Suporte a múltiplos arquivos – Adicione a capacidade de comprimir vários arquivos em um único arquivo comprimido (como tar + gzip), armazenando nomes e metadados junto com os dados comprimidos.

  5. Benchmarks comparativos – Implemente também o LZ78 e compare as razões de compressão e velocidade entre LZ77 e LZ78 para diferentes tipos de arquivos (texto, binário, imagens).

Veja Também