SwadhyAI Notes

All DSA notes

DSA Greedy / Compression

Huffman Coding

The telemetry alphabet is tiny, but 'OK' appears constantly and 'PANIC' almost never. Fixed 8-bit symbols waste bits. You want a code that minimizes .

Huffman merges the two lightest trees repeatedly. Optimality intuition is an : rarest symbols become deepest siblings.

Algorithm journey

1

Count frequencies

Known model for this alphabet.

2

Min-heap of trees

Weight = subtree frequency sum.

3

Merge two lightest

New parent weight = sum.

4

Read root-to-leaf bits

Prefix-free by construction.

What actually happens

Naive idea: assign shorter codes ad hoc, or use fixed width. Breaks: ambiguous decoding if not prefix-free, or wasted bits on skewed frequencies.

Better idea: build an optimal binary prefix code for the given frequencies by always combining the two lightest weights. The tree depth of a symbol is its code length.

The improved design still fails as β€œuniversal compression”: wrong frequency model, ignoring headers, or needing dictionary/context methods (LZ) for repeated substrings.

Invariant

Every forest tree is a valid prefix code for its leaves. Merging two trees preserves prefix-freeness. The final tree minimizes weighted path length among binary prefix codes for those frequencies (up to ties).

How to choose Huffman

1. Known symbol frequencies? Huffman is the classic optimal prefix code.

2. Streaming unknown stats? Adaptive Huffman or arithmetic/ANS with models.

3. Repeated phrases? Dictionary methods first; Huffman may still code literals.

Quantitative check

Symbols with freqs 0.5, 0.25, 0.125, 0.125: Huffman lengths 1,2,3,3 give expected length 1.75 bits/symbol vs 2 bits for fixed-width 2-bit codes.

Huffman Tree Builder

Step 1 of 0

πŸ”‘ Core Concepts

1. Prefix-Free Codes

No code is a prefix of another code. This ensures unambiguous decoding - you can read the bit stream left-to-right without needing separators!

Example: If 'A' = 0, then no other character can start with 0 (like 01, 001, etc.)

2. Variable-Length Encoding

A byte stream uses fixed 8-bit source symbols; Unicode text first needs a specified encoding such as UTF-8. Huffman uses different output lengths and minimizes average length within its prefix-code model.

Example: 'E' might be 2 bits, 'Z' might be 10 bits

3. Binary Tree Representation

Huffman codes are built from a binary tree where characters are leaves. Left edge = 0, right edge = 1. Path from root to leaf gives the code!

Example: Left-Left-Right = 001

πŸ” Did You Know? ZIP, JPEG, MP3!

Huffman coding is a component in many established formats.DEFLATE combines LZ77 with Huffman codes and is common in gzip and ZIP entries; many JPEG streams entropy-code quantized coefficients with Huffman tables; andMP3 uses specified Huffman codebooks for quantized spectral data. Developed by David Huffman in 1952 as a graduate student, it's been compressing data for 70+ years!

πŸ“ How It Works

1

Count Character Frequencies

Scan the input and count how many times each character appears.

2

Create Leaf Nodes

Create a leaf node for each character with its frequency. Put all nodes in a priority queue (min-heap).

3

Build Tree (Greedy Merging)

Repeatedly: Extract two nodes with lowest frequencies, create a parent node with their sum, add parent back to queue. Continue until one node remains (the root).

4

Generate Codes

Traverse the tree: assign 0 for left edges, 1 for right edges. The path from root to each leaf is that character's code!

🎯 Key Insight: Why Greedy Works

Huffman's greedy strategy (always merge two smallest frequencies) is provably optimal for this model.

Proof sketch:

1. A prefix code can be represented by a full binary tree whose cost is Ξ£ frequency(symbol) Γ— depth(symbol).

2. In an optimal tree, swap the two least-frequent symbols into a deepest sibling pair; the exchange cannot increase cost.

3. Contract those siblings into one pseudo-symbol with their summed frequency. An optimal solution to the smaller instance expands to an optimal original tree.

This is one of the few greedy algorithms with a mathematical proof of optimality!

🎯 Visual Example

Encoding "AABBBCCCC"

Step 1: Frequencies
A:2, B:3, C:4
Step 2: Build Tree
Initial: [A:2, B:3, C:4]
Merge A+B: [(AB):5, C:4]
Merge (AB)+C: [(ABC):9] ← Root!
Step 3: Assign Codes
        (ABC):9
       /        \
      0          1
     /            \
  (AB):5         C:4
  /    \
 0      1
/        \
A:2      B:3

A = 00 (2 bits)

B = 01 (2 bits)

C = 1 (1 bit)

Step 4: Encode

"AABBBCCCC" β†’ 00 00 01 01 01 1 1 1 1 = 14 payload bits

vs ASCII: 9 chars Γ— 8 bits = 72 bits

Payload only: 80.6% fewer bits; a real stream must also carry a header and padding length.

Swapping left and right children changes the bit patterns but not code lengths or cost. The deterministic reference implementation below emits C=0, A=10, B=11 for this histogram; the visual tree is an equally optimal alternative.

πŸ’» Deterministic byte-oriented implementation

The reference code models symbols as bytes, defines a total heap tie-break order, handles empty and one-symbol inputs, and validates malformed payloads. Bit strings keep the teaching example readable; a real codec packs bits into bytes.

Python

python
from __future__ import annotations

from collections import Counter
from dataclasses import dataclass
import heapq


@dataclass
class Node:
    frequency: int
    symbol: int | None = None
    left: "Node | None" = None
    right: "Node | None" = None

    @property
    def is_leaf(self) -> bool:
        return self.left is None and self.right is None


def build_tree(data: bytes) -> Node | None:
    """Build a deterministic tree over byte symbols 0..255."""
    heap = []
    serial = 0
    for symbol, frequency in sorted(Counter(data).items()):
        node = Node(frequency, symbol)
        heapq.heappush(heap, (frequency, symbol, serial, node))
        serial += 1

    # Invariant: heap entries are roots of disjoint subtrees whose leaves
    # partition the source alphabet; their frequencies sum to len(data).
    while len(heap) > 1:
        left_item = heapq.heappop(heap)
        right_item = heapq.heappop(heap)
        left, right = left_item[3], right_item[3]
        parent = Node(left.frequency + right.frequency, None, left, right)
        minimum_symbol = min(left_item[1], right_item[1])
        heapq.heappush(
            heap, (parent.frequency, minimum_symbol, serial, parent)
        )
        serial += 1
    return heap[0][3] if heap else None


def build_codes(root: Node | None) -> dict[int, str]:
    codes: dict[int, str] = {}

    def visit(node: Node, prefix: str) -> None:
        if node.is_leaf:
            assert node.symbol is not None
            codes[node.symbol] = prefix or "0"
            return
        assert node.left is not None and node.right is not None
        visit(node.left, prefix + "0")
        visit(node.right, prefix + "1")

    if root is not None:
        visit(root, "")
    return codes


def encode(data: bytes) -> tuple[str, dict[int, str], Node | None]:
    root = build_tree(data)
    codes = build_codes(root)
    return "".join(codes[symbol] for symbol in data), codes, root


def decode(bits: str, root: Node | None, expected_symbols: int) -> bytes:
    """Reject invalid bits, truncated paths, extras, and bad singleton data."""
    if expected_symbols < 0:
        raise ValueError("negative symbol count")
    if root is None:
        if bits or expected_symbols:
            raise ValueError("nonempty payload without a tree")
        return b""
    if root.is_leaf:
        if bits != "0" * expected_symbols:
            raise ValueError("invalid single-symbol payload")
        assert root.symbol is not None
        return bytes([root.symbol]) * expected_symbols

    output = bytearray()
    current = root
    for bit in bits:
        if bit not in "01":
            raise ValueError("payload contains a non-bit")
        current = current.left if bit == "0" else current.right
        if current is None:
            raise ValueError("bit path is absent from tree")
        if current.is_leaf:
            assert current.symbol is not None
            output.append(current.symbol)
            if len(output) > expected_symbols:
                raise ValueError("payload contains too many symbols")
            current = root
    if current is not root or len(output) != expected_symbols:
        raise ValueError("truncated or incorrectly sized payload")
    return bytes(output)


if __name__ == "__main__":
    for sample in (b"", b"AAAA", b"AABBBCCCC", bytes(range(32))):
        bits, codes, tree = encode(sample)
        assert decode(bits, tree, len(sample)) == sample
    bits, codes, tree = encode(b"AABBBCCCC")
    assert len(bits) == 14
    print(bits, {chr(symbol): code for symbol, code in codes.items()})

Java

java
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;

public class HuffmanCoding {
    static final class Node {
        final long frequency;
        final int symbol; // -1 for internal nodes
        final Node left;
        final Node right;

        Node(long frequency, int symbol, Node left, Node right) {
            this.frequency = frequency;
            this.symbol = symbol;
            this.left = left;
            this.right = right;
        }

        boolean isLeaf() {
            return left == null && right == null;
        }
    }

    static final class Entry {
        final long frequency;
        final int minimumSymbol;
        final long serial;
        final Node node;

        Entry(long frequency, int minimumSymbol, long serial, Node node) {
            this.frequency = frequency;
            this.minimumSymbol = minimumSymbol;
            this.serial = serial;
            this.node = node;
        }
    }

    static final class Encoded {
        final String bits;
        final String[] codes;
        final Node root;
        final int originalLength;

        Encoded(String bits, String[] codes, Node root, int originalLength) {
            this.bits = bits;
            this.codes = codes;
            this.root = root;
            this.originalLength = originalLength;
        }
    }

    static Node buildTree(byte[] data) {
        long[] frequencies = new long[256];
        for (byte value : data) frequencies[value & 0xff]++;

        Comparator<Entry> order = Comparator
                .comparingLong((Entry entry) -> entry.frequency)
                .thenComparingInt(entry -> entry.minimumSymbol)
                .thenComparingLong(entry -> entry.serial);
        PriorityQueue<Entry> heap = new PriorityQueue<>(order);
        long serial = 0L;
        for (int symbol = 0; symbol < frequencies.length; symbol++) {
            if (frequencies[symbol] > 0L) {
                Node leaf = new Node(frequencies[symbol], symbol, null, null);
                heap.add(new Entry(frequencies[symbol], symbol, serial++, leaf));
            }
        }

        // Invariant: entries are disjoint subtree roots whose leaf
        // frequencies sum to data.length.
        while (heap.size() > 1) {
            Entry left = heap.remove();
            Entry right = heap.remove();
            long frequency = Math.addExact(left.frequency, right.frequency);
            Node parent = new Node(frequency, -1, left.node, right.node);
            heap.add(new Entry(frequency,
                    Math.min(left.minimumSymbol, right.minimumSymbol),
                    serial++, parent));
        }
        return heap.isEmpty() ? null : heap.remove().node;
    }

    static String[] buildCodes(Node root) {
        String[] codes = new String[256];
        if (root != null) generateCodes(root, "", codes);
        return codes;
    }

    private static void generateCodes(Node node, String prefix, String[] codes) {
        if (node.isLeaf()) {
            codes[node.symbol] = prefix.isEmpty() ? "0" : prefix;
            return;
        }
        generateCodes(node.left, prefix + "0", codes);
        generateCodes(node.right, prefix + "1", codes);
    }

    static Encoded encode(byte[] data) {
        Node root = buildTree(data);
        String[] codes = buildCodes(root);
        StringBuilder bits = new StringBuilder();
        for (byte value : data) bits.append(codes[value & 0xff]);
        return new Encoded(bits.toString(), codes, root, data.length);
    }

    static byte[] decode(String bits, Node root, int expectedSymbols) {
        if (expectedSymbols < 0) throw new IllegalArgumentException("negative size");
        if (root == null) {
            if (!bits.isEmpty() || expectedSymbols != 0) {
                throw new IllegalArgumentException("payload without tree");
            }
            return new byte[0];
        }
        if (root.isLeaf()) {
            if (bits.length() != expectedSymbols
                    || bits.chars().anyMatch(bit -> bit != '0')) {
                throw new IllegalArgumentException("invalid singleton payload");
            }
            byte[] output = new byte[expectedSymbols];
            Arrays.fill(output, (byte) root.symbol);
            return output;
        }

        ByteArrayOutputStream output = new ByteArrayOutputStream();
        Node current = root;
        for (int index = 0; index < bits.length(); index++) {
            char bit = bits.charAt(index);
            if (bit != '0' && bit != '1') {
                throw new IllegalArgumentException("non-bit in payload");
            }
            current = bit == '0' ? current.left : current.right;
            if (current == null) {
                throw new IllegalArgumentException("absent tree path");
            }
            if (current.isLeaf()) {
                output.write(current.symbol);
                if (output.size() > expectedSymbols) {
                    throw new IllegalArgumentException("too many symbols");
                }
                current = root;
            }
        }
        if (current != root || output.size() != expectedSymbols) {
            throw new IllegalArgumentException("truncated or mis-sized payload");
        }
        return output.toByteArray();
    }

    public static void main(String[] args) {
        byte[][] samples = {
            new byte[0],
            "AAAA".getBytes(StandardCharsets.US_ASCII),
            "AABBBCCCC".getBytes(StandardCharsets.US_ASCII)
        };
        for (byte[] sample : samples) {
            Encoded encoded = encode(sample);
            assert Arrays.equals(
                    decode(encoded.bits, encoded.root, encoded.originalLength),
                    sample);
        }
        Encoded encoded = encode(samples[2]);
        assert encoded.bits.length() == 14;
        System.out.println(encoded.bits);
    }
}

Tree, heap, and decoding invariants

Prefix-free by construction

Symbols appear only at leaves. A leaf cannot be an ancestor of another leaf, so no root-to-leaf code can prefix another. Decoding may emit immediately at a leaf and restart at the root.

Heap partition invariant

Every heap entry roots a subtree over a disjoint symbol set; together those sets partition the alphabet, and root frequency equals the sum of leaf frequencies. A merge preserves all three facts.

Deterministic ties

Equal frequencies can yield different valid trees. If a decoder rebuilds rather than receiving a tree, frequency, minimum symbol, and creation order must be part of the format, or use canonical code lengths.

⚑ Complexity Analysis

Time Complexity

  • βœ“
    O(N + k log k) - Histogram + tree

    N source symbols and k distinct symbols; exactly kβˆ’1 heap merges

  • βœ“
    O(N + B) / O(B) - Encode / decode

    B is the emitted bit count, which any encoder/decoder must write/read

Space Complexity

  • βœ“
    O(k) metadata + O(B) payload

    Tree/code lengths are O(k); materializing the teaching bit string costs O(B)

πŸ’‘ Pro Tip: Canonical Huffman Codes

Standard Huffman codes aren't unique - different tree structures can give same compression.Canonical Huffman standardizes codes for a given set of lengths:

  • β€’ Sort symbols by (code length, symbol value)
  • β€’ Left-shift when length grows; assign consecutive integers within one length
  • β€’ Transmit code lengths plus symbol order instead of pointer-shaped tree structure

Decoder and encoder reconstruct the same table from lengths alone. Formats such as DEFLATE specify their own compact representation of those lengths; canonicalization does not eliminate the header.

Canonical worked example

Canonical orderLengthAssigned codeRule
C10first code at shortest length
A210increment 0, then shift for the longer length
B211next integer at the same length

Code lengths must satisfy Kraft's inequality; malformed or oversubscribed length sets must be rejected while rebuilding the canonical table. Canonical codes change bit patterns, not their lengths or weighted payload cost.

A codec is more than a tree

Header and bit packing

Store a tree preorder, a deterministic frequency table, or canonical lengths; also store the original symbol count or exact payload-bit count so byte padding is not decoded as data. Add a version and checksum when corruption matters.

Decode defensively

Reject non-bits, impossible branches, truncated final paths, oversubscribed canonical lengths, unexpected symbol counts, and resource-exhausting headers. Prefix-free does not mean corrupted input is automatically safe.

Empty and one-symbol alphabets

Empty input has no tree or payload. A one-symbol alphabet needs an explicit convention. Here one zero bit per symbol, plus the original count.

Bytes versus Unicode

Compress bytes for a portable file format. β€œCharacter” differs between Python code points, Java UTF-16 code units, and user-perceived grapheme clusters; encode text (for example UTF-8) before building a byte histogram.

When Huffman expands or leaves savings behind

For an ideal memoryless source with at least two positive-probability symbols, the average Huffman length L satisfies H ≀ L < H + 1 bits per symbol. That is a payload statement. This page's one-symbol stream deliberately assigns a one-bit code for simple framing, so that degenerate convention is outside the strict bound. A file is smaller only when payload savings exceed the codebook, original-length field, framing, and final-byte padding.

  • Small inputs often lose because metadata dominates.
  • A nearly uniform alphabet approaches fixed-width coding, leaving little or no payload gain.
  • Already compressed, encrypted, or high-entropy bytes usually do not benefit.
  • Independent-symbol Huffman ignores repeated strings and context; LZ, arithmetic, or ANS stages can exploit different structure.
  • Production decoders may cap code length and use lookup tables, adding constraints absent from the pure greedy tree.

βœ… Pros & ❌ Cons

βœ… Advantages

  • βœ“Provably optimal for symbol-by-symbol encoding
  • βœ“Lossless compression (perfect reconstruction)
  • βœ“Prefix-free codes enable unambiguous decoding
  • βœ“Fast encoding and decoding
  • βœ“Simple static model with predictable linear-time payload decoding

❌ Disadvantages

  • βœ—Requires two passes (count frequencies, then encode)
  • βœ—Must store/transmit the tree or code table
  • βœ—Uniform frequencies leave little compression even though the prefix code is still optimal
  • βœ—Doesn't exploit patterns beyond single characters
  • βœ—Often needs a context/dictionary transform or a finer-grained entropy coder to approach source entropy

πŸ’Ό Real World Applications

πŸ“¦ File Compression (ZIP, GZIP)

Gzip uses DEFLATE, and ZIP can store DEFLATE-compressed entries. LZ77 first represents repeated strings; Huffman coding then encodes the resulting literal/length and distance alphabets.

Example: .zip, .gz files, HTTP compression

πŸ–ΌοΈ Image Compression (JPEG)

Many JPEG bitstreams Huffman-code symbols derived from quantized DCT coefficients. Quantization is the lossy stage; Huffman coding itself is a lossless entropy-coding stage.

Example: .jpg, .jpeg images

🎡 Audio Compression (MP3)

MP3 uses Huffman coding to compress quantized frequency coefficients. After psychoacoustic modeling, Huffman reduces the final bitrate.

Example: .mp3 audio files

πŸ“‘ Data Transmission

Network protocols use Huffman-based compression to reduce bandwidth. HTTP/2 uses HPACK (Huffman + tables) for header compression.

Example: HTTP/2, fax machines

Short takeaway

Huffman builds an optimal binary prefix code for known frequencies by repeatedly merging the two lightest nodes. Optimality is about weighted path length under a fixed symbol model, not all of compression.

Exercises

  1. Frequencies 5,2,2,1. Build the Huffman tree (break ties consistently). What are the code lengths?
  2. Explain in one exchange-style sentence why two rarest symbols can be made siblings in some optimum.
  3. When would LZ77 + Huffman beat plain Huffman on English text?

Primary sources

  • Huffman, D. A. (1952): A method for the construction of minimum-redundancy codes.
  • Cormen et al., CLRS: Huffman codes and the greedy-choice / exchange argument.
  • Cover & Thomas, Elements of Information Theory: prefix codes and entropy bounds.