← Todos os algoritmos

Huffman Coding

Guloso · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Greedy

O problema

Codificar um fluxo de símbolos em bits da forma mais compacta possível sem perder nenhuma informação — usar um número fixo de bits por símbolo (8 para ASCII simples) desperdiça espaço sempre que alguns símbolos aparecem com frequência muito maior que outros, o que é o caso normal em texto real e dados de log.

A solução

Construir uma árvore binária de baixo para cima: começar com uma folha para cada símbolo distinto, ponderada por sua frequência de ocorrência, e então repetidamente pegar os dois nós atualmente menos frequentes e mesclá-los em um novo nó interno cujo peso é a soma dos dois — guloso, porque a cada passo os dois menores nós disponíveis são mesclados primeiro, sem nenhum lookahead. Repetir até restar um único nó. O código de cada símbolo é o caminho da raiz até sua folha (0 para a esquerda, 1 para a direita), de modo que símbolos mesclados por último — os frequentes — acabam rasos, com códigos curtos, e símbolos mesclados cedo — os raros — acabam profundos, com códigos longos. Essa ordem gulosa de mesclagem é comprovadamente ótima entre todos os códigos binários livres de prefixo possíveis para uma distribuição de frequência conhecida: nenhuma outra atribuição de códigos produz um comprimento médio ponderado de código menor. "Livre de prefixo" também é o que torna o resultado decodificável a partir de um único fluxo contínuo de bits, sem separadores — nenhum código é jamais prefixo de outro, então percorrer a árvore bit a bit sempre termina em exatamente uma folha antes que o próximo código possa começar.

flowchart TD
    R((root)) -->|0| A["'A' — freq 10"]
    R -->|1| N1((merged))
    N1 -->|0| N2((merged))
    N1 -->|1| D["'D' — freq 1"]
    N2 -->|0| C["'C' — freq 2"]
    N2 -->|1| B["'B' — freq 4"]

Exemplo clássico

classic/HuffmanCoding expõe encode/decode construídos em torno de um tipo Node privado ordenado por frequência para uma PriorityQueue, além do caso extremo que uma implementação feita do zero precisa acertar de propósito: um único símbolo distinto nunca dispara uma mesclagem, então ele não pode obter um código a partir de um caminho na árvore — é tratado explicitamente com um bit 0 por ocorrência. HuffmanCodingTest comprova a fidelidade de ida e volta em uma entrada assimétrica, comprova que houve compressão real (contagem de bits codificados abaixo de length * 8) e — a propriedade que de fato define um código de Huffman válido — comprova que nenhum código atribuído é prefixo de outro, verificando cada par diretamente, em vez de simplesmente confiar na construção.

Exemplo aplicado: compressão em lote de CDRs de telecom

applied/CdrFieldCompressor comprime um lote de campos de texto de registros de detalhes de chamada (CDR — call detail record) antes do arquivamento — códigos de causa e strings de status que repetem esmagadoramente os mesmos poucos valores (NORMAL_CLEARING muito mais que NETWORK_CONGESTION), exatamente a forma assimétrica que a codificação de Huffman foi feita para explorar. CompressionReport.compressionRatio() reporta a fração da contagem de bits original que a forma comprimida efetivamente usa. CdrFieldCompressorTest constrói um lote realisticamente assimétrico, confirma que a taxa de compressão volta abaixo de 1.0, confirma que a forma comprimida decodifica de volta exatamente para o lote original e verifica a proteção contra nulo/vazio.

Benchmark

./gradlew :greedy:huffman-coding:jmh

Execução real nesta máquina (JMH 1.37, JDK 26.0.2, 2 iterações de warmup + 3 de medição, 1 fork). A codificação é O(n + k log k) — uma passada para contar frequências, um heap de no máximo k símbolos distintos para construir a árvore, mais uma passada para emitir os códigos. Para texto realista, k é fixo e minúsculo em relação ao comprimento da entrada n, então o crescimento deve acompanhar n de forma quase linear:

Comprimento da entrada Tempo de codificação
1,000 caracteres 41.45 µs
10,000 caracteres 393.19 µs
100,000 caracteres 3,869.94 µs

Cada aumento de 10x no comprimento da entrada produziu um aumento aproximado de 10x no tempo de codificação — 9.49x ao ir de 1,000 para 10,000 caracteres, 9.84x ao ir de 10,000 para 100,000 — acompanhando de perto a previsão O(n) em ambos os passos, exatamente o que se espera quando o termo de tamanho do alfabeto é pequeno o bastante para ser desprezado.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :greedy:huffman-coding:jacocoTestReport

Relatório em greedy/huffman-coding/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

src/test/java/com/algorithms/greedy/huffmancoding/classic/HuffmanCodingTest.java
package com.algorithms.greedy.huffmancoding.classic;

import com.algorithms.greedy.huffmancoding.classic.HuffmanCoding.HuffmanResult;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class HuffmanCodingTest {

    @Test
    void roundTripsASkewedFrequencyInputExactly() {
        String input = "AAAAAAAAAABBBBCCD"; // A:10, B:4, C:2, D:1

        HuffmanResult result = HuffmanCoding.encode(input);

        assertThat(HuffmanCoding.decode(result.encodedBits(), result.root())).isEqualTo(input);
    }

    @Test
    void skewedFrequenciesCompressBelowFixedEightBitsPerCharacter() {
        String input = "AAAAAAAAAABBBBCCD"; // 17 chars

        HuffmanResult result = HuffmanCoding.encode(input);

        assertThat(result.encodedBits().length()).isLessThan(input.length() * 8);
    }

    @Test
    void noCodeIsAPrefixOfAnotherCode() {
        HuffmanResult result = HuffmanCoding.encode("AAAAAAAAAABBBBCCD");
        List<String> codes = List.copyOf(result.codes().values());

        for (int i = 0; i < codes.size(); i++) {
            for (int j = 0; j < codes.size(); j++) {
                if (i == j) {
                    continue;
                }
                assertThat(codes.get(j)).as("code %s should not prefix code %s", codes.get(i), codes.get(j))
                        .doesNotStartWith(codes.get(i));
            }
        }
    }

    @Test
    void aSingleDistinctCharacterGetsOneBitPerOccurrenceAndRoundTrips() {
        String input = "ZZZZZ";

        HuffmanResult result = HuffmanCoding.encode(input);

        assertThat(result.codes()).isEqualTo(Map.of('Z', "0"));
        assertThat(result.encodedBits()).isEqualTo("00000");
        assertThat(HuffmanCoding.decode(result.encodedBits(), result.root())).isEqualTo(input);
    }

    @Test
    void aTwoCharacterInputRoundTrips() {
        String input = "AB";

        HuffmanResult result = HuffmanCoding.encode(input);

        assertThat(HuffmanCoding.decode(result.encodedBits(), result.root())).isEqualTo(input);
    }

    @Test
    void rejectsNullOrEmptyInput() {
        assertThatThrownBy(() -> HuffmanCoding.encode(null)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> HuffmanCoding.encode("")).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsNullArgumentsToDecode() {
        HuffmanResult result = HuffmanCoding.encode("AB");

        assertThatThrownBy(() -> HuffmanCoding.decode(null, result.root())).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> HuffmanCoding.decode(result.encodedBits(), null)).isInstanceOf(IllegalArgumentException.class);
    }
}
src/test/java/com/algorithms/greedy/huffmancoding/applied/CdrFieldCompressorTest.java
package com.algorithms.greedy.huffmancoding.applied;

import com.algorithms.greedy.huffmancoding.applied.CdrFieldCompressor.CompressionReport;
import com.algorithms.greedy.huffmancoding.classic.HuffmanCoding;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CdrFieldCompressorTest {

    private final CdrFieldCompressor compressor = new CdrFieldCompressor();

    @Test
    void compressesASkewedCauseCodeBatchAndRoundTrips() {
        String batchField = "NORMAL_CLEARING".repeat(50)
                + "BUSY".repeat(10)
                + "NO_ANSWER".repeat(5)
                + "NETWORK_CONGESTION";

        CompressionReport report = compressor.compress(batchField);

        assertThat(report.compressionRatio()).isLessThan(1.0);
        String decoded = HuffmanCoding.decode(report.huffman().encodedBits(), report.huffman().root());
        assertThat(decoded).isEqualTo(batchField);
    }

    @Test
    void rejectsNullOrEmptyBatchField() {
        assertThatThrownBy(() -> compressor.compress(null)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> compressor.compress("")).isInstanceOf(IllegalArgumentException.class);
    }
}

Ver relatório completo de cobertura JaCoCo →