在开始之前,我们要知道以下几个内容。
什么是压缩?
所谓压缩,归根结底就是让文件变得更小,并且可以无损的还原。
为什么对文件进行压缩?
1.文件太大,为了节省空间。
2.提高数据在网络上传输的效率。
3.对文件进行保护(加密)。
文件压缩的分类:
1.无损压缩:源文件被压缩之后,通过解压缩还能还原成和源文件完全相同的格式。
2.有损压缩:源文件被压缩之后,通过解压缩不能还原成和源文件完全相同的格式。
下面我们来看看如何将文件进行压缩。
此处我用GZIP压缩算法来达到压缩的目的,GZIP算法是将两种算法结合在一起:
1.LZ77算法变形(第一阶段):是一种基于字典的算法,原理是将重复出现的语句用更短的小标记(距离长度对)来替换。
那么我们具体怎么来实现LZ77的算法变形进行压缩呢?
原理:
LZ77算法中是用一个64K大小的运动的窗口来保存我们将要压缩的数据,里面分为两部分:查找缓冲区、先行缓冲区;查找缓冲区就是我们之前遍历过的字符,先行缓冲区是还未遍历到的字符。先行缓冲区内的数据每次都是在查找缓冲区中找匹配的,如果找到了匹配,则用更短的长度(重复字符的长度)距离(待压缩字符串首字符到匹配字符串首字符的距离)对来替换。如果没有找到匹配,则将该字节的字符写入压缩文件中。
举个例子:
那么此时就有一个问题:当我们解压缩的时候,怎么区分压缩文件中的数字是原文件字符还是替换的长度距离对?
因此我们就需要在我们每写一个压缩数据时候,对我们的数据进行一个比特位的标记,最后写入新的文件中。比如:如果此时写入的是原字符,就用0表示,若为长度距离对,则用1表示。
上图中对应的标记信息就是:00000000 00001000 01000000 00000000 00000000 0
这样当我们解压缩是:
1.先从保存标记信息的文件中获取获取一个字节。
2.检测每个比特位:
0:代表当前从压缩文件中读出来的一个字符为原数据。直接写入解压缩文件中。
1:代表当前从压缩文件中读出来的一个字符为长度距离对的距离,再获取下一个字节(长度),根据长度距离来在之前已经解压缩的是数据中找对应的字符串写入解压缩文件中。
上述问题解决之后,那么又有新的问题:我们是将与之前重复的内容替换成长度距离对,那么当重复的字符串中有几个字符进行长度距离对替换的效率最好?
首先,我们先要知道长度距离对中的长度我们是用一个字节[0-255] 来进行存储的。
原因:因为一个字节最大值为255,并且正常文件中一般匹配的长度不会超过255,所以一个字节存储就够了,如果用两个字节,不仅第二个字节用不上,而且还还会影响压缩率。
其次,我们一般规定距离用两个字节[0-32768] 进行存储。
原因:因为距离的大小是根据查找缓冲区的大小而定的。而我们知道查找缓冲区越大那么找到重复几率就会越大,所以按理说我们应该在整个查找缓冲区中找匹配,但是实际上不是这样做的。实际上,重复都是有局部原理性,也就是说一般重复的字符串不会离的太远,虽然在整个查找缓冲区中,找重复几率大,但是会严重增大查找的效率。所以我们规定查找缓冲区的大小最大为32K时,也就是32768(2个字节),到达这个数就不会增长了。会随着压缩的进行,舍弃最早的数据。
画个图理解一下:
最后我们就可以来解决最开始的那个问题了:
因为我们的长度为1个字节,距离为2个字节,因此长度距离对一共就需要三个字节。很显然我们只能在有3个或者以上的字符重复时,进行长度距离对替换。因为我们长度使用一个字节存储的,范围是0-255,但是我们是从长度为3才开始替换的,因此我们实际可以达到的匹配长度范围为3-258。
接下来就到了重头戏、重头戏、重头戏
前面说明了,最小匹配长度为3,所以我们每次要从先行缓冲区中去三个字符,组成一个字符串来到查找缓冲区中找匹配。那么现在就存在一个问题如何高效的从查找缓冲区找匹配?
此处就需要用到哈希的思想来保存我们查找缓冲区中的以3个字符为一组的字符串。
原理:
1.每次从先行缓冲区中拿一个字符串(以后字符串说的都是三个字符组成的),先通过哈希函数计算出哈希地址。
2.在哈希表中对应的哈希地址上,存储的是字符串的首字符在窗口中的下标(下标占2个字节)。(此处要注意,往哈希表中插入数据的同时,查找匹配是同时进行的。也就是说如果此时某个哈希地址发生冲突,说明找到匹配,求得长度距离)。
此时就会有个问题哈希表应该多大?
其实哈希表的大小取决于哈希地址有多少种,而哈希地址又是哈希函数算出来的。
通过观察LZ77中哈希函数的计算方法,可以发现,哈希地址是通过三个字符取用每个字符的5个比特位来进行计算,所以哈希地址一共有2^15个,32K个
实际上在LZ77中用的哈希表开散列的变形:
举个例子来说说哈希表发生冲突的插入过程:
知道了LZ77中哈希地址是如何插入的之后,接下来就是如何查找匹配,并且因为重复的字符串不止一个,且长度也不一定为3,所以如何查找最长的匹配呢?
缺陷:LZ77可以消除文件中重复的语句,但还存在字节方面的重复。所以就要用到第二阶段,基于对字节的压缩。
2.基于Huffman编码的压缩(第二阶段):此算法是对第一节阶段压缩完成的数据进行字节上的压缩。是基于对字节的压缩,本项目用的是基于Huffman编码的压缩。
举个例子:
从上图可以发现若用不等长编码效率要高些。
不难看出,将出现次数多的字符其编码给的短,出现次数少的字符编码给的长,就可以大大提高压缩效率。我们可以将每个字符出现的次数作为Huffman树每个叶子结点的权值。并规定左子树为0,右子树为1。用Huffman树的性质来给出最合适的字符编码,那么得到合适的编码,随后压缩的效率也一定最好。如下图:
先看看如何来创建Huffman树
假设用户提供了一组N个权值信息
1.以每个权值为节点创建N颗二叉树的森林,也就是有N颗二叉树,每棵二叉树只有一个节点。
2.若森林中有超过两棵树,则:
a.从二叉树森林中取出根节点权值最小的两棵二叉树
(此处用优先级队列来保存权值,这样要取出最小的二叉树根节点权值更方便些)
b.以这两棵二叉树作为某个节点的左右子树创建一颗新的二叉树,新二叉树中根节点的权值为其左右子树权值之和。
c.将新建的二叉树再次插入到森林中。(如果森林中一直有超过两棵树,则一直循环上述三部操作,直到森林中只有一棵树,这棵树就是Huffman树)
随后,我们就可以进行对文件的压缩:
1.统计源文件中每个字符(字节)出现的次数。
(1)此时需要建立一个结构体数组,里面包含的是,字符的种类、出现次数、对应的编码。
(2)读取源文件,得到一共读到的大小。
(3)遍历源文件,用结构体中的_count变量,统计每个字符出现的次数。
2.用统计出来的次数,创建Huffman树。
(1)调用创建Huffman树的函数,将结构体数组传进去。
3.从Huffman中获取是对应字符(字节)的编码。
(1)从叶子向根找编码。
(2)用递归找到叶子结点,定义一个cur指向叶子结点,一个parent指针,指向叶子的父节点,根据cur是parent的左孩子还是右孩子来,找编码的第一个码,cur指向parent,parent指向此时cur的父节点,以此类推知道找到cur为根节点,parent为空时,这个字符编码全部找到。
(3)因为是由叶子结点,向根节点找,所以需要翻转编码后,才是正确的编码。
(4)将编码,赋给对应字符的结构体的编码变量中保存。
4.用获取到的字符编码重新改写原文件。
(1)因为编码本身就是二进制的,所以再次遍历原文件,将每个字符对应的编码读取出来,将每一个码对应的二进制数字,放入一个字节中。(如果存在这个字节放不下一个完整的编码,可以将剩下的部分,放入下一个字节)
(2)若一个字节放满了,先将这个字节写入压缩文件中,将这个字节所有比特位置为0,继续下面的放入。
(3)有可能存在,所有编码已经放完,但是最后一个字节没放满,这样再进行解压的时候会造成文件内容误差。只需将这个字节左移剩下还没写入的比特位即可。
5.自此就将文件压缩完成。但是如果压缩文件中只有压缩的数据,是没有办法解压缩的。因为我们要通过重构Huffman树来进行解压缩,所以还需将源文件的后缀、文件中字符出现的次数信息、包含字符信息行数(有几行内容放的是字符出现次数,与压缩数据区分开)一起写到压缩文件中,以便解压缩用。
举个例子:
这样文件才算完全压缩。
下面就是,对压缩文件进行解压
1.从压缩文件中获取字符信息、后缀。
2.重构Huffman树。
3.根据Huffman树、压缩数据进行解压缩。
文件解压完后,我们无法自己判断文件是否是无损解压缩,所以这就需要自己设计一个工具,或者借助第三方工具对比(推荐beyond compare工具)。
补充:打开文件要以二进制文件形式打开,因为二进制文件中可能会出现某一个字节里放的是0XFF,所以如果按照文本方式打开,那么我们压缩文件很有可能只操作了一半,所以不管是读/写都要用二进制格式进行。
项目源码:文件压缩