繁体   English   中英

存储哈夫曼树的有效方式

[英]Efficient way of storing Huffman tree

我正在编写一个霍夫曼编码/解码工具,并且正在寻找一种有效的方法来存储为存储在输出文件中而创建的霍夫曼树。

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

  1. 这个是将整个文件逐个字符读入内存,并为整个文件建立一个频率表。 这只需要输出一次树,因此效率不是那么重要,除非输入文件很小。
  2. 我使用的另一种方法是读取大约 64 KB 大小的数据块并对其运行频率分析,创建一棵树并对其进行编码。 但是,在这种情况下,在每个块之前,我需要输出我的频率树,以便解码器能够重新构建其树并正确解码编码文件。 这是效率确实发挥作用的地方,因为我想尽可能多地节省空间。

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

由于您已经必须实现代码以在字节组织的流/文件之上处理逐位层,因此这是我的提议。

不存储实际频率,解码时不需要它们。 但是,您确实需要实际的树。

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

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

要阅读,请执行以下操作:

  1. 读位。 如果为1,则读取N位字符/字节,返回其周围没有子节点的新节点
  2. 如果位为0,则以相同的方式解码左右子节点,并使用这些子节点返回它们周围的新节点,但没有值

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

使用这种方法,您可以在编写输出之前计算输出的确切大小,以确定增益是否足以证明该工作的合理性。 这假设您有一个键/值对的字典,其中包含每个字符的频率,其中frequency是实际出现的次数。

用于计算的伪代码:

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

频率:

  • 答: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为右):

  • 答: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个字节。

最终输出,包括开始的树,如下所示。 流(AE)中的每个字符编码为8位,而0和1只是一位。 流中的空间只是将树与编码数据分开,并且不占用最终输出中的任何空间。

001A1C01E01B1D 0000000000001100101010101011111111010101010

对于你在评论AABCDEF中的具体例子,你会得到这个:

输入:AABCDEF

频率:

  • A2
  • 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

路径:

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

树:001A1B001C1D01E1F = 59位
数据:000001100101110111 = 18位
总和:59 + 18 = 77位= 10个字节

由于原始版本是8位= 56的7个字符,因此这些小块数据的开销过大。

如果你对树的生成有足够的控制,你可以让它做一个规范的树(例如,与DEFLATE一样),这基本上意味着你创建规则来解决构建树时的任何模糊情况。 然后,像DEFLATE一样,您实际需要存储的是每个字符的代码长度。

也就是说,如果你有上面提到的树/代码Lasse:

  • 答: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

分支是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(当读取时你会知道树的形状有多少个字符)。 然后输出消息的代码。 然后,您有一长串的位,您可以将它们分成输出字符。

如果你正在对它进行分块,你可以测试为下一个chuck存储树的效率就像重新使用前一个块的树一样高效,并且树形状为“1”作为指示器,只重用上一个块中的树。

树通常根据字节的频率表创建。 因此,存储该表,或只是按频率排序的字节,并动态重新创建树。 这当然假设您构建树来表示单个字节,而不是更大的块。

更新 :正如j_random_hacker在评论中所指出的,你实际上不能这样做:你需要自己的频率值。 当你构建树时,它们被组合并向上“冒泡”。 此页面描述了从频率表构建树的方式。 作为奖励,它还通过提及保存树的方法来保存此答案:

输出霍夫曼树本身最简单的方法是从根部开始,首先是左手侧,然后是右手侧。 对于每个节点,输出0,对于每个叶子,输出1,后跟表示该值的N位。

更好的方法

树:

           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]

您不需要使用相同的二叉树,只需保留计算的树深度即编码位数。 因此,只需按树深度排序未压缩值[ABCDEF]的向量,使用相对索引代替此单独的向量。 现在重新创建每个深度的对齐位模式:

   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行),实际上第一个模式总是为null,你可以在执行模式的二进制搜索时完全忽略它在其中(这里只需要比较1个模式以知道位深度是2还是3并获得相关数据存储在向量中的第一个索引)。 在我们的示例中,您需要在最多31个值的搜索空间中对输入模式执行快速二进制搜索,即最多5个整数比较。 这31个比较例程可以在31个代码中进行优化,以避免所有循环,并且在浏览整数二进制查找树时必须管理状态。 所有这些表都适合小的固定长度(对于不超过32位的霍夫曼码,LUT最多需要31行,而上面的其他2列最多将填充32行)。

换句话说,上面的LUT需要31个32位大小的整数,32个字节来存储位深度值:但是你可以通过暗示深度列(以及深度1的第一行)来避免它:

    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个分支,到达的分支直接指示不具有的位深度需要存储;然后对第二个表执行单个直接索引查找以返回第一个索引;您可以在解码值的向量中附加地导出最终索引)。

一旦在查找表中找到一个位置(在第一列中搜索),就会立即获得从输入中获取的位数,然后从起始索引到向量。 您获得的位深度可用于在减去第一个索引后通过基本位掩码直接导出调整后的索引位置。

总结:从不存储链接的二进制树,并且您不需要任何循环来执行查找,这只需要5个嵌套ifs比较31个模式的表中固定位置的模式,以及包含31个模式中的起始偏移量的31个整数的表。解码值的向量(在嵌套的if / then / else测试的第一个分支中,暗示了向量的起始偏移量,它总是为零;它也是最常用的分支,因为它匹配最短的代码这是最频繁的解码值)。

有两种主要方法可以将霍夫曼代码 LUT 存储为其他答案状态。 您可以存储树的几何形状,节点为 0,叶子为 1,然后放入所有叶子值,或者您可以使用规范的霍夫曼编码,存储霍夫曼代码的长度。

问题是,根据情况,一种方法比另一种更好。 假设您希望压缩的数据中唯一符号的数量( aabbbcdddd ,有 4 个唯一符号, a, b, c, d )是n

沿树中符号存储树几何的位数为10n - 1

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

当您有大量唯一符号时,例如 256 个,将需要 2559 位来存储树的几何形状。 在这种情况下,代码长度表的效率要高得多。 准确地说,效率提高了 511 位。

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

树几何对于n < 205最有效,而代码长度表对于n >= 205更有效。 那么,为什么不两全其美,并同时使用两者呢? 在压缩数据的开头有 1 位表示接下来的多少位将采用代码长度表的格式,还是霍夫曼树的几何结构。

其实为什么不加两个bit,而且都是0的时候,没有表,数据是解压的。 因为有时,您无法获得压缩! 最好在文件开头有一个字节 0x00 告诉您的解码器不要担心做任何事情。 通过不包括树的表或几何图形来节省空间,并节省时间,不必对数据进行不必要的压缩和解压缩。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM