【问题标题】:Efficient way of storing Huffman tree存储霍夫曼树的有效方法
【发布时间】:2010-10-20 01:41:54
【问题描述】:

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

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

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

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

【问题讨论】:

    标签: c++ performance huffman-code


    【解决方案1】:

    将霍夫曼代码 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 位表示下一个无论多少位都将采用代码长度表的格式,还是哈夫曼树的几何形状。

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

    【讨论】:

    • 代码长度本身可以被压缩(参见 deflate 示例),因此您的截止值仅对传输代码长度的幼稚方法有效。
    • 忘记了那部分。还没有完全实现类似的东西!所以我会一直闭嘴直到我这样做。 >:P
    • @spitconsumer 我看到了你关于生成霍夫曼代码比构建树更快的问题,除了通常的“在数组中就地创建树”(很多版本)还有Polar codes和Engel codes 这使得 no tree 甚至不是数组中的一棵偷偷摸摸的树
    【解决方案2】:

    更好的方法

    树:

               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 的尺寸非常小(即使你的 Huffman 代码可以是 32 位长,它也只会包含 32 行),实际上第一个模式始终为空,你可以在执行二进制时完全忽略它搜索其中的模式(这里只需要比较一个模式以了解位深度是 2 还是 3,并获得关联数据存储在向量中的第一个索引)。在我们的示例中,您需要在最多 31 个值的搜索空间中对输入模式执行快速二进制搜索,即最多 5 个整数比较。这 31 个比较例程可以在 31 个代码中进行优化,以避免所有循环,并且在浏览整数二叉查找树时必须管理状态。 所有这个表都适合小的固定长度(对于不超过 32 位的 Huffman 代码,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(30times)]。要在其中搜索,您必须找到输入位模式在两个模式之间的位置:它必须低于此 LUT 中下一个位置的模式,但仍高于或等于当前位置的模式(如果两个位置包含相同的模式,当前行将不匹配,输入模式适合下面)。然后你将分而治之,最多使用 5 次测试(二分查找需要单个代码,其中嵌入了 5 个 if/then/else 嵌套级别,它有 32 个分支,达到的分支直接指示不存在的位深度需要存储;然后您对第二个表执行单个直接索引查找以返回第一个索引;您在解码值向量中附加地导出最终索引)。

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

    总而言之:永远不要存储链接的二叉树,并且您不需要任何循环来执行查找,它只需要 5 个嵌套 if 比较 31 个模式表中固定位置的模式,以及一个包含开始的 31 个整数表解码值向量内的偏移量(在嵌套 if/then/else 测试的第一个分支中,向量的起始偏移量是隐含的,它始终为零;它也是匹配时将采用的最频繁的分支用于最频繁解码值的最短代码)。

    【讨论】:

      【解决方案3】:

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

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

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

      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;
              }
          }
      }
      

      这是来自特定示例的示例输出。

      输入:AAAAAAABCCCCCCDDEEEEE

      频率:

      • 答:6
      • 乙: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
      • 乙:110
      • C: 01
      • D: 111
      • E: 10

      所以要计算输出大小:

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

      对于您在 cmets 中的具体示例 AABCDEF,您将得到:

      输入:AABCDEF

      频率:

      • 答:2
      • 乙: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
      • 乙:01
      • C: 100
      • D: 101
      • 电子:110
      • F: 111

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

      由于原来是 8 位的 7 个字符 = 56,所以对于这么小的数据,你会有太多的开销。

      【讨论】:

      • 请注意,我添加的第一个计算对于树的大小是不正确的,现在已更正它并添加了一个特定示例以启动。
      • 您不需要实际的树。您需要字母表中每个字母的位长。这就是 GZIP 存储动态霍夫曼块的方式:rfc-deflate
      • @LasseV.Karlsen 这正是我正在做的,但是使用001A1C01E01B1D 你如何重建树?你有什么建议
      • 尽管这个答案得票最多,而且这是被接受的答案,但这个解决方案通常比放气方式使用更多的空间。因此,任何真正考虑过空间的人都应该选择 deflate 之类的东西。或者更好的是,应该考虑有多种方法来编码一棵树,并选择占用最小空间的一种。
      • 没有问题,在读取树时,您将毫无疑问接下来会期待什么,要么您期待 1 位对应于内部节点或叶节点,要么您重新期望叶节点的值有 8 位。你总是从解码根节点开始,它是一个内部节点,所以你总是从读取 1 位开始,然后从那里开始。您永远不会阅读大量位,然后决定您刚刚阅读的内容,您提前知道。
      【解决方案4】:

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

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

      • 答:00
      • 乙: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, 3, 2, 3, 2重新生成霍夫曼表?
      • 结帐en.wikipedia.org/wiki/Canonical_Huffman_code 或放气规范。这就是典型的霍夫曼发束的一般存储方式。
      • @Ezran 只有当我知道字母表中的所有字母时它才有效吗?如果我有很多不同的标点符号或 unicode 符号,例如 йцшщзъ,该怎么办?
      • 这是正确答案。接受的答案说您需要传输树,而您不需要。
      【解决方案5】:

      树通常是根据字节的频率表创建的。因此,存储该表,或者仅存储按频率排序的字节本身,并即时重新创建树。这当然假设您正在构建树以表示单个字节,而不是更大的块。

      更新:正如 j_random_hacker 在评论中指出的那样,您实际上不能这样做:您需要频率值本身。当您构建树时,它们会组合并向上“冒泡”。 This page 描述了从频率表构建树的方式。作为奖励,它还通过提及一种保存树的方法来避免删除此答案:

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

      【讨论】:

      • 这行得通,只有一个缺点,那就是它有一个非常糟糕的最坏情况,如果字符串是 AABCDEF,我现在存储的树是 ABCDEF,基本上我回到了第一方,在英语中发现的正常频率下,我存储的表的大小仍然会导致压缩几乎不存在,在某些情况下甚至会导致数据比开始时更大!
      • 当然。一种解决方案可能是不压缩小于 512 字节左右的输入数据。如果我们将其用于基于磁盘的数据,那无论如何都是有道理的,因为大多数文件系统都有开销,这意味着小文件占用的实际磁盘空间比它们包含的有效数据要多。
      • 恐怕您将不得不意识到并非所有数据都是可压缩的。无论如何,你都会产生一些开销,如果数据很小,或者熵很低,开销可能会超过压缩算法的收益。您应该有一个不会针对这些场景进行压缩的后备编码。
      • 如果表格是英文表格,则无需存储它以供传输。在这种情况下,您只需要同意您的实现将使用哪一个。
      • 我认为您不能仅根据按频率排序的字节重新创建霍夫曼树 - 您需要实际频率,因为不同的频率向量可能导致不同的即使频率的排序顺序相同,霍夫曼树也是如此。但我愿意被证明是错误的。
      【解决方案6】:

      分支是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”作为仅重用树的指示符上一个块。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-03-18
        相关资源
        最近更新 更多