信息熵
信息熵也叫香农信息熵,百科上有介绍。主要公式:
有个结论:编码一个符号的最佳bit长度是
-logP
,P是这个符号出现的概率。
问题来了
-logP
不一定是整数呢。
哈夫曼编码
使用长度不一的01串编码符号,主要是为了让最后输出的串更短。
就是让 最小。
Huffman使用自底向上构建二叉树的方式,构建Huffman树,每个字符的最终编码就是从根走到叶子的01序列。
构建的方式也很简单:
- 初始集合里有n棵树,每棵树只有1个字符节点,树根的值是这个字符出现的频次或者概率。
- 选择树根值最小的两颗树,添加一个中间节点做根,两颗树作为左右子树,树根值是两颗子树的根值的和。删掉两颗子树,把新的中间节点做根的子树添加进集合。
- 重复过程2,直到集合里只有一颗树。
huffman编码简单方便,效率不错,但理论值还有差别,毕竟编码长度是整数。
算数编码
英文名 Arithmetic coding ,维基百科上有介绍,写的有些难懂,但是操作思路还是很简单的。
简单来说就是用一个0~1之间的小数表示输入串。实数的小数位可能无限多,故而能覆盖任意可能的输入串。
那们怎么编码可解码呢?方法也比较简单,核心操作是区间查找。
初始每个字符的区间长度是字符出现的概率,所有字符的概率和是1,正好覆盖0~1区间。
每一步编码过程:
- 读取下个字符,找到字符的初始概率区间。
- 按1步骤找到初始概率区间,在当前区间里划分出一个更小的区间,作为当前区间。
最后,在当前区间里随便找个数字就可以了。有个注意的地方,最后一个字符是额外加的终止字符,用来让解码过程停止。
解码过程:
- 查找数字在当前区间里适合的字符概率区间,判断概率区间对应的字符。
- 如果是终止字符,解码完成,退出。
- 否则输出对应字符。
- 把找到的区间作为当前区间,重复步骤1。
wiki上一张好理解的解码过程图,可以看出是如何进行区间查找的。
说法是:算数编码可以无限接近理论值,熵编码问题可以算是解决了。
但是,算数编码用二进制cpu难实现啊。有个区间编码,据说也接近香农信息熵理论值。
ANS+FSE
看zip压缩算法是,发现facebook搞的zstandard(zstd)算法综合素质非常优异。
看了下,用了一个厉害的熵编码算法,作者叫它FSE(Finite State Entropy)(有限状态熵)。
用到的理论是Jarek Duda提出的非对称数系(Asymmetric Numeral System,ANS),没看,应该很厉害。
作者有了10篇相关blog,看完发现没懂,囧,琢磨两天后明白了,觉得作者讲的难懂~
FSE索引blog
按自己理解概况下:
- 首先是如何逼近
-logP
理论值。举个例子,如果字符编码长度的理论值是9.5,那么一半字符用9bit编码,一半用10bit编码就好了。 - 如何实现呢?作者提供了一个方案,按自己的方式理解了下。编解码的伪代码
// 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。
这个表是根据字符出现的频次来创建的,后面说。
在有这个表的定义的情况下,解码的过程就很好理解了
- 输出当前state对应FSETable里的symbol
- 取出state对应FSETable里的nbBits,从bitStream读取nbBits个bit,存入rest,作为偏移。
- 取出state对应的FSETable里的newStateBaseline,作为偏移基准。
- 算出下一个state,state = newStateBaseline + rest。
这样为啥就有用呢?因为编码过程可以有效编码任意的字符串。下面看编码过程。
- 读下一个符号symbol
- 根据当前state和符号symbol找到下一个状态idx。【这个状态的symbol等于当前准备编码的符号。】
- 取出状态idx对应的newStateBaseline,算出偏移rest = state - newStateBaseline。【解码是正好加回来】
- 取出状态idx的nbBits,把rest的低nbBits位输出到bitStream。【有个条件:rest最多只有nbBits有效二进制位,这样才不会出现数据丢失】。
- 更新state,state = idx。
编码过程第一个关键点:findNextState找到的状态的nbBits需要能匹配rest。
有个简单的方法,nbBits长度定成12不就行了,4096的长度都能表示。
但是想一下会发现nbBits就是编码每个字符的bit长度,我们希望它是-logP
,让n = floor(-logP)
,我们希望nbBits在n和n+1里取。
这么做到这一点呢?
符号区间的理解
先要理解一个事实,newStateBaseline和nbBits组成一个 长度的区间,左边界是newStateBaseline。FSETable里的每个元素都表示这么一个区间。
findNextState本质就是要找到一个这样的区间能够覆盖到当前的state,并且这个区间对应的状态的symbol和当前要编码的符号一样。
再想一想,这就要求:一个symbol的所有FSETable值的区间总和要能覆盖整个FSETable表的区间。
这时来看作者举的例子,出现频率是 的符号,理论编码长度是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
其实符号区间不一定要是连续的,也可以这样。
这种情况下每次读写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.