【实习题目】
文本文件的哈夫曼编码压缩实现
【问题描述】
哈夫曼编码是一种有效且可逆的编码方式。要求用哈夫曼编码方式实现对一个文本文件的压缩操作。
【基本要求】
1) 要求程序同时具有哈夫曼编码压缩和解压的功能模块。
2) 压缩模块:统计源文件中各字符出现频率,建立哈夫曼树,通过哈夫曼树求得各字符的哈夫曼编码,最后将哈夫曼树的数据和编码数据写入压缩文件。
3) 解压模块:从压缩文件中读取哈夫曼数据和编码后的数据,解码得到源文件。
【需求分析】
1) 考虑制作图形化界面。
2) 保证有压缩效果。即,生成的压缩文件应该比源文件体积小。
3) 解压缩后的文件应该与源文件完全一致。
4) 最好保留程序调试功能以便于检查错误。
【概要设计】
(本实验程序使用Delphi 2005编写)
设计程序界面如下:
http://student.csdn.net/attachment/201101/3/1392850_1294067190n5J5.png
“选择文件”按钮:选择磁盘上的文件,用于压缩或解压;
“压缩”按钮:对选定文件执行压缩操作;
- 包括统计文件中字符出现频率、建立哈夫曼树、获取字符编码以及写入文件四个执行步骤。
“解压”按钮:对选定文件执行解压操作;
- 包括检查压缩文件合法性、从文件读取哈夫曼编码、写入解码文件三个步骤。
“调试模式”复选框:让程序以调试模式运行;
- 正常模式下,执行完压缩或解压操作后程序将自动退出;调试模式下不立即退出,可执行检查哈夫曼树、查看编码等操作。
“退出”按钮:结束程序。
主要数据结构为二叉链表表示的哈夫曼二叉树,辅助数据结构有用于求字符频率的线性表等。
(1)
数据类型定义
type
HuffmanTree = ^HTNode;
HTNode = record
data: Integer;
dataC: Char;
lchild, rchild: HuffmanTree;
end;
// 二叉树。为了方便,字符型和整数型的数据域各设置了一个。
HuffmanElem = record
ch: Char;
freq: Integer;
code: string;
end;
HuffmanTableArr = Array of HuffmanElem;
HuffmanTable = record
data: HuffmanTableArr;
top: SmallInt;
end;
// 线性表。top域用于表中元素个数,即叶节点个数。
// 对每个元素而言,ch存放字符本身,freq存放字符在源文件中出现的频数,code存放其编码。
// 程序刚开始时此表为空。执行求频数操作后得ch和freq域的值,编码结束后才能得code域的值。
TZipForm = class(TForm)
...
end;
// TZipForm窗体类。为本程序运行的主窗体。具体定义略。
(2)
压缩模块
procedure enc_GetHuffmanFreq(F: TStrings; var H: HuffmanTable);
// 求字符串表F中各字符的出现频数,录入线性表H各元素的freq域中。
// 其中F从读入的文本文件中直接载入。
procedure enc_HuffmanCoding(var H: HuffmanTable; var T: HuffmanTree);
// 根据线性表H中已经求得的各字符出现频率,自下而上构建哈夫曼树T。
// 哈夫曼树的具体构造方法参见教材相关章节,这里不再赘述。
// 构建哈夫曼树T完成后,再利用此树进行深度优先搜索,获得各字符(叶节点)的哈夫曼编码。
procedure enc_WriteToFile(HFMFileName: string);
// 将哈夫曼树相关信息和编码后的文件数据写入以HFMFileName为文件名的文件中。
// 输入的信息包含本程序所生成的压缩文件独有标识,以便于解码时检验文件合法性。
procedure TZipForm.Button2Click(Sender: TObject); // Compress
// 主窗体上“压缩”按钮的单击事件。
// 在此处执行输入合法性检验、文件覆盖确认等操作处理,并通过源文件名获取HFMFileName。
// 若源文件名为abc.txt,则经处理之后得到HFMFileName为abc.hfm。
(3)
解压模块
procedure dec_DecodeHFMFile(HFMFileName: string; ExtractedFileName: string);
// 将文件HFMFileName解码,将解码后的信息写入文件ExtractedFileName中。
// 首先检查待解码文件中是否包含本程序所独有的标识。如有才能执行解码操作,否则提示错误。
// 本过程主要包含读取待解码文件中的哈夫曼树和解开哈夫曼编码两步操作。
(4)
编码文件格式
- 默认文件后缀名为.hfm
- 文件的前三个字节为’hfm’。用于检验此文件是否为合法生成的哈夫曼编码文件。
- 接下来两个字节是一个ShortInt整数,记录源文件的字符数量(以下用n表示)。
- 再以后是n组哈夫曼编码数据。每一组的构成为:原字符 + 编码长度 + 编码。其中编码每8位占据一个字节,多余位数用0填补。
- 然后是编码内容正文。每8位以一个字节的方式存储。同样的,文件末端多余位数补0。
- 文件的末尾一个字节为编码内容正文末端的补0数目。
【细节说明】
由于程序代码过长,这里不直接贴出源代码,仅将程序中一些细节上的实现作简单的解释如下:
(1)
文件合法性校验
点击压缩按钮或解压按钮时,会首先作文件合法性校验。
根据待压缩/待解压的文件的文件名生成压缩/解压的文件名之后,要首先检查程序执行目录下是否已经存在此文件名的文件。如有,则弹出对话框询问是否覆盖。只有用户选择允许覆盖之后,程序才能继续运行。
if FileExists(FileName) then
if MessageDlg('文件 "' + FileName + '" 已经存在,覆盖吗?', mtConfirmation, [mbYes, mbNo], 0) = mrNo then
Exit;
此段代码表示:假使以FileName为名的文件存在,则弹出一个包含Yes/No按钮的对话框。如果用户选择No则取消当前的压缩或解压操作。
对于解压模块而言,还需检验选择的待解压文件是否具有特定标识(即前三个字符为’hfm’),只有检验通过后,才能继续执行解压操作。
FReadStream.ReadBuffer(Buffer, 3);
if not ((Buffer[0] = 'h') and (Buffer[1] = 'f') and (Buffer[2] = 'm')) then
begin
ShowMessage('错误:此文件不是一个合法的hfm文件。');
Halt;
end;
Buffer是一个字符数组。此段代码从输入流读入三个字符存入Buffer中。如果检查出这三个字符不是’hfm’,则提示错误并中断程序运行。
(2)
输入输出的方法
为了提高效率,故不能使用文本文件(TextFile)的读写类型,改用文件流类型(TFileStream)。文件流的读写方法分别为:
procedure ReadBuffer(Buffer, Count: Integer);
function Read(Buffer, Count: Integer): Integer;
procedure WriteBuffer(Buffer, Count: Integer);
function Write(Buffer, Count: Integer): Integer;
其中,Buffer是未定义类型变量,通常使用字符数组(Array of Char)。Count则表示预计输入/输出的长度。函数Read和Write有返回值,其返回值为一个整数,表示实际输入/输出的字节数量。如果此返回值小于Count参数,则表示可能已经到达文件末尾。过程ReadBuffer和WriteBuffer则没有返回值。
然而在压缩模块中读入待压缩的文本文件时,使用字符串列表TStrings仍然比较方便。TStrings类的基础是一个以字符串为元素构成的数组,可用其LoadFromFile方法直接从文本文件中以字符串的形式读入每一行,操作较为简便。
虽然使用TString无法直接从字符串中读入回车符,但回车符的数量可由行数(TStrings的Count属性) 直接得到。
有全局变量:
var
FileSS: TStringList;
压缩模块刚开始执行时,用以下语句将输入文件(FileName为文件名)中的文本读入到字符串序列FileSS之中。
FileSS := TStringList.Create;
if FileExists(FileName) then
FileSS.LoadFromFile(FileName);
(3)
建立哈夫曼树的算法
procedure enc_HuffmanCoding(var H: HuffmanTable; var T: HuffmanTree);
type
LeafArr = Array of HuffmanTree;
// 记录叶节点的线性表类型
var
leaf: LeafArr;
i, k, lc, rc: Integer;
p: HuffmanTree;
s: string;
// 哈夫曼树的建树方法是一种“由下而上归并叶节点”的策略。指针p用于新建节点、整数lc和rc
// 则用于在叶数组leaf中选取值最小的两个用于归并。
// 叶数组leaf是动态更新的。每一次lc和rc归并后,lc用归并后的节点p替代,而rc标记为
// nil(空指针)。后面的搜索将无视所有已经标记为nil的数组元素。
// 执行归并的次数为(节点数-1),用for循环即可。。
procedure FindTwoMins(arr: LeafArr; var Min1: Integer; var Min2: Integer);
// 从LeafArr类数组arr中找出两个值最小的,将其下标存入Min1和Min2中。
procedure HuffmanEncode(T: HuffmanTree);
var
i: Integer;
begin
if (T^.lchild = nil) and (T^.rchild = nil) then
begin
for i := 0 to H.top do
if H.data[i].ch = T^.dataC then
begin
H.data[i].code := s;
Exit;
end;
end;
s := s + '0'; HuffmanEncode(T^.lchild); Delete(s, Length(s), 1);
s := s + '1'; HuffmanEncode(T^.rchild); Delete(s, Length(s), 1);
end;
// 深度优先建立哈夫曼树。S用于记录路径。向左子树搜索则添0,向右则添1。
// 当前节点深搜完成之后回溯,尝试另一条路径。
begin
SetLength(leaf, H.top+1);
for i := 0 to H.Top do
begin
New(leaf[i]);
leaf[i]^.lchild := nil; leaf[i]^.rchild := nil;
leaf[i]^.data := H.data[i].freq; leaf[i]^.dataC := H.data[i].ch;
end;
// 建立叶节点
for k := 0 to H.top-1 do
begin
FindTwoMins(leaf, lc, rc);
New(p);
p^.data := leaf[lc]^.data + leaf[rc]^.data;
p^.lchild := leaf[lc]; p^.rchild := leaf[rc];
leaf[lc] := p; leaf[rc] := nil;
end;
// 归并叶节点,并更新叶节点数组
T := leaf[lc];
s := '';
HuffmanEncode(T);
end;
(4)
使用哈希表
为了提高效率,编码和解码的时候,都需要用到哈希表。
将编码写入压缩文件时,要挨个扫描源文件中的字符,然后从哈夫曼编码表中找到字符对应的编码。由于字符是混乱无序的(如按照普通数组下标那样存储势必浪费存储空间),故采用哈希表。使用哈希函数,通过字符可以求得下标,通过下标又能很方便的访问编码字符串。这样就建立了字符与编码之间的对应关系。
解码时,在读取哈夫曼编码的同时,需构建通过编码可以直接得到相应字符的哈希函数,这样便能够比较容易的进行解码操作。
Delphi提供了一个TStringHash类,使用它可以直接建立一种比较简单的哈希表,其关键字是字符串、值为整数。这样一来,上面提到的编码用哈希表只需要建立线性表中字符与下标的对应关系,解码用哈希表只需要建立编码与相应字符的ASCII编码的对应关系即可。
TStringHash类主要有以下方法:
Procedure Add(Str: string, Int: Integer)
// 此过程可以在哈希表中建立Str与Int的对应关系。
Function ValueOf(Str: string)
// 此函数的返回值为哈希表中关键字Str所对应的整数值。若Str不对应任何值,则返回-1。
写入哈夫曼树之前,先通过记录各叶子节点的线性表来建立哈希表。
// 定义:
var
HuffmanHash: TStringHash;
// 建立:
HuffmanHash := TStringHash.Create;
for i := 0 to HT.top do
HuffmanHash.Add(HT.data[i].ch, GetSubscript(HT.data[i].ch));
HT是记录叶的线性表。其中GetSubscript函数用于在HT.data之中搜索获得相应字符的下标。
(5)
哈夫曼树录入压缩文件
只有将哈夫曼树数据成功写入压缩文件,此文件才能正常被解压。在“概要设计”的“编码文件格式”一节中已经描述了存储方式。其具体实现方法如下:
// --- 录入哈夫曼编码于压缩文件之中 ---
// var
//
i, j, CodeLen, ByteCount: Integer;
//
FOutStream: TFileStream;
//
TempStr: String;
//
c, CodeLenCh: Char;
// 以上为变量定义
for i := 0 to HT.top do
begin
FOutStream.WriteBuffer(HT.data[i].ch, Sizeof(Char));
// 先写入当前字符,即线性表中第i个元素的ch域。
CodeLen := Length(HT.data[i].code);
ByteCount := (CodeLen - 1) div 8 + 1;
// 以上两句分别求出该字符编码(0-1串)长度和存储此编码所需字节数。
CodeLenCh := Chr(CodeLen);
FOutStream.WriteBuffer(CodeLenCh, Sizeof(Char));
// 接下来,把该字符编码长度转换为字符型,并写入文件。
TempStr := HT.data[i].code;
for j := 0 to ByteCount-1 do
begin
c := Chr(Str01ToInt(Copy(TempStr, 1, 8)));
Delete(TempStr, 1, 8);
FOutStream.WriteBuffer(c, Sizeof(Char));
end;
// 最后,把编码按8位一组转换为字符的方式写入文件,末位用0补齐。
end;
其中用到的Str01ToInt子函数定义为:
function Str01ToInt(s: string): Integer;
使用此函数要求s为8位以内的0-1串。其功能为将0-1串末尾补零(若长度不足8位)后,总体看作二进制数以转换为十进制数,再在程序中转换为字符,以存入压缩文件中。
(6)
写入编码以及末尾补零处理
有了以上的准备,写入编码就相对比较容易了。逐行扫描(源文件生成的)字符串列表,利用哈希表获取当前字符的编码,写入文件就可以了。
可以用一个字符串变量作为缓冲区,每扫描一个源字符写入编码后,检查缓冲区长度是否大于8.若是,则不断截取其前8位转换为字符类型写入压缩文件,直到其长度小于8。可用while语句实现:
Str01 := Str01 + HT.data[HuffmanHash.ValueOf(FileSS[i][j])].code;
while Length(Str01) >= 8 do
begin
c := Chr(Str01ToInt(Copy(Str01, 1, 8)));
FOutStream.WriteBuffer(c, Sizeof(Char));
Delete(Str01, 1, 8);
end;
其中Str01为字符串缓冲区。i扫描行,j扫描每行的字符串的每个字符。并且注意到缓冲区更新的过程中已经使用到了事先建立的哈希表。
另外需要注意的问题是回车符的处理。由于采用字符串列表的读入方式,故无法直接读入回车符。解决办法是每读完一行(i值改变的时候),执行一次回车符的写入。当然还必须判断是否已经到达最后一行,如果是,就不需要写入回车符了。即:
for i := 0 to FileSS.Count-1 do
begin
// j循环处理当前行
if i <> FileSS.Count-1 then
begin
// 如果未读到最后一行,即i值不等于(行数-1),则写入一个回车符的编码
end;
end;
如果扫描完所有字符之后,字符串缓冲区不为空,则末尾必须补充若干个零才能将所有的编码信息转化为字符序列存储。相应处理方法为:
if Str01 <> '' then
begin
ZeroFill := 8 - Length(Str01);
c := Chr(Str01ToInt(Str01));
FOutStream.WriteBuffer(c, Sizeof(Char));
end
else
ZeroFill := 0;
FOutStream.WriteBuffer(ZeroFill, Sizeof(Byte));
整数变量ZeroFill记录下需要补零的个数,最后写入文件末尾。
上面已经提到Str01ToInt函数能够自行在末尾补零再转换为十进制数。
例如,如果最后缓冲区Str01 = ‘10011’,则Str01ToInt(Str01)的结果为(10011000)
2,即(152)
10。又因为对ZeroFill的赋值是在Str01ToInt执行之前,故ZeroFill最后的结果为3,表示成功补了3个零。
(7)
解码
解码的步骤包含:建立相应读写文件流、检验文件合法性、读取编码字符数目、依次读取各字符编码并建立哈希表、读取编码正文并写入目标文件(解压后的文件)。
程序中,整个解码过程由dec_DecodeHFMFile这个过程来完成。
解码必须得先读取哈夫曼树。包括读取编码的第4、5个字符(前三个字符为’hfm’),得到总编码数目,然后建立哈希表,用for循环依次读入每一个字符及其对应的编码存入哈希表中。
// 已经读入节点个数存放于整数变量NodeNum中
HashForDecode := TStringHash.Create;
for i := 1 to NodeNum do
begin
FReadStream.ReadBuffer(Buffer, 2);
CodeLen := Ord(Buffer[1]);
ByteCount := (CodeLen - 1) div 8 + 1;
Str := '';
for j := 1 to ByteCount do
begin
FReadStream.ReadBuffer(c, Sizeof(Char));
TempInt := Ord(c);
Str := Str + DecToBin(TempInt);
end;
Str := Copy(Str, 1, CodeLen);
HashForDecode.Add(Str, Ord(Buffer[0]));
end;
其中用到的的DecToBin函数:
function DecToBin(DecInt: Byte): string;
可以看作前面提及的Str01ToInt的反函数,其功能为将十进制数转化为0-1串。不足8位则高位补零。
哈希表建立完成之后,便可以开始解码。
同样建立一个字符串缓冲区,将压缩文件逐字节转换为0-1串读入。为了提高效率,一次读入三个字节。另外需要注意的是文件末尾判定。因为补零数存放于文件的最后一个字节,所以读入的时候需要随时检查是否已经到了文件尾部。由于要一次读入3个字节,我们定义一个字符数组Buffer。
type
TBuffer = Array [0..2] of Char;
var
Buffer: TBuffer;
现在我们要明确,TFileStream类有两个属性:Position和Size。前者标定当前的流指针指向的位置,后者标定流的大小。容易知道,对于输入流而言,每次执行Read方法读取n个字节,Position将自加n。而Size在把文件载入流的过程中就已经确定了。
我们用:
ReadCount := FReadStream.Read(Buffer, Sizeof(TBuffer));
读入字节。这样一来,读到文件尾部时候,会出现三种情况:
a. FReadStream.Position = FReadStream.Size - 1
此时可以确定,这一趟的三个字符读入之后,输入流中仅剩下最后一个字符。它就是ZeroFill。所以我们单独读入最终字符:
FReadStream.ReadBuffer(c, Sizeof(Char));
Zerofill := Ord(c);
成功得到补零数,再把Buffer[0]和Buffer[1]和Buffer[2]读入缓冲区并删除缓冲区的末ZeroFill位。
b. FReadStream.Position = FReadStream.Size
此时正好读完整个流。显然Buffer[2]就是ZeroFill。此时先把Buffer[0]和Buffer[1]读入缓冲区,再:
Zerofill := Ord(Buffer[2]);
即可,最后删除缓冲区的末ZeroFill位。
c. ReadCount < Sizeof(TBuffer)
此时ReadCount应等于2。Buffer[1]是ZeroFill。所以应先把Buffer[0]读入字符串缓冲区,再:
Zerofill := Ord(Buffer[1]);
即可,最后删除缓冲区的末ZeroFill位。
程序运行到最后,三种情况必择其一。以上任意一种情况执行后,都要执行Flag := False。
删除末ZeroFill位的语句:
Delete(Str, Length(Str) - Zerofill + 1, Zerofill);
while Flag do
begin
ReadCount := FReadStream.Read(Buffer, Sizeof(TBuffer));
if ReadCount < Sizeof(TBuffer) then
// 情况c
else if FReadStream.Position = FReadStream.Size then
// 情况b
else if FReadStream.Position = FReadStream.Size - 1 then
// 情况a
else
begin
Str := Str + DecToBin(Ord(Buffer[0])) + DecToBin(Ord(Buffer[1]))
+ DecToBin(Ord(Buffer[2]));
end;
i := 0;
while i < Length(Str) do
begin
Inc(i);
TempInt := HashForDecode.ValueOf(Copy(Str, 1, i));
// 尝试缓冲区的前i位。如果恰好是有编码的0-1串片段,则TempInt为相应的哈希函数值,
// 否则TempInt返回-1,继续尝试缓冲区的前i+1位。
if TempInt > -1 then
begin
c := Chr(TempInt);
if c = #13 then
FWriteStream.WriteBuffer(#13#10, 2)
// Delphi中要写入回车必须写#13#10而不能直接写入#13
else
FWriteStream.WriteBuffer(c, Sizeof(Char));
// 如果待写入的字符不是回车,则直接将c写入即可
Delete(Str, 1, i);
i := 0;
Continue;
// 删去已经解码完成的0-1串片段,并重新将i置零
end;
end;
end;
(8)
调试模式
为方便调试程序,特设立调试模式。如果在程序运行时选中调试模式复选框,将会激活一个新的程序窗体作为调试窗体,并在建立哈夫曼树和读取哈夫曼编码的同时,将读得的编码和字符写入该窗体的一个列表框组件中,程序运行完成后不会立即退出,只是不能继续进行压缩、解压等操作,但可以呼出调试窗体,检验建树或读取过程是否正确。
【调试分析】
测试数据为两个文本文件:
Alice in Wonderland.txt (141kb)
科大商家联盟第六期商家见面会会议材料.txt (280kb)
选定调试模式,压缩爱丽丝漫游仙境英文版,程序执行时间1秒左右。
http://student.csdn.net/attachment/201101/3/1392850_1294067191xy7x.png
得到Alice in Wonderland.hfm (79.9kb)
此时可呼出调试窗体查看哈夫曼编码。
http://student.csdn.net/attachment/201101/3/1392850_1294067191V0fZ.png
退出并重新进入程序,选择Alice in Wonderland.hfm进行解压,同样选中调试模式。
http://student.csdn.net/attachment/201101/3/1392850_1294067192UUZ4.png
解压耗时稍长。得到Alice in Wonderland_ext.txt (141kb),用记事本打开查看,与原文本文件完全一致。
呼出调试窗体查看读取编码结果:
http://student.csdn.net/attachment/201101/3/1392850_1294067193yx1L.png
科大商家联盟第六期商家见面会会议材料的测试结果也比较成功。压缩过后218kb。解压之后也与源文件完全一致。