存储霍夫曼树的有效方法

时间:2009-04-17 09:20:20

标签: c++ performance huffman-code

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

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

  1. 这个文件逐个字符地将整个文件读入内存,并为整个文档构建频率表。这只需要输出一次树,因此除了输入文件很小外,效率并不是很大的问题。
  2. 我正在使用的另一种方法是读取大小为64千字节的大块数据并对其进行频率分析,创建一个树并对其进行编码。但是,在这种情况下,在每个块之前,我将需要输出我的频率树,以便解码器能够重新构建其树并正确解码编码文件。这就是效率确实到位的地方,因为我想节省尽可能多的空间。
  3. 到目前为止,在我的搜索中,我还没有找到一种在尽可能小的空间内存储树的好方法,我希望StackOverflow社区可以帮助我找到一个好的解决方案!

5 个答案:

答案 0 :(得分:70)

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

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

因此对于每个节点,从root开始:

  1. 如果leaf-node:输出1位+ N位字符/字节
  2. 如果不是叶节点,则输出0位。然后以相同的方式编码两个子节点(左边第一个然后右边)
  3. 要阅读,请执行以下操作:

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

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

      频率:

      • 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字节

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

答案 1 :(得分:9)

如果你对树的生成有足够的控制权,你可以让它做一个规范的树(例如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

答案 2 :(得分:4)

分支是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存储树的效率就像重新使用前一个chunk的树一样高效,并且树的形状为“1”作为指示器,只需重用来自上一个块。

答案 3 :(得分:2)

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

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

  
    
      

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

    
  

答案 4 :(得分:0)

更好的方法

树:            7      -------------      | 4      | ---------      3 2 2    ----- ----- -----    A B C D E F.    2 1 1 1 1 1:频率    2 2 3 3 3 3:树深度(编码位)

现在只需导出此表:    深度代码数


 2   2 [A B]
 3   4 [C D E F]

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

深度代码数


 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的第一行)来避免它: / p>

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

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

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

相关问题