--- comments: true --- # 6.3 ハッシュアルゴリズム 前の2つの節では、ハッシュ表の動作原理とハッシュ衝突を処理する方法を紹介しました。しかし、オープンアドレス法と連鎖法はどちらも**衝突が発生した際にハッシュ表が正常に機能することのみを保証でき、ハッシュ衝突の発生頻度を減らすことはできません**。 ハッシュ衝突があまりにも頻繁に発生すると、ハッシュ表の性能は劇的に悪化します。下図に示すように、連鎖法ハッシュ表では、理想的なケースではキー値ペアがバケット間に均等に分散され、最適なクエリ効率を実現します。最悪のケースでは、すべてのキー値ペアが同じバケットに格納され、時間計算量が$O(n)$に悪化します。 { class="animation-figure" }
図 6-8 ハッシュ衝突の理想的および最悪のケース
**キー値ペアの分布はハッシュ関数によって決定されます**。ハッシュ関数の計算ステップを思い出すと、まずハッシュ値を計算し、次に配列長で剰余を取ります: ```shell index = hash(key) % capacity ``` 上記の式を観察すると、ハッシュ表の容量`capacity`が固定されている場合、**ハッシュアルゴリズム`hash()`が出力値を決定し**、それによってハッシュ表におけるキー値ペアの分布を決定します。 これは、ハッシュ衝突の確率を減らすために、ハッシュアルゴリズム`hash()`の設計に焦点を当てるべきであることを意味します。 ## 6.3.1 ハッシュアルゴリズムの目標 「高速で安定した」ハッシュ表データ構造を実現するために、ハッシュアルゴリズムは以下の特性を持つべきです: - **決定性**: 同じ入力に対して、ハッシュアルゴリズムは常に同じ出力を生成するべきです。そうでなければハッシュ表は信頼できません。 - **高効率**: ハッシュ値を計算するプロセスは十分に高速である必要があります。計算オーバーヘッドが小さいほど、ハッシュ表はより実用的になります。 - **均等分散**: ハッシュアルゴリズムはキー値ペアがハッシュ表に均等に分散されることを保証するべきです。分散が均等であるほど、ハッシュ衝突の確率は低くなります。 実際、ハッシュアルゴリズムはハッシュ表の実装だけでなく、他の分野でも広く応用されています。 - **パスワード保存**: ユーザーパスワードのセキュリティを保護するために、システムは通常平文パスワードを保存せず、パスワードのハッシュ値を保存します。ユーザーがパスワードを入力すると、システムは入力のハッシュ値を計算し、保存されているハッシュ値と比較します。一致すれば、パスワードは正しいと見なされます。 - **データ整合性チェック**: データ送信者はデータのハッシュ値を計算して一緒に送信できます。受信者は受信したデータのハッシュ値を再計算し、受信したハッシュ値と比較できます。一致すれば、データは完全であると見なされます。 暗号化アプリケーションでは、ハッシュ値から元のパスワードを推測するなどの逆行分析を防ぐために、ハッシュアルゴリズムはより高いレベルのセキュリティ機能が必要です。 - **一方向性**: ハッシュ値から入力データに関する情報を推測することは不可能であるべきです。 - **衝突耐性**: 同じハッシュ値を生成する2つの異なる入力を見つけることは極めて困難であるべきです。 - **雪崩効果**: 入力の小さな変更は、出力に大きく予測不可能な変化をもたらすべきです。 **「均等分散」と「衝突耐性」は2つの別々の概念**であることに注意してください。均等分散を満たしても、必ずしも衝突耐性があるとは限りません。例えば、ランダムな入力`key`の下で、ハッシュ関数`key % 100`は均等に分散された出力を生成できます。しかし、このハッシュアルゴリズムは過度にシンプルで、下二桁が同じすべての`key`は同じ出力を持つため、ハッシュ値から使用可能な`key`を簡単に推測でき、パスワードを破ることができます。 ## 6.3.2 ハッシュアルゴリズムの設計 ハッシュアルゴリズムの設計は多くの要因を考慮する必要がある複雑な問題です。しかし、要求が少ない一部のシナリオでは、いくつかの簡単なハッシュアルゴリズムを設計することもできます。 - **加算ハッシュ**: 入力の各文字のASCIIコードを合計し、合計をハッシュ値として使用します。 - **乗算ハッシュ**: 乗算の非相関性を利用し、各ラウンドで定数を乗算し、各文字のASCIIコードをハッシュ値に累積します。 - **XORハッシュ**: 入力データの各要素をXORすることでハッシュ値を累積します。 - **回転ハッシュ**: 各文字のASCIIコードをハッシュ値に累積し、各累積前にハッシュ値に回転操作を実行します。 === "Python" ```python title="simple_hash.py" def add_hash(key: str) -> int: """加法ハッシュ""" hash = 0 modulus = 1000000007 for c in key: hash += ord(c) return hash % modulus def mul_hash(key: str) -> int: """乗法ハッシュ""" hash = 0 modulus = 1000000007 for c in key: hash = 31 * hash + ord(c) return hash % modulus def xor_hash(key: str) -> int: """XORハッシュ""" hash = 0 modulus = 1000000007 for c in key: hash ^= ord(c) return hash % modulus def rot_hash(key: str) -> int: """回転ハッシュ""" hash = 0 modulus = 1000000007 for c in key: hash = (hash << 4) ^ (hash >> 28) ^ ord(c) return hash % modulus ``` === "C++" ```cpp title="simple_hash.cpp" /* 加算ハッシュ */ int addHash(string key) { long long hash = 0; const int MODULUS = 1000000007; for (unsigned char c : key) { hash = (hash + (int)c) % MODULUS; } return (int)hash; } /* 乗算ハッシュ */ int mulHash(string key) { long long hash = 0; const int MODULUS = 1000000007; for (unsigned char c : key) { hash = (31 * hash + (int)c) % MODULUS; } return (int)hash; } /* XORハッシュ */ int xorHash(string key) { int hash = 0; const int MODULUS = 1000000007; for (unsigned char c : key) { hash ^= (int)c; } return hash & MODULUS; } /* 回転ハッシュ */ int rotHash(string key) { long long hash = 0; const int MODULUS = 1000000007; for (unsigned char c : key) { hash = ((hash << 4) ^ (hash >> 28) ^ (int)c) % MODULUS; } return (int)hash; } ``` === "Java" ```java title="simple_hash.java" /* 加算ハッシュ */ int addHash(String key) { long hash = 0; final int MODULUS = 1000000007; for (char c : key.toCharArray()) { hash = (hash + (int) c) % MODULUS; } return (int) hash; } /* 乗算ハッシュ */ int mulHash(String key) { long hash = 0; final int MODULUS = 1000000007; for (char c : key.toCharArray()) { hash = (31 * hash + (int) c) % MODULUS; } return (int) hash; } /* XORハッシュ */ int xorHash(String key) { int hash = 0; final int MODULUS = 1000000007; for (char c : key.toCharArray()) { hash ^= (int) c; } return hash & MODULUS; } /* 回転ハッシュ */ int rotHash(String key) { long hash = 0; final int MODULUS = 1000000007; for (char c : key.toCharArray()) { hash = ((hash << 4) ^ (hash >> 28) ^ (int) c) % MODULUS; } return (int) hash; } ``` === "C#" ```csharp title="simple_hash.cs" [class]{simple_hash}-[func]{AddHash} [class]{simple_hash}-[func]{MulHash} [class]{simple_hash}-[func]{XorHash} [class]{simple_hash}-[func]{RotHash} ``` === "Go" ```go title="simple_hash.go" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "Swift" ```swift title="simple_hash.swift" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "JS" ```javascript title="simple_hash.js" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "TS" ```typescript title="simple_hash.ts" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "Dart" ```dart title="simple_hash.dart" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "Rust" ```rust title="simple_hash.rs" [class]{}-[func]{add_hash} [class]{}-[func]{mul_hash} [class]{}-[func]{xor_hash} [class]{}-[func]{rot_hash} ``` === "C" ```c title="simple_hash.c" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "Kotlin" ```kotlin title="simple_hash.kt" [class]{}-[func]{addHash} [class]{}-[func]{mulHash} [class]{}-[func]{xorHash} [class]{}-[func]{rotHash} ``` === "Ruby" ```ruby title="simple_hash.rb" [class]{}-[func]{add_hash} [class]{}-[func]{mul_hash} [class]{}-[func]{xor_hash} [class]{}-[func]{rot_hash} ``` 各ハッシュアルゴリズムの最後のステップが大きな素数$1000000007$の剰余を取ることで、ハッシュ値が適切な範囲内にあることを保証していることが観察されます。なぜ素数の剰余を取ることが強調されるのか、または合成数の剰余を取ることの欠点は何かを考える価値があります。これは興味深い質問です。 結論として:**大きな素数を剰余として使用することで、ハッシュ値の均等分散を最大化できます**。素数は他の数と共通因子を持たないため、剰余演算によって引き起こされる周期的パターンを減らし、ハッシュ衝突を回避できます。 例えば、合成数$9$を剰余として選択するとします。これは$3$で割り切れるため、$3$で割り切れるすべての`key`はハッシュ値$0$、$3$、$6$にマッピングされます。 $$ \begin{aligned} \text{modulus} & = 9 \newline \text{key} & = \{ 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, \dots \} \newline \text{hash} & = \{ 0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6,\dots \} \end{aligned} $$ 入力`key`がたまたまこの種の等差数列分布を持つ場合、ハッシュ値がクラスターし、ハッシュ衝突を悪化させます。今度は`modulus`を素数$13$に置き換えるとします。`key`と`modulus`の間に共通因子がないため、出力ハッシュ値の均等性が大幅に改善されます。 $$ \begin{aligned} \text{modulus} & = 13 \newline \text{key} & = \{ 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, \dots \} \newline \text{hash} & = \{ 0, 3, 6, 9, 12, 2, 5, 8, 11, 1, 4, 7, \dots \} \end{aligned} $$ `key`がランダムで均等に分散されることが保証されている場合、剰余として素数または合成数を選択しても、両方とも均等に分散されたハッシュ値を生成できることは注目に値します。しかし、`key`の分布にある種の周期性がある場合、合成数の剰余はクラスタリングを引き起こしやすくなります。 要約すると、通常は素数を剰余として選択し、この素数は周期的パターンを可能な限り排除し、ハッシュアルゴリズムの堅牢性を向上させるために十分大きくある必要があります。 ## 6.3.3 一般的なハッシュアルゴリズム 上記で言及した簡単なハッシュアルゴリズムはかなり「脆弱」で、ハッシュアルゴリズムの設計目標から程遠いことは難しくありません。例えば、加算とXORは交換法則に従うため、加算ハッシュとXORハッシュは同じ内容だが順序が異なる文字列を区別できず、ハッシュ衝突を悪化させ、セキュリティ問題を引き起こす可能性があります。 実際には、通常MD5、SHA-1、SHA-2、SHA-3などの標準ハッシュアルゴリズムを使用します。これらは任意の長さの入力データを固定長のハッシュ値にマッピングできます。 過去1世紀にわたって、ハッシュアルゴリズムは継続的なアップグレードと最適化のプロセスにありました。一部の研究者はハッシュアルゴリズムの性能向上に努め、ハッカーを含む他の人々はハッシュアルゴリズムのセキュリティ問題を見つけることに専念しています。以下の表は、実用的なアプリケーションで一般的に使用されるハッシュアルゴリズムを示しています。 - MD5とSHA-1は複数回攻撃に成功しており、さまざまなセキュリティアプリケーションで放棄されています。 - SHA-2シリーズ、特にSHA-256は、現在最も安全なハッシュアルゴリズムの1つで、成功した攻撃は報告されておらず、さまざまなセキュリティアプリケーションとプロトコルで一般的に使用されています。 - SHA-3はSHA-2と比較して実装コストが低く、計算効率が高いですが、現在の使用範囲はSHA-2シリーズほど広範囲ではありません。表 6-2 一般的なハッシュアルゴリズム