私のプロジェクトで、既知のインスタンスと等しいかどうかについてさまざまなツリーオブジェクトを比較する必要があるシナリオに遭遇しました。任意のツリーで動作するある種のハッシュアルゴリズムが非常に役立つと考えました。
次のツリーを例にとります。
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;
}
このメソッドのいいところは、私が見るように、ノードまたはその子孫の1つが変更されたときにのみハッシュコードをキャッシュして再計算できることです。 (これを指摘してくれたvatineとJason Orendorffに感謝します)。
とにかく、私がここで提案した解決策について人々がコメントできれば幸いです-それがうまく機能すれば、素晴らしいです、さもなければ、可能な改善は歓迎されます。
これを行うとしたら、おそらく次のようなことをするでしょう。
各リーフノードについて、0の連結とノードデータのハッシュを計算します。
内部ノードごとに、1の連結と任意のローカルデータのハッシュ(NB:該当しない場合があります)および子のハッシュを左から右に計算します。
これにより、何かを変更するたびにツリーがカスケードアップしますが、価値のあるオーバーヘッドが十分にない場合があります。変更の量に比べて変更の頻度が比較的低い場合は、暗号で保護されたハッシュを使用することも理にかなっています。
Edit1:各ノードに「ハッシュ有効」フラグを追加して、ツリーに「false」を伝搬する(または「ハッシュ無効」に「true」を伝搬する)こともできます。ノード変更時。そうすることで、ツリーハッシュが必要な場合に完全な再計算を回避し、使用されない複数のハッシュ計算を回避できる可能性があります。
Edit3:質問でNoldorinによって提案されたハッシュコードは、GetHashCodeの結果が0になる可能性がある場合、衝突の可能性があるように見えます。基本的に、構成されているツリーを区別する方法はありません。 「シンボルハッシュ」30と「値ハッシュ」25および2ノードツリーを持つ単一ノード。ルートには「シンボルハッシュ」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 =子 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 if self.valid: return self.hash digester = crypto.hash .SHA.new() digester.update(self.contents) if self.children: for 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, 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]
次に、リスト(つまり、文字列)を好きな方法でハッシュできます。別のオプションとして、ハッシュ関数の結果としてこのリストを返すこともできるので、衝突のないツリー表現になります。
しかし、構造全体に関するprecise情報を追加することは、ハッシュ関数が通常行うことではありません。提案された方法は、すべてのノードのハッシュ関数を計算するだけでなく、ツリー全体をトラバースする必要があります。したがって、以下で説明するハッシュの他の方法を検討することができます。
ツリー全体を行き来したくない場合:
すぐに頭に浮かんだアルゴリズムの1つがこのようなものです。大きな素数H
を選択します(これは、子の最大数よりも大きい)。ツリーをハッシュするには、ルートをハッシュし、子番号H mod n
を選択します。ここで、n
はルートの子の数であり、この子のサブツリーを再帰的にハッシュします。
木々が葉の近くでのみ大きく異なる場合、これは悪いオプションのようです。しかし、少なくとも、それほど高くない木に対しては高速で実行する必要があります。
ハッシュする要素は少ないがツリー全体を通過する場合:
サブツリーをハッシュする代わりに、レイヤーごとにハッシュしたい場合があります。つまり最初にルートをハッシュし、その子であるノードの1つをハッシュしてから、子の子の1つをハッシュします。そのため、特定のパスの1つではなくツリー全体をカバーします。もちろん、これによりハッシュ処理が遅くなります。
--- 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
ルールで選択されます。
このバージョンと以前のバージョンとの違いは、ハッシュ関数を保持するためにツリーがかなり非論理的な変換を受ける必要があることです。
シーケンスをハッシュする通常の手法は、いくつかの数学的な方法でその要素の値(またはそのハッシュ)を結合することです。木はこの点でどんな違いもないと思います。
たとえば、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;
}
これは、典型的な長さのタプルの最良の結果を得るために実験的に選択された定数との比較的複雑な組み合わせです。このコードスニペットで表示しようとしているのは、問題が非常に複雑で非常にヒューリスティックであり、結果の品質はおそらくデータのより具体的な側面に依存することです。つまり、ドメインの知識は、より良い結果を得るのに役立つ場合があります。ただし、十分な結果を得るには、あまり遠くを見ないでください。このアルゴリズムを採用し、すべてのタプル要素ではなくツリーのすべてのノードを組み合わせ、さらにそれらの位置を追加することで、かなり良いアルゴリズムが得られると思います。
位置を考慮に入れる1つのオプションは、ツリーの順不同のウォークにおけるノードの位置です。
ツリーの再帰を使用しているときはいつでも頭に浮かぶはずです。
_public override int GetHashCode() {
int hash = 5381;
foreach(var node in this.BreadthFirstTraversal()) {
hash = 33 * hash + node.GetHashCode();
}
}
_
ハッシュ関数は、ツリー内のすべてのノードのハッシュコードとその位置に依存する必要があります。
小切手。ツリーのハッシュコードの計算では、明示的にnode.GetHashCode()
を使用しています。さらに、アルゴリズムの性質上、ノードの位置はツリーの最終的なハッシュコードで役割を果たします。
ノードの子を並べ替えると、結果のハッシュコードが明確に変わるはずです。
小切手。それらは、異なるハッシュコードにつながる順序トラバーサルの異なる順序で訪問されます。 (同じハッシュコードを持つ2つの子がある場合、それらの子の順序を入れ替えると、同じハッシュコードになることに注意してください。)
ツリーの一部を反映すると、結果のハッシュコードが明確に変わるはずです。
小切手。この場合も、ノードは異なる順序でアクセスされ、異なるハッシュコードが生成されます。 (すべてのノードが同じハッシュコードを持つノードに反映される場合、リフレクションが同じハッシュコードにつながる状況があることに注意してください。)
これのcollision-freeプロパティは、ノードデータに使用されるハッシュ関数がどのように衝突なしであるかに依存します。
特定のノードのハッシュが、順序が重要な子ノードのハッシュの組み合わせであるシステムが必要なように思えます。
このツリーを何度も操作することを計画している場合は、ツリーで操作を実行するときに再計算のペナルティを回避するために、各ノードでハッシュコードを格納するスペースの代金を支払うことができます。
子ノードの順序が重要であるため、ここで機能する可能性がある方法は、素数の倍数といくつかの大きな数を法とする加算を使用してノードデータと子を結合することです。
Javaの文字列ハッシュコードに似たものを探すには:
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/
比較するツリーのセットが多い場合は、ハッシュ関数を使用して候補のセットを取得し、直接比較できます。
機能する部分文字列は、LISP構文を使用してツリーを角かっこで囲み、各ノードの識別子を事前に書き出すだけです。しかし、これはツリーの事前順序比較と計算上同等なので、なぜそれをしないのですか?
私は2つのソリューションを提供しました。1つは完了したときに2つのツリーを比較するため(衝突を解決するために必要)、もう1つはハッシュコードを計算するためのものです。
ツリー比較:
比較する最も効率的な方法は、固定された順序で各ツリーを単純に再帰的にトラバースすることです(事前順序は単純であり、他の何よりも優れています)。各ステップでノードを比較します。
したがって、ツリーのプレオーダーで次のノードを連続して返すVisitorパターンを作成するだけです。つまり、コンストラクタはツリーのルートを取得できます。
次に、ビジターの2つのインスタンスを作成します。これらは、プレオーダーの次のノードのジェネレーターとして機能します。つまり、Vistor v1 = new Visitor(root1)、Visitor v2 = new Visitor(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
エンドモジュール
ハッシュコードの生成:
ツリーの文字列表現を書き出す場合は、ツリーの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文字ごとに取得します。
エンドモジュール
ノードがいつアクセスされたかに依存するハッシュ関数と一緒に、(決定論的な順序で)単純な列挙が機能するはずです。
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();
}
私はこれを再帰的に実行できると思います:任意の長さの文字列をハッシュするハッシュ関数h(たとえばSHA-1)があるとします。これで、ツリーのハッシュは、現在の要素のハッシュ(そのための独自の関数があります)とそのノードのすべての子のハッシュ(の再帰呼び出しから)の連結として作成された文字列のハッシュです。関数)。
二分木の場合、次のようになります。
Hash( h(node->data) || Hash(node->left) || Hash(node->right) )
ツリージオメトリが適切に考慮されているかどうかを注意深く確認する必要がある場合があります。少し努力すれば、そのようなツリーの衝突を見つけるのは、基礎となるハッシュ関数で衝突を見つけるのと同じくらい難しいかもしれない方法を導き出すことができると思います。
私は、あなたの要件はハッシュコードの概念全体にいくらか反対していると言わざるを得ません。
ハッシュ関数の計算の複雑さは非常に制限されるべきです。
計算の複雑さは、コンテナ(ツリー)のサイズに直線的に依存するべきではありません。そうでなければ、ハッシュコードベースのアルゴリズムを完全に壊します。
ノードのハッシュ関数の主要なプロパティとして位置を考慮することも、ツリーの概念に多少反しますが、要件を置き換えると、位置に依存する必要があることを実現できます。
私が提案する全体的な原則は、MUST要件をSHOULD要件に置き換えることです。そうすれば、適切で効率的なアルゴリズムを思い付くことができます。
たとえば、整数のハッシュコードトークンの限定されたシーケンスを構築することを検討し、このシーケンスに必要なものを優先順に追加します。
このシーケンスの要素の順序は重要であり、計算値に影響します。
たとえば、計算するノードごとに:
限られた深さまで祖父母とこれを繰り返します。
//--------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つの数値は、ハッシュ分散を完璧に近いものにするでしょう。
自分でハッシュ関数を書くことは、ほとんどの場合バグです。基本的には、数学を上手に実行するには学位が必要だからです。ハッシュ関数は信じられないほど直感的ではなく、非常に予測できない衝突特性を持っています。
子ノードのハッシュコードを直接組み合わせようとしないでください。これにより、基になるハッシュ関数の問題が拡大します。代わりに、各ノードからの生のバイトを順番に連結し、これをバイトストリームとして実証済みのハッシュ関数に送ります。すべての暗号化ハッシュ関数は、バイトストリームを受け入れることができます。ツリーが小さい場合は、バイト配列を作成して、1回の操作でハッシュすることをお勧めします。
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は2つのハッシュコードを組み合わせる関数なので、シーケンスが重要です。何なのかわかりませんが、わかります。少しビットシフト、丸め、知っています...
つまり、達成したい品質に応じて、ノードの環境を分析する必要があります。