我刚刚在我的项目中遇到了一个场景,我需要比较不同的树对象与已知实例的相等性,并且认为在任意树上运行的某种散列算法非常有用。
以下面的树为例:
O / \ / \ O O /|\ | / | \ | O O O O / \ / \ O O
其中每个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指出这一点)。
无论如何,如果人们可以在这里评论我建议的解决方案,我将不胜感激 - 如果它做得很好,那么很好,否则任何可能的改进都会受到欢迎。
答案 0 :(得分:23)
如果我这样做,我可能会做以下事情:
对于每个叶节点,计算0的串联和节点数据的散列。
对于每个内部节点,计算1的串联和任何本地数据的散列(NB:可能不适用)和从左到右的子节点的散列。
每当你改变任何东西时,这将导致树的级联,但这可能是一个足够低的开销,值得。如果与变更量相比变化相对较少,那么获取加密安全散列甚至是有意义的。
Edit1:还有可能向每个节点添加“哈希有效”标志,并简单地在树上传播“假”(或“哈希无效”并传播“真”)在节点上更改树。这样,当需要树形哈希时可以避免完全重新计算,并且可能避免未使用的多个哈希计算,但是在需要时可能会有更少的可预测时间来获取哈希值。
Edit3:如果GetHashCode的结果可能为0,Noldorin在问题中建议的哈希码看起来有可能发生冲突。实际上,没有办法区分树由单个节点组成,具有“符号散列”30和“值散列”25以及双节点树,其中根具有0的“符号散列”和30的“值散列”并且子节点具有这个例子完全是发明的,我不知道预期的哈希范围是什么,所以我只能评论我在所提出的代码中看到的内容。
使用31作为乘法常量是好的,因为它会导致在非位边界上发生任何溢出,尽管我认为,如果树中有足够的子节点和可能的对抗性内容,则来自项的哈希贡献早期的哈希可以由后来的哈希项目占主导地位。
但是,如果散列在预期数据上运行得体,那么它看起来就像是在完成这项工作。它肯定比使用加密哈希更快(如下面列出的示例代码中所做的那样)。
Edit2:至于所需的特定算法和最小数据结构,如下所示(Python,翻译成任何其他语言应该相对容易)。
#! /usr/bin/env python import Crypto.Hash.SHA class Node: def __init__ (self, parent=None, contents="", children=[]): self.valid = False self.hash = False self.contents = contents self.children = children def append_child (self, child): self.children.append(child) self.invalidate() def invalidate (self): self.valid = False if self.parent: self.parent.invalidate() def gethash (self): if self.valid: return self.hash digester = crypto.hash.SHA.new() digester.update(self.contents) if self.children: for child in self.children: digester.update(child.gethash()) self.hash = "1"+digester.hexdigest() else: self.hash = "0"+digester.hexdigest() return self.hash def setcontents (self): self.valid = False return self.contents
答案 1 :(得分:8)
好的,在编辑之后,您已经引入了一个要求,即散列结果对于不同的树布局应该是不同的,您只能选择遍历整个树并将其结构写入单个数组。
这样做是这样的:你遍历树并转储你所做的操作。对于可能的原始树(对于左子 - 右 - 兄弟结构):
[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
是root的子项数,并递归地对该子项的子树进行哈希处理。
如果树木仅在树叶附近深处不同,这似乎是一个不好的选择。但至少它应该在不高大的树木上快速运行。
如果你想散列更少的元素但是要遍历整个树:
您可能希望以分层方式散列,而不是散列子树。即首先是哈希根,而不是作为其子节点的哈希之一,然后是孩子的子节点之一等等。因此,您覆盖整个树而不是特定路径之一。当然,这会使散列过程变慢。
--- 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
规则挑选图层中的节点。
此版本与之前版本之间的区别在于树应该进行非常不合逻辑的转换以保留哈希函数。
答案 2 :(得分:7)
散列任何序列的常用技术是以某种数学方式组合其元素的值(或其散列)。我不认为树在这方面会有任何不同。
例如,这里是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;
}
这是一个相对复杂的组合,通过实验选择常数,以获得典型长度元组的最佳结果。我试图用这段代码展示的是,问题非常复杂且非常具有启发性,结果的质量可能取决于数据的更具体方面 - 即领域知识可能会帮助您获得更好的结果。但是,为了获得足够好的结果,你不应该看得太远。我猜想采用这种算法并结合树的所有节点而不是所有的元组元素,加上将它们的位置添加到游戏中会给你一个非常好的算法。
将位置考虑在内的一个选择是节点在树的顺序行走中的位置。
答案 3 :(得分:6)
任何时候你都在想树树递归应该浮现在脑海中:
public override int GetHashCode() {
int hash = 5381;
foreach(var node in this.BreadthFirstTraversal()) {
hash = 33 * hash + node.GetHashCode();
}
}
散列函数应该取决于树中每个节点的散列码及其位置。
检查。我们在计算树的哈希码时明确使用node.GetHashCode()
。此外,由于算法的性质,节点的位置在树的最终哈希码中起作用。
重新排序节点的子节点应明显更改生成的哈希码。
检查。它们将在有序遍历中以不同的顺序被访问,从而导致不同的哈希码。 (请注意,如果有两个具有相同哈希码的子代码,则在交换这些子代码的顺序时,最终会得到相同的哈希代码。)
反映树的任何部分应明显更改生成的哈希码
检查。同样,将以不同的顺序访问节点,从而导致不同的哈希码。 (请注意,如果每个节点都反映到具有相同哈希码的节点中,则存在反射可能导致相同哈希码的情况。)
答案 4 :(得分:4)
无冲突属性取决于用于节点数据的哈希函数的无冲突。
听起来你想要一个系统,其中特定节点的散列是子节点散列的组合,其中顺序很重要。
如果您计划大量操作这棵树,您可能需要为每个节点支付存储哈希码的空间价格,以避免在树上执行操作时重新计算的代价。
由于子节点的顺序很重要,这里可能有用的方法是将节点数据和子节点组合使用素数倍和加模数大数。
寻找类似于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/
答案 5 :(得分:3)
我可以看到,如果你要比较一大堆树,那么你可以使用哈希函数来检索一组潜在候选者,然后进行直接比较。
可以使用的子字符串只是使用lisp语法在树周围放置括号,按预编程写出每个节点的标识符。但这在计算上等同于树的预订比较,那么为什么不这样做呢?
我给出了两个解决方案:一个用于在完成时比较两个树(需要解决冲突),另一个用于计算哈希码。
TREE比较:
最有效的比较方法是简单地以固定顺序递归遍历每棵树(预订很简单,与其他任何东西一样好),比较每一步的节点。
因此,只需创建一个访客模式,该模式会按预先为树返回下一个节点。即它的构造函数可以取树的根。
然后,只需创建两个访问者的insces,它们作为预订中下一个节点的生成器。即Vistor v1 =新访客(root1),访客v2 =新访客(root2)
编写一个比较函数,可以将自己与另一个节点进行比较。
然后只访问树的每个节点,进行比较,如果比较失败则返回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
结束模块
HASH代码生成:
如果要写出树的字符串表示,可以使用树的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个字符。
结束模块
答案 6 :(得分:1)
我认为你可以递归地执行此操作:假设你有一个哈希函数h,它具有任意长度的字符串(例如SHA-1)。现在,树的散列是一个字符串的散列,它是作为当前元素的散列(你有自己的函数)的串联而创建的,并且是该节点的所有子节点的散列(来自的递归调用)功能)。
对于二叉树,您将拥有:
Hash( h(node->data) || Hash(node->left) || Hash(node->right) )
您可能需要仔细检查是否正确考虑了树几何。我认为通过一些努力,您可以推导出一种方法,可以找到这些树的碰撞,就像在底层哈希函数中查找碰撞一样难。
答案 7 :(得分:1)
一个简单的枚举(以任何确定的顺序)和一个取决于访问节点的散列函数应该可以工作。
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();
}
答案 8 :(得分:0)
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是一个函数,它结合了两个哈希码,因此序列很重要。 我不知道它是什么,但你可以搞清楚。有点变化,四舍五入,你知道......
这个想法是你必须分析节点的某些环境,这取决于你想要达到的质量。
答案 9 :(得分:0)
我必须说,你的要求在某种程度上违背了哈希码的整个概念。
散列函数的计算复杂度应该非常有限。
计算复杂度不应该线性地依赖于容器(树)的大小,否则它会完全打破基于哈希码的算法。
将位置视为节点散列函数的主要属性也有点违背树的概念,但如果替换要求,则可以实现,它必须依赖于位置。
我建议的总体原则是用SHOULD要求取代MUST要求。 这样你就可以提出合适而有效的算法。
例如,考虑构建有限的整数哈希码令牌序列,并按优先顺序添加您想要的序列。
此序列中元素的顺序很重要,它会影响计算值。
例如,您要计算的每个节点:
与祖父母一起重复一次到有限的深度。
//--------5------- ancestor depth 2 and it's left sibling;
//-------/|------- ;
//------4-3------- ancestor depth 1 and it's left sibling;
//-------/|------- ;
//------2-1------- this;
您正在添加直接兄弟的底层对象的哈希码,这为哈希函数提供了一个位置属性。
如果这还不够,请添加孩子: 你应该添加每个孩子,只需要一些孩子来提供一个像样的哈希码。
添加第一个子节点,它是第一个子节点,它是第一个子节点..将深度限制为某个常量,不要递归计算任何内容 - 只是底层节点的对象的哈希码。
//----- this;
//-----/--;
//----6---;
//---/--;
//--7---;
这种方式的复杂性与底层树的深度呈线性关系,而不是元素的总数。
现在你有一个整数序列,将它们与已知的算法结合起来,就像上面提到的Ely。
1,2,... 7
这样,你将拥有一个轻量级的哈希函数,它具有一个位置属性,不依赖于树的总大小,甚至不依赖于树的深度,也不需要重新计算整个树的哈希函数。你改变了树的结构。
我敢打赌,这7个数字会让哈希的分布接近完美。
答案 10 :(得分:0)
编写自己的哈希函数几乎总是一个错误,因为你基本上需要数学学位才能做好。 Hashfunctions非常不直观,并且具有高度不可预测的碰撞特性。
不要尝试直接组合子节点的哈希码 - 这将放大底层哈希函数中的任何问题。相反,按顺序连接每个节点的原始字节,并将其作为字节流提供给经过验证的哈希函数。所有加密哈希函数都可以接受字节流。如果树很小,您可能只想创建一个字节数组并在一次操作中对其进行哈希处理。