一、Huffman 编码的理论基础
Huffman 编码是一种最优前缀码——给定字符频率分布,它生成的变长编码使得期望码长最小,且没有任何一个编码是另一个编码的前缀(前缀性保证解码无歧义)。
1.1 前缀码与 Kraft 不等式
设字符集为 {c_1, c_2, …, c_n},对应码长为 l_1, l_2, …, l_n。存在前缀码的充要条件是 Kraft 不等式成立:
∑(2^(-l_i)) ≤ 1
必要性证明思路:将每个编码映射到二叉树的一条路径(0 = 左子树,1 = 右子树)。前缀性意味着没有编码是另一个的祖先——所有编码对应的叶子节点互不包含。深度为 l_i 的叶子节点”占据”了 2^(-l_i) 的叶子空间比例。所有叶子不重叠,比例之和不超过 1。
充分性证明思路:给定满足 Kraft 不等式的码长序列,可以按码长升序贪心地为每个码字分配一条二叉树路径(优先取左子树的空闲路径),必定成功——因为 Kraft 条件恰好保证了空间充足。
1.2 Huffman 的最优性
定理:Huffman 算法生成的编码在所有前缀码中使期望码长达到最小。
交换论证(Exchange Argument)证明轮廓:
引理 1:在最优编码树中,频率最低的两个字符一定位于最深的位置(否则交换可以降低期望码长)。
引理 2:频率最低的两个字符可以共享同一个父节点而不影响最优性(因为它们在最优树中已经是最深的,合并它们产生的新”虚拟字符”频率为两者之和)。
引理 3:将含 n 个字符的最优编码问题转化为含 n-1 个字符的问题(合并两个最低频率字符),递归地应用 Huffman 算法即得全局最优。
合并最低频率的两个字符相当于贪心选择——这恰好是该问题具有最优子结构和贪心选择性质的体现。
1.3 Huffman 编码的理论性能边界
对于单符号编码(逐符号编码),Huffman 的期望码长 L 满足:
H(X) ≤ L < H(X) + 1
其中 H(X) = ∑ p_i · (-log₂ p_i) 是信源熵。这 +1 bit 的上界在符号独立的情况下无法突破。但实际数据中的符号不是独立的——字符之间存在上下文相关性。这解释了为何 Huffman 编码单独使用压缩率不及上下文自适应编码。
二、Huffman 编码的算法实现
2.1 标准 Huffman 树构建
HUFFMAN(freq[1..n]): |
时间复杂度:堆初始化为 O(n),每次 extract_min 和 insert 为 O(log n),共 2n-2 次操作,总复杂度 O(n log n)。
2.2 C++ 实现
|
边界情况:当只有一种字符时,Huffman 编码退化为 "0"(1 比特表示),解码时必须确保这是唯一可能的解释。
三、Canonical Huffman Code
真实的压缩格式(JPEG、DEFLATE、PNG)几乎从不存储完整的 Huffman 树(空间开销太大),而是使用规范 Huffman 编码——只需存储每个符号的码长,编码值按确定性的规则重新生成。
3.1 规范 Huffman 的性质
- 码长升序排列:短码的数值小于长码的数值
- 相同码长的编码是连续的整数值
- 从较短码长过渡到较长码长时:first_code[L] = (first_code[L-1] + count[L-1]) << 1
3.2 规范 Huffman 的重建算法
// 输入: bit_lengths[0..255] = 每个 symbol 的码长(0 表示该符号未出现) |
关键点:规范 Huffman 码字的 bit 序在存储时需要反转——编码时将最低有效位先写入,解码时按此序还原。这与 DEFLATE 和 JPEG 规范一致。反转的原因:bit 流通常是 LSB first(低位在前),而规范的构建是按 MSB first(高位在前)进行的。
四、Huffman 编码在图像格式中的应用
4.1 JPEG 中的 Huffman 编码
JPEG 压缩管线中,经过 DCT → 量化 → Zig-Zag 扫描后,系数被编码为(run-length, category)对,然后使用 Huffman 编码进一步压缩。
DC 系数的差分编码
相邻 8×8 块的 DC 系数高度相关,JPEG 对 DC 系数采用 DPCM(差分编码):
ΔDC_i = DC_i - DC_{i-1}
差分值的编码分为两步:
- 查表确定 Δ 值的 magnitude category(如 category=3 表示 -7~-4 或 4~7)
- category 本身用 Huffman 编码(DC Huffman 表),category 内的 VLI(Variable Length Integer)用 category 个 bit 直接编码
AC 系数的游程编码
Zig-Zag 扫描后,非零 AC 系数之前有连续多个零。JPEG 将 (run_length, category) 组合编码为一个 Huffman 码字——即 run/category 联合查表(AC Huffman 表)。EOB(End Of Block)是一个特殊 Huffman 码字,表示块中剩余所有系数全为零。
ZRL(Zero Run Length):当 run_length 超过 15 时,使用 ZRL(run=15, category=0)编码 16 个连续的零,然后继续编码剩余的零。
4.2 DEFLATE 中的 Huffman 编码
DEFLATE(用于 gzip、PNG、ZIP、HTTP Content-Encoding)采用 LZ77 + Huffman 的两级压缩:
- LZ77 产生两种输出:(a) literal 字节 (b) (length, distance) 匹配对
- literal 值和 length 值合并为一个 alphabet(0-285),用一棵 Huffman 树编码
- distance 值(0-29)用另一棵 Huffman 树编码
DEFLATE 引入动态 Huffman 码——根据数据的实际统计动态构建 Huffman 树,并将码长表编码进压缩流。与”固定 Huffman 码”相比,动态码通常压缩率更高,但需要在流中传输码长表。
DEFLATE 对 Huffman 码长表本身再进行一层 run-length 编码(使用重复码——重复 3-6 次或 7-138 次),进一步压缩描述 Huffman 码长的开销。
4.3 PNG IDAT 中的 Huffman
PNG 的 IDAT chunk 数据使用 DEFLATE 格式压缩(RFC 1951)。在 filter byte 之后的所有像素数据都经过 DEFLATE 压缩。PNG 的滤波(Predictor)在压缩前对每一行做预处理(如 Sub、Up、Average、Paeth),提高了相邻像素的相似性,进而提高 Huffman 的压缩效果。
五、Huffman 解码器实现
5.1 逐 bit 解码
uint8_t decodeSymbol(const HuffNode* root, BitReader& reader) { |
缺点:每解码一个 bit 需要一次分支判断——对于高吞吐场景,速度不够。
5.2 查表法解码(Table Lookup)
对于固定数量的前导 bits(如 8~10 bits),预先建立查找表:
struct DecodeEntry { |
查表法的优缺点:一次解 8 bit,可以一次查表完成短码的解码;对长码(超过查表位宽)需采用二级查表或多级查表。DEFLATE 使用 9-bit 的初始查找表,JPEG 因码长 ≤ 16 通常使用 8-bit 查找表 + 二级表。
5.3 多级表查找优化
对于码长分布范围大的情况(如 DEFLATE 中最长码长 15),使用单级 15-bit 表需要 32768 个条目(不实际)。多级表策略:
- 在码长[1, 8]范围内使用 8-bit 一级表
- 码长 > 8 的符号,在一级表中使用”逃逸”标记,指向二级表
- 二级表根据剩余的 bits 索引
六、Huffman 在 Android NDK 上的实战
6.1 JNI 接口设计
extern "C" JNIEXPORT jbyteArray JNICALL |
6.2 BitWriter / BitReader 实现
class BitWriter { |
七、Huffman 编码的局限性及现代替代方案
7.1 理论限制
Huffman 对于单符号编码,期望码长 L 满足 H(X) ≤ L < H(X) + 1。这个 +1 bit 的上界在符号独立的情况下无法突破。但实际数据中的符号不是独立的——字符间存在上下文相关性。
7.2 算术编码 vs Huffman
算术编码将整个消息编码为一个 [0, 1) 区间的实数,不需要为每个符号分配整数个 bit。其期望码长可以达到 H(X) ≤ L < H(X) + 2/n(n 为消息长度),比 Huffman 的 +1 bit 更接近熵下界。
现代压缩格式(H.264/H.265 的 CABAC、JPEG 2000、JPEG XL、Brotli、Zstandard)均采用算术编码或其自适应变体。Huffman 的持续存在主要是因为:专利历史(算术编码长期受 IBM 专利限制)、解码速度(Huffman 的查表法解码非常快)、格式兼容性(JPEG、PNG、DEFLATE 的海量存量文件)。
7.3 ANS(Asymmetric Numeral Systems)
ANS 是 2014 年左右流行起来的新型熵编码,兼具 Huffman 的速度和算术编码的压缩率。Facebook 的 Zstandard(RFC 8878)采用了 Finite State Entropy(FSE),即 tANS(tabled ANS)的一种实现,其压缩率接近算术编码,解码速度与 Huffman 在同一数量级。Apple 的 LZFSE 也使用 ANS。ANS 被认为是当前最优秀的通用熵编码方法。
ANS 的核心思想:将消息编码为一个自然数 x。编码时,将符号 s 和当前状态 x 映射为新状态 x’ = C(x, s);解码时,从 x’ 恢复 s 和 x = D(x’)。通过精心设计编码表 C 使得 x’ 的分布近似于 P(s),从而实现接近熵的压缩率。
面试常考问题
Q1:Huffman 编码为什么是最优的?用交换论证简要证明。
首先,最优前缀树中频率最低的两个符号一定在最深层(若不然,与之交换深度可以降低期望码长)。其次,这两个最低频率符号可以共享父节点合并为一个”超级符号”而不影响最优性。由此将 n 符号问题归约为 n-1 符号问题,Huffman 算法的贪心过程恰好完成此归约,因此得到的编码是最优的。
Q2:规范 Huffman 编码和标准 Huffman 编码的差异?为什么真实格式用规范版?
标准 Huffman 需存储完整树结构。规范 Huffman 只需存储每个符号的码长(256 bytes 即完整描述码表),接收端按确定性的重建算法恢复所有码字。规范版存储开销大幅降低,且码长表本身可进一步被 run-length 编码压缩——这正是 DEFLATE 的做法。
Q3:Huffman 编码的压缩率为什么不如算术编码?
Huffman 对每个符号分配整数个 bit,即使符号概率为 0.01,也必须分配 ≥1 bit,产生的最优码长期望为 ∑ p_i · ceil(-log₂ p_i),而熵下界是 ∑ p_i · (-log₂ p_i)。当符号概率不是 2 的幂次时,Huffman 的舍入误差(fractional bit waste)无法避免。算术编码将整个消息编码为一个实数,符号的编码位数可以是分数的,几乎达到熵下界。
Q4:在 Android 中实现 Huffman 压缩,Java 和 Native 差异大吗?
较大。Huffman 编码涉及大量位操作(shift, OR, masking),Java 中所有位操作都涉及 int/long 的 boxing 推断和数组边界检查。NDK 的 C++ 实现可以用 uint8_t* 直接操作字节数组,避免 JNI 调用开销。实测在 1MB 文本压缩中,native 版本通常快 3-5 倍。另外 native 端的 BitWriter/BitReader 可以利用 ARM NEON 的 vector bit-shift 指令进一步优化。
Q5:为什么 DEFLATE 要对 Huffman 码长表再进行 run-length 编码?
DEFLATE 支持动态 Huffman 码,需要在压缩流中传输 256+ 个符号的码长。当大量符号的码长为 0(未出现在输入中)时,逐符号写码长(256 bytes)效率低。Run-length 编码用重复码表示”连续 N 个符号的码长为 0(或重复某个码长)”,将码长表的描述从数百字节压缩到几十字节。这是”压缩描述压缩数据的元数据”的典型案例。
八、Huffman 编码在特定 Android 场景中的应用
8.1 APK 压缩中的 DEFLATE 使用
APK 本质上是一个 ZIP 文件,ZIP 内部的每个文件条目可以使用 DEFLATE 或 stored(未压缩)模式。DEFLATE 使用 Huffman + LZ77:
ZIP 文件格式中的 DEFLATE 条目: |
Android 构建工具(AAPT2)默认对 resources.arsc 和 classes.dex 使用最大压缩级别(9),对已压缩的媒体文件(PNG、JPEG、MP3)使用 stored 模式(不重新压缩以避免膨胀)。
8.2 WebP 格式的 Huffman 使用
WebP 的压缩模式中使用 Huffman 编码(在 VP8 视频压缩框架下):
WebP 有损模式 = VP8 视频帧(内部使用 Huffman 编码变换系数) |
Android 4.0+ 原生支持 WebP,4.2+ 支持透明 WebP。Glide/Coil 使用 BitmapFactory 或 libwebp 解码 WebP,其中 Huffman 解码在 native 层完成。
8.3 Brotli 压缩与 Android
Android 5.0+ 支持 Brotli 压缩作为 Web 内容编码。Brotli 使用基于上下文的 Huffman 编码(与 DEFLATE 相似但码表更复杂):
Brotli 与 DEFLATE 的 Huffman 差异: |
九、Huffman 编码的性能优化技巧
9.1 频率排序优化
在构建 Huffman 树时,使用桶排序(counting sort)代替堆排序用于频率排序:
// 桶排序优化:O(n) vs 堆排序 O(n log n) |
9.2 SIMD 加速位操作
使用 ARM NEON 指令加速 BitWriter/BitReader 的位操作:
// 使用 NEON 一次处理 128 bits(16 bytes) |
9.3 内存池优化
频繁的 new/delete HuffNode 会加重 GC 压力。在 JNI 层使用对象池:
class HuffNodePool { |
十、实践:Huffman 压缩 vs zlib vs zstd 的 Android 场景对比
| 算法 | 压缩率 | 压缩速度 | 解压速度 | Android 支持 |
|---|---|---|---|---|
| 纯 Huffman | 低(熵+1 bit) | 快 | 极快 | 需自行实现 |
| zlib (DEFLATE) | 中 | 中 | 中 | 系统自带(java.util.zip) |
| zstd | 高 | 快 | 极快 | 需引入 .so(~300KB) |
| Brotli | 最高 | 慢 | 中 | Android 5.0+ (Web) |
对于日志压缩、APK 内资源压缩:
- 日志实时压缩:zstd(压缩速度和解压速度都很快)
- APK 内资源:DEFLATE/zlib(兼容性最好,无需额外依赖)
- 自定义二进制协议:Canonical Huffman(16 bytes 码长表,开销最小)
- 网络传输:Brotli(最高的文本压缩率)
参考
- Huffman, D.A. (1952). “A Method for the Construction of Minimum-Redundancy Codes”
- RFC 1951: DEFLATE Compressed Data Format Specification
- JPEG Standard (ITU-T T.81): Annex C — Huffman tables
- PNG Specification (ISO/IEC 15948): Section 5.4 — Filtering, Section 5.5 — Compression (DEFLATE)
- Duda, J. (2013). “Asymmetric Numeral Systems: Entropy Coding Combining Speed of Huffman Coding with Compression Rate of Arithmetic Coding”
- Introduction to Algorithms (CLRS), 3rd ed. Chapter 16.3 — Huffman Codes







