基于Huffman算法和LZ77算法的文件压缩(六)
前面基于Huffman算法和LZ77算法的文件压缩(四)
基于Huffman算法和LZ77算法的文件压缩(五)
已经充分讲解LZ77到基本原理和实现细节。
本文开始讲解文件的压缩过程。
一、回顾整个压缩过程
1.打开带压缩的文件(注意:必须按照二进制格式打开,因为用户进行压缩的文件不确定)
2.获取文件大小,如果文件大小小于3个字节,则不进行压缩
3.读取一个窗口的数据,即64K,
4.用前两个字符计算第一个字符与其后两个字符构成字符串哈希地址的一部分,因为哈希地址是通过三个字节算出来的,先用前两个字节算出一部分,在压缩时,再结合第三个字节算出第一个字符串完整的哈希地址。
5.循环开始压缩
a.计算哈希地址,将该字符串首字符在窗口中的位置插入到哈希桶中,并返回该桶的状态matchHead
b.根据matchHead检测是否找到匹配
- 如果matchHead等于0,未找到匹配,表示该三个字符在前文中没有出现过,将该当前字符 作为源字符写到压缩文件中
- 如果matchHead不等于0,表示找到匹配,matchHead代表匹配链的首地址,从哈希桶matchHead位置开始找最长匹配,找到后用该(距离,长度对)替换该字符串写到压缩文件中,然后将该替换串三个字符一组添加到哈希表中。
6 . 如果窗口中的数据小于MIN_LOOKAHEAD时,将右窗口中数据搬移到左窗口,从文件中新读取一个窗口的数据放置到右窗,更新哈希表,继续压缩,直到压缩结束。
二、先封装一个哈希表
1.先给一个公共信息的文件
common.h
#pragma once
#include <iostream>
#include <string>
#include <assert.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
//记录程序中用到的常量数据
typedef unsigned char UCH;
typedef unsigned short USH;
typedef unsigned long long ULL;
const USH MIN_MATCH = 3; //最小匹配长度
const USH MAX_MATCH = 258; //最大匹配长度
const USH WSIZE = 32 * 1024; //32k
2.哈希表的整体框架
:
#pragma once
#include "Common.hpp"
class HashTable
{
public:
HashTable(USH size);
~HashTable();
//哈希表插入,
//matchHead为出参,带出整个匹配链的头,ch为匹配的字符,pos为
//在缓冲区当中的下标。hashAddr为哈希地址:出入输出型参数,
//因为计算本次哈希地址需要用到上一个哈希地址
void Insert(USH& matchHead, UCH ch, USH pos, USH& hashAddr);
//哈希函数
void HashFunc(USH& hashAddr, UCH ch);
//获取哈希表前一个匹配头
USH GetNext(USH matchHead);
//在处理大于64k的大文件时,需要把右窗中的文件拷贝到左窗,需要更新哈希表
void Update();
private:
//哈希函数相关
USH H_SHIFT();
private:
USH* prev_;
USH* head_;
};
3.哈希表的构造
:
HashTable::HashTable(USH size)
:prev_(new USH[size * 2]) //哈希表中存放的是索引字符串的首地址
//(距离字符串开始的相对下标)
, head_(prev_ + size)
{
memset(prev_, 0, size * 2 * sizeof(USH));//初始化为0,0也代表当前匹配是
//链表的末尾(相当于用数组模拟的链表)
}
4.哈希表的析构函数
:
HashTable::~HashTable()
{
delete[] prev_;
prev_ = nullptr;
}
5.定义哈希函数相关变量
:
const USH HASH_BITS = 15; //哈希地址15位
const USH HASH_SIZE = (1 << HASH_BITS); //哈希地址个数 32K
const USH HASH_MASK = HASH_SIZE - 1; //防止溢出 低15位全1,因为prev的大小就WSIZE,而当start到达右窗
//时,下标明显大于WSIZE,如果不处理就会下标越界
6.哈希函数相关
:
//abcdefgh字符串
//hashaddr1:abc
//hashaddr2:bcd
//hashAddr:前一次计算出的哈希地址 abc
//本次需要计算bcd哈希地址
//ch:本次匹配三个字符中的最后一个
//本次哈希地址是在上一次哈希地址的基础上计算出来的
void HashTable::HashFunc(USH& hashAddr, UCH ch)
{
//hashAddr是输入,输出型参数,ch是所查找字符串中第一个字符
hashAddr = (((hashAddr) << H_SHIFT()) ^ (ch)) & HASH_MASK;
}
USH HashTable::H_SHIFT()
{
return (HASH_BITS + MIN_MATCH - 1) / MIN_MATCH; //5
}
7.向哈希表中插入元素
:
//matchHead:匹配链的头
//ch:查找字符串的第三个字符(也就是最后一个)
//pos:查找字符串的头到字符串开始的距离
//hashAddr:输入时是上一次的哈希地址,输出时是本次哈希地址
void HashTable::Insert(USH& matchHead, UCH ch, USH pos, USH& hashAddr)
{
HashFunc(hashAddr, ch); //获取本次插入的哈希地址
matchHead = head_[hashAddr];//找当前三个字符在查找缓冲区中找到的最近的个,
//即匹配链的头,将来会用这个头来找匹配
//将新的哈希地址插入链表
//pos & HASH_MASK的目的是防止越界
prev_[pos & HASH_MASK] = head_[hashAddr];
head_[hashAddr] = pos;
}
三、开始压缩
1.整体框架
#pragma once
#include "HashTable.h"
class LZ77
{
public:
LZ77();
~LZ77();
void CompressFile(const std::string& strFilePath);
void UNCompressFile(const std::string& strFilePath);
private:
//找最长匹配
USH LongestMatch(USH matchHead, USH& curMatchDist, USH start);
//写标记文件
void WriteFlag(FILE* fOUT, UCH& chFlag, UCH& bitCount, bool isLen);
//合并压缩数据文件和标记信息文件
void MergeFile(FILE* fOut, ULL fileSize);
//在处理大于64k文件时,需要重新填充缓冲区的右窗口
void FillWindow(FILE* fIn, size_t& lookAhead, USH& start);
//获取文件的后缀
std::string GetStr(std::string filename);
private:
//用来保存待压缩数据的缓冲区即滑动窗口
UCH* pWin_;
//哈希表
HashTable ht_;
};
2.构造函数
:
LZ77::LZ77()
:pWin_(new UCH[WSIZE * 2])//初始化缓冲区的大小
,ht_(WSIZE) //初始化hash表大小
{}
3.析构函数
:
LZ77::~LZ77()
{
delete[] pWin_;
pWin_ = nullptr;
}
3.根据MIN_LOCKAHEAD来过滤文件,如果文件大小小于MIN_LOCKAHEAD则不进行匹配
。
- 注意文件的打开方式,和Huffman压缩的原理一样,为了保证压缩算法的通用性,既可以压缩文本文件也可以压缩二进制文件,需要以
rb
方式打开 - 如何获取文件大小??
结合fseek函数和ftell函数来获取文件大小
FILE* fIn = fopen(strFilePath.c_str(), "rb");
if (nullptr == fIn)
{
std::cout << "打开文件失败" << std::endl;
return;
}
//获取文件大小
fseek(fIn, 0, SEEK_END);//把文件指针移动到文件的末尾
ULL fileSize = ftell(fIn);//获取当前文件指针到文件开始位置的偏移量
//1. 如果源文件的大小小于最小匹配长度 MIN_MATCH,则不进行处理,文件太小去进行压缩反而达不到压缩的目的
if (fileSize <= MIN_MATCH)
{
//此处是小于3个字符,在common.hpp文件中定义的
std::cout << "文件太小,不压缩" << std::endl;
return;
}
4.从压缩文件中读取一个缓冲区的数据到窗口中
- 注意在计算文件大小的时候已经把文件指针移动到文件的末尾,所以此处要把文件指针重新定位
//从压缩文件中读取一个缓冲区的数据到窗口中
//上面把文件指针移动到文件末尾,还得移回来,
//否则读不到任何数据
fseek(fIn, 0, SEEK_SET);
//表示先行缓冲区中剩余的字节数
size_t lookAhead = fread(pWin_, 1, 2 * WSIZE, fIn);
5 .先计算最开始两个字符的哈希地址
因为哈希地址是根据前面的字符的哈希地址求的
,所以要先计算前两个字符的哈希地址。如abcdefg……,那么满3个字符才可以进行匹配,所以需要先计算ab的哈希地址才可以计算c的哈希地址,然后进行匹配
USH hashAddr = 0;//记录哈希地址
//设置起始hashAddr (前两个字符的hash地址)
for (USH i = 0; i < MIN_MATCH - 1; ++i)
{
ht_.HashFunc(hashAddr,pWin_[i]);
}
6.循环进行压缩
- 注意匹配链头matchHead的含义。如果为0,代表没找到匹配,因为哈希表初始化的时候都为0
- 那么如果找到匹配,就顺着匹配链往下
找最长匹配
- 封装一个
LongestMatch
函数来进行查找最长匹配 - 在找最长匹配LongestMatch的时候需要获得当前匹配链的下一个匹配头
//获取当前匹配头的下一个匹配头(如果为0代表匹配结束)
USH HashTable::GetNext(USH matchHead)
{
return prev_[matchHead & HASH_MASK];
}
- 找到最长匹配就需要
返回长度距离信息
//在找的过程中,需要将每次找到的匹配结果进行比较,保持最长匹配
USH LZ77::LongestMatch(USH matchHead, USH& MatchDist, USH start)
{
//找最长匹配
USH curMatchLen = 0; //一次匹配的长度
USH maxMatchLen = 0;
UCH maxMatchCount = 255; //最大的匹配次数,解决环状链
USH curMatchStart = 0; //当前匹配在查找缓冲区中的起始位置
//在先行缓冲区中查找匹配时,不能太远即不能超过MAX_DIST
USH limit = start > MAX_DIST ? start - MAX_DIST : 0;
do {
//字符串在先行缓冲区匹配范围
UCH* pstart = pWin_ + start;
UCH* pend = pstart + MAX_MATCH;
//从查找缓冲区匹配串的起始开始进行字符比较
UCH* pMatchStart = pWin_ + matchHead;
curMatchLen = 0;
//可以进行本次匹配
while (pstart < pend && *pstart == *pMatchStart)
{
//更新变量
++curMatchLen;
++pstart;
++pMatchStart;
}
//一次匹配结束
if (curMatchLen > maxMatchLen)
{
maxMatchLen = curMatchLen;
//记录当前匹配的开始位置,方便后面计算匹配距离
curMatchStart = matchHead;
}
} while ((matchHead = ht_.GetNext(matchHead)) > limit
&& maxMatchCount--);
MatchDist = start - curMatchStart;
return maxMatchLen;
}
-
while ((matchHead = ht_.GetNext(matchHead)) > limit && maxMatchCount--)
:matchHead代表下一个匹配头即在缓冲区当中的下标。 -
USH limit = start > MAX_DIST ? start - MAX_DIST
: 0;代表如果start > MAX_DIST就从start - MAX_DIST找匹配头,否则就从开始找匹配头 -
matchHead = ht_.GetNext(matchHead)) > limit
代表在limit的范围内进行找匹配,太远就不进行匹配 -
maxMatchCount--是为了解决&WMASK带来死循环的问题及太远不进行匹配
-
执行找最长匹配后,需要验证是否成功找到匹配,因为matchHead可能为0,就没有执行找最长匹配函数
-
前面我们说过最大匹配长度为[0,258],如果用一个字节来记录是有问题的,需要用两个字节来保存,而写入文件的时候又不能写入两个字节,所以在写入长度的时候临时定义一个字节变量保存原来用两个字节来保存长度的变量-3,最后把一个字节的临时变量写入当压缩文件当中,这样就解决问题了
-
如果没找到匹配就需要把当前字符写入到压缩文件当中,如果找到最长匹配,就需要写入长度距离信息到压缩文件当中(
注意写长度的时候要把长度-3
),但是压缩数据和长度距离对信息无法区分 -
所以还要写入标记信息:
用0标记原字符,1标记长度
(距离不用标记,下两个字节就是,) -
但是
标记信息和压缩数据应该保存在不同的文件当中
,无法一边写压缩数据一边写标记信息,那样无法区分
//chFlag:该字节中的每个比特位是用来区分当前字节是原字符还是长度?
//0:原字符
//1:长度
//bitCount:该字节中的多少个比特位已经被设置
//isCharOrLen:代表该字节是源字符还是长度,判断压缩数据还是长度信息
void LZ77::WriteFlag(FILE* fOUT, UCH& chFlag, UCH& bitCount, bool isLen)
{
chFlag <<= 1;
if (isLen)//如果是长度,就给当前的比特位置为1
chFlag |= 1;
bitCount++;
if (bitCount == 8)
{
//代表chFlag8位已经设置完,将该标记写入到压缩文件中
fputc(chFlag, fOUT);
//重新开始
chFlag = 0;
bitCount = 0;
}
}
- 注意如果找到匹配,在执行写入长度距离信息后,还需要把匹配的字符写入到哈希表当中,否则无法进行下一次匹配(匹配的长度那么多的字符是不进行下一次匹配的,因为已经被替换掉),如abcdefg,如果abc找到匹配,把长度距离信息写完后
还要把bcd、cde写入到哈希表当中
,下一次直接从d开始找匹配,而又因为abc在之前插入的时候已经写过了,此处就不用写。
7.压缩文件完整代码
//压缩文件
void LZ77 :: CompressFile(const std::string& strFilePath)
{
FILE* fIn = fopen(strFilePath.c_str(), "rb");
if (nullptr == fIn)
{
std::cout << "打开文件失败" << std::endl;
return;
}
//获取文件大小
fseek(fIn, 0, SEEK_END);//把文件指针移动到文件的末尾
ULL fileSize = ftell(fIn);//获取当前文件指针到文件开始位置的偏移量
//1. 如果源文件的大小小于最小匹配长度 MIN_MATCH,则不进行处理,文件太小去进行压缩反而达不到压缩的目的
if (fileSize <= MIN_MATCH)
{
//此处是小于3个字符,在common.hpp文件中定义的
std::cout << "文件太小,不压缩" << std::endl;
return;
}
//从压缩文件中读取一个缓冲区的数据到窗口中
fseek(fIn, 0, SEEK_SET);//上面把文件指针移动到文件末尾,还得移回来,否则读不到任何数据
size_t lookAhead = fread(pWin_, 1, 2 * WSIZE, fIn);//表示先行缓冲区中剩余的字节数
USH hashAddr = 0;//记录哈希地址
//设置起始hashAddr (前两个字符的hash地址)
for (USH i = 0; i < MIN_MATCH - 1; ++i)
{
ht_.HashFunc(hashAddr,pWin_[i]);
}
//开始写压缩数据:
//根据获取的文件后缀打开一个同样文件后缀的压缩数据存储文件
//std::string str = GetStr(strFilePath);
//FILE* ffIn = fopen("5.txt","wb");
//fwrite(str.c_str(),sizeof(str),1,ffIn);
//fclose(ffIn);
//压缩
FILE* fOUT = fopen("2.lzp", "wb");
assert(fOUT);
USH start = 0;//查找字符串在缓冲区的地址,随着压缩的不断进行,start不断的在先行缓冲区中增加
//与查找最长匹配相关的变量
USH matchHead = 0; //匹配字符串的头
USH curMatchLength = 0; //一次匹配字符串的长度
USH curMatchDist = 0; //一次匹配字符串的距离
//与写标记相关的变量
UCH chFlag = 0;//代表当前字符是字符还是长度
UCH bitCount = 0;//代表1个字节已经用了多少字节
//写标记的文件
FILE* fOutF = fopen("3.txt", "wb");
assert(fOutF);
//lookAhead表示先行缓冲区中剩余字节的个数
while (lookAhead)
{
//1.将第三个字符插入到哈希表中,因为前两个字符已经插入到哈希表当中,
//(pWin_[start],pWin_[satrt + 1].pWin_[start + 2])并获取匹配链的头
ht_.Insert(matchHead, pWin_[start + 2], start, hashAddr);
//因为不只进行一此匹配,每次匹配前都要置为0,防止影响后面的数据
curMatchLength = 0;
curMatchDist = 0;
//2.验证在查找缓冲区中是否找到匹配,如果有匹配,找最长匹配
//因为在初始化哈希表的时候都设置为0,代表没有任何匹配,如果不为0,代表有匹配
if (matchHead)
{
//顺着匹配链找最长匹配,最终带出<长度,距离>对
curMatchLength = LongestMatch(matchHead, curMatchDist, start);
}
//3.验证是否找到匹配
if (curMatchLength < MIN_MATCH)
{
//在查找缓冲区中未找到重复字符串
//将start位置的字符写入到压缩文件中
fputc(pWin_[start], fOUT); //写字符
//写当前原字符对应的标记
WriteFlag(fOutF, chFlag, bitCount, false);//写标记
//更新变量
++ start;
lookAhead--;
}
else
{
//找到匹配
//将《长度,距离》对写入压缩文件中
//写长度
UCH chLen = curMatchLength - 3;//因为长度是1个字节,其范围本来是
//[0,255],但是因为是3个一组,所以其范围是[3,258]
fputc(chLen, fOUT);
//写距离
fwrite(&curMatchDist, sizeof(curMatchDist), 1, fOUT);
//写当前原字符对应的标记
WriteFlag(fOutF, chFlag, bitCount, true);
//更新先行缓冲区中剩余的字节数
lookAhead -= curMatchLength;
//将已经匹配的字符串按照三个一组将其插入到哈希表中
--curMatchLength; //当前字符串已经插入
++start;
while (curMatchLength)
{
ht_.Insert(matchHead, pWin_[start + 2], start, hashAddr);
--curMatchLength;
++start;
}
}
//检测先行缓冲区中剩余字符个数,如果小于最短匹配长度,
//需要更新缓冲区和哈希表,向缓冲区中重新填充内容,
if (lookAhead <= MIN_LOOKAHEAD)
FillWindow(fIn, lookAhead, start);
}
//标记位数如果不够八位,因为写标记是8位写一次,最后可能
//不够8bit,所以要另外判断
if (bitCount > 0 && bitCount < 8)
{
chFlag <<= (8 - bitCount);
fputc(chFlag, fOutF);
}
fclose(fOutF);
//把压缩数据文件和标记信息文件合并
MergeFile(fOUT, fileSize);
//删除标记信息文件
remove("./3.txt");
fclose(fIn);
fclose(fOUT);
}
到这里文件压缩过程基本结束了,还有大文件处理方式在后面解决