压缩算法 Compress
压缩算法
不存在能够压缩任意比特流的算法
归谬法论证:如果该算法存在,由于比特流为离散值,压缩后的比特流至少比原比特流少一个比特,最终可以是任意小的,这显然是荒谬的
小规模字母表
ASCII 字符由 7 位表示,如果所使用的字符种类不足 65 种就可以用少于 7 位的比特记录每个字符,只需要添加一个字符映射表。
例子:碱基序列 ACTG
较长的连续相同位或字符
游程编码:用连续的 0 或 1 的长度作为编码,广泛用于位图中。
频繁使用的字符
霍夫曼编码(赫夫曼编码):属于一种变长编码,为输入中的定长模式产生变长的编码编译表。用较少的比特表示使用频繁的字符,较多的比特表示使用频率低的字符。为了保持解压缩的唯一性,需要使用非前缀码,即任何字符的编码都不会成为其他字符编码的前缀。其本质为一种贪心算法。
前缀树
非前缀码可以用前缀树来表示,只需要所有字符都处于树的叶子节点即可,处理后的前缀树将会是一颗满二叉树。
在该树中,左右子树表示 0 或 1,从根节点到叶节点的路径就是一串比特,与在叶节点存储的字符构成一对映射,其路径长度即深度为这串比特的长度。
构造前缀树的方法比较简单:
- 将每个符号及其出现次数作为一个对,构成一个节点;
- 将节点看作一个只有一个元素的子树,则产生一个森林,开始构造
- 选择出现次数最少的两个子树,作为新建节点的两个子树,新子树的出现次数为左右子树的出现次数的和,森林中的子树数量减一;
- 重复上一步直到森林中只有一个子树。
下列给出前缀树的构造算法,只需关注 class Tree
的构造算法 Tree(const string&)
class Tree
{
class Node
{
public:
Node(char c, int freq)
: left_(nullptr), right_(nullptr), freq_(freq), c_(c) {}
Node(Node *left, Node *right, int freq)
: left_(left), right_(right), freq_(freq), c_(0) {}
~Node()
{
// 递归删除节点
if (left_ != nullptr) {
delete left_;
left_ = nullptr;
}
if (right_ != nullptr) {
delete right_;
right_ = nullptr;
}
c_ = 0;
freq_ = 0;
}
bool is_leaf() const
{ return left_ == nullptr && right_ == nullptr; }
Node *left() const { return left_; }
Node *right() const { return right_; }
int freq() const { return freq_; }
char c() const { return c_; }
private:
Node *left_, *right_;
int freq_;
char c_;
};
public:
Tree(const std::string &s)
{
// 假设只使用ASCII
constexpr size_t R = 128UL;
std::array<size_t, R> char_counts{};
for (char c : s) ++char_counts[c];
// 以freq为关键字的最小堆
PriorityQueue<Node *> pq([](Node *lhs, Node *rhs)
{ return lhs->freq() > rhs->freq(); });
for (size_t c = 0; c != R; ++c) {
if (char_counts[c] != 0)
pq.insert(new Node(c, char_counts[c]));
}
// 将频率最小的子树作为新节点的左右子树,森林的树-1
while (pq.size() > 1) {
Node *lhs = pq.delete_head();
Node *rhs = pq.delete_head();
pq.insert(new Node(lhs, rhs, lhs->freq() + rhs->freq()));
}
// 若字符串为空串,root为空树
root_ = pq.empty() ? new Node(nullptr, nullptr, 0) : pq.delete_head();
}
~Tree()
{
if (root_ != nullptr) {
delete root_;
root_ = nullptr;
}
}
private:
Node *root_;
};
证明:霍夫曼算法构建的前缀码是最优的(所得前缀树加权路径和最小)
设有 \(r\) 个节点,将节点字符、高度和频数设为 \(s_i,d_i,f_i(i=1,2,\dots,r)\),该应用该符号集对原字符串的输出为 \(T\),长度为 \(W(T)=\sum_{i=1}^r d_i \cdot f_i\)。
-
命题 1:最优前缀码必定对应一个满二叉树
逆否命题:不满的二叉树不可能对应一个最优的前缀码。
二叉树不满说明存在非叶节点只有一个子树
- 节点为根节点:将根节点删除,子树作为根节点可以使树中所有路径减一;
- 节点不为根节点:将该节点唯一的子树连接到其上级节点,删除该节点可以使子树中所有路径减一。
两种方法都可以使路径减少,最终加权路径和减小,故不是最优的前缀码。
-
命题 2:在最优前缀码中,频率最低的字符的深度最大,深度最大的字符的频率最低
若存在两个节点 \((s_i,d_i,f_i)\) 为频率最低的节点,\((s_j,d_j,f_j)\) 为深度最大的节点,则有 \(f_i \le f_j, d_i \le d_j, s_i \ne s_j\) 只考虑两节点不同的情况(因为相等时命题直接成立),交换两节点前后的前缀码设为 \(T,T^*\)
\[\begin{align*} W(T) - W(T^*) &= \sum_{k=1}^r d_k\cdot f_k - \sum_{k=1}^r d_k^*\cdot f_k^* \\ &= d_if_i + d_jf_j - d_i^*f_i^* - d_j^*f_j^* \\ &= d_if_i + d_jf_j - d_jf_i - d_if_j \\ &= (d_i-d_j)(f_i-f_j) \\ &\ge 0 \end{align*} \]故交换后前缀树不可能变差,且不变好的情况为 \(d_i = d_j \vee f_i = f_j\),表明 \(深度最大 \iff 频率最低\)。
-
命题 3
由命题 1 和命题 2,频率最低的字符应当有一个兄弟字符。不妨将两个字符设为 \(x,y\),其对应节点为 \((s_x,d_x,f_x),(s_y,d_y,f_y)\),有 \(d_x = d_y, f_x \le f_y\)。
\(x\) 和 \(y\) 的加权路径和为 \(d_xf_x+d_yf_y\),不妨令 \(d_z+1 = d_x = d_y,f_z = f_x+f_y\),则加权路径和为 \((d_z+1)f_z\),表明可以用一个频率为 \(f_x+f_y\) 的字符代替 \(x,y\)。此时 \(x,y\) 合成 \(z\),由于子树减少,为了保持满二叉树,将子树上拉。
将三种状态的树标记为 \(T,T',T^*\),命题 3:如果 \(T^*\) 为最优前缀码,则 \(T\) 也为最优前缀码。
T T' T* q q q / \ / \ / \ p ... ==> p ... ==> z ... / \ | x y z
同样使用逆否命题证明:若 \(T\) 不为最优前缀码,则 \(T^*\) 不为最优前缀码
\[\begin{align*} \text{易得 }W(T) - f_z &= W(T^*), \\ T \text{ 不为最优前缀码} &\implies \exists W(T_H)[W(T_H) < W(T)], \\ &\implies W(T_H^*) = W(T_H) - f_z < W(T) - f_z = W(T^*) \\ \end{align*} \]这表明,如果 \(T\) 不是最优前缀码,那么将 \(x,y\) 替换为 \(z\) 得到的 \(T^*\) 也不是最优前缀码。
将命题 2 和命题 3 重新组织一下:
- 命题 2:在最优前缀码中,频率最低的字符的深度最大,深度最大的字符的频率最低;
- 命题 3:对频率最低的 \(x,y\) 和 \(z\) 的合成和分解保持最优前缀码的状态;
考虑霍夫曼算法的关键步骤:选择出现次数最少的两个子树,作为新建节点的两个子树,新子树的出现次数为左右子树的出现次数的和。
等价于 \(d_z = d_x-1 = d_y-1,f_z=f_x+f_y\),并将 \(x,y\) 作为 \(z\) 的左右子树,此操作不改变最优前缀码的状态,一直合并到只有一个根节点。由于只有根节点为最优,那么整棵树也最优了。
较长的连续重复的位或字符
LZW 压缩算法
该算法维护一张字符串和编码的编译表,其特点是不需要在压缩后的数据中添加这张编译表,原因我概括为:1. 对可以由 7 位比特表示的 ASCII 码并没有任何更改;2. 可以在 ASCII 的基础上通过输入推出编译表。压缩和展开本质上都是在推出这个编译表。
算法的压缩和展开都需要前后进行两次输入才能在编码表中添加新的项。
压缩
- 用 ASCII 码的字符和十六进制表示(00H~7FH)初始化一颗前缀树,并将 80H 作为压缩结束的编码;
- 开始压缩并扩张前缀树
- 输入:不断读取输入(如果还有输入的话)并在前缀树中匹配到最长前缀,该前缀记为
s
; - 输出:将最长前缀对应的编码输出;
- 更新:再读取一个字符
c
,将str + c
添加到前缀树中,从剩余未使用的编码中取下一个作为它的编码。
- 输入:不断读取输入(如果还有输入的话)并在前缀树中匹配到最长前缀,该前缀记为
展开
-
展开不需要压缩时使用的前缀树,但是需要获得压缩使用的字符数
R
、压缩时编码表的条目数L
、压缩产生的编码的比特长度W
。用R
初始化一个前R
个字符的编码表(而不是前缀树)。编码表的键为编码,值为字符串,有L
个条目。 -
开始读取编码并展开,每次读取时都检测是否为压缩结束的编码
-
输入:读取
W
位的编码,在编码表中获得对应的字符串str
; -
输出:输出
str
; -
更新:
再次读取
W
位编码,此时可能会出现编译表中不存在对应字符串的问题,原因为压缩时将新表项添加后,立刻就使用了新表项,因此在展开时会出现要获取的对应字符串就是要更新的字符串。将新表项添加后,立刻就使用了新表项说明新表项的前缀为上一次编码对应的字符串str
,新字符为str[0]
。因此获得对应字符串new_str
。将
str + new_str[0]
添加到编码表中。
-
参考资料
- 《算法导论 第3版》16.3 赫夫曼编码
- 《算法 第4版》5.5 数据压缩