熵压缩:信息熵、Huffman编码、算数编码、ANS+FSE

信息熵

信息熵也叫香农信息熵,百科上有介绍。主要公式:


有个结论:编码一个符号的最佳bit长度是-logP,P是这个符号出现的概率。
问题来了-logP不一定是整数呢。

哈夫曼编码

使用长度不一的01串编码符号,主要是为了让最后输出的串更短。
就是让 \sum_{i=1}^n{count_i*len_i} 最小。
Huffman使用自底向上构建二叉树的方式,构建Huffman树,每个字符的最终编码就是从根走到叶子的01序列。
构建的方式也很简单:

  1. 初始集合里有n棵树,每棵树只有1个字符节点,树根的值是这个字符出现的频次或者概率。
  2. 选择树根值最小的两颗树,添加一个中间节点做根,两颗树作为左右子树,树根值是两颗子树的根值的和。删掉两颗子树,把新的中间节点做根的子树添加进集合。
  3. 重复过程2,直到集合里只有一颗树。

huffman编码简单方便,效率不错,但理论值还有差别,毕竟编码长度是整数。

算数编码

英文名 Arithmetic coding ,维基百科上有介绍,写的有些难懂,但是操作思路还是很简单的。
简单来说就是用一个0~1之间的小数表示输入串。实数的小数位可能无限多,故而能覆盖任意可能的输入串。
那们怎么编码可解码呢?方法也比较简单,核心操作是区间查找。
初始每个字符的区间长度是字符出现的概率,所有字符的概率和是1,正好覆盖0~1区间。
每一步编码过程:

  1. 读取下个字符,找到字符的初始概率区间。
  2. 按1步骤找到初始概率区间,在当前区间里划分出一个更小的区间,作为当前区间。

最后,在当前区间里随便找个数字就可以了。有个注意的地方,最后一个字符是额外加的终止字符,用来让解码过程停止。
解码过程:

  1. 查找数字在当前区间里适合的字符概率区间,判断概率区间对应的字符。
    • 如果是终止字符,解码完成,退出
    • 否则输出对应字符。
  2. 把找到的区间作为当前区间,重复步骤1。

wiki上一张好理解的解码过程图,可以看出是如何进行区间查找的。

说法是:算数编码可以无限接近理论值,熵编码问题可以算是解决了。
但是,算数编码用二进制cpu难实现啊。有个区间编码,据说也接近香农信息熵理论值。

ANS+FSE

看zip压缩算法是,发现facebook搞的zstandard(zstd)算法综合素质非常优异。
看了下,用了一个厉害的熵编码算法,作者叫它FSE(Finite State Entropy)(有限状态熵)。
用到的理论是Jarek Duda提出的非对称数系(Asymmetric Numeral System,ANS),没看,应该很厉害。
作者有了10篇相关blog,看完发现没懂,囧,琢磨两天后明白了,觉得作者讲的难懂~
FSE索引blog
按自己理解概况下:

  1. 首先是如何逼近-logP理论值。举个例子,如果字符编码长度的理论值是9.5,那么一半字符用9bit编码,一半用10bit编码就好了。
  2. 如何实现呢?作者提供了一个方案,按自己的方式理解了下。编解码的伪代码
// encode的关键循环。初始state随意,最后的state需要写入bitStream
Byte symbol = readSymbol(byteSteam);
int idx = findNextState(state, symbol);// idx就是新的state了
int rest= state - FSETable[idx].newStateBaseline;
int nbBits = FSETable[idx].nbBits; 
ouputBits(bitStream, rest, nbBits);
state = idx;

// decode的关键循环。初始state读自bitStream
outputSymbol (FSETable[state].symbol);
int nbBits =FSETable[state].nbBits;
int rest = readBits (bitStream, nbBits);
state =FSETable[state].newStateBaseline + rest;

上面两段伪代码是对称的。注意,编码是反向的,解码是正向的。解码过程和作者写的有些不一样,是按自己的理解搞的。
先说伪代码里的FSETable和state。这就是FSE的状态和状态机数组。
一般FSETable是4096长度,保存了对应的字符symbol、编码长度nbBits和下一个状态的偏移基准newStateBaseline。
这个表是根据字符出现的频次来创建的,后面说。
在有这个表的定义的情况下,解码的过程就很好理解了

  1. 输出当前state对应FSETable里的symbol
  2. 取出state对应FSETable里的nbBits,从bitStream读取nbBits个bit,存入rest,作为偏移。
  3. 取出state对应的FSETable里的newStateBaseline,作为偏移基准。
  4. 算出下一个state,state = newStateBaseline + rest。

这样为啥就有用呢?因为编码过程可以有效编码任意的字符串。下面看编码过程。

  1. 读下一个符号symbol
  2. 根据当前state和符号symbol找到下一个状态idx。【这个状态的symbol等于当前准备编码的符号。】
  3. 取出状态idx对应的newStateBaseline,算出偏移rest = state - newStateBaseline。【解码是正好加回来】
  4. 取出状态idx的nbBits,把rest的低nbBits位输出到bitStream。【有个条件:rest最多只有nbBits有效二进制位,这样才不会出现数据丢失】。
  5. 更新state,state = idx。

编码过程第一个关键点:findNextState找到的状态的nbBits需要能匹配rest。
有个简单的方法,nbBits长度定成12不就行了,4096的长度都能表示。
但是想一下会发现nbBits就是编码每个字符的bit长度,我们希望它是-logP,让n = floor(-logP),我们希望nbBits在n和n+1里取。
这么做到这一点呢?

符号区间的理解

先要理解一个事实,newStateBaseline和nbBits组成一个 2^{nbBits} 长度的区间,左边界是newStateBaseline。FSETable里的每个元素都表示这么一个区间。
findNextState本质就是要找到一个这样的区间能够覆盖到当前的state,并且这个区间对应的状态的symbol和当前要编码的符号一样。
再想一想,这就要求:一个symbol的所有FSETable值的区间总和要能覆盖整个FSETable表的区间。
这时来看作者举的例子,出现频率是 \frac{5}{4096} 的符号,理论编码长度是9.678,划分的5个区间是这样的。

对于所有的符号都有类似的划分。

符号的分布

剩下一个问题,所有的符号如何在FSETable里分布。
最简单的分布方式,举个8长度空间的例子。


这样其实也是可以工作的。只是和上面的区间划分方式一起作用时,会导致C或D后面的A和B编码一直用n+1个bit(3bit)。要是构造数据,n和n+1的选择不够平均。
作者的意思是尽可能让同一个字符的各个state分散开,应该有理论支持,作者的文章提到了用到的理论Jarek Duda's ANS theory,这个没看。
分散分布有各种方法,就像上面的例子可以这么分布:


注意:不要出现状态循环
符号分布有个约束的,nbBits 为0的状态对应的区间不能覆盖自己,如果出现这种情况,会无法判断符号重复了多少次。
原因是,这种状态,不需要任何输入就可以转到下一个状态。
处理起来也不难,调整下具体的分布就好,因为只有出现频率超过0.5的符号会有这种情况,这个符号只会有一个。
最简单的,同一个符号与他的多个区间对应时,偏移一位就行。
另外,如果只有一个符号出现,这时一定会出现状态循环(可以是多个状态形成圈)。
但这时概率100%,实际信息熵是0,不需要任何bit来编码,知道个数就行。

到这里主要的思路就OK了,剩下就是实现的优化了。

实现的优化

主要是优化上面的encoding:下一个状态的查找,编码长度的确定。
作者给的编码的伪代码是这样的:

nbBitsOut = (state + symbolTT.deltaNbBits) >> 16; // 快速计算需要x个bit
flushBits(bitC, state, nbBitsOut); // 根据之前的符号区间的定义,可以知道其实rest就是state的低x位
state = stateTable[(state>>nbBitsOut) + symbolTT.deltaFindState]; // 查表计算下个state

YY

其实符号区间不一定要是连续的,也可以这样。


image.png

这种情况下每次读写bitStream的是state的高x位。
有个优点,符号分布可以就用上面提到的最简单的分布方法。
不过要倒过来,出现概率最高的放在最后面,这样可以避免状态循环。



因为符号分布有规律,编解码伪代码:

// encode的关键循环。看上去沒有原作者的性能高,优点是不需要stateTable。
Byte symbol = readSymbol(byteSteam);
symbolTT = symbolTable[symbol];
int nBits = symbolTT.nBits;
if ( (state & symbolTT.BitMask) > symbolTT.threshold) { // 这儿应该可以做什么优化,来避免出现分支判断。
    nBits++;
}
ouputHighBits(bitStream, state, nBits);
int lowbits = state & ((1<<(n-nBits)) - 1);
state = symbolTT.firstState + lowbits;

// decode的关键循环。
Byte symbol = FSETable[state].symbol;
int nbBits = FSETable[state].nbBits;
int rest = readHighBits(bitStream, nbBits);
state = FSETable[state].lowbits + rest;

FSE的效果

FSE压缩的效果不比Huffman高多少,但是最终代码实现要简单不少,速度要快很多。
zstd和zlib相比,压缩率高一点点,速度高许多。
贴个作者提供的数据。

The speed of this implementation is fairly good, and even on modern high-end CPU, it can prove a valuable replacement to standard Huffman implementations.

Compared to zlib's Huffman entropy coder, it manages to outperform its compression ratio while besting it on speed, especially decoding speed.

Benchmark platform : Core i5-3340M (2.7GHz), Window Seven 64-bits

Benchmarked file : win98-lz4-run

Algorithm Ratio Compression Decompression

FSE    2.688    290 MS/s    415 MS/s

zlib    2.660    200 MS/s    220 MS/s

Benchmarked file : proba70.bin

Algorithm Ratio Compression Decompression

FSE    6.316    300 MS/s    420 MS/s

zlib    5.575    250 MS/s    270 MS/s

Benchmarked file : proba90.bin

Algorithm Ratio Compression Decompression

FSE    15.21    300 MS/s    420 MS/s

zlib    7.175    250 MS/s    285 MS/s

As could be guessed, the higher the compression ratio, the more efficient FSE becomes compared to Huffman, since Huffman can't break the "1 bit per symbol" limit.

FSE speed is also very stable, under all probabilities.

I'm quite please with the result, especially considering that, since the invention of arithmetic coding in the 70's, little new has been brought to this field.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容