Compression in Practice:
Arithmetic Coding, LZ & Beyond

Shannon told us the limit. This is how real tools get close to it:
zip, gzip, PNG, bzip2, xz, zstd, JPEG, MP3 and video.

Model + coder

Predict the next symbol, then spend −log2p bits on it.

Entropy coders

Huffman, arithmetic, range and ANS coding.

Dictionary & sorting

LZ77, LZW, DEFLATE, BWT, context mixing.

Lossy

Rate–distortion, quantization, DCT, JPEG, video.

Every coder here is written in stdlib-only Python, round-trip tested, and measured. All outputs shown are from real runs (Python 3.11+).

Roadmap WHERE WE GO

  1. Lossless vs lossy
  2. No free lunch: the counting argument
  3. Modeling vs coding
  4. Our test corpus
  5. Huffman, its 1-bit problem, and Huffman by hand
  6. Arithmetic coding: the idea and decoding by hand
  7. Arithmetic coding: theory
  8. An integer arithmetic coder
  9. Adaptive context models
  10. Range coding & ANS
  11. Check yourself 1
  12. Run-length encoding
  13. LZ77, and LZ77 by hand
  14. Why LZ works: universality
  15. LZ78 & LZW
  16. DEFLATE (zip, gzip, PNG)
  17. Burrows–Wheeler, bzip2, and BWT by hand
  18. PPM & context mixing
  19. Benchmark: zlib, bz2, lzma
  20. Dictionaries for small data
  21. Transforms before coding
  22. Rate–distortion theory
  23. Transform coding & the DCT
  24. JPEG, audio, video, neural codecs
  25. Check yourself 2
  26. History, pitfalls, summary, glossary

Lossless vs Lossy THE TWO WORLDS

Lossless: a pair (C, D) with D(C(x)) = x for every input x. The code must be injective.

Lossy: D(C(x)) = x̂ with small distortion d(x, x̂). We trade quality for size.

LosslessLossy
Limitentropy Hrate–distortion R(D)
Datatext, code, archives, databasesimages, audio, video
Formatszip, gzip, PNG, FLAC, zstd, xzJPEG, MP3, AAC, H.264, AV1
Typical ratio2–5× on text10–200×
Key toolmodeling + entropy codingtransform + quantize + entropy code

What makes data compressible?

  • Skewed symbols: "e" is more common than "z".
  • Context: after "q" comes "u".
  • Repeats: the same phrase shows up again.
  • Smoothness: the next pixel is close to this one.
  • Irrelevance (lossy only): the eye cannot see fine color detail.

Every compressor is a bet on which of these patterns your data has. When the bet is wrong, the output gets slightly bigger.

No Free Lunch: The Counting Argument THEOREM

Theorem. No lossless compressor can shrink every input. If it shrinks some n-bit input, it must grow another.

Proof (pigeonhole)

There are 2n strings of exactly n bits. The number of strictly shorter strings is

20 + 21 + … + 2n−1 = 2n − 1

An injective map cannot send 2n inputs into 2n − 1 outputs. So at least one n-bit input maps to ≥ n bits. ∎

A stronger fact

Fewer than 2n−k+1 of the 2n inputs can shrink by k or more bits. So fewer than 1 in 500 inputs can save even 10 bits.

import random, zlib

n = 16
strings_of_n_bits = 2 ** n
shorter_strings = sum(2 ** k for k in range(n))   # lengths 0 .. n-1
print(f"{strings_of_n_bits} inputs, only {shorter_strings} shorter outputs")

blob = random.Random(0).randbytes(100_000)
print("random bytes:", len(blob), "->", len(zlib.compress(blob, 9)))
text = b"to be or not to be " * 5000
print("repetitive  :", len(text), "->", len(zlib.compress(text, 9)))
65536 inputs, only 65535 shorter outputs
random bytes: 100000 -> 100041
repetitive  : 95000 -> 271
  • Random bytes grow by 41 bytes. That is zlib's header, checksum and "stored block" markers.
  • Repetitive text shrinks 350×.
  • Good formats cap the loss. DEFLATE falls back to stored blocks, costing about 5 bytes per 64 KiB.

This is why "compress it twice" never helps, and why compressed or encrypted files do not shrink.

Modeling vs Coding THE BIG SPLIT

input bytes MODEL p(next symbol | context) counts, contexts, matches, neural nets CODER spend ≈ −log₂ p bits Huffman, arithmetic, ANS bitstream The decoder runs the same model on the symbols it has already decoded, so no model needs to be sent.

Compression = prediction

Arithmetic coding solved the coding half in the 1970s. It gets within a few bits of Σ −log2 p(xt | x<t). That is the model's cross-entropy on the data.

So since then, a better model is the only way to win. Every format in this deck is a different model bolted to a near-ideal coder.

The split also lets you swap parts. zstd uses Huffman for literals and tANS for everything else. LZMA uses a range coder with bit-level context models.

Kinds of models

ModelPredicts fromExample
Static countswhole-file statsHuffman table
Adaptive countsstats so faradaptive arithmetic
Contextlast k bytesPPM, CM
Matchearlier repeatsLZ77, LZMA
Sorted contextfollowing textBWT / bzip2
TransformneighborsPNG filters, DCT
Neuraleverythingcmix, nncp, LLMs

Our Test Corpus & How Much Context Helps MEASURED

Test corpus used on every later slide. It is seeded, so it is the same on each run:

import random
random.seed(42)
WORDS = ("the of and to a in is that for it as was with be by on not he this are "
         "or his from at which but have an they you were her she there been one all "
         "we their has would when if so no will more can who its said about into "
         "data code bits model symbol entropy compress window match stream").split()
weights = [1 / (r + 1) for r in range(len(WORDS))]       # Zipf-like word frequencies
sentences = []
for _ in range(400):
    n = random.randint(5, 14)
    s = " ".join(random.choices(WORDS, weights, k=n))
    sentences.append(s.capitalize() + ".")
TEXT = " ".join(sentences).encode()
print(len(TEXT), "bytes:", TEXT[:60].decode())
15019 bytes: The of of he be we. For the the of it that. To a of there ar

Zipf-weighted words: a few are very common, most are rare. That matches real text, but there are no long repeated phrases.

Empirical conditional entropy H(Xt | previous k bytes):

from collections import Counter, defaultdict
from math import log2

def cond_entropy(data, k):      # bits/byte, given k previous bytes
    ctx = defaultdict(Counter)
    for i in range(k, len(data)):
        ctx[data[i - k:i]][data[i]] += 1
    n = len(data) - k
    return -sum(c * log2(c / sum(cnt.values()))
                for cnt in ctx.values() for c in cnt.values()) / n

for k in range(4):
    print(f"order-{k} model: {cond_entropy(TEXT, k):.3f} bits/byte")
order-0 model: 3.799 bits/byte
order-1 model: 2.219 bits/byte
order-2 model: 1.457 bits/byte
order-3 model: 1.286 bits/byte

Reading the numbers

  • ASCII uses 8 bits/byte. Letter counts give 3.8. The last 1–3 bytes cut it to 1.3–2.2.
  • High-order numbers are optimistic. A real coder must learn the counts as it goes. We will measure that cost.

Huffman Coding & Its 1-Bit Problem BASELINE

Huffman (1952). Among prefix codes for a known distribution, Huffman's greedy merge gives the minimum average length L. It satisfies H ≤ L < H + 1.

Proof idea

In some optimal code, the two rarest symbols are siblings at the deepest level. Merge them into one symbol with the summed probability. An optimal code for the smaller problem extends to an optimal code for the original. Induct. ∎

The limitation

Every symbol costs a whole number of bits. A symbol with p = 0.95 deserves 0.074 bits but pays 1. On binary or very skewed data Huffman can be 10× worse than entropy. Arithmetic coding removes this rounding.

Canonical codes are fully set by the code lengths. DEFLATE sends only those lengths. They meet Kraft's inequality with equality: Σ 2−L = 1. Order-0 entropy here is 3.7995, so Huffman loses 0.03 bits/byte.

import heapq
from collections import Counter

def huffman_lengths(data):
    heap = [(n, i, {s: 0}) for i, (s, n) in enumerate(Counter(data).items())]
    heapq.heapify(heap)
    tie = len(heap)
    while len(heap) > 1:                   # merge the two rarest subtrees
        n1, _, a = heapq.heappop(heap)
        n2, _, b = heapq.heappop(heap)
        merged = {s: L + 1 for s, L in {**a, **b}.items()}
        heapq.heappush(heap, (n1 + n2, tie, merged)); tie += 1
    return heap[0][2]

def canonical(lengths):                    # DEFLATE sends only the lengths
    code, prev, out = 0, 0, {}
    for s, L in sorted(lengths.items(), key=lambda kv: (kv[1], kv[0])):
        code <<= L - prev
        out[s] = format(code, f"0{L}b"); code += 1; prev = L
    return out

L = huffman_lengths(TEXT)
codes = canonical(L)
for s in b"e t.":
    print(repr(chr(s)), codes[s])
bits = sum(L[b] for b in TEXT)
print(f"Huffman: {bits / len(TEXT):.4f} bits/byte")
'e' 1001
' ' 00
't' 011
'.' 10110
Huffman: 3.8289 bits/byte

Huffman by Hand WORKED EXAMPLE

Six letters in a 100-letter file (the classic CLRS example). Rule: take the two smallest counts, join them, repeat.

StepTwo smallestNew nodeLeft in the pool
1F 5 + E 914A45 B13 C12 D16 (14)
2C 12 + B 1325A45 D16 (14) (25)
3(14) + D 1630A45 (25) (30)
4(25) + (30)55A45 (55)
5A 45 + (55)100done: the root

Now walk down from the root. Left is 0, right is 1. A letter's code is its path.

LetterABCDEF
Count4513121695
Code010110011111011100

Score it

Average = (45·1 + 13·3 + 12·3 + 16·3 + 9·4 + 5·4) / 100 = 2.24 bits.

Fixed-length needs 3 bits for 6 letters. Entropy is H = 2.2199. So Huffman sits just above H, as the theorem says. Kraft sum: 1/2 + 3·1/8 + 2·1/16 = 1.

Encode and decode "FACE"

1100 0 100 1101 → 110001001101 (12 bits). Fixed-length also needs 12 here, since F and E are rare. Over the whole file Huffman averages 2.24 bits, a 25% saving.. To decode, walk the tree bit by bit. Each leaf you hit prints a letter, then restart at the root.

Common mistakes

  • Merging the two biggest. Always merge the two smallest. Rare letters must end up deep.
  • "The codes must match mine." Ties and 0/1 choices change the codes. The lengths and the 2.24 average do not.

Arithmetic Coding: The Idea INTERVALS

Encode "BAC" with p(A)=.5, p(B)=.3, p(C)=.2 A B C 0 .5 .8 1 A B C .5 .65 .74 .8 A B C .5 .575 .62 .65 final interval [0.62, 0.65), width 0.5 × 0.3 × 0.2 = 0.03

How it works

  1. Start with [0, 1).
  2. Split the current interval in proportion to the model's probabilities.
  3. Keep the piece for the actual symbol. Repeat.
  4. Send the shortest binary fraction that pins down the final interval.

The payoff

The final width is the product of the probabilities, P(message). Naming an interval of width w takes about −log2 w bits.

−log2 0.03 = 5.06 bits

Here 6 bits suffice: 0.101000₂ = 0.625. Every number that starts with those 6 bits lies inside [0.62, 0.65).

Fractions of a bit per symbol are fine. They add up across the whole message.

Arithmetic Decoding by Hand WORKED EXAMPLE

The decoder gets the number 0.625 and the same model: A = [0, .5), B = [.5, .8), C = [.8, 1).

Each step: rescale the number into the current interval, t = (x − low) / (high − low). See which slot t falls in. Output that symbol and shrink.

StepIntervaltSlotNew interval
1[0, 1)0.625B[.5, .8)
2[.5, .8)(.625−.5)/.3 = 0.4167A[.5, .65)
3[.5, .65)(.625−.5)/.15 = 0.8333C[.62, .65)
4?[.62, .65)(.625−.62)/.03 = 0.1667A?[.62, .635)

Steps 1–3 give back BAC, the message from the last slide.

When do we stop?

Step 4 happily outputs another A. The number alone never says "the end". So real coders either send the message length first, or add an EOF symbol to the model. The Python decoder on the next slides takes the length.

Common mistakes

  • Comparing x to the original slots every time. 0.625 is always in B's original slot. You must rescale with the current interval.
  • Using floats in real code. After about 50 symbols, a double has no bits left. Real coders use integers and send bits as the top bits settle.

Check yourself

Decode 2 symbols from x = 0.3 with the same model.

t = 0.3 → A, interval [0, .5). Then t = 0.3/0.5 = 0.6 → B. Answer: AB, interval [.25, .4).

Arithmetic Coding: Theory & Engineering THEOREM

Theorem. Arithmetic coding encodes message x1..n in at most ⌈−log2 P(x1..n)⌉ + 1 bits. Averaged over the source, this is at most n·H + 2 bits.

Proof sketch

Let the final interval be [L, L + P) and k = ⌈−log2 P⌉ + 1. Then 2−k ≤ P/2. Round L up to a multiple of 2−k. This adds less than P/2. So the whole dyadic interval [v, v + 2−k) fits inside. Any stream starting with those k bits decodes to the same message. ∎

Overhead is 2 bits per message, not per symbol. Huffman can lose up to 1 bit per symbol.

Making it work with integers

  • Use 32-bit low and high instead of real numbers.
  • E1: both in the lower half. The next bit is 0. Emit it and double.
  • E2: both in the upper half. Emit 1, subtract ½, double.
  • E3: the interval straddles the middle but sits in [¼, ¾). We do not know the bit yet. Count a pending bit and expand around the middle. When the next real bit comes out, emit the opposite bit for each pending one.
  • Frequency totals must stay below 230 so each interval keeps a nonzero width.

This is the Witten–Neal–Cleary coder (CACM 1987). Rissanen and Pasco found the idea in 1976. IBM's patents slowed its use for about 20 years.

Arithmetic Encoder CODE

FULL, HALF, QTR = (1 << 32) - 1, 1 << 31, 1 << 30

class Encoder:
    def __init__(self):
        self.low, self.high, self.pending, self.bits = 0, FULL, 0, []

    def emit(self, b):
        self.bits.append(b)
        self.bits.extend([1 - b] * self.pending)   # flush straddle bits
        self.pending = 0

    def encode(self, lo, hi, total):               # symbol owns [lo, hi) of total
        rng = self.high - self.low + 1
        self.high = self.low + rng * hi // total - 1
        self.low = self.low + rng * lo // total
        while True:
            if self.high < HALF:
                self.emit(0)
            elif self.low >= HALF:
                self.emit(1); self.low -= HALF; self.high -= HALF
            elif self.low >= QTR and self.high < HALF + QTR:
                self.pending += 1; self.low -= QTR; self.high -= QTR
            else:
                break
            self.low, self.high = 2 * self.low, 2 * self.high + 1

    def finish(self):
        self.pending += 1
        self.emit(0 if self.low < QTR else 1)
        return self.bits

Interface

The model gives each symbol a slice [lo, hi) of an integer range [0, total). So its probability is (hi − lo)/total.

The encoder narrows [low, high] to that slice. It then shifts out every bit that is already settled.

Invariants

  • After the loop, high − low > ¼ of the full range. So a slice of width ≥ 1 in total < 230 never collapses.
  • high is inclusive. That is why we write - 1 after the update and 2*high + 1 when we shift.
  • finish writes two final bits. They pick a point that stays inside the last interval, however the stream is padded.

About 30 lines. The same design, with 64-bit words and byte output, sits inside LZMA, CABAC (H.264/HEVC) and many PAQ-family compressors.

Arithmetic Decoder CODE

class Decoder:
    def __init__(self, bits):
        self.bits, self.pos = bits, 0
        self.low, self.high, self.value = 0, FULL, 0
        for _ in range(32):
            self.value = 2 * self.value + self.next_bit()

    def next_bit(self):
        b = self.bits[self.pos] if self.pos < len(self.bits) else 0
        self.pos += 1
        return b

    def target(self, total):                       # where does value fall?
        rng = self.high - self.low + 1
        return ((self.value - self.low + 1) * total - 1) // rng

    def consume(self, lo, hi, total):              # mirror the encoder exactly
        rng = self.high - self.low + 1
        self.high = self.low + rng * hi // total - 1
        self.low = self.low + rng * lo // total
        while True:
            if self.high < HALF:
                pass
            elif self.low >= HALF:
                self.low -= HALF; self.high -= HALF; self.value -= HALF
            elif self.low >= QTR and self.high < HALF + QTR:
                self.low -= QTR; self.high -= QTR; self.value -= QTR
            else:
                break
            self.low, self.high = 2 * self.low, 2 * self.high + 1
            self.value = 2 * self.value + self.next_bit()

The decoder mirrors the encoder

  1. Read the first 32 bits into value.
  2. target(total) maps value back into [0, total). The model then finds the symbol whose slice holds that number.
  3. consume applies the same narrowing and E1/E2/E3 steps. value shifts in lockstep and pulls in one new bit per doubling.

Things that must match exactly

  • Integer rounding in the interval update.
  • The model state at every step: same counts, same order of updates.
  • The message length, or an explicit end-of-stream symbol.

One off-by-one and the rest of the file decodes to garbage. That is why every codec here has a round-trip test.

Reading past the end returns 0 bits. The encoder's two final bits make any padding decode the same.

Arithmetic Coding Hits the Entropy MEASURED

from collections import Counter
from math import log2

counts = Counter(TEXT)                        # static order-0 model
syms = sorted(counts)
cum, c = {}, 0
for s in syms:
    cum[s] = (c, c + counts[s]); c += counts[s]
total = c

enc = Encoder()
for b in TEXT:
    enc.encode(*cum[b], total)
bits = enc.finish()

dec = Decoder(bits)
out = bytearray()
for _ in range(len(TEXT)):
    t = dec.target(total)
    s = next(s for s in syms if cum[s][0] <= t < cum[s][1])
    dec.consume(*cum[s], total)
    out.append(s)

H = -sum(n / len(TEXT) * log2(n / len(TEXT)) for n in counts.values())
print("roundtrip ok:", bytes(out) == TEXT)
print(f"entropy bound : {H * len(TEXT):.0f} bits")
print(f"arith. coded  : {len(bits)} bits ({len(bits) / len(TEXT):.4f} bits/byte)")
roundtrip ok: True
entropy bound : 57063 bits
arith. coded  : 57064 bits (3.7995 bits/byte)
Coder (static order-0 model)bits/byte
Raw ASCII8.0000
Huffman3.8289
Arithmetic3.7995
Entropy H3.7995

1 bit over the bound

57,064 bits against a limit of 57,063. That is the "+2 bits per message" from the theorem. Huffman wastes 0.03 bits on every byte, about 440 bits here.

Fine print

A static model must also be sent, for example 256 counts. That costs a few hundred bytes. Adaptive models avoid it by learning on the fly, which is next.

Adaptive Context Models LEARN WHILE CODING

class AdaptiveModel:
    """Order-k context model: counts start at 1 and grow as we code."""
    def __init__(self, k):
        self.k, self.tables = k, {}

    def freqs(self, ctx):
        return self.tables.setdefault(ctx[-self.k:] if self.k else b"", [1] * 256)

    def update(self, ctx, sym):
        self.freqs(ctx)[sym] += 32              # big step = learn fast

def interval(f, s):
    lo = sum(f[:s])
    return lo, lo + f[s], sum(f)

def compress(data, k):
    enc, m = Encoder(), AdaptiveModel(k)
    for i, b in enumerate(data):
        enc.encode(*interval(m.freqs(data[:i]), b))
        m.update(data[:i], b)
    return enc.finish()

Uses the Encoder/Decoder from the two slides before. The O(256) scan per symbol is for clarity. Real coders use Fenwick trees or binary decomposition.

def decompress(bits, n, k):
    dec, m, out = Decoder(bits), AdaptiveModel(k), bytearray()
    for _ in range(n):
        f = m.freqs(bytes(out))
        t, acc, s = dec.target(sum(f)), 0, 0
        while acc + f[s] <= t:
            acc += f[s]; s += 1
        dec.consume(*interval(f, s))
        m.update(bytes(out), s); out.append(s)
    return bytes(out)

for k in range(3):
    bits = compress(TEXT, k)
    assert decompress(bits, len(TEXT), k) == TEXT
    print(f"order-{k} adaptive: {len(bits) / len(TEXT):.3f} bits/byte")
order-0 adaptive: 3.822 bits/byte
order-1 adaptive: 2.389 bits/byte
order-2 adaptive: 1.970 bits/byte

What changed

  • No model is sent. Both sides start with counts of 1 and update after each symbol. Order-1 already saves 37%.
  • Order-2 reaches 1.97, but the empirical bound was 1.46. The gap is the learning cost. There are thousands of contexts, and each one starts out ignorant.

Range Coding & ANS FAST ENTROPY CODERS

rANS (Duda, 2009–2013). Quantize frequencies to fs summing to M = 2r, with cumulative cs. The whole state is one integer x:

encode:  x ← ⌊x / fs⌋·M + cs + (x mod fs)

decode:  s = sym(x mod M),   x ← fs⌊x / M⌋ + (x mod M) − cs

Each step multiplies x by about M / fs = 1/ps. That adds −log2 ps bits to its length.

  • LIFO: encode in reverse, decode forward.
  • Real coders keep x in a machine word and renormalize by streaming out bytes.
  • tANS precomputes a table (FSE in zstd, LZFSE). Hundreds of MB/s, no multiplies.
from collections import Counter

def build(data, M_BITS=12):               # quantize counts to sum M = 4096
    M, cnt = 1 << M_BITS, Counter(data)
    freq = {s: max(1, c * M // len(data)) for s, c in cnt.items()}
    top = max(freq, key=freq.get)
    freq[top] += M - sum(freq.values())    # fix rounding so the sum is exactly M
    cum, c = {}, 0
    for s in sorted(freq):
        cum[s] = c; c += freq[s]
    return M, freq, cum
def rans_encode(data, M, freq, cum):
    x = 1
    for s in reversed(data):              # ANS is last-in, first-out
        f = freq[s]
        x = (x // f) * M + cum[s] + x % f
    return x

def rans_decode(x, n, M, freq, cum):
    slot_to_sym = [s for s in sorted(freq) for _ in range(freq[s])]
    out = bytearray()
    for _ in range(n):
        s = slot_to_sym[x % M]
        x = freq[s] * (x // M) + x % M - cum[s]
        out.append(s)
    return bytes(out)

M, freq, cum = build(TEXT)
x = rans_encode(TEXT, M, freq, cum)
assert rans_decode(x, len(TEXT), M, freq, cum) == TEXT
print(f"rANS state: {x.bit_length()} bits "
      f"({x.bit_length() / len(TEXT):.4f} bits/byte)")
rANS state: 57076 bits (3.8003 bits/byte)

Python big integers let us skip renormalization. 3.8003 vs 3.7995 bits/byte: the tiny gap comes from rounding the frequencies to 12 bits. JPEG XL, zstd and Apple's LZFSE all use ANS.

Range coding (Martin, 1979) is arithmetic coding that outputs whole bytes. It is faster, and its patent status was clearer. LZMA/xz uses it.

Recap & Check Yourself (Part 1) PRACTICE

What we have so far

  • No compressor shrinks everything. It wins only on data it can predict.
  • Compression = a model (predict) + a coder (turn guesses into bits).
  • Huffman is within 1 bit per symbol of H, but never below 1 bit.
  • Arithmetic coding, range coding and ANS reach H up to a few bits total.
  • Adaptive models learn the statistics as they go. No table is sent.

Common mistake

"A better coder gives better compression." Only up to H. Past that, only a better model helps. Coders are a solved problem. Models are where the work is.

Try these first, then open the answers

  1. A binary source has p(a) = 0.9. What do Huffman and H give per symbol?
    AnswerHuffman must use 1 bit per symbol. H = 0.469 bits. Huffman is more than twice the entropy here. Arithmetic coding fixes this.
  2. Why does zipping a zip file not help?
    AnswerA good compressor's output looks random. There are no patterns left for the model to predict. Often it grows a little from headers.
  3. With p(A) = .5, p(B) = .3, p(C) = .2, what interval does "AA" get?
    AnswerA → [0, .5). Then A again takes the first half: [0, .25). Width 0.25, so 2 bits.
  4. What is odd about ANS decoding order?
    AnswerANS is a stack (last in, first out). The decoder gets symbols in reverse order. So encoders run backwards over the data.
  5. "BAC" has width 0.03. What is the bit bound from the theory slide?
    Answer⌈−log2 0.03⌉ + 1 = 6 + 1 = 7 bits at most. We managed 6.

Run-Length Encoding THE SIMPLEST MODEL

def rle_encode(data):
    out, i = [], 0
    while i < len(data):
        j = i
        while j < len(data) and data[j] == data[i] and j - i < 255:
            j += 1
        out.append((j - i, data[i]))
        i = j
    return out

def rle_decode(pairs):
    return b"".join(bytes([v]) * n for n, v in pairs)

row = b"\x00" * 40 + b"\xff" * 12 + b"\x00" * 76     # one scanline of a fax
pairs = rle_encode(row)
print(pairs)
assert rle_decode(pairs) == row
print(len(row), "bytes ->", 2 * len(pairs), "bytes")
[(40, 0), (12, 255), (76, 0)]
128 bytes -> 6 bytes

The < 255 cap keeps each count in one byte. Longer runs just split.

Where RLE shows up

  • Fax (ITU T.4, 1980): runs of white/black pixels, then Huffman-coded run lengths.
  • PackBits (TIFF, early Mac), PCX, BMP RLE.
  • bzip2: RLE before BWT, and on the zero runs after MTF.
  • JPEG: runs of zero DCT coefficients in zigzag order.
  • Columnar databases (Parquet, ORC): sorted columns hold long runs.

Its weakness

On text with no runs, naive RLE doubles the size: every byte becomes (1, byte). Real formats add an escape or a "literal run" mode. RLE only models one pattern: "the same symbol again".

RLE is LZ77 limited to distance 1. Generalize the distance and you get LZ77.

LZ77: Point Back at Repeats ZIV & LEMPEL 1977

a b r a c a d a b r a (distance 7, length 4) search window (already coded) lookahead: "abra" becomes one token Output: a b r a c a d (7,4)
def lz77_encode(data, window=4096, min_len=3):
    i, out = 0, []
    while i < len(data):
        best_len = best_dist = 0
        for j in range(max(0, i - window), i):     # naive O(n * window) search
            L = 0
            while i + L < len(data) and data[j + L] == data[i + L]:
                L += 1                             # may run past i: overlap is OK
            if L > best_len:
                best_len, best_dist = L, i - j
        if best_len >= min_len:
            out.append((best_dist, best_len)); i += best_len
        else:
            out.append(data[i]); i += 1
    return out
['a', 'b', 'r', 'a', 'c', 'a', 'd', (7, 4), ' ', (12, 13), (1, 9)]

Decoding is just copying, so it runs at GB/s. The encoder does all the searching.

def lz77_decode(tokens):
    out = bytearray()
    for t in tokens:
        if isinstance(t, tuple):
            dist, length = t
            for _ in range(length):     # byte by byte: overlap OK
                out.append(out[-dist])
        else:
            out.append(t)
    return bytes(out)

s = b"abracadabra abracadabra aaaaaaaaaa"
toks = lz77_encode(s)
print([chr(t) if isinstance(t, int) else t for t in toks])
assert lz77_decode(toks) == s

Reading the output

  • (7, 4): copy 4 bytes from 7 back ("abra").
  • (12, 13): the second "abracadabra " plus one "a".
  • (1, 9): an overlapping copy. It repeats the last byte 9 times: RLE for free.

LZ77 by Hand WORKED EXAMPLE

Encode abababcaba (min match 3)

PosLooking atLongest match behindToken
0ababab…nothing behind yet'a'
1bababc…none'b'
2ababcabafrom pos 0, runs 4 long (overlaps itself)(2, 4)
6cabano c behind'c'
7abafrom pos 0, 3 long(7, 3)

Output: ['a', 'b', (2, 4), 'c', (7, 3)]. Ten bytes became five tokens.

More traces from the deck's encoder

InputTokens
abcabcabcx['a','b','c',(3,6),'x']
aaaaaaab['a',(1,6),'b']
xyzxyzxyzxy['x','y','z',(3,8)]

Decode (2, 4) after "ab"

Copy one byte at a time, each from 2 back:

  1. ab + a (2 back) → aba
  2. aba + b → abab
  3. abab + a → ababa. This byte was written in step 1!
  4. ababa + b → ababab

Length 4 is bigger than distance 2. That is fine. The copy reads bytes it just wrote. (1, 6) is the same trick: repeat one byte 6 times.

Common mistakes

  • Copying the whole block at once (like out[-d:-d+L]). It breaks when L > d. Copy byte by byte.
  • Distance counted from the wrong end. Distance is how far back from the current position the match starts.

Check yourself

Decode ['x', 'y', (2, 5), 'z'].

xy, then 5 bytes from 2 back: xyxyx. Then z. Result: xyxyxyxz.

Why LZ Works: Universality THEORY + ENGINEERING

Theorem (Ziv–Lempel 1978; Wyner–Ziv 1994). For any stationary ergodic source with entropy rate H, the LZ78 and LZ77 code lengths per symbol converge to H as n → ∞ (for LZ77, as the window grows). No knowledge of the source is needed.

Intuition

In a typical sequence, a phrase of length ℓ recurs about every 2ℓH symbols. So a pointer to it costs about log2 2ℓH = ℓH bits, which is H per symbol.

But convergence is slow

The redundancy shrinks only like O(1/log n) or slower. On real, finite files, context models beat plain LZ. That is why DEFLATE adds Huffman and why xz adds context-modeled range coding on top.

How real encoders find matches

  • Hash chains (zlib): hash the next 3 bytes and walk a linked list of earlier positions. The level (1–9) caps the chain length.
  • Binary trees / suffix arrays (LZMA, zstd high levels): find the longest match fast.
  • Lazy matching: before taking a match, check if the next position has a longer one.
  • Optimal parsing: dynamic programming over token costs (zstd --ultra, xz, Zopfli).
  • Repeat offsets: "same distance as last time" gets a very short code (LZMA, zstd).
FormatWindow
DEFLATE32 KiB
LZ464 KiB
zstdup to 2 GiB (long mode)
LZMA / xzup to 1.5 GiB

LZ78 & LZW: Grow a Dictionary WELCH 1984

def lzw_encode(data):
    table = {bytes([i]): i for i in range(256)}
    w, out = b"", []
    for b in data:
        wc = w + bytes([b])
        if wc in table:
            w = wc
        else:
            out.append(table[w])
            table[wc] = len(table)              # learn a new phrase
            w = bytes([b])
    if w:
        out.append(table[w])
    return out

def lzw_decode(codes):
    table = {i: bytes([i]) for i in range(256)}
    w = table[codes[0]]
    out = [w]
    for c in codes[1:]:
        entry = table[c] if c in table else w + w[:1]   # the "cScSc" case
        out.append(entry)
        table[len(table)] = w + entry[:1]
        w = entry
    return b"".join(out)

s = b"TOBEORNOTTOBEORTOBEORNOT"
codes = lzw_encode(s)
print(codes)
assert lzw_decode(codes) == s
big = lzw_encode(TEXT)
print(f"TEXT: {len(TEXT)} bytes -> {len(big)} codes "
      f"(~{len(big) * 12 / 8:.0f} bytes at 12 bits/code)")
[84, 79, 66, 69, 79, 82, 78, 79, 84, 256, 258, 260, 265, 259, 261, 263]
TEXT: 15019 bytes -> 3520 codes (~5280 bytes at 12 bits/code)

How it works

  • Start with all 256 single bytes in the table.
  • Extend the current phrase w while w + c is known. Then emit w's code and add w + c as a new entry.
  • The decoder rebuilds the same table, one step behind.
  • The tricky case: the code for the entry being defined right now. It only happens for patterns like cScSc, and then it must be w + w[0].

History

LZW powered Unix compress (1985) and GIF (1987). Unisys held the patent and began charging for GIF in 1994. That pushed the web to create PNG (1996) with patent-free DEFLATE. The patent ran out in 2003–04.

DEFLATE: LZ77 + Huffman ZIP, GZIP, PNG, HTTP

bytes LZ77 32 KiB window, len 3–258 literal/length alphabet 286 symbols distance alphabet 30 symbols + extra bits 2 Huffman codes blocks

The format (Katz 1993, RFC 1951 1996)

  • Symbols 0–255 are literals. 256 ends the block. 257–285 are match lengths, plus extra bits.
  • Distances use 30 bucket codes plus extra bits, up to 32,768.
  • Three block types: stored (raw), fixed Huffman, dynamic Huffman.
  • Dynamic blocks send canonical code lengths. Those lengths are RLE'd and Huffman coded too.
  • Wrappers: zlib adds Adler-32. gzip adds CRC-32 and a file name. zip adds a directory.

Everywhere, 30 years on

  • zip, gzip, PNG, PDF streams, HTTP Content-Encoding: gzip, git objects, Java JARs, Office files.
  • Python's zlib, gzip, zipfile modules.
  • Zopfli (Google 2013) spends 100× the time for about 5% smaller DEFLATE output. Any decoder can still read it.
  • Successors keep the LZ + entropy design but improve both halves: Brotli, zstd.

Burrows–Wheeler Transform & bzip2 SORTING AS MODELING

def bwt(s):
    s += b"\x00"                                   # unique end marker
    rots = sorted(range(len(s)), key=lambda i: s[i:] + s[:i])
    return bytes(s[i - 1] for i in rots)

def ibwt(last):
    first = sorted(range(len(last)), key=lambda i: (last[i], i))  # stable LF map
    i, out = first[0], bytearray()
    for _ in range(len(last) - 1):
        i = first[i]
        out.append(last[i])
    return bytes(out)

def mtf(data):
    table, out = list(range(256)), []
    for b in data:
        k = table.index(b)
        out.append(k)
        table.insert(0, table.pop(k))              # move to front
    return out

s = b"banana_bandana_banana"
L = bwt(s)
print(L)
print(mtf(L))
assert ibwt(L) == s
b'aaannnndnbbb_\x00_naaaaaa'
[97, 0, 0, 110, 0, 0, 0, 101, 1, 100, 0, 0, 99, 5, 1, 3, 5, 0, 0, 0, 0, 0]

Real BWT uses suffix arrays (SA-IS, O(n)). The same trick powers the FM-index for DNA alignment (BWA, Bowtie).

Why sorting helps

Sort all rotations. Rows that start with the same following text end up next to each other. The last column holds the byte before each of those contexts. Similar contexts are preceded by similar bytes. So the output has long runs like aaa, nnnn, aaaaaa.

Invertibility. The i-th occurrence of byte c in the last column L is the same text position as the i-th occurrence of c in the first column F (the "LF mapping"). So follow the F→L map from the end-marker row to read the text forward, one byte per step. That is ibwt.

bzip2 (Seward 1996)

RLE → BWT on 100–900 KB blocks → MTF → zero-run RLE → Huffman with up to 6 tables. It beats DEFLATE on text, but it is slower.

Move-to-front

Output each byte's position in a recency list, then move it to the front. Runs become zeros, and recent bytes become small numbers. The skewed output is ideal for Huffman.

BWT by Hand: banana WORKED EXAMPLE

Add an end marker $ that sorts first. Write all 7 rotations of banana$. Sort them.

RowFSorted rotationL
0$$bananaa
1aa$banann
2aana$bann
3aanana$bb
4bbanana$$
5nna$banaa
6nnana$baa

Output: L = annb$aa. First column F = $aaabnn is just L sorted, so it is free.

See the grouping? Rows 1–3 all start with "a", and two of them end in "n". "n" comes before "a" in banana. On real text these runs get long.

Invert it: walk the LF map

The k-th a in L is the k-th a in F. Start at row 0. Its L is the text's last letter. Jump to the row where that letter sits in F.

Row 0 (L=a, 1st a) → row 1 (n, 1st n) → row 5 (a, 2nd a) → row 2 (n, 2nd n) → row 6 (a, 3rd a) → row 3 (b) → row 4 ($, stop).

We read ananab backwards. Reverse it: banana. The deck's ibwt follows the same map the other way, so it reads forwards.

Then move-to-front

MTF outputs each byte's place in a list, then moves it to the front. Repeats become 0s. aaabbb → [97, 0, 0, 98, 0, 0]. Lots of zeros are easy for Huffman.

Common mistake

"BWT compresses." It does not. L is exactly as long as the input. It only reorders bytes so MTF and Huffman can do better. Without the end marker (or a row index) you can't invert it.

PPM & Context Mixing THE STRONGEST LOSSLESS MODELS

PPM: prediction by partial matching (Cleary & Witten 1984)

  • Try the longest context seen before, say order 5.
  • If this byte never followed that context, code an escape. Then drop to order 4, and so on.
  • Escape probabilities are the art: PPMC, PPMD, PPMII.
  • PPMd ships in 7-Zip and RAR. It is very strong on text.

Context mixing (PAQ, Mahoney 2002–)

  • Run hundreds of models at once: order-n, word, sparse, record and image contexts.
  • Each gives p(bit = 1). A small neural net (logistic mixing) blends them and learns online.
  • Then APM/SSE stages refine the result, and a binary arithmetic coder encodes it.
  • Descendants: cmix, paq8px, and nncp (a transformer).

Logistic mixing. With st(p) = ln(p/(1−p)):

p = σ( Σi wi · st(pi) )

wi ← wi + η (bit − p) st(pi)

This is online logistic regression that minimizes coding cost. The update is the cross-entropy gradient, "target minus prediction".

Compression = prediction = learning

The Hutter Prize (2006–) pays for compressing Wikipedia text (enwik8, then enwik9). Every record holder has been a context-mixing compressor.

Any language model plus an arithmetic coder is a compressor. Its size is the model's cross-entropy in bits. DeepMind's 2023 paper "Language Modeling Is Compression" makes this point with LLMs.

The price: CM runs at about 1–100 KB/s, and decoding is as slow as encoding. Use it for archives, not for the web.

Benchmark: zlib vs bz2 vs lzma MEASURED

import bz2, lzma, zlib, random

samples = {
    "text (TEXT)": TEXT,
    "random": random.Random(1).randbytes(15000),
    "zeros": bytes(15000),
}
codecs = {
    "zlib -9": lambda d: zlib.compress(d, 9),
    "bz2 -9": lambda d: bz2.compress(d, 9),
    "lzma": lambda d: lzma.compress(d, preset=9),
}
print(f"{'':12}" + "".join(f"{c:>9}" for c in codecs))
for name, d in samples.items():
    print(f"{name:12}" + "".join(f"{len(f(d)):>9}" for f in codecs.values()))
              zlib -9   bz2 -9     lzma
text (TEXT)      4616     3450     4364
random          15011    15481    15060
zeros              37       46      116
On TEXT (15,019 B)bytesbits/byte
LZW (ours, 12-bit)5,2802.81
zlib -9 (DEFLATE)4,6162.46
lzma (xz)4,3642.32
order-2 adaptive (ours)3,6981.97
bz2 -9 (BWT)3,4501.84

What the numbers say

  • Our text is word salad with no long repeats. LZ has little to point at. So the context coders (BWT, order-2) win.
  • On random data every codec loses a little: container overhead. bz2's block format costs the most.
  • On zeros, lzma's 116 bytes is almost all header. Small inputs punish heavy formats.
CodecRatioSpeedSweet spot
LZ4lowGB/sRAM, caches, RPC
zstd -1..-19mid–highfast decodedefault for new systems
Brotlihighslow max levelsstatic web assets
gzipmidokcompatibility
xzhighslow encodesoftware packages

Always benchmark on your data. Rankings flip between text, logs, binaries and images.

Dictionaries for Small Messages PRIMING THE MODEL

import zlib

msgs = [f'{{"user": {i}, "event": "click", "page": "/home", "ok": true}}'.encode()
        for i in range(100)]
dictionary = b'{"user": , "event": "click", "view", "page": "/home", "ok": true}'

plain = sum(len(zlib.compress(m, 9)) for m in msgs)

def with_dict(m):
    c = zlib.compressobj(9, zdict=dictionary)
    return c.compress(m) + c.flush()

primed = sum(len(with_dict(m)) for m in msgs)
d = zlib.decompressobj(zdict=dictionary)
assert d.decompress(with_dict(msgs[7])) == msgs[7]
print(f"raw {sum(map(len, msgs))}  zlib {plain}  zlib+dict {primed}")
raw 5890  zlib 5890  zlib+dict 2090

Each JSON event is about 59 bytes. Alone, zlib cannot shrink it at all. With a 65-byte shared dictionary, the total drops by 65%.

Security note

Never compress secrets together with attacker-chosen text in one stream. Output length leaks matches. That is the CRIME (2012) and BREACH (2013) attacks on TLS and HTTP.

Why it works

An adaptive compressor starts every message knowing nothing. For short messages that learning cost is everything. A dictionary is pre-loaded history. LZ can point into it from the first byte.

In production

  • zstd --train builds a dictionary from sample messages. It suits small records, RPC payloads, and database pages.
  • Brotli ships a built-in 120 KB dictionary of common web text and HTML.
  • Compression Dictionary Transport lets browsers use the previous version of a file as the dictionary for the next one.

Transforms Before Coding DECORRELATE FIRST

import zlib
from math import sin

signal = bytes(int(128 + 100 * sin(i / 40)) for i in range(20000))  # smooth wave

def delta(d):                                   # PNG "Sub" filter idea
    return bytes([d[0]] + [(d[i] - d[i - 1]) % 256 for i in range(1, len(d))])

def undelta(d):
    out = bytearray([d[0]])
    for b in d[1:]:
        out.append((out[-1] + b) % 256)
    return bytes(out)

assert undelta(delta(signal)) == signal
print("zlib raw  :", len(zlib.compress(signal, 9)))
print("zlib delta:", len(zlib.compress(delta(signal), 9)))
zlib raw  : 1962
zlib delta: 1337

Neighboring samples of a smooth signal are close. Their differences are small numbers, near 0. Those have a skewed distribution that the entropy coder loves. Same data, 32% smaller.

Reversible transforms in lossless codecs

CodecTransform
PNGper-row filters: Sub, Up, Average, Paeth
FLAClinear prediction, then Rice-coded residuals
Lossless JPEG / JPEG-LSpixel prediction from neighbors
Executables (xz BCJ)relative jump addresses made absolute
Time-series DBsdelta-of-delta, XOR of floats (Gorilla)
Columnar formatsdictionary + bit-packing + delta

Pattern: predict, then code the residual. A residual close to 0 has low entropy. This is the same "model + coder" split again.

Lossy codecs push this further. Their transform (DCT, wavelet) packs energy into a few coefficients, so the rest can be thrown away.

Rate–Distortion Theory THE LOSSY LIMIT

Rate–distortion function (Shannon 1959). The fewest bits per sample that allow expected distortion at most D:

R(D) = minp(x̂|x) : E[d(X,X̂)] ≤ D I(X; X̂)

Gaussian source, squared error. For X ~ N(0, σ²): R(D) = ½ log2(σ²/D) for D ≤ σ². Equivalently D(R) = σ² 2−2R. Each extra bit cuts the error 4×, which is 6.02 dB.
D R achievable impossible σ²
import random
from math import log2

random.seed(3)
xs = [random.gauss(0, 1) for _ in range(100_000)]      # sigma = 1

def quantize(x, step):
    return step * round(x / step)                     # uniform mid-tread quantizer

print(" step   MSE     entropy(bits)  Shannon D(R)")
for step in (2.0, 1.0, 0.5, 0.25):
    q = [quantize(x, step) for x in xs]
    mse = sum((a - b) ** 2 for a, b in zip(xs, q)) / len(xs)
    counts = {}
    for v in q:
        counts[v] = counts.get(v, 0) + 1
    R = -sum(c / len(q) * log2(c / len(q)) for c in counts.values())
    print(f"{step:5}  {mse:.4f}   {R:.3f}          {2 ** (-2 * R):.4f}")
 step   MSE     entropy(bits)  Shannon D(R)
  2.0  0.3314   1.244          0.1784
  1.0  0.0832   2.104          0.0541
  0.5  0.0209   3.061          0.0144
 0.25  0.0052   4.050          0.0036

A simple uniform quantizer plus ideal entropy coding lands at a fixed gap from the bound. At high rate, MSE / D(R) → πe/6 ≈ 1.42. That is 1.53 dB, or 0.25 bits per sample. At step 0.25 we measure 0.0052 / 0.0036 = 1.44. Vector quantization and trellis coding close some of that gap.

Transform Coding & the DCT ENERGY COMPACTION

from math import cos, pi, sqrt

def dct(x):                                    # orthonormal DCT-II
    N = len(x)
    return [sqrt((1 if k == 0 else 2) / N) *
            sum(x[n] * cos(pi * (n + 0.5) * k / N) for n in range(N))
            for k in range(N)]

def idct(X):                                   # its inverse (DCT-III)
    N = len(X)
    return [sum(sqrt((1 if k == 0 else 2) / N) * X[k] * cos(pi * (n + 0.5) * k / N)
                for k in range(N)) for n in range(N)]

row = [52, 55, 61, 66, 70, 61, 64, 73]         # 8 pixels from a JPEG test block
X = dct(row)
print("DCT :", [round(v, 1) for v in X])
Q = [round(v / 10) for v in X]                 # quantize with step 10
print("Q   :", Q)
back = idct([q * 10 for q in Q])
print("back:", [round(v) for v in back])
print("max error:", round(max(abs(a - b) for a, b in zip(row, back)), 2))
DCT : [177.5, -14.4, -5.7, -6.7, 7.1, -3.1, -0.7, 2.4]
Q   : [18, -1, -1, -1, 1, 0, 0, 0]
back: [53, 55, 64, 74, 70, 60, 61, 72]
max error: 7.6

DCT-II (Ahmed, Natarajan, Rao 1974):

Xk = αk Σn=0N−1 xn cos[ π(n + ½)k / N ]

It is orthonormal, so the transform itself loses nothing, and energy is preserved (Parseval).

Why it compresses

  • Smooth pixels put almost all energy in the first few coefficients. Here X0 = 177.5 and the rest are small.
  • For highly correlated signals, the DCT is close to the optimal (Karhunen–Loève) transform, but it is fast and fixed.
  • Quantization is the only lossy step. 8 numbers become 5 small integers and 3 zeros. Max pixel error is 7.6 at this coarse step.
  • Coarse steps for high frequencies match the eye. We barely see fine detail.

MP3/AAC use the MDCT, a lapped version that avoids block edges. JPEG 2000 uses wavelets. H.264 and later use small integer DCTs, so encoder and decoder match bit for bit.

JPEG, Step by Step 1992

RGBpixels YCbCr4:2:0 chroma 8×8 DCTper block Quantizethe lossy step Zigzag + RLEDC as delta Huffman(or arithmetic)

Color

Eyes resolve brightness (Y) much better than color (Cb, Cr). So 4:2:0 keeps color at half resolution each way. That discards half the raw samples before any math.

Quantization table

Each of the 64 coefficients gets its own step. Steps are small for low frequencies and large for high ones. The "quality" slider just scales this table.

Entropy coding

Zigzag order puts the many high-frequency zeros at the end. Each nonzero value is coded as (zero run, size) + bits. An end-of-block code ends the block early.

Artifacts

Blocking at 8×8 edges, ringing near sharp edges, and color bleeding. Re-saving a JPEG adds loss each time (generation loss).

Successors

WebP and AVIF use video intra-frame tools. JPEG XL adds ANS, variable block sizes, and a lossless JPEG recompression mode that saves about 20%.

Audio, Video & Neural Codecs THE BIG RATIOS

Perceptual audio

  • MP3 (1993), AAC, Opus.
  • MDCT splits sound into frequency bands.
  • A psychoacoustic model finds what is masked. Loud tones hide nearby quiet ones, and very high or low sounds are hard to hear.
  • Bits go only where the ear would notice the noise.
  • CD audio is 1,411 kbit/s. Good AAC is 128–256 kbit/s.

Video

  • H.264 (2003), HEVC (2013), AV1 (2018), VVC (2020).
  • Motion compensation: predict each block from a shifted block in an earlier frame. Code only the motion vector and the residual.
  • I-frames stand alone. P-frames look back. B-frames look both ways.
  • Residual: integer DCT + quantization.
  • CABAC: context-adaptive binary arithmetic coding. It is our arithmetic coder with adaptive contexts.

Neural compression

  • Ballé et al. (2017–18): an encoder net, quantized latents, and a learned entropy model (hyperprior). Train on rate + λ · distortion.
  • The rate term is a cross-entropy, so it is just the KL deck again.
  • Neural audio codecs (SoundStream, EnCodec) reach speech at a few kbit/s.
  • LLM + arithmetic coder = strong text compressor, but a slow one.

Every one of these is transform/predict → quantize → entropy code. Only the models get smarter.

Recap & Check Yourself (Part 2) PRACTICE

What we added

  • LZ77 and LZ78 replace repeats with pointers. They reach entropy on long inputs.
  • DEFLATE = LZ77 + Huffman. BWT + MTF sorts text so context shows up as runs.
  • PPM and context mixing are the strongest models, but slow.
  • Lossy coding trades bits for error. R(D) is the limit.
  • Transform (DCT), quantize, then code: JPEG, MP3 and video all do this.

Common mistake

"The DCT is where JPEG loses data." No. The DCT is exactly invertible. The loss happens in quantization, when coefficients are rounded.

Try these first, then open the answers

  1. Decode the LZ77 tokens ['a', 'b', (2, 4), 'c', (7, 3)].
    Answerab → ababab (overlap copy) → abababc → copy aba from 7 back. Result: abababcaba.
  2. What does MTF output for aaabbb?
    Answer[97, 0, 0, 98, 0, 0]. Each first "a" and "b" costs its byte value. Every repeat is 0.
  3. A uniform quantizer gets one more bit per sample. What happens to MSE?
    AnswerThe step size halves, so MSE drops 4×. That is about 6 dB per bit.
  4. Why does 4:2:0 chroma cut the raw samples in half?
    AnswerY keeps all pixels. Cb and Cr each keep 1 of every 4. So 1 + ¼ + ¼ = 1.5 samples per pixel, not 3.
  5. Why is (3, 6) fine after abc, even though only 3 bytes exist?
    AnswerThe decoder copies byte by byte. Bytes 4–6 of the copy read bytes written moments earlier. Result: abcabcabc.

History & Pitfalls REFERENCE

YearMilestone
1838Morse code: short codes for common letters
1948Shannon: entropy, the source coding theorem
1952Huffman coding
1959Shannon: rate–distortion theory
1976Rissanen, Pasco: arithmetic coding
1977–78Ziv & Lempel: LZ77, LZ78
1984Welch: LZW. Cleary & Witten: PPM
1992–93JPEG, MP3, PKZIP's DEFLATE
1994Burrows–Wheeler transform
1996RFC 1951, PNG, bzip2
1998–99LZMA / 7-Zip
2002PAQ context mixing
2003H.264 with CABAC
2009–14Duda: ANS. FSE follows (2013)
2015–16Brotli, zstd
2017–Learned image and audio codecs

Pitfalls

  • Compress, then encrypt. Encrypted data looks random and will not shrink.
  • But mixing secrets with attacker text leaks through the length (CRIME/BREACH).
  • Zip bombs: 42 KB can expand to petabytes. Cap output size when you decompress untrusted input.
  • Re-compressing JPEG, MP4 or zip files wastes CPU for about 0% gain.
  • Tiny messages need dictionaries, or they will grow.
  • Benchmarks on the wrong data mislead. Test on your own.
  • Lossy generation loss: edit from the original, not a re-saved copy.
  • Decoder speed often matters more than ratio. Data is decoded many more times than it is encoded.

Summary TAKEAWAYS

  1. No free lunch: most inputs cannot shrink. Compression is a bet on structure.
  2. Model + coder. Arithmetic coding and ANS spend within a few bits of Σ −log2 p. So compression = prediction.
  3. Huffman rounds to whole bits. Arithmetic coding does not.
  4. Adaptive models learn as they go. Learning costs real bits.
  5. LZ points back at repeats. It is universal but converges slowly, so it is paired with entropy coding: DEFLATE, zstd, xz.
  6. BWT sorts contexts together. CM mixes many models. Both beat LZ on text.
  7. Lossy: transform → quantize → entropy code, bounded by R(D).

One sentence

A compressor is a predictor wired to an arithmetic coder.

Further reading

  • Sayood, Introduction to Data Compression
  • Salomon & Motta, Handbook of Data Compression
  • Witten, Neal & Cleary, "Arithmetic Coding for Data Compression", CACM 1987
  • Duda, "Asymmetric Numeral Systems", arXiv 2013
  • Mahoney, Data Compression Explained (free online)

Glossary QUICK LOOKUP

TermMeaning
Entropy HAverage bits per symbol a perfect coder needs for a source.
Lossless / lossyExact rebuild / close-enough rebuild with fewer bits.
ModelThe part that predicts the next symbol's probabilities.
Entropy coderTurns probabilities into bits: Huffman, arithmetic, ANS.
Prefix codeNo codeword starts another, so no separators needed.
Canonical HuffmanCodes rebuilt from lengths alone. DEFLATE sends only lengths.
Arithmetic codingCodes a whole message as one number in a shrinking interval.
Range coder / ANSFast integer forms of arithmetic coding. ANS is LIFO.
Adaptive modelUpdates its counts as it codes. The decoder mirrors it.
RLERun-length encoding: "7 × a" instead of aaaaaaa.
TermMeaning
LZ77 / LZ78 / LZWReplace repeats with (distance, length) or a dictionary index.
DEFLATELZ77 + Huffman. Used in zip, gzip and PNG.
BWTSorts rotations so similar contexts sit together. Reversible.
MTFMove-to-front: turns local repeats into small numbers.
PPM / context mixingPredict from the last few bytes. Mix many models.
Rate–distortion R(D)Fewest bits per sample for average error at most D.
MSE / PSNRMean squared error / the same on a log (dB) scale.
DCTTurns a block of pixels into frequencies. Energy piles up in a few.
QuantizationRounding values to a coarse grid. This is where loss happens.
Chroma subsamplingStore color at lower resolution than brightness (4:2:0).