高效存储哈夫曼树的方法

39

我正在编写一种Huffman编/解码工具,希望找到一种有效的方法来存储创建的Huffman树以便储存在输出文件中。

目前我正在实现两个不同版本。

  1. 这个版本会将整个文件逐个字符地读入内存,并为整个文档构建一个频率表。这只需要输出一次树结构,因此效率并不是很大的问题,除非输入文件很小。
  2. 另一种方法是读取大小为64KB的数据块,对其运行频率分析,创建一棵树并进行编码。然而,在这种情况下,在每个数据块之前,我需要输出我的频率树,以便解码器能够重新构建其树并正确解码编码后的文件。这就是效率至关重要的地方,因为我想尽可能地节省空间。

到目前为止,我在搜索中没有找到一种在尽可能少的空间中存储树结构的好方法,希望StackOverflow社区能够帮助我找到一个好的解决方案!

6个回答

92

既然你已经需要实现代码来处理位层,那么这是我的建议:不要存储实际的频率,因为它们在解码时不需要。但你确实需要实际的树。

所以对于每个节点,从根节点开始:

  1. 如果是叶子节点: 输出1位 + N位字符/字节
  2. 如果不是叶子节点,输出0位。然后以相同的方式编码两个子节点(先左后右)

读取时,按照以下步骤进行:

  1. 读取一位比特。如果是1,则读取N位字符/字节,并返回围绕它的新节点,没有子节点。
  2. 如果比特为0,则对左右子节点进行解码,方法与上述相同,然后返回围绕它们、没有值但带有这些子节点的新节点。

叶子节点基本上是任何没有子节点的节点。

采用这种方法,你可以在编写输出之前计算出精确的输出大小,以确定收益是否足以证明努力的价值。这假设你有一个键值对字典,其中包含每个字符的频率,其中频率是实际发生的数量。

计算的伪代码如下:

Tree-size = 10 * NUMBER_OF_CHARACTERS - 1
Encoded-size = Sum(for each char,freq in table: freq * len(PATH(char)))

计算树的大小需要考虑叶子节点和非叶子节点,而字符数比内联节点少一个。

SIZE_OF_ONE_CHARACTER表示每个字符占用的比特数,这两个值加起来可以得出我的方法对于树和编码数据总共将占用的比特数。

PATH(c)是一个函数/表,会返回从根节点到该字符在树中的位路径。

下面是一个类似于C#的伪代码实现,假定一个字符只是一个简单的字节。

void EncodeNode(Node node, BitWriter writer)
{
    if (node.IsLeafNode)
    {
        writer.WriteBit(1);
        writer.WriteByte(node.Value);
    }
    else
    {
        writer.WriteBit(0);
        EncodeNode(node.LeftChild, writer);
        EncodeNode(node.Right, writer);
    }
}

读取它的方法如下:

Node ReadNode(BitReader reader)
{
    if (reader.ReadBit() == 1)
    {
        return new Node(reader.ReadByte(), null, null);
    }
    else
    {
        Node leftChild = ReadNode(reader);
        Node rightChild = ReadNode(reader);
        return new Node(0, leftChild, rightChild);
    }
}

一个示例(简化,使用属性等)节点实现:

public class Node
{
    public Byte Value;
    public Node LeftChild;
    public Node RightChild;

    public Node(Byte value, Node leftChild, Node rightChild)
    {
        Value = value;
        LeftChild = leftChild;
        RightChild = rightChild;
    }

    public Boolean IsLeafNode
    {
        get
        {
            return LeftChild == null;
        }
    }
}

以下是一个具体示例的输出:

输入:AAAAAABCCCCCCDDEEEEE

字符频率:

  • A:6
  • B:1
  • C:6
  • D:2
  • E:5

每个字符只占8位,因此树的大小将为10 * 5 - 1 = 49位。

树可能长这样:

      20
  ----------
  |        8
  |     -------
 12     |     3
-----   |   -----
A   C   E   B   D
6   6   5   1   2

因此,每个字符的路径如下(0表示左,1表示右):

  • A:00
  • B:110
  • C:01
  • D:111
  • E:10

因此,要计算输出大小:

  • A:6次出现* 2位=12位
  • B:1次出现* 3位=3位
  • C:6次出现* 2位=12位
  • D:2次出现* 3位=6位
  • E:5次出现* 2位=10位

编码字节的总和为12 + 3 + 12 + 6 + 10 = 43位

将其加入树的49位并输出将是92位或12个字节。与原始的20个未编码字符所需的20 * 8个字节相比,您将节省8个字节。

最终输出,包括树在内,如下所示。流中的每个字符(A-E)都编码为8位,而0和1仅为单个位。流中的空格只是用于分隔树和编码数据,并不占据最终输出中的任何空间。

001A1C01E01B1D 0000000000001100101010101011111111010101010

以你在评论中提到的具体示例AABCDEF为例,你将得到以下结果:

输入:AABCDEF

频率:

  • A:2
  • B:1
  • C:1
  • D:1
  • E:1
  • F:1

树形结构:

        7
  -------------
  |           4
  |       ---------
  3       2       2
-----   -----   -----
A   B   C   D   E   F
2   1   1   1   1   1

路径:

  • A:00
  • B:01
  • C:100
  • D:101
  • E:110
  • F:111

树形结构:001A1B001C1D01E1F = 59个比特(位)
数据:000001100101110111 = 18个比特(位)
总共:59 + 18 = 77个比特(位) = 10个字节

由于原始数据是7个8比特(位)字符 = 56个比特(位),因此对于这样小的数据块会有太多的开销。


2
你不需要实际的树形结构,而是需要知道字母表中每个字符的位长度。这就是GZIP存储动态霍夫曼块的方式:rfc-deflate - Igor Gatis
尽管这个答案得票最多,也是被接受的答案,但这种解决方案通常比压缩方式使用更多的空间。因此,任何真正关心空间的人都应该选择像deflate一样的东西。甚至更好的是,应该考虑采用多种编码树的方法,并选择占用最小空间的方法。 - geza
为什么在计算树的大小时使用10?为什么不是8,因为它代表一个字符? - Maikkeyy
这是一个很好的解释。不过,我对在“树”中混合二进制和字符数据感到好奇。如果原始字符串中包含字符'0'和'1',那么会发生什么?你将如何将它们与“树”的二进制内容分开? - mappo
2
阅读树时没有问题,您永远不会怀疑下一个期望是什么,无论是期望对应于内部节点还是叶节点的1位,还是期望叶节点值的8位。您始终从解码根节点开始,这是一个内部节点,因此您始终从读取1位开始,然后跟随该位。您永远不会读取一些位,然后决定您刚刚读取了什么,您事先就知道。 - Lasse V. Karlsen
显示剩余4条评论

12
如果你可以对树的生成有足够的控制,你可以让它生成一个规范树(例如DEFLATE所做的),这基本上意味着你创建规则来解决构建树时的任何模糊情况。然后,就像DEFLATE一样,你实际上只需要存储每个字符的代码长度。
也就是说,如果你有Lasse上面提到的树/代码:
A: 00 B: 110 C: 01 D: 111 E: 10
那么你可以将它们存储为: 2,3,2,3,2
实际上,这已经足够重新生成哈夫曼表了,假设你始终使用相同的字符集——比如ASCII。(这意味着你不能跳过字母——即使它的长度为零,你也必须列出每个字母的代码长度。)
如果你还限制了位长度(比如7位),你可以使用短的二进制字符串存储每个数字。因此,2,3,2,3,2变成了010 011 010 011 010——它适合于2个字节。
如果你想变得更加疯狂,你可以像DEFLATE一样,制作另一个哈夫曼表,用于存储这些代码的长度,并在此之前存储它的代码长度。特别是因为它们添加了额外的代码,用于“连续插入零N次”以进一步缩短长度。

如果你已经熟悉霍夫曼编码,那么 DEFLATE 的 RFC 并不难理解:http://www.ietf.org/rfc/rfc1951.txt


1
你如何从序列2, 3, 2, 3, 2重新生成哈夫曼表? - Aadit M Shah
请查看http://en.wikipedia.org/wiki/Canonical_Huffman_code或deflate规范。这是通常存储规范哈夫曼树的方法。 - Ezran
1
@Ezran 这只适用于我知道字母表中的所有字母吗?如果我有很多不同的标点符号或Unicode符号,比如 йцшщзъ,会怎样? - Mikhail_Sam
这是正确的答案。被接受的答案说你需要传输树,但实际上不需要。 - Mark Adler

7

分支为0,叶子为1。深度优先遍历树以获取其“形状”。

e.g. the shape for this tree

0 - 0 - 1 (A)
|    \- 1 (E)
  \
    0 - 1 (C)
     \- 0 - 1 (B)
         \- 1 (D)

would be 001101011

按照深度优先顺序AECBD依次输出字符的位,通过树的形状可以知道期望的字符数量。接着输出消息的代码。你会得到一长串比特流,可以将其分割成字符并输出。
如果进行分块,可以测试存储下一个块的树是否与重用前一个块的树一样有效,并将树形状为“1”作为指示符,以便从前一个块中重用树。

2
树通常是从字节的频率表中创建的。因此,存储该表或按频率排序的字节本身,并在运行时重新创建树。当然,这假设您正在构建表示单个字节而不是更大块的树。
更新:正如j_random_hacker在评论中指出的那样,实际上你不能这样做:你需要频率值本身。它们结合起来并随着树的构建向上“冒泡”。此页面描述了从频率表构建树的方式。作为奖励,它还提到了保存树的方法,以免被删除:
最简单的输出Huffman树本身的方法是从根开始,首先转储左侧,然后转储右侧。对于每个节点,您输出0,对于每个叶子,您输出1,后跟表示值的N位。

这个方法可以工作,只有一个缺点,就是它有一个非常糟糕的最坏情况,如果字符串是AABCDEF,那么我现在存储的树是ABCDEF,基本上我回到了起点,对于英语中普通频率发现的情况,我正在存储的表的大小仍然会导致压缩几乎不存在,甚至在某些情况下会导致数据比原始数据更大! - X-Istence
1
当然。一个解决方案可能是不压缩长度小于512字节的输入数据。如果这用于基于磁盘的数据,那么这是有意义的,因为大多数文件系统都有开销,这意味着小文件占用的实际磁盘空间比它们包含的有效数据还要多。 - unwind
很抱歉,您需要认识到并非所有数据都可以压缩。无论如何,您都将产生一些开销,如果数据很小或熵值较低,则开销可能会超过压缩算法的收益。对于这些情况,您应该有一个不进行压缩的备用编码方案。 - Lasse V. Karlsen
如果表格是用于英语的,你不需要将其存储以进行传输。在这种情况下,你只需要确定你的实现将使用哪个表格即可。 - Sam Hasler
3
认为仅依据按频率排序的字节无法重新创建霍夫曼树——您需要实际的频率,因为即使频率排序相同,不同的频率向量也可能导致不同的霍夫曼树。但我愿意接受证明我错误的事实。 - j_random_hacker
没错,那还不够,你需要更多的东西来恢复完整的树形结构。请看我的帖子,它输出额外的位来布局这棵树。 - Lasse V. Karlsen

1

更好的方法

树:

           7
     -------------
     |           4
     |       ---------
     3       2       2
   -----   -----   -----
   A   B   C   D   E   F
   2   1   1   1   1   1 : frequencies
   2   2   3   3   3   3 : tree depth (encoding bits)

现在只需推导出这个表格:
   depth number of codes
   ----- ---------------
     2   2 [A B]
     3   4 [C D E F]

你不需要使用相同的二叉树,只需保留计算出的树深度,即编码位数。因此,只需保留未压缩值向量[A B C D E F]按树深度排序,使用相对索引而不是单独的向量。现在为每个深度重新创建对齐的位模式:
   depth number of codes
   ----- ---------------
     2   2 [00x 01x]
     3   4 [100 101 110 111]

你立即看到的是每一行中只有第一个比特模式是重要的。 你得到以下查找表:
    first pattern depth first index
    ------------- ----- -----------
    000           2     0
    100           3     2

这个LUT非常小(即使您的霍夫曼编码可以是32位长,它仅包含32行),而且实际上第一个模式始终为空,当在其中执行模式的二进制搜索时,可以完全忽略它(这里只需要比较1个模式以了解位深度为2还是3,并获得相关数据存储在向量中的第一个索引)。在我们的示例中,您需要在最多31个值的搜索空间中快速进行输入模式的二进制搜索,即最多5次整数比较。这31个比较程序可以优化为31个代码,以避免所有循环并管理状态,从而浏览整数二进制查找树。 所有这些表都适合于固定长度(LUT仅需要31行,最多用于32位长的Huffman编码,而上面的两列将填充至多32行)。
换句话说,上述LUT每个需要31个32位大小的整数和32字节来存储位深度值:但是您可以暗示深度列(以及深度1的第一行)而避免使用这32个字节。
    first pattern (depth) first index
    ------------- ------- -----------
    (000)          (1)    (0)
     000           (2)     0
     100           (3)     2
     000           (4)     6
     000           (5)     6
     ...           ...     ...
     000           (32)    6

所以您的LUT包含[000,100,000(30次)]。要在其中搜索,必须找到输入位模式位于两个模式之间的位置:它必须低于此LUT中下一个位置处的模式,但仍高于或等于当前位置处的模式(如果两个位置都包含相同的模式,则当前行将不匹配,输入模式适合以下)。然后,您将使用分而治之,并且最多使用5个测试(二进制搜索需要具有5个嵌套if / then / else级别的单个代码,它有32个分支,达到的分支直接指示不需要存储的位深度;然后,您执行单个直接索引查找以返回第一个索引的第二个表格;您通过基本位掩码计算出最终索引在解码值向量中的附加值)。
一旦您在查找表中获得位置(在第1列中搜索),即可立即获得要从输入中获取的位数,然后是向量的起始索引。您获得的位深度可以用于通过基本位掩码在减去第一个索引后直接推导出调整后的索引位置。
总之:永远不要存储链接二叉树,执行查找只需要5个嵌套的if比较31个模式表中固定位置的模式和包含解码值向量中起始偏移的31个int的表格,而且在嵌套的if/then/else测试的第一个分支中,向量的起始偏移是隐含的,它总是为零;这也是最频繁的分支,因为它匹配最短的代码,即最常见的解码值。

0

正如其他答案所述,存储哈夫曼编码LUTs有两种主要方法。您可以存储树的几何形状,0表示节点,1表示叶子,然后放入所有叶子值;或者您可以使用规范化哈夫曼编码,存储哈夫曼编码的长度。

问题在于,一种方法比另一种方法更好,这取决于具体情况。假设您希望压缩的数据中有n个唯一符号(例如aabbbcdddd,有4个唯一符号a、b、c、d)。

存储树的几何形状以及树中的符号所需的位数为10n-1。

假设您按符号顺序存储代码长度,并且代码长度为8位(256个符号字母的代码长度不会超过8位),则代码长度表的大小将是2048位。

当您有大量唯一符号时,比如256个,存储树的几何形状需要2559位。在这种情况下,代码长度表更加高效。确切地说,比存储树的几何形状节省了511位。

但是,如果您只有5个唯一的符号,则树形几何仅需要49位,在这种情况下,与存储代码长度表相比,存储树形几何要好近2000位。

对于 n < 205,树形几何最有效,而代码长度表对于 n >= 205 更有效。那么,为什么不兼顾两者,同时使用两者呢?在压缩数据的开头有1位表示接下来的多少位将以哈夫曼树的格式或代码长度表的格式进行存储。

实际上,为什么不添加两位,当它们都为0时,没有表格,数据未经压缩。因为有时候无法进行压缩!最好在文件开头有一个单独的字节为0x00,告诉解码器不用担心任何事情。通过不包括树的表格或几何形状来节省空间,并节省时间,不必不必要地压缩和解压缩数据。


代码长度本身可以被压缩(参见deflate),因此您的截止值仅适用于传输代码长度的朴素方法。 - Mark Adler
忘了那部分。而且我还没有实现类似的东西!所以在我做这件事之前,我会保持沉默的!>:P - fortytoo
@spitconsumer,我看到了你有关于如何更快地生成哈夫曼编码的问题。除了通常的“在数组中原地创建树”(有很多版本),还有Polar codesEngel codes,它们根本不需要建立任何树,甚至不会在数组中偷偷摸摸地建立一棵树。 - harold

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接