最近几天在一直在忙于一个H5小应用的更改,一直没有时间来继续研究我们在课堂上实现的这一些压缩算法。今天好不容易抽出时间来,一定要把这个曾经的落下的重新捡起来并且完善。
我们知道在原来很多的编码当中,我们对字符串进行压缩,对文件进行压缩等绝大多数都需要我们预先对整个字符串和整个文件中的数据的分布有一些大致的了解,也就是说,我们在压缩之前就差不多了解这个文件中的基本的字符都有哪些,大致出现的频率是怎样的等。这种算法我个人认为还有有点不足,正是因为我们需要对整个文件里的数据有个整体的了解,对于这些算法我们在进行那些不易知道文件内容的文件就显得比较不太好进行了。但是字典编码在这些不了解文件的基础上我们也能够进行比较高效的压缩,那么接下来我们就来看看,字典编码的发展、原理以及代码实现。
字典编码本质上就是利用我们从头开始,然后将在字典中出现过的字符串使用一个索引值代替,以此来达到压缩目的。
一、历史
以色列两位大神Ziv和Lempel于1977年发表题为“顺序数据压缩的一个通用算法(A Universal Algorithm for Sequential Data Compression )”的论文,论文中描述的算法被后人称为LZ77算法。
次年,这两位搭档对他们的算法进行改进,并发表了该论文的续篇“通过可变比率编码的 立序列的压缩(Compression of Individual Sequences via Variable Rate Coding)”,描述了后来被命名为LZ78的压缩算法。
1984年,T. A. Welch 发表了名为“高性能数据压缩技术( A Technique for High Performance Data Compression )”的论文,描述了他在Sperry研究中心的研究成果,这是LZ78算法的一个变种,也就是后来非常有名的LZW算法。1990年后,T.C.Bell等人又陆续提出了许多LZ系列算法的变体或改进版本。
……
二、原理
从以上的历史我们可以看到啊,字典算法事实上包含很多很多中不同的算法,在这里我们选择最原始的LZ77以及LZ78这两个算法最为重点进行研究,后来的LZW算法就直接是LZ78的变体,原理基本上都是相似的,只要LZ78能够完全实现,那么实现LZW起来也比较容易了。
(一)LZ77算法
A、压缩编码原理
可能有好多人会好奇为什么叫LZ77算法,从以上的历史阶段我们大致应该了解了,这个算法是由ziv和Lempel两位大神一起在1977年研究出的,因此我们取名LZ77算法,简单粗暴,深入人心。
好啦,废话不多说了。我们直接来看看LZ77就行是怎样在进行数据的压缩:
事实上在LZ77算法中,整体思路是,我们先手动设定一个窗口,这个窗口包含两个部分,其中之一是位于左半部分的字典区域,另一个是位于窗口右端的待编码区。
大致上就是这样的:
然后我们在这个窗口内,将待编码区中出现的字符串在字典区进行查找,以此来达到压缩的目的。
LZ77算法的输出是一个三元组,(off, len, char)分别表示是匹配字符串的偏移(在这里的偏移实际上可以有两种,第一种是匹配字符串离字典区左边的距离,第二种是匹配字符串离字典区右边的距离,我更加习惯使用第二种),第二个len是表示匹配字符的长度,第三个则是表示匹配之后的下一个字符。
这样说起来可能还是有点抽象,我们在这里用一个实例说明一下流程:
我们以字符串aacaacabcabaaac
先来看看LZ77压缩算法是怎样进行压缩和解压缩的——
因为我们在LZ77中我们的字典事实上是动态生成的,而且随着窗口长度的不同而具有不同的字典,因此我们刚开始进行编码的时候我们的字典为空。
我们在进行算法之前,需要进行一些初始化的工作,就是将待编码区的左端和带编码的字符串的左端对齐。这样就能保证我们能够直接进行编码。
准备工作完成之后,我们就可以开始进行算法的操作了。
算法的基本功能就是在字典区中找到与待编码区(从开始)相匹配的最长字符串,然后返回这个最长字符串的起始字符离cursor的距离,以及该最长字符串的长度,做完这一步之后,我们将窗口向右平移len + 1个字符,然后继续进行搜寻。
现在我们开始i进行编码,因为字典(此时字典为空)中没有与之匹配的字符,因此就直接输出这个三元组。然后我们将窗口向又移动一格,然后现在我们的字典区就有了一个字符,也就是刚才我们未匹配的字符,然后我们就直接输出一个三元组<0, 0, a>这里的a表示的是第一个字符,于是得到以下的结果:
现在我们的字典区有了一个字符,然后我们继续进行我们的算法,易知在字典区和待编码区的最长匹配是一个字符a
,因此我们输出以下的三元组<1, 1, c>,然后将窗口向右移动len + 1个单位,也就是2个字符长度,就得到以下的结果:
然后我们继续按照算法来继续进行字符的匹配,由以上的图我们可以明确是aac
,因此我们返回的三元组为<3, 4, b>,然后将该窗口向右移动len + 1 个单位,也就是5个字符长度,得到的结果如下:
然后根据原来的匹配然后继续进行,得到的结果如下:<3, 3, a>然后再将窗口向右移动len + 1个长度,也就是4个字符长度:
继续进行该算法,得到的结果如下,输出的三元组为:<1, 2, c>,最后就直接移动len + 1个单位,这样就完成了整个字符串的编码:
这样也即当我们输入字符串aacaacabcabaaac
最后得到的是一个三元组序列:<0, 0, a>、<1, 1, c>、<3, 4, b>、<3, 3, a>、<1, 2, c>
注:窗口外的字符不作为字典并且也不参与编码。
我们从以上的过程可以看出,其实LZ77算法的最大难点集中在最长字符的匹配上,如果我们采用最原始的便利来进行字符串的匹配,那么匹配字符串的时间复杂度为O(n2),再到后来我们嵌套一次,整个算法的时间复杂度就为O(n3)了,这样的方法并不可取,试想当n很大的时候时间复杂度太大代价太高,这显然是我们根本没法容忍的,而且我们的压缩算法就是要用在大型的文件上进行压缩,若这个算法对大型文件并不友好的话,那么这个算法将会是很失败的。
因此,我们可以考虑以下的集中字符匹配的方式来减小我们的时间复杂度
1) 我们首先可以限制可匹配字符串的最大长度(假设设置该长度为a),则将窗口中每一个长度为a的字符串取出来,然后由这a个字符创建一棵搜索二叉树,然后在这样的二叉树中及逆行字符串的查找与匹配,效率将会得到相当大的提升;但是这种方法会限制最长的压缩长度,综合考虑这种方式还是比较好的一种;
2)将窗口中每个长度为 3 (视情况也可取 2 或 4)的字符串建立索引,先在此索引中匹配,之后对得出的每个可匹配位置进行顺序查找,直到找到最长匹配字符串。因为长度为 3 的字符串可以有 2563 种情况,我们不可能用静态数组存储该索引结构。使用 Hash 表是一个明智的选择。我们可以仅用 MAX_WND_SIZE - 1 的数组存储每个索引点,Hash 函数的参数当然是字符串本身的 3 个字符值了,Hash 函数算法及 Hash 之后的散列函数很容易设计。每个索引点之后是该字符串出现的所有位置,我们可以使用单链表来存储每一个位置。值得注意的是,对一些特殊情况比如 aaaaaa…之类的连续字串,字符串 aaa 有很多连续出现位置,但我们无需对其中的每一个位置都进行匹配,只要对最左边和最右边的位置操作就可以了。解决的办法是在链表节点中纪录相同字符连续出现的长度,对连续的出现位置不再建立新的节点。这种方法可以匹配任意长度的字符串,压缩效果要好一些,但缺点是查找耗时多于第一种方法。
3)使用字符树( trie )来对窗口内的字符串建立索引,因为字符的取值范围是 0 - 255,字符树本身的层次不可能太多,3 - 4 层之下就应该换用其他的数据结构例如 Hash 表等。这种方法可以作为第二种方法的改进算法出现,可以提高查找速度,但空间的消耗较多。
http://www.cnblogs.com/napoleon_liu/archive/2010/12/16/1908573.html )
B、解压缩编码
解压缩码相对起来就简单许多了,首先我们遇到的三元组可能会有以下的三种情况:(我们刚开始在初始化的时候将off和len的值都设置为-1,这样便于判断)
a、off = -1, len = -1的时候,这个时候说明字典里没有数据,即第一步遇到的情况,这个时候就直接输出三元组中的字符就好;
b、off >= len的时候,我们就直接取出字典中的[off : off + len + 1]即可;
c、off < len的时候,这就说明了我们在编码的过程中有重复的,需要从左到右循环拼接:
例如:dict = acb, 编码为<2, 5, d>,那么解压码就是acbcbcbcd
C、代码实现
首先我们还是先写类:
/**
* @author mica_dai
* @modify 2017.9.23 22.:15
* class LZ77
*/
class LZ77
{
public:
LZ77(){
windowSize = 0;
bufferSize = 0;
}
~LZ77(){}
void setWindowSize(int windowSize){
this -> windowSize = windowSize;
}
void setBufferSize(int bufferSize){
this -> bufferSize = bufferSize;
}
void encode(string str);
string decode(std::vector<TriTuple> triTuples);
std::vector<TriTuple> getV(){
return this -> v;
}
private:
// 定义的三个私有成员分别是字典区长度,待编码区长度,以及一个用来存放输出结果的三元组向量
int windowSize;
int bufferSize;
std::vector<TriTuple> v;
};
因为我们在LZ77中的输出实际上是一个三元组,那么我们就直接定义一个三元组的类:
/**
* @author mica_dai
* @modify 2017.9.23 22.:19
* class Trituple
*/
class TriTuple
{
public:
TriTuple(){
}
TriTuple(int off, int len, char ch){
this -> off = off;
this -> len = len;
this -> ch = ch;
}
~TriTuple(){}
int getOff(){
return this -> off;
}
int getLen(){
return this -> len;
}
char getCharacter(){
return this -> ch;
}
void setOff(int off){
this -> off = off;
}
void setLen(int len){
this -> len = len;
}
void setCh(char ch){
this -> ch = ch;
}
private:
int off;
int len;
char ch;
};
准备工作做完之后,我们开始进行正式的编码
——我们在上面讨论过,整个LZ77算法的核心代码就是窗口的移动以及字符串的匹配因此我们重点来看看这两个模块的实现:
首先,我们考虑窗口移动的问题:我们可以直接设置一个cursor作为指针,正如我在以上的图片中画的那样,以cursor作为一个指针,然后待编码的区域就可以表示为(假设我们要将str及逆行压缩)str[cursor : cursor + buffer],字典区就可以表示成为str[cursor - windowsize : cursor],然后再根据cursor的移动就将整个待编码区和字典区全部带动,这就实现了窗口的移动;
然后我们在开始进行研究这个字符串匹配的算法,在这里由于我的实力有限,我其实是使用遍历的方法来进行字符串的匹配的因此效率还是比较低下的,等我以后实力上升一个台阶之后在对这个算法进行完善。
现在我们直接看代码:
/**
* @author mica_dai
* @modify 2017.9.23 22.:19
* class LZ77 method encode
* @params string str, to be encoded
*/
void LZ77::encode(string str){
TriTuple triTuple;
int endBuffer = 0;
int startDict = 0;
int repetation = 0;
int rest = 0;
int off = 0, len = 0;
char ch;
string substring = "";
string matchSub = "";
off =-1;
len = -1;
int size = str.length();
for (int cursor = -1; cursor < size - 1; ){
endBuffer = std::min(cursor + bufferSize, size - 1);
for (int j = cursor + 1; j <= endBuffer; ++ j){
startDict = std::max(cursor + 1 - windowSize, 0);
/* 从索引处进行向后的子序列然后进行匹配*/
substring = str.substr(cursor + 1, j - cursor);
/* i的顺序就是完成字典框*/
for (int i = startDict; i <= cursor; ++ i){
/* 对于局部的字典与待编码区,如果字典区域小于待编码区域时这个时候只有可能是具有重复编码,所以需要得到重复的倍数;
而在当字典区域大于待编码区的时候这个时候相当于重复为0正好和我们的倍数的表达式一致*/
repetation = substring.length() / (cursor + 1 - i);
rest = (substring.length()) % (cursor + 1 - i);
if (repetation == 0){
matchSub = str.substr(i, rest);
}else{
for (int k = 0; k < repetation; ++ k){
matchSub += str.substr(i, cursor + 1 - i);
}
matchSub += str.substr(i, rest);
}
if (matchSub == substring && substring.length() >= 1){
off = cursor - i + 1;
len = substring.length();
ch = str[j + 1];
}
matchSub = "";
}
}
if (off == -1 && len == -1){
triTuple.setOff(0);
triTuple.setLen(0);
triTuple.setCh(str[cursor + 1]);
v.push_back(triTuple);
cursor += 1;
off = -1;
len = -1;
continue;
}else{
triTuple.setOff(off);
triTuple.setLen(len);
triTuple.setCh(ch);
v.push_back(triTuple);
cursor += len + 1;
off = -1;
len = -1;
continue;
}
}
}
解码就是直接按照以上的三种情况进行解码就好:——
/**
* @author mica_dai
* @modify 2017.9.23 23:10
* class LZ77 method decode
* @params Trituples the vector contains the trituple
* @return string original string
*/
string LZ77::decode(std::vector<TriTuple> triTuples){
string str = "";
int cursor = -1;
for (auto i = triTuples.begin(); i != triTuples.end(); ++ i){
if (i -> getOff() == 0 && i -> getLen() == 0){
}else if (i -> getOff() >= i -> getLen()){
str += str.substr(cursor - (i -> getOff()) + 1, i -> getLen());
}else if (i -> getOff() < i -> getLen()){
int repetation = i -> getLen() / i -> getOff();
int rest = i -> getLen() % i -> getOff();
if (repetation == 0){
str += str.substr(cursor - (i -> getOff()) + 1, rest);
}else{
for (int k = 0; k < repetation; ++ k){
str += str.substr(cursor - (i -> getOff()) + 1, i -> getOff());
}
str += str.substr(cursor - (i -> getOff()) + 1, rest);
}
}
str += i -> getCharacter();
cursor += (i -> getLen() + 1);
}
return str;
}
然后最后,我们完成之后进行代码的执行:
PS mica\压缩算法\词典压缩算法\LZ77> .\LZ77.exe
(0 , 0)a (1 , 1)c (3 , 4)b (3 , 3)a (1 , 2)c
aacaacabcabaaac