跳轉到

5.3 算術編碼#

概念說明#

Huffman 編碼有個先天限制:每個符號至少占 1 個位元(碼是二叉樹上的整條路徑)。當某符號機率高達 0.9 時,理想碼長是 −log₂0.9 ≈ 0.15 位元,Huffman 卻只能給 1 位元,壓縮效率大跌。算術編碼(arithmetic coding)徹底跳脫「整數位元、一符號一碼」的框架:把整篇訊息編成一個 [0,1) 區間內的小數,訊息的機率越大,最終區間越寬,所需的小數位數越少——位元數可以精確到任意小數,理論平均長度可逼近熵的極限到幾個位元以內(Huffman 通常差到 1 位元以上)。

核心概念:[0,1) 依各符號機率按序切成子區間。編碼每讀一個符號,就把當前區間縮小到「該符號對應的子區間」內(區間寬度乘以該符號機率)。讀完全部符號後,輸出最終區間內任意一個用足夠位元表示的二進位小數。解碼時反向操作:持同一張機率表,看當前小數落在哪個子區間就推出哪個符號,然後把區間縮小、繼續。

例:符號 {a:0.5, b:0.3, c:0.2},編 "ab":初始 [0,1) → 讀 a 取 [0,0.5) → 讀 b 取 [0,0.5) 內的 b 段 [0.15,0.3) → 最終輸出 [0.15,0.3) 內的小數,如 0.001(0.125 也在內?不行,需落於 [0.15,0.3),用 0.01₂=0.25 即可),共 2 位元,而理想資訊量是 −log₂(0.5×0.3) ≈ 2.74……此例位元數因對齊而略省;整體期望上,長訊息的位元數會收斂到 Σ −log₂ pᵢ。

實作上有兩大工程問題與解法:

  1. 浮點精度:區間會指數級縮小,浮點數幾十個符號就耗盡精度。解法:用固定整數寬度(如 32 位元)表示區間,每當區間頂端與底端最高位元相同,就輸出該位元並左移(區間重整 renormalization);區間過窄時用 E3 條件(底下 1/4 的情況)計數 pending 位元。
  2. 自適應建模:機率表不必預先傳送——編解碼兩端用同一套自適應規則(如按已出現頻率即時更新),每編一個符號同步更新統計。這讓壓縮器能隨資料「學習」分布,也是現代壓縮(如 LZMA 的 context modeling、zstd 的 FSE)的共同思想。

算術編碼在 1980 年代由 Rissanen、Witten 等人工程化(CACM 1987 經典實作),曾因專利受限,2004 年後專利到期已自由使用;JPEG 的 QM-coder、H.264/H.265 的 CABAC、zstd 的 ANS 皆是其思想後裔。

演算法步驟(整數版概念流程)#

  1. 兩端共享機率模型:每個符號有累積機率區間 cum[c] ≤ x < cum[c]+p[c],總寬 2^P。
  2. 初始化 low = 0、high = 2^P − 1。
  3. 對每個符號 c:把 [low, high] 縮到 cum 對應的子區間(子區間寬 = 當前寬 × p[c])。
  4. 重整:若 [low, high] 完全落在上半(≥ 2^(P−1))或下半(< 2^(P−1)),輸出最高位元(1 或 0),low、high 各左移一位並補 0/補 1;若落在中間 1/4 重疊區(E3),左移但記 pending,之後一次輸出「0 後接 pending 個 1」或反之。
  5. 全部符號編完後,輸出足夠定位 [low, high] 的位元。
  6. 解碼:維護同樣的 [low, high] 與一個「目前值」暫存器,每步比較值落在哪個符號子區間即輸出該符號並同步縮區間、重整。

Python 程式碼範例#

import math
from collections import Counter
from fractions import Fraction

def arithmetic_encode(text):
    """教學版:用 Fraction 展示區間概念(無精度問題,但非工程實作)"""
    cnt = Counter(text)
    n = len(text)
    probs = []
    cum = Fraction(0)
    table = {}
    for s, c in cnt.items():
        table[s] = (cum, cum + Fraction(c, n))
        cum += Fraction(c, n)
    low, high = Fraction(0), Fraction(1)
    for ch in text:
        width = high - low
        lo, hi = table[ch]
        high = low + width * hi
        low = low + width * lo
    return low, high, table

def bits_to_represent(low, high):
    """找最短二進位小數落於 [low, high)"""
    k = 1
    while True:
        m = Fraction(math.ceil(low * (1 << k)), 1 << k)   # 向上取整才可能 ≥ low
        if low <= m < high:
            return format(int(m * (1 << k)), f"0{k}b"), k
        k += 1

text = "banana"
low, high, table = arithmetic_encode(text)
bits, k = bits_to_represent(low, high)
print(f"理論資訊量: {-sum((c/len(text))*math.log2(c/len(text)) for c in Counter(text).values())*len(text):.2f} bits")
print("位元串:", bits)

複雜度分析#

  • 時間:教學版每符號 O(|Σ|)(區間查表可用累積表二分到 O(log|Σ|));工程版每符號均攤 O(1)(重整均攤每位元一次)。
  • 壓縮率:總位元數 < Σ −log₂ pᵢ + 2 位元——幾乎精確達到熵;對高偏斜分布(某符號 p→1)遠勝 Huffman。
  • 空間:機率表 O(|Σ|);自適應版與解碼端共用模型,免傳碼表。
  • 代價:實作複雜(整數重整、E3 處理、位元 I/O);專利歷史曾阻礙普及;單位元錯誤會毀掉其後整個訊息(區間一縮就回不來),對傳輸容錯較敏感。

練習#

  1. 把教學版延伸成自適應算術編碼:機率表初始為均勻,每編/解一個符號後兩端同步 +1 更新;驗證解碼正確,並在英文文本上比較「靜態表+傳表」與「自適應免傳表」的總位元數。
  2. 手動推導:符號 {a:0.8, b:0.2} 編 "aaaa",理想資訊量 0.64 位元;用教學版程式實際編出,觀察位元數與理想值的差距,並解釋為何 Huffman 在此例需要 5 位元。
  3. 研究 ANS(非對稱數字系統):閱讀 zstd 的 tANS/FSE 簡介,寫 300 字說明它如何以更簡單的表格達到與算術編碼相近的壓縮率,這也是 zstd 快速普及的關鍵。