【问题标题】:Hashing a Tree Structure散列树结构
【发布时间】:2010-01-01 14:00:05
【问题描述】:

我刚刚在我的项目中遇到了一个场景,我需要比较不同的树对象与已知实例的相等性,并认为在任意树上运行的某种散列算法将非常有用。

以下面的树为例:

○ / \ / \ 哦哦 /|\ | / | \ | 哦哦哦 / \ / \ 哦哦

其中每个O 代表树的一个节点,是一个任意对象,具有关联的哈希函数。所以问题归结为:给定树结构节点的哈希码和已知结构,为整个树计算(相对)无冲突哈希码的体面算法是什么?

关于哈希函数属性的几点说明:

  • 哈希函数应取决于树中每个节点的哈希码及其位置。
  • 对节点的子节点进行重新排序应该明显改变生成的哈希码。
  • 反映树的任何部分应该明显改变生成的哈希码

如果有帮助,我会在我的项目中使用 C# 4.0,尽管我主要是在寻找理论上的解决方案,所以伪代码、描述或其他命令式语言的代码都可以。


更新

嗯,这是我自己提出的解决方案。这里的几个答案对它有很大帮助。

每个节点(子树/叶节点)具有以下哈希函数:

public override int GetHashCode()
{
    int hashCode = unchecked((this.Symbol.GetHashCode() * 31 +
        this.Value.GetHashCode()));
    for (int i = 0; i < this.Children.Count; i++)
        hashCode = unchecked(hashCode * 31 + this.Children[i].GetHashCode());
    return hashCode;
}

在我看来,这种方法的好处是可以缓存哈希码,并且仅在节点或其后代之一发生更改时才重新计算。 (感谢 vatine 和 Jason Orendorff 指出这一点)。

无论如何,如果人们可以在这里评论我建议的解决方案,我将不胜感激 - 如果它做得很好,那就太好了,否则任何可能的改进都会受到欢迎。

【问题讨论】:

  • @Eli Bendersky:确实如此。我已修改问题以暗示“尽可能无碰撞”。
  • 这些答案都不能很好地解释它,但树只是一个元组(节点本地数据、subtree0、subtree1,...)。元组是可散列的。完毕。有关详细信息,请参阅 vatine 和 pnm 的答案。
  • @Jason,知道这一点,我假设(至少在第二次编辑之前)树“太大”而不能仅仅作为元组进行散列。
  • @Eli Bendersky:出于所有实际目的,无碰撞非常简单。例如,SHA1 已有 15 年的历史,只有 160 位,但即使使用我们最好的超级计算机,也没有人找到具有相同 SHA1 哈希值的两个值(尽管我猜这很快就会发生)。
  • @BlueRaja 是的,但是尝试将 SHA1 的输出映射到一个串行且线性增加的可寻址空间,即 1,000 个元素长。现在试着告诉我这将是无碰撞的。

标签: algorithm data-structures tree hash


【解决方案1】:

如果我要这样做,我可能会执行以下操作:

对于每个叶子节点,计算0的串联和节点数据的散列。

对于每个内部节点,计算 1 的串联和任何本地数据的哈希(注意:可能不适用)以及从左到右的子节点的哈希。

这将导致每次更改任何内容时都会在树上级联,但这可能足够低的开销是值得的。如果与更改的数量相比,更改相对较少,那么使用加密安全哈希甚至可能是有意义的。

Edit1: 也有可能为每个节点添加一个“哈希有效”标志,并简单地将“假”传播到树上(或“哈希无效”并传播“真”)在节点更改上向上树。这样,当需要树哈希时可能避免完全重新计算,并可能避免未使用的多个哈希计算,但有可能在需要时获得哈希的可预测时间略短。

Edit3: Noldorin 在问题中建议的哈希码看起来有可能发生冲突,如果 GetHashCode 的结果可能永远为 0。本质上,没有办法区分树由单个节点组成,“符号哈希”为 30,“值哈希”为 25,以及一个双节点树,其中根的“符号哈希”为 0,“值哈希”为 30,子节点具有总哈希为 25。这些示例完全是虚构的,我不知道预期的哈希范围是什么,所以我只能评论我在提供的代码中看到的内容。

使用 31 作为乘法常数很好,因为它会导致在非位边界上发生任何溢出,尽管我认为,在树中有足够的孩子和可能的对抗性内容时,项目的哈希贡献早期的散列可能被后来的散列项支配。

但是,如果散列在预期数据上表现不错,它看起来好像可以完成这项工作。它肯定比使用加密散列更快(如下面列出的示例代码中所做的那样)。

Edit2:至于具体算法和所需的最小数据结构,类似如下(Python,翻译成任何其他语言应该相对容易)。

#! /usr/bin/env 蟒蛇 导入 Crypto.Hash.SHA 类节点: def __init__ (self, parent=None, contents="", children=[]): self.valid = 假 self.hash = 假 self.contents = 内容 self.children = 孩子 def append_child(自我,孩子): self.children.append(孩子) self.invalidate() def 无效(自我): self.valid = 假 如果self.parent: self.parent.invalidate() def gethash(自我): 如果 self.valid: 返回 self.hash 消化器 = crypto.hash.SHA.new() 消化器更新(self.contents) 如果 self.children: 对于self.children中的孩子: 消化器更新(child.gethash()) self.hash = "1"+digester.hexdigest() 别的: self.hash = "0"+digester.hexdigest() 返回 self.hash def setcontents(自我): self.valid = 假 返回 self.contents

【讨论】:

  • +1。这是正确的答案。计算这个可以 O(1) 摊销,因为你可以在每个节点缓存从那里的子树的哈希。 (当进行更改时,您可以向上遍历树,只需将每个缓存的哈希码标记为无效,而不是重新计算它们。这样,当一个又一个进行许多更改时,您不必每次都向上遍历树。 )
  • 好建议;至少,对于级联更改树和缓存哈希代码的提议。
  • +1,但我有一个修改建议,假设节点哈希码计算具有可衡量的成本:不是在更改时重新计算缓存的哈希码,而是使它们无效。无论哪种方式,您都必须沿着树向上走,但在需要它们之前无需重新计算您的哈希码,因此如果您在比较之间获得多次更新,则每次比较只需支付一次重新计算成本。
  • +1 表示正确答案。海报要求最好的理论解决方案,而这正是他们在密码学论文中的做法。如果安全不是问题,那么您可能只需连接所有值(及其数字位置)并对其进行散列处理,以实现非常快速、通常无冲突的假设-无恶意-用户哈希。
  • 是的,我肯定倾向于接受这个答案。如果您可以对我自己提出的解决方案的特定算法提出任何 cmets/建议,将不胜感激,并且一定会给您答案。
【解决方案2】:

好的,在您进行编辑后,您引入了哈希结果对于不同的树布局应该不同的要求,您只能选择遍历整个树并将其结构写入单个数组。

这样做是这样的:您遍历树并转储您所做的操作。对于可能是(对于左子右兄弟结构)的原始树:

[1, child, 2, child, 3, sibling, 4, sibling, 5, parent, parent, //we're at root again
 sibling, 6, child, 7, child, 8, sibling, 9, parent, parent]

然后您可以按照您喜欢的方式散列列表(实际上是一个字符串)。作为另一种选择,您甚至可以将此列表作为哈希函数的结果返回,因此它成为无冲突树表示。

但是添加关于整个结构的精确信息并不是散列函数通常所做的。提出的方法应该计算每个节点的哈希函数以及遍历整个树。因此,您可以考虑其他散列方式,如下所述。


如果您不想遍历整棵树:

我立即想到的一个算法是这样的。选择一个大素数H(大于最大子数)。要对树进行哈希处理,对其根进行哈希处理,选择一个子编号H mod n,其中n 是根的子节点数,然后递归哈希该子节点的子树。

如果树木仅在靠近树叶的地方很深,这似乎是一个糟糕的选择。但至少对于不太高的树来说它应该跑得很快。

如果你想散列更少的元素但遍历整个树

您可能想要逐层散列,而不是散列子树。 IE。首先散列根,然后散列作为其子节点的节点之一,然后是子节点的子节点之一等。因此,您覆盖了整个树而不是特定路径之一。当然,这会使散列过程变慢。

    --- O  ------- layer 0, n=1
       / \
      /   \
 --- O --- O ----- layer 1, n=2
    /|\    |
   / | \   |
  /  |  \  |
 O - O - O O------ layer 2, n=4
          / \
         /   \
 ------ O --- O -- layer 3, n=2

使用H mod n 规则从层中挑选一个节点。

这个版本与之前版本的不同之处在于,一棵树需要经过相当不合逻辑的变换才能保留散列函数。

【讨论】:

  • 有趣的建议。不确定这可能会造成多大的问题,但这些树可能非常深,并且实际上并没有最大数量的节点(可能是 10、100、1000,甚至更大)。
  • 嗯,为什么大家的回答都这么复杂?您可以在 5 行中生成一个精确的哈希码,并对其进行采样以生成更短的哈希码(请参阅下面的答案)。
  • @Larry,更棘手的解决方案通常比简单的解决方案更复杂。
  • -1。建议的方法(遍历树来构建列表)太复杂了。这个问题有一个简单直接的解决方案,由 vatine 和 pnm 给出。
  • @Jason Orenforff,(a)我提出了三种方法,(b)我认为这并不复杂。这是否是我的问题,我不确定。
【解决方案3】:

散列任何序列的常用技术是以某种数学方式组合其元素的值(或其散列)。我不认为一棵树在这方面会有什么不同。

例如,这里是 Python 中元组的哈希函数(取自 Python 2.6 源代码中的 Objects/tupleobject.c):

static long
tuplehash(PyTupleObject *v)
{
    register long x, y;
    register Py_ssize_t len = Py_SIZE(v);
    register PyObject **p;
    long mult = 1000003L;
    x = 0x345678L;
    p = v->ob_item;
    while (--len >= 0) {
        y = PyObject_Hash(*p++);
        if (y == -1)
            return -1;
        x = (x ^ y) * mult;
        /* the cast might truncate len; that doesn't change hash stability */
        mult += (long)(82520L + len + len);
    }
    x += 97531L;
    if (x == -1)
        x = -2;
    return x;
}

这是一个相对复杂的组合,通过实验选择常数以获得典型长度的元组的最佳结果。我试图用这段代码 sn-p 表明问题非常复杂且非常具有启发性,结果的质量可能取决于数据的更具体方面——即领域知识可能会帮助您获得更好的结果.但是,为了获得足够好的结果,您不应该看得太远。我猜想采用这种算法并结合树的所有节点而不是所有元组元素,再加上它们的位置将给你一个很好的算法。

考虑位置的一个选项是节点在树的有序遍历中的位置。

【讨论】:

  • 总的来说,你说得很好。但是,树与序列略有不同,因为它们包含更大的结构 - 树不能简单地由序列表示(除非您明确知道它是二元/三元/等树)。可能能够很容易地适应算法但是......
  • 一棵树可以表示为一个序列。我在我的答案和例子中展示了如何。
  • “位置”包含路径信息。例如,对于每个节点,为节点本身分配一个位置值 0,为从左到右的 n 个子节点中的每一个分配 1..n。当您在遍历中访问子编号 i 时,您将 i 包含在哈希中。当您访问节点本身时,包括 0 和节点的哈希内容。常数 0, 1, ..., n 的选择是任意的,应该根据特定领域的知识来选择,例如也许“0-mississippi”、“1-mississippi”等会更好。
  • @Pavel Shved:确实可以,但序列仍然是一种模棱两可的表示。例如:pastebin.com/m44d5b6b6(同样适用于深度优先遍历)
  • @Noldorin,这就是为什么在序列中添加附加符号很重要,因此它的长度会比原始树长。
【解决方案4】:

任何时候你都应该想到使用树递归:

public override int GetHashCode() {
    int hash = 5381;
    foreach(var node in this.BreadthFirstTraversal()) {
        hash = 33 * hash + node.GetHashCode();
    }
}

哈希函数应该取决于树中每个节点的哈希码及其位置。

检查。我们在树的哈希码计算中明确使用node.GetHashCode()。此外,由于算法的性质,节点的位置在树的最终哈希码中起着重要作用。

对节点的子节点重新排序应该会明显改变生成的哈希码。

检查。它们将在中序遍历中以不同的顺序被访问,从而导致不同的哈希码。 (请注意,如果有两个具有相同哈希码的孩子,那么在交换这些孩子的顺序时,您最终会得到相同的哈希码。)

反映树的任何部分都应该明显改变生成的哈希码

检查。同样,节点将以不同的顺序被访问,从而导致不同的哈希码。 (请注意,如果每个节点都被反射到具有相同哈希码的节点中,则反射可能会导致相同的哈希码。)

【讨论】:

  • @Jason:感谢您的回复。这确实是一个不错的简单解决方案 - 这是我心目中的第一个游戏,但它不符合我在这里提出的条件:pastebin.com/m44d5b6b6(抱歉在我原来的问题中说明了这一点)。
  • 这有一个错误。而不是this.InOrderTraversal(),它应该说this.ChildNodes。否则每个节点将被访问 2^(n-1) 次,其中 n 是它拥有的祖先的数量...
  • @Noldorin:你是对的,深度优先搜索也会有类似的问题。因此,我认为您需要对进入流程的路径进行编码。
  • @Jason Orendorff:嗯?在树遍历中,每个节点都被访问一次。
  • @Jason:是的,这与我自己建议的解决方案相当相似。我想知道,将hash 初始化为非零(质数)值有什么真正的优势吗?
【解决方案5】:

它的无冲突属性将取决于用于节点数据的哈希函数的无冲突程度。

听起来您想要一个系统,其中特定节点的哈希是子节点哈希的组合,其中顺序很重要。

如果您打算大量操作这棵树,您可能需要为每个节点存储哈希码的空间付出代价,以避免在树上执行操作时重新计算的惩罚。

由于子节点的顺序很重要,一个可行的方法是使用素数倍数和加法模一些大数来组合节点数据和子节点。

使用类似于 Java 的 String 哈希码:

假设你有 n 个子节点。

hash(node) = hash(nodedata) +
             hash(childnode[0]) * 31^(n-1) +
             hash(childnode[1]) * 31^(n-2) +
             <...> +
             hash(childnode[n])

可以在此处找到有关上述方案的更多详细信息:http://computinglife.wordpress.com/2008/11/20/why-do-hash-functions-use-prime-numbers/

【讨论】:

    【解决方案6】:

    我可以看到,如果您有大量的树要比较,那么您可以使用哈希函数来检索一组潜在的候选者,然后进行直接比较。

    一个可行的子字符串只是使用lisp语法在树周围放置括号,预先写出每个节点的标识符。但这在计算上相当于树的预排序比较,那么为什么不这样做呢?

    我给出了两种解决方案:一种是在完成后比较两棵树(需要解决冲突),另一种是计算哈希码。

    树比较:

    最有效的比较方法是简单地以固定顺序递归遍历每棵树(预排序很简单,与其他任何方法一样好),在每一步比较节点。

    1. 因此,只需创建一个访问者模式,该模式依次返回树的预排序中的下一个节点。即它的构造函数可以取树的根。

    2. 然后,只需创建访问者的两个 insces,它们充当 preorder 中下一个节点的生成器。即访问者 v1 = 新访问者(root1),访问者 v2 = 新访问者(root2)

    3. 编写一个比较函数,可以将自己与另一个节点进行比较。

    4. 然后只需访问树的每个节点,进行比较,如果比较失败则返回 false。即

    模块

     Function Compare(Node root1, Node root2)
          Visitor v1 = new Visitor(root1)
          Visitor v2 = new Visitor(root2)
    
          loop
              Node n1 = v1.next
              Node n2 = v2.next
              if (n1 == null) and (n2 == null) then
                    return true
              if (n1 == null) or (n2 == null) then
                    return false
              if n1.compare(n2) != 0 then
                    return false
          end loop
          // unreachable
     End Function
    

    结束模块

    哈希码生成:

    如果你想写出树的字符串表示,你可以对树使用 lisp 语法,然后对字符串进行采样以生成更短的哈希码。

    模块

     Function TreeToString(Node n1) : String
            if node == null
                return ""
            String s1 = "(" + n1.toString()
            for each child of n1
                s1 = TreeToString(child)
    
            return s1 + ")"
     End Function
    

    node.toString() 可以返回该节点的唯一标签/哈希码/任何内容。然后,您可以从 TreeToString 函数返回的字符串中进行子字符串比较,以确定树是否等效。对于较短的哈希码,只需对 TreeToString 函数进行采样,即每 5 个字符取一次。

    结束模块

    【讨论】:

    • 在我的情况下,我经常将各种树与相同其他树进行多次比较。在这种情况下,计算哈希肯定会更有效,因为您只需要在公共树的节点上递归一次?
    • 我明白了——这就是为什么我修改了我的答案以包含哈希码生成器。您可以简单地将树写成​​字符串,然后对其进行采样。如果您保留每个字符,则可以保证不会发生冲突,但效率会降低。每隔一个字符,更有效,可能的碰撞。您可以根据您的应用程序、数据等选择权衡。
    • @Larry:啊,这是一种新颖的方法。 +1 我认为这当然简单而有效......虽然可能不是最有效的。会考虑的。
    • 好吧,您总是可以通过跳过字符而不是实际生成字符来轻松优化它。只要跳过函数是一致的,它仍然会返回一个有效的哈希码。先把它做好,然后再优化 :) 这种简单的方法很容易优化和扩展。如果您查看其他方法,您会发现它们只是对哈希码使用惰性生成方法,但它比这种方法更临时,更简单。从简单正确开始,然后再优化。
    【解决方案7】:

    我认为您可以递归地执行此操作:假设您有一个散列函数 h 可以散列任意长度的字符串(例如 SHA-1)。现在,树的散列是一个字符串的散列,它是作为当前元素的散列(您有自己的函数)和该节点的所有子节点的散列(来自递归调用函数)。

    对于二叉树,您将拥有:

    Hash( h(node->data) || Hash(node->left) || Hash(node->right) )

    您可能需要仔细检查是否正确考虑了树的几何形状。我认为,通过一些努力,您可以推导出一种方法,在这种方法中找到此类树的冲突可能与在底层哈希函数中找到冲突一样困难。

    【讨论】:

      【解决方案8】:

      一个简单的枚举(以任何确定的顺序)和一个取决于何时访问节点的哈希函数应该可以工作。

      int hash(Node root) {
        ArrayList<Node> worklist = new ArrayList<Node>();
        worklist.add(root);
        int h = 0;
        int n = 0;
        while (!worklist.isEmpty()) {
          Node x = worklist.remove(worklist.size() - 1);
          worklist.addAll(x.children());
          h ^= place_hash(x.hash(), n);
          n++;
        }
        return h;
      }
      
      int place_hash(int hash, int place) {
        return (Integer.toString(hash) + "_" + Integer.toString(place)).hash();
      }
      

      【讨论】:

      • 我认为这不能满足区分具有相同前序遍历但结构不同的树的要求。
      • 我不认为这被列为要求。如果需要,可以将节点深度添加到哈希中。我怀疑前序索引加上节点深度会决定一棵唯一的树。
      • @Keith:Jason 是对的——遍历的顺序不够——结构也需要考虑。
      【解决方案9】:
      class TreeNode
      {
        public static QualityAgainstPerformance = 3; // tune this for your needs
        public static PositionMarkConstan = 23498735; // just anything
        public object TargetObject; // this is a subject of this TreeNode, which has to add it's hashcode;
      
        IEnumerable<TreeNode> GetChildParticipiants()
        {
         yield return this;
      
         foreach(var child in Children)
         {
          yield return child;
      
          foreach(var grandchild in child.GetParticipiants() )
           yield return grandchild;
        }
        IEnumerable<TreeNode> GetParentParticipiants()
        {
         TreeNode parent = Parent;
         do
          yield return parent;
         while( ( parent = parent.Parent ) != null );
        }
        public override int GetHashcode()
        {
         int computed = 0;
         var nodesToCombine =
          (Parent != null ? Parent : this).GetChildParticipiants()
           .Take(QualityAgainstPerformance/2)
          .Concat(GetParentParticipiants().Take(QualityAgainstPerformance/2));
      
         foreach(var node in nodesToCombine)
         {
          if ( node.ReferenceEquals(this) )
            computed = AddToMix(computed, PositionMarkConstant );
          computed = AddToMix(computed, node.GetPositionInParent());
          computed = AddToMix(computed, node.TargetObject.GetHashCode());
         }
         return computed;
        }
      }
      

      AddToTheMix 是一个函数,它结合了两个哈希码,因此顺序很重要。 我不知道它是什么,但你可以弄清楚。一些位移,四舍五入,你知道的......

      这个想法是你必须分析节点的一些环境,这取决于你想要达到的质量。

      【讨论】:

      • @George:介意再评论/解释一下代码吗?
      【解决方案10】:

      我不得不说,你的要求在某种程度上违背了哈希码的整个概念。

      哈希函数的计算复杂度应该是非常有限的。

      它的计算复杂度不应该线性依赖于容器(树)的大小,否则它会完全破坏基于哈希码的算法。

      将位置视为节点散列函数的主要属性也有点违背树的概念,但如果您替换要求,它必须依赖于位置是可以实现的。

      我建议的总体原则是将 MUST 要求替换为 SHOULD 要求。 这样您就可以提出适当且有效的算法。

      例如,考虑构建一个有限的整数哈希码标记序列,然后按优先顺序将您想要的内容添加到该序列中。

      此序列中元素的顺序很重要,它会影响计算值。

      例如对于您要计算的每个节点:

      1. 添加底层对象的哈希码
      2. 添加最近兄弟的底层对象的哈希码(如果有)。我认为,即使是一个左兄弟也足够了。
      3. 添加父对象的底层对象和它最近的兄弟节点的哈希码,就像节点本身一样,与2相同。
      4. 在有限的深度上与祖父母重复此操作。

        //--------5------- ancestor depth 2 and it's left sibling;
        //-------/|------- ;
        //------4-3------- ancestor depth 1 and it's left sibling;    
        //-------/|------- ;
        //------2-1------- this;
        

        添加直接兄弟对象的底层对象的哈希码这一事实为哈希函数提供了位置属性。

        如果这还不够,添加孩子: 您应该添加每个孩子,只添加一些以提供体面的哈希码。

      5. 添加第一个孩子,它是第一个孩子,它是第一个孩子。将深度限制为某个常数,并且不递归计算任何东西 - 只是底层节点的对象的哈希码。

        //----- this;
        //-----/--;
        //----6---;
        //---/--;
        //--7---;
        

      这样,复杂度与底层树的深度成线性关系,而不是元素的总数。

      现在你有了一个整数序列,将它们与已知算法结合起来,就像上面 Ely 建议的那样。

      1,2,...7

      这样,你将拥有一个轻量级的哈希函数,具有位置属性,不依赖于树的总大小,甚至不依赖于树的深度,并且不需要重新计算整个树的哈希函数你改变了树结构。

      我敢打赌,这 7 个数字会给出接近完美的哈希分布。

      【讨论】:

      • -1。我知道的所有哈希码算法都使用所有数据。哈希码被简单地缓存,以便在实践中以恒定时间计算它们。
      【解决方案11】:

      编写自己的哈希函数几乎总是一个错误,因为您基本上需要数学学位才能做好。散列函数非常不直观,并且具有高度不可预测的碰撞特性。

      不要尝试直接组合子节点的哈希码——这会放大底层哈希函数中的任何问题。相反,按顺序连接来自每个节点的原始字节,并将其作为字节流提供给经过验证的哈希函数。所有加密散列函数都可以接受字节流。如果树很小,您可能只想创建一个字节数组并在一次操作中对其进行散列。

      【讨论】:

        猜你喜欢
        • 2016-11-07
        • 2017-06-17
        • 2019-05-25
        • 2014-09-28
        • 2011-12-31
        • 2011-12-15
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多