3. 数据压缩基础:无损压缩 vs 有损压缩,熵编码原理
聊到物联网数据压缩,我习惯先问一个问题:你愿意牺牲一点数据精度,来换取更长的电池寿命吗?
这个问题没有标准答案。它取决于你的业务场景。我在做智能水表项目时,就遇到过这种抉择——水表读数必须精确到小数点后两位,少一位都不行。但另一个温湿度传感器项目,温度差个0.5度,完全不影响判断。
所以,咱们得先搞清楚两种压缩方式的本质区别。
3.1 无损压缩 vs 有损压缩:选哪个?
说白了,无损压缩就是压缩完还能原样解压回来。数据一个比特都不丢。有损压缩呢,会丢掉一些不太重要的信息,换来更高的压缩比。
我整理了一个对比表,你一看就明白:
| 特性 | 无损压缩 | 有损压缩 |
|---|---|---|
| 数据完整性 | 100%还原 | 部分信息丢失 |
| 压缩比 | 通常 2:1 ~ 5:1 | 可达 10:1 甚至更高 |
| 典型算法 | 霍夫曼、LZ77、算术编码 | JPEG、MP3、OPUS |
| 适用场景 | 传感器数值、控制指令、日志 | 音频、图像、视频 |
| 对LPWAN的影响 | 节省带宽,不丢精度 | 节省更多带宽,但需容忍误差 |
3.2 熵编码原理:信息论的核心
熵编码,名字听着挺唬人。其实核心思想就一句话:给出现频率高的符号分配短码,给出现频率低的符号分配长码。
为什么会这样?你想想看,如果每个符号都用固定长度的二进制表示,比如8位,那不管这个符号出现1000次还是1次,都占8位。这显然浪费。熵编码就是打破这种“平均主义”。
我画了一张图,帮你理解熵编码的核心逻辑:
3.3 霍夫曼编码:最经典的熵编码
霍夫曼编码,我估计你听过。它是David Huffman在1952年提出的,到现在70多年了,依然活跃在物联网领域。
它的做法很简单:构建一棵二叉树,频率高的符号离根近,频率低的离根远。
我举个例子。假设我们要编码四个符号:A、B、C、D,频率分别是40%、30%、20%、10%。
构建霍夫曼树的步骤:
- 把所有符号按频率排序,放到一个列表里
- 取出频率最小的两个(D:10% 和 C:20%),合并成一个节点,频率30%
- 把这个新节点放回列表,重新排序
- 重复步骤2-3,直到只剩一个节点
最终得到的编码表是这样的:
| 符号 | 频率 | 霍夫曼编码 | 固定长度编码 |
|---|---|---|---|
| A | 40% | 0 | 00 |
| B | 30% | 10 | 01 |
| C | 20% | 110 | 10 |
| D | 10% | 111 | 11 |
你看,A只用1位,D用了3位。平均码长是多少?算一下:0.4×1 + 0.3×2 + 0.2×3 + 0.1×3 = 1.9位。而固定长度编码需要2位。压缩比提升了约5%。
3.4 算术编码:更极致的压缩
霍夫曼编码有个限制:每个符号必须用整数个比特位表示。但算术编码打破了这一点。
算术编码的思路是:把整个消息映射到[0,1)区间上的一个小数。频率高的符号占的区间大,频率低的占的区间小。
举个例子。还是A、B、C三个符号,频率分别是50%、25%、25%。
编码过程:
- 初始区间 [0, 1)
- A占 [0, 0.5),B占 [0.5, 0.75),C占 [0.75, 1.0)
- 要编码 "AB",先选A的区间 [0, 0.5)
- 在这个区间内,再按比例划分:A占 [0, 0.25),B占 [0.25, 0.375),C占 [0.375, 0.5)
- 选B的区间 [0.25, 0.375)
- 最终输出这个区间内的任意一个数,比如 0.3
解码时,根据0.3落在哪个区间,反向推导出原始符号序列。
3.5 两种编码的对比与选择
我直接给你结论:
| 对比维度 | 霍夫曼编码 | 算术编码 |
|---|---|---|
| 压缩率 | 接近熵,但受整数位限制 | 理论上可达熵极限 |
| 计算复杂度 | 低,适合MCU | 高,需要乘除运算 |
| 内存占用 | 需要存储编码表 | 只需维护区间状态 |
| 实时性 | 好,逐符号编码 | 较差,需处理完整个消息 |
| 适用场景 | 小数据包、符号种类少 | 大数据包、追求极致压缩 |
3.6 实战:在LoRaWAN节点上实现霍夫曼编码
最后,我给你一个实际可用的C代码片段。这是我之前在SX1262项目里用过的:
// 霍夫曼编码表结构
typedef struct {
uint8_t symbol;
uint32_t code;
uint8_t length;
} HuffmanCode;
// 预定义的编码表(基于统计)
const HuffmanCode huff_table[] = {
{0x00, 0b00, 2}, // 0x00 出现最多
{0x01, 0b01, 2}, // 0x01 次之
{0x02, 0b100, 3}, // 0x02
{0x03, 0b101, 3}, // 0x03
{0xFF, 0b110, 3}, // 0xFF
{0xFE, 0b1110, 4}, // 0xFE
{0xFD, 0b1111, 4}, // 0xFD
};
// 编码函数
uint8_t huffman_encode(uint8_t *input, uint16_t len,
uint8_t *output, uint16_t *out_len) {
uint32_t bit_buf = 0;
uint8_t bit_cnt = 0;
uint16_t out_idx = 0;
for(uint16_t i = 0; i < len; i++) {
// 查找符号对应的编码
for(uint8_t j = 0; j < 7; j++) {
if(huff_table[j].symbol == input[i]) {
// 将编码写入bit buffer
bit_buf = (bit_buf << huff_table[j].length) |
huff_table[j].code;
bit_cnt += huff_table[j].length;
// 满8位就输出
while(bit_cnt >= 8) {
bit_cnt -= 8;
output[out_idx++] = (bit_buf >> bit_cnt) & 0xFF;
}
break;
}
}
}
// 处理剩余位
if(bit_cnt > 0) {
output[out_idx++] = (bit_buf << (8 - bit_cnt)) & 0xFF;
}
*out_len = out_idx;
return 0;
}
这段代码在Cortex-M0上实测,编码100字节数据只需约200微秒。解码速度也差不多。嗯,对于LoRaWAN来说,这个开销完全可以接受。
记住一点:编码表需要发送端和接收端保持一致。要么双方预置相同的统计表,要么在数据包头部携带编码表。我习惯用预置表,省带宽。但如果数据分布变化很大,就得动态更新了。