霍夫曼编码及在windows下的两个坑
前言
最近学到霍夫曼编码,算法并不难,只是比较复杂。
压缩需要三步:
-
构造霍夫曼树
-
将树写入文件
-
使用霍夫曼树编码数据并写入文件
解压需要两步:
-
读取霍夫曼树,保存在文件开始处
-
使用树解码其他字节流
在windows下遇到两个坑
-
导致莫名其妙的解压失败,
-
导致原文件与解压文件不相同。
一、霍夫曼压缩
这是一位天才发明的算法,打破固定位长字符存储方法,改用根据字符频率重新编码,高频字符短位,低频长位,用以压缩文件,不限于文本。
1.构造霍夫曼树
这是一颗霍夫曼树,其对应文本是:
it was the best of times it was the worst of times
每个字母都对应唯一一个二进制数字,这是一个天才的构造方式,字母只在叶子节点,从root出发,所有左节点是 0,右节点是 1,因此除了节点的子节点,无法找到含有此节点的二进制数,而字母都在叶子节点,无子节点,所以没有字母对应的二进制数是其他任何字母的前缀。
我们通过递推的方法,根据字母频率构建节点,让频率最小的两个节点组成一个大节点,此大节点的频率是两个子节点频率的和,一直递推构造,直至成为霍夫曼树。
对应代码:
//构建霍夫曼树
static auto buildTrie(const std::vector<int32_t> &freq) -> Node *
{
std::priority_queue<Node *, std::vector<Node *>,
std::function<bool(Node *, Node *)>>
pq(greatNode);
for (int32_t c = 0; c != alphaSize; ++c)
{
if (freq[c] != 0)
{
pq.push(
new Node(static_cast<char>(c), freq[c], nullptr, nullptr));
}
}
while (pq.size() > 1)
{
Node *x = pq.top();
pq.pop();
Node *y = pq.top();
pq.pop();
Node *parent = new Node('\0', x->freq + y->freq, x, y);
pq.push(parent);
}
return pq.top();
}
2.根据霍夫曼树构建编码表
有了树,就有了编码,现在需要将编码和字符对应起来,我们通过将每个 char 映射为下标,对应 vector 中的 “0101” 类似这种表示二进制的编码。
对应代码:
//构建每个字母对应的二进制字符编码表
auto buildCode(Node *root) -> std::vector<std::string>
{
std::vector<std::string> encodeTable(alphaSize);
buildCode(encodeTable, root, "");
return encodeTable;
}
//构建每个字母对应的二进制字符编码表
void buildCode(std::vector<std::string> &encodeTable, Node *x,
const std::string &codeStr)
{
if (x->isLeaf())
{
encodeTable[static_cast<unsigned char>(x->ch)] = codeStr;
return;
}
buildCode(encodeTable, x->left, codeStr + '0');
buildCode(encodeTable, x->right, codeStr + '1');
}
3.将霍夫曼树以二进制方式写入文件(读取解压时需要)
遍历霍夫曼树,对于非叶子节点,输出 0,对于叶子节点,输出 1 后接字符原本的二进制编码。
对应代码
//将霍夫曼树以二进制方法写入标准输出
void writeTrie(Node *x)
{
if (x->isLeaf())
{
BIO::bout.writeBool(true);
BIO::bout.writeChar(x->ch);
return;
}
BIO::bout.writeBool(false);
writeTrie(x->left);
writeTrie(x->right);
}
4.将字符数量值以二进制写入文本
这个就不放代码了。
5.再次读取文本,编码为二进制写入文件
现在每个字母都能对应 “0110” 这种二进制值,我们读取每个字母,进行转换,将二进制值写入文件。
//根据字符编码表和输入字符串进行压缩。
static void zip(const std::vector<std::string> &encodeTable,
const std::vector<char> &input)
{
for (char i : input)
{
std::string code = encodeTable[static_cast<unsigned char>(i)];
for (char j : code)
{
if (j == '1')
{
BIO::bout.writeBool(true);
}
else
{
BIO::bout.writeBool(false);
}
}
}
}
二、解压霍夫曼二进制文件
解压就是压缩的反过程,理解了压缩,解压就比较简单了。
1.读取霍夫曼树
//从标准输入以二进制方法读入霍夫曼树
auto readTrie() -> Node *
{
bool bl;
if (BIO::bin.readBool(bl) && bl)
{
char ch;
BIO::bin.readChar(ch);
return new Node(ch, 0, nullptr, nullptr);
}
Node *left = readTrie();
Node *right = readTrie();
return new Node('\0', 0, left, right);
}
2.读取字母数量
不放代码了。
3.根据霍夫曼树还原文本
for (int64_t i = 0; i != charNum; ++i)
{
x = expRoot;
while (!x->isLeaf() && BIO::bin.readBool(bl))
{
if (bl)
{
x = x->right;
}
else
{
x = x->left;
}
}
BIO::bout.writeChar(x->ch);
}
三、Windows的两个坑
在Windows环境编程,坑不少,但如此CD的,不多。
1.莫名奇妙的解压错误
当压缩正确,但解压莫名奇妙的只能解压一部分,不同的文本解压的完整度不同,但相同的文本都是断在一个地方。
一开始我是觉得我的二进制读写代码出问题,改了多次,没有任何逻辑问题,于是怀疑二进制文件问题。
用C++最原始的函数进行拷贝,也是只能拷贝一部分,但问题找到了:“ 1A ” ,这是 2 个十六进制数,代表一个字节,这个字节在Linux和Unix系统都很普通,且在文本文档中,不存在。
但是经霍夫曼压缩的二进制文件,可能出现这个字节,它在Windows的意思是,文档结束符号。标准输入碰到此字节直接 eof。
这个坑可以说是上世纪的遗留bug,具体原因不知,和系统有关,经 StackOverflow 证实,确实如此。
2.解压后与源文件不同
如果是文本文档,不一定能看出来,但二进制分析,和源文件确实不同,换成非文本文件,容易摸不着头脑。
很简单,就是当你写入 ‘\n’,结果系统帮你改成 ‘\r\n’,凭空多了一个字符。
在文本文档不成问题,都是换行,其他文件就问题大了。
3.解决方法
具体解决方案很简单,用 fstream 方式二进制读取和写入。
但我的程序架构是用标准输入输出进行管道处理,如此一来,就无法完成了。
不得已,还得用微软的东西补窟窿,更改 cin cout 的读写模式:
#ifdef _WIN64
#include <fcntl.h>
#include <io.h>
#endif
//使用标准输入前
#ifdef _WIN64
int result = _setmode(_fileno(stdin), _O_BINARY);
result = _setmode(_fileno(stdout), _O_BINARY);
#endif
//使用标准输入后
#ifdef _WIN64
result = _setmode(_fileno(stdin), _O_TEXT);
result = _setmode(_fileno(stdout), _O_TEXT);
#endif
这个bug藏得很深。
总结
霍夫曼编码是一个天才的发明,理解简单,实现容易,但防不住 Windows 的坑,就是程序员的问题了。
以下是多次改造后的二进制读写类,比前几个版本更 C++ 了,效率低了点,因为 buffer 是 1 字节。
改成 8 字节比较合适,毕竟 64 位系统,一次读 8 字节比较快。
#ifndef BINSTDIO
#define BINSTDIO
#include <bitset>
#include <cassert>
#include <cstdio>
#include <iostream>
#include <string>
namespace BIO
{
struct binstream
{
//不允许进行构造
binstream(const binstream &) = delete;
//不允许进行构造
binstream(binstream &&) = delete;
//不允许进行拷贝
auto operator=(const binstream &) -> binstream & = delete;
//不允许进行拷贝
auto operator=(binstream &&) -> binstream & = delete;
//返回局部静态变量
static auto getbin() -> binstream &
{
static binstream bin;
return bin;
}
//比特流是否为空
inline auto isEmpty() -> bool
{
return !std::cin.good();
}
//读取1位数据并返回一个bool值
auto readBool(bool &bit) -> bool
{
if (N == 0)
{
fillBuffer();
}
bit = inbuffer[--N];
return !isEmpty();
}
//读取8位数据并返回一个char值
auto readChar(char &ch) -> bool
{
if (N == buffersize)
{
auto x = inbuffer;
fillBuffer();
ch = static_cast<char>(x.to_ulong());
return true;
}
if (N >= 0)
{
auto x = inbuffer;
x <<= (buffersize - N);
int oldN = N;
fillBuffer();
N = oldN;
x |= (inbuffer >> N);
ch = static_cast<char>(x.to_ulong());
return !isEmpty();
}
return false;
}
//读取r(1~16)位数据并返回一个char值
auto readChar(char &ch, int r) -> bool
{
if (r < 1 || r > buffersize)
{
return false;
}
if (r == buffersize)
{
return readChar(ch);
}
char x = 0;
for (int i = 0; i != r; ++i)
{
x <<= 1;
bool bit;
readBool(bit);
if (bit)
{
x |= 1;
}
}
ch = x;
return !isEmpty();
}
//读取int值
auto readInt(int &num) -> bool
{
int x = 0;
char ch;
for (int i = 0; i != 4; i++)
{
readChar(ch);
x <<= buffersize;
x |= ch;
}
num = x;
return !isEmpty();
}
//读取int64
auto readInt64(int64_t &num) -> bool
{
int64_t x = 0;
char ch;
for (int i = 0; i != 8; i++)
{
readChar(ch);
x <<= buffersize;
x |= ch;
}
num = x;
return !isEmpty();
}
//关闭比特流
void close()
{
std::cin.setstate(std::ios::eofbit);
}
//恢复流状态
void clear()
{
std::cin.clear();
}
private:
//私有化默认构造,用于单例模式
binstream() = default;
//读流到缓冲区
void fillBuffer()
{
char ch;
// if (fread(&ch, 1, 1, stdin) != 0U)
if (std::cin.read(&ch, 1))
{
inbuffer = ch;
N = buffersize;
}
else
{
// inbuffer = -1;
N = -1;
}
}
static constexpr int buffersize = 8;
//缓冲区
std::bitset<buffersize> inbuffer = 0;
//缓冲区比特指针
int N = 0;
};
auto &bin = BIO::binstream::getbin();
struct boutstream
{
boutstream(const boutstream &) = delete;
boutstream(boutstream &&) = delete;
auto operator=(const boutstream &) -> boutstream & = delete;
auto operator=(boutstream &&) -> boutstream & = delete;
//取得局部静态变量
static auto getbout() -> boutstream &
{
static boutstream bout;
return bout;
}
//写入指定的比特
void writeBool(bool bit)
{
outbuffer <<= 1;
if (bit)
{
outbuffer[0] = true;
}
N++;
if (N == buffersize)
{
char ch = static_cast<char>(outbuffer.to_ulong());
std::cout.write(&ch, 1);
std::cout.flush();
N = 0;
outbuffer = 0;
}
}
//写入指定的8位字符
void writeChar(char ch)
{
if (N == 0)
{
std::cout.write(&ch, 1);
return;
}
std::bitset<buffersize> bf = ch;
for (int i = buffersize - 1; i != -1; --i)
{
writeBool(bf[i]);
}
}
//写入指定字符的r(1~8)位
void writeChar(char ch, int r)
{
if (r == buffersize)
{
writeChar(ch);
return;
}
if (r < 1 || r > buffersize)
{
return;
}
std::bitset<buffersize> bf = ch;
for (int i = r - 1; i != -1; --i)
{
writeBool(bf[i]);
}
}
//写入int值
void writeInt(int32_t x)
{
writeChar(static_cast<char>(x >> 24));
writeChar(static_cast<char>(x >> 16));
writeChar(static_cast<char>(x >> 8));
writeChar(static_cast<char>(x >> 0));
}
//写入int64类型值
void writeInt64(int64_t x)
{
writeChar(static_cast<char>(x >> 56));
writeChar(static_cast<char>(x >> 48));
writeChar(static_cast<char>(x >> 40));
writeChar(static_cast<char>(x >> 32));
writeChar(static_cast<char>(x >> 24));
writeChar(static_cast<char>(x >> 16));
writeChar(static_cast<char>(x >> 8));
writeChar(static_cast<char>(x >> 0));
}
//关闭比特流, 刷新缓冲流
void close()
{
clearBuffer();
std::cout.flush();
}
private:
boutstream() = default;
void clearBuffer()
{
if (N <= 0)
{
return;
}
if (N > 0)
{
outbuffer <<= (buffersize - N);
}
char ch = static_cast<char>(outbuffer.to_ulong());
std::cout.write(&ch, 1);
N = 0;
outbuffer = 0;
}
static constexpr int buffersize = 8;
//缓冲区
std::bitset<buffersize> outbuffer = 0;
//缓冲区比特指针
int N = 0;
};
auto &bout = BIO::boutstream::getbout();
inline void BinaryDump(char *argv[])
{
int width = std::stoi(argv[1]);
if (width == 0)
{
return;
}
int cnt = 0;
bool bl;
while (bin.readBool(bl))
{
if (cnt != 0 && cnt % width == 0)
{
std::cout << '\n';
}
if (bl)
{
std::cout << '1';
}
else
{
std::cout << '0';
}
++cnt;
}
std::cout << std::endl;
std::cout << cnt << " bits" << std::endl;
}
} // namespace BIO
#endif