* Retranslate the complete Japanese online edition from Chinese * Fit Japanese homepage labels and localize heading permalinks * Point Japanese contribution instructions to the Japanese preview * Replace locale conditionals with multilingual UI tables
9.4 KiB
ハッシュ衝突
前節で述べたように、通常、ハッシュ関数の入力空間は出力空間よりはるかに大きいため、理論上、ハッシュ衝突は避けられません。例えば、入力がすべての整数で、出力の範囲が配列の容量なら、複数の整数が同じバケットのインデックスに写されることは必然です。
ハッシュ衝突は誤った照会結果を生み、ハッシュテーブルの利用に重大な影響を与えます。衝突するたびに、解消するまで容量を拡張すれば、この問題を解決できます。単純で強引ながら有効ですが、多くのデータの移動とハッシュ値の計算が必要なため、効率が低すぎます。効率を高めるには、次の戦略を使えます。
- データ構造を改良して、ハッシュ衝突が起きても正しく動作できるようにします。
- 必要な場合、つまり衝突が多くなった場合だけ容量を拡張します。
主な構造の改良方法には、「チェイン法」と「オープンアドレス法」があります。
チェイン法
元のハッシュテーブルでは、各バケットに一つのペアしか格納できません。チェイン法(separate chaining)では、単一の要素を連結リストに置き換え、キーと値のペアをノードとして、衝突したペアをすべて同じリストに格納します。下図にその例を示します。
チェイン法では、操作が次のように変わります。
- 要素の照会:
keyを入力し、ハッシュ関数でバケットのインデックスを求め、リストの先頭ノードにアクセスします。その後、リストを走査してkeyを比較し、目的のペアを探します。 - 要素の追加:ハッシュ関数でリストの先頭ノードにアクセスし、ノード、つまりキーと値のペアをリストに追加します。
- 要素の削除:ハッシュ関数の結果に基づいてリストの先頭にアクセスし、走査して目的のノードを見つけ、削除します。
チェイン法には、次の制約があります。
- 使用領域が増える:連結リストはノードのポインタを含むため、配列よりメモリを多く使います。
- 照会の効率が下がる:目的の要素を探すために、リストを線形に走査する必要があります。
以下は単純な実装です。次の二点に注意してください。
- コードを簡単にするため、連結リストの代わりにリスト(動的配列)を使います。この場合、ハッシュテーブルの配列は複数のバケットを持ち、各バケットが一つのリストになります。
- 容量拡張の処理も含めています。負荷率が
\frac{2}{3}を超えると、容量を元の2倍にします。
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
連結リストが長い場合、O(n) の照会は効率が悪くなります。その場合、リストを AVL 木や赤黒木へ変換すれば、照会の時間計算量を O(\log n) に改善できます。
オープンアドレス法
オープンアドレス法(open addressing)は、追加のデータ構造を導入せず、複数回の探索で衝突に対処します。主な方法には線形探索法、平方探索法、複数のハッシュ関数を使う方法などがあります。
以下では線形探索法を例に、その仕組みを説明します。
線形探索法
線形探索法は、一定の間隔で線形に探します。通常のハッシュテーブルとは操作が異なります。
- 要素の挿入:ハッシュ関数でバケットのインデックスを求め、すでに要素があれば、衝突位置から後ろへ一定間隔(通常は $1$)で走査し、空のバケットを見つけて挿入します。
- 要素の探索:衝突したら同じ間隔で後ろへ線形に走査し、目的の要素を見つけて
valueを返します。空のバケットに出会ったら、その要素は存在しないのでNoneを返します。
下図は、オープンアドレス法(線形探索法)におけるペアの分布です。このハッシュ関数では、下二桁が等しい key は同じバケットに写されます。線形探索によって、そのバケットと後続のバケットへ順に格納されます。
ただし、線形探索法では「クラスタリング」が起きやすくなります。連続して使用中の場所が長くなるほど、そこで衝突する確率が高まり、さらに塊が成長するという悪循環が生じます。その結果、追加、削除、照会、更新の効率が悪化します。
オープンアドレス法では、要素を直接削除できないことにも注意が必要です。削除すると配列内に空のバケット None ができ、照会時の線形探索はそこで終了してしまいます。その先の要素に到達できなくなり、存在しないと誤判定する可能性があります。下図に示します。
この問題には、遅延削除(lazy deletion)を使えます。要素を直接取り除かず、定数 TOMBSTONE でそのバケットに印を付けます。None と TOMBSTONE はどちらも空のバケットを表し、新しいペアを置けます。ただし、TOMBSTONE の先にはペアがある可能性があるため、線形探索を続ける必要があります。
一方、遅延削除は性能の悪化を早める可能性があります。削除のたびに印が増え、TOMBSTONE が多くなると、目的の要素までいくつも飛び越える必要が生じ、探索時間が長くなるためです。
そこで、線形探索で最初に出会った TOMBSTONE のインデックスを記録し、見つかった目的の要素と位置を交換します。照会や追加のたびに、要素が理想の位置、つまり探索開始位置に近いバケットへ移るため、照会効率を改善できます。
以下は、遅延削除を備えたオープンアドレス法(線形探索法)の実装です。領域を十分に活用するため、ハッシュテーブルを循環配列と見なし、末尾を越えたら先頭に戻って走査を続けます。
[file]{hash_map_open_addressing}-[class]{hash_map_open_addressing}-[func]{}
平方探索法
平方探索法も、線形探索法と同じくオープンアドレス法の代表的な戦略です。衝突時には一定の歩数ではなく、探索回数の二乗、つまり 1, 4, 9, \dots 歩を飛び越えます。
主な利点は次のとおりです。
- 探索回数の二乗の距離を飛び越えることで、線形探索法のクラスタリングを緩和しようとします。
- より大きな距離を飛び越えて空きを探すことで、データの分布を均等にしやすくなります。
ただし、完璧な方法ではありません。
- ほかより埋まりやすい場所があるという、クラスタリングは依然として生じます。
- 二乗で距離が増えるため、テーブル全体を探索できない場合があります。空のバケットがあっても、到達できない可能性があります。
複数のハッシュ関数を使う方法
名前のとおり、複数のハッシュ関数 $f_1(x)$、$f_2(x)$、$f_3(x)$、\dots を使って探索します。
- 要素の挿入:
f_1(x)で衝突したらf_2(x)を試し、空きが見つかるまで順に試して挿入します。 - 要素の探索:挿入時と同じ順序でハッシュ関数を使い、目的の要素が見つかったら返します。空きに出会うか、すべての関数を試しても見つからなければ、存在しないので
Noneを返します。
線形探索法よりクラスタリングは起きにくいものの、複数のハッシュ関数による追加の計算が必要です。
!!! tip
線形探索法、平方探索法、複数のハッシュ関数を使う方法のいずれも、オープンアドレス法には「要素を直接削除できない」という問題があります。
プログラミング言語ごとの選択
言語ごとに異なる実装戦略が採用されています。いくつか例を挙げます。
- Python はオープンアドレス法を採用しています。辞書
dictは疑似乱数を使って探索します。 - Java はチェイン法を採用しています。JDK 1.8 以降では、
HashMap内の配列の長さが 64 に、連結リストの長さが 8 に達すると、リストを赤黒木へ変換して探索性能を高めます。 - Go 1.24 以降、組み込みの
mapはデフォルトで Swiss Table に基づくオープンアドレス法を使います。Go 1.23 以前はチェイン法で、各バケットに最大 8 ペアを格納し、容量を超えるとオーバーフローバケットをつなぎます。


