ハッシュテーブルの基本
ハッシュ関数とは何ですか?
配列のクエリおよび変更にかかる時間の複雑さは O(1) です。オブジェクトの属性間にマッピング関係がある場合は、配列の利点を利用して「キー」を配列のインデックスに変換できます。ハッシュ関数が行うものです。
人生の「鍵」を指標化することで生まれる思考
クラスに生徒が 30 人いる場合、生徒番号は 1 ~ 30 になります。このとき、生徒番号から 1 を引いた値を配列のインデックスとして使用すると、30 人の生徒の情報を格納できます。この「キー」からインデックスへの変換方法は比較的簡単です。
ただし、ほとんどの場合、当社が処理するデータは比較的複雑です。たとえば、住民の情報に興味がある場合、ID カードの番号が大きすぎるため、住民の固有の ID は ID 番号 (18 桁) になる可能性があります。整数の制限を超えています。この数値を配列のインデックスとして直接使用することはできません。実際、これは非常に大きな数値でもあります。この数値を配列のインデックスとして使用する場合でも、膨大なスペースを申請する必要があります、17 ビット以下の未使用メモリは重大な問題を引き起こす可能性があり、大きな無駄です。
さらに、一部の一意の識別子は数値と直接関係がありません。最も一般的なのは文字列です。生徒の情報はまだクリとして使いましょう、生徒の情報を特定するための「鍵」として生徒の名前を使用するとします。このとき「キー」は文字列ですが、文字列を数値に変換するハッシュ関数はどのように設計すればよいのでしょうか?これは、ハッシュテーブルを設計するときに考慮する必要がある最初の問題です~
学生番号をインデックスとして設計されたハッシュ関数によって得られるインデックスは一意です。インデックスの範囲は十分に小さいため、配列を使用して保存すると便利ですが、文字列、日付、浮動小数点数などのより多くのデータ型の場合は、 .、設計したハッシュ関数の変換を通じて、すべての「キー」が異なるインデックスに対応することを保証することは困難です。つまり、2 つの異なるキーが、私たちが設計したハッシュ関数によって変換された後、同じインデックスを生成します。これを「ハッシュ競合」と呼びます。これは、ハッシュ テーブルを設計する際に解決する必要がある 2 番目の問題でもあります~
時間と空間について考える
ハッシュ テーブルは、空間を時間と交換するという、アルゴリズム設計の分野における古典的なアイデアを完全に体現しています。上記の ID カードの例として、18 ナインの大きなスペースを申請できた場合、ユーザー情報クエリの時間計算量は O(1) になります。極端な場合を想定すると、配列空間が 1 つしか適用できない場合、すべてのデータがインデックスに変換されるときにハッシュ競合が発生します。このとき、リンク リストなどのデータ構造を使用してデータを格納すると、クエリもO(n ) 時間の計算量。
上記は 2 つの極端な状況です。1 つは空間が非常に大きく、消費時間が非常に小さい場合です。もう 1 つは空間が小さく、時間が比較的大きい場合です。ハッシュ テーブルは時間と空間のバランスです~
ハッシュ関数の設計
ハッシュ関数の設計はベンチマークに従っています
- 一貫性: a==b の場合、ハッシュ(a)==ハッシュ(b)
- 効率: 計算は効率的で簡単です。
- 均一性: 得られたインデックス分布がほぼ均一であるほど良好です。
では、ハッシュ関数はどのように設計すればよいのでしょうか?
ハッシュ関数の設計には多くの特殊な分野で多くの特殊な慣行があるため、これは特定の問題に基づいて分析する必要があります。この記事では、Java int integer をインデックスとして使用してハッシュ関数を設計します。
-
狭い範囲の正の整数は配列インデックスとして直接使用でき、狭い範囲の負の整数は区間オフセットを考慮できます。たとえば、区間 [-100,100] 内の数値の場合、すべての負の数値を [100,200] にマッピングできます。
-
Long 型などの範囲が広い整数の場合、一般的なアプローチは、剰余法を採用することです。たとえば、ID番号は18桁の数字ですが、それをより小さい整数に変更するにはどうすればよいでしょうか? 現時点では、次の 4 桁を取得し、モジュロ法を使用して最後の数桁を取得できます。ただし、通常は素数を法とし、素数を法とすることは、インデックスの不均等な分布を解決し、大きな整数のすべてのデジタル情報をより有効に利用するのに役立ちます。この背後には、多数の数学理論が検証されています。深く掘り下げる必要はありませんが、栗との均一性を検証でき、ハッシュ競合の可能性は小さいです。
一連の数字 | 素数以外の場合は 4 を選択してください | 素数として 7 を選択します |
---|---|---|
10 | 2 | 3 |
20 | 0 | 6 |
30 | 2 | 2 |
40 | 0 | 4 |
50 | 2 | 1 |
- 文字列のハッシュ関数を設計するにはどうすればよいですか? 実際、文字列は大きな整数として扱うこともでき、各文字を数値として、26 文字を 16 進数として扱うことができます。のように:
10 進数の 100 は、1 * 10 2 + 0 * 10 1 + 0 * 10 0と書くことができます。
同様に、文字列も同様で、たとえば、「code」という単語は、 c * 26 3 + o * 26 2 + d * 26 1 + e * 26 0 c, o, d, e と書くことができます。 16 進数で定義される数値は次のとおりです。現時点では、ハッシュ関数は次のように設計されています。
hash(code) = (c * 26 3 + o * 26 2 + d * 26 1 + e * 26 0 )% M ここで、M は素数です。
Javaのハッシュコード
Java には、クラスのハッシュ値を簡単に取得できるように hashCode メソッドが用意されています。既存のクラスの場合は、hashCode メソッドを通じて直接取得できます。カスタム クラスの場合は、hashCode メソッドをオーバーライドして取得できます。
/**
* Create by SunnyDay on 2022/05/06 17:55
*/
public class Student {
private int age;
private String name;
private String sex;
// 主要用于计算hash值
@Override
public int hashCode() {
int M = 31;
int hash = 0;
hash = hash * M + age;
hash = hash * M + name.hashCode();
hash = hash * M + sex.hashCode();
return hash;
}
// hash 冲突时可利用这个判断对象是否相等
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (null == obj) return false;
if (obj.getClass() != this.getClass()) return false;
Student another = (Student) obj;
return this.age == another.age &&
this.name.equals(another.name) &&
this.sex.equals(another.sex);
}
}
ただし、Java の hashCode メソッドによって返される値は 32 ビットの int 値であり、これは符号付き整数であるため、この値は負の数になる可能性があります。負の数値を配列内のインデックスに変換するには、独自のハッシュ テーブルで変換する必要があります。実際、Java の hashCode の設計も比較的合理的です。ハッシュ テーブルを設計するとき、通常は素数を変調する必要があり、この素数は通常ハッシュ テーブルのサイズであるからです。ハッシュテーブルがなければ素数を取得することはできません。したがって、クラス定義時にインデックスを直接取得することはできません。これは Java hashCode の設計上の考慮事項です。
ハッシュタブの実装
まず、HashTab をどのように設計するかを考えます。次の 2 つの問題を解決する必要があります。
ハッシュ関数の設計
ここではJavaのhashCodeメソッドでハッシュ値を取得することができますが、この値は負の値になる可能性があるため手動で処理する必要があり、このときHashTabのインデックス値は配列の容量に基づいて設計することができます。
- まず、Java の hashCode メソッドを使用してハッシュ値を取得します。
- 次に、ハッシュ値に対して非負の処理を実行します (Java の hashCode メソッドは負の可能性がある整数を返します)。
- 最後に、結果を法として一様に分布した値を取得します (通常は素数を法とします)。
ハッシュ競合の解決
モジュロ演算で素数が適切に選択された場合でも、ハッシュの競合が発生する場合があります。この場合、ハッシュの競合を解決する必要があります。最も一般的に使用される解決策は、リンク リスト アドレス方式です。
第 1 版: 基本的な実装
Java8以前はHashMapの各位置がリンクリストに対応していましたが、Java8からはハッシュ競合が一定レベルに達するとリンクリストが赤黒ツリーに変換されるようになりました。
TreeMap の最下層は赤黒ツリー実装であるため、リンク リスト アドレス メソッドの最下層では、リンク リスト ノードを作成して実装する必要は必ずしもありません。それで、それを使ってバージョンを書くだけです~
/**
* Create by SunnyDay on 2022/05/06 14:23
* custom hashTable base on TreeMap.
*/
public class MyHashTable<K, V> {
private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
private int M;//capacity
private int size;
public MyHashTable(int M) {
this.M = M;
this.size = 0;
hashTable = new TreeMap[M];
for (int i = 0; i < M; i++) {
hashTable[i] = new TreeMap<>();
}
}
/**
* default constructor,default capacity is 97.
*/
public MyHashTable() {
this(97);
}
/**
* calculate index
*/
private int hash(K key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public int getSize() {
return size;
}
/**
* add element.
*/
public void add(K key, V value) {
TreeMap<K, V> map = hashTable[hash(key)];
if (map.containsKey(key)) {
map.put(key, value);
} else {
map.put(key, value);
size++;
}
}
/**
* delete element.
*/
public V remove(K key) {
TreeMap<K, V> map = hashTable[hash(key)];
V element = null;
if (map.containsKey(key)) {
element = map.remove(key);
size--;
}
return element;
}
/**
* Detect whether the target element exists.
*/
public boolean containKey(K key) {
return hashTable[hash(key)].containsKey(key);
}
/**
* query the target element.
*/
public V get(K key) {
return hashTable[hash(key)].get(key);
}
}
時間計算量分析: N 個の要素がある場合、合計 M 個のアドレスが存在します。
通常のリンク リストを使用して実装された場合、各アドレスの平均時間計算量は O(N/M)、最悪の場合の時間計算量は O(N) になります。
ただし、上記は TreeMap を使用して実装されており、バランスのとれたツリーとしての各アドレスの平均時間計算量は O(log(N/M))、最悪の場合の時間計算量は O(logN) です。
第 2 版: 配列の動的空間処理
前述したように、HashTab の計算量は O(1) レベルですが、計算量は配列の要素数に関係しているようです。M と N の間には関係があることがわかります。M はアレイ容量の固定値です。N が無限大に近づくと、N/M の値も無限大に近づきます。時間計算量が O(1) に近づくことは不可能です。ただし、空間を動的に拡張して、時間計算量を O(1) に近づけることはできます。
チェーン アドレス方式を使用したリンク リストは全容量を持たないため、ArrayList と同じ方法で拡張することはできませんが、次のような標準を使用できます。
- 各アドレスの平均負荷容量が一定以上になると容量が拡張されます。例: N/M >= upperTol の場合に展開します (N: 要素の総数、M の配列容量、upperTol の容量制限)
- 各アドレスの平均負荷容量が一定以下の場合、容量が削減されます。例: N/M < lowerTol の場合に縮小します (N: 要素の総数、M の配列容量、 lowerTol の容量下限値)
/**
* Create by SunnyDay on 2022/05/06 14:23
* custom hashTable base on TreeMap.
*/
public class MyHashTable<K, V> {
// about resize
private static final int upperTol = 10;
private static final int lowerTol = 2;
private static final int initCapacity = 7;
private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
private int M;
private int size;
public MyHashTable(int M) {
this.M = M;
this.size = 0;
hashTable = new TreeMap[M];
for (int i = 0; i < M; i++) {
hashTable[i] = new TreeMap<>();
}
}
/**
* default constructor,default capacity is 97.
*/
public MyHashTable() {
this(initCapacity);
}
/**
* calculate index
*/
private int hash(K key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public int getSize() {
return size;
}
/**
* add element.
*/
public void add(K key, V value) {
TreeMap<K, V> map = hashTable[hash(key)];
if (map.containsKey(key)) {
map.put(key, value);
} else {
map.put(key, value);
size++;
//size就是N,与size/M >= upperTol 等价,这里改除法为乘法。
if (size >= upperTol * M) {
resize(2 * M);
}
}
}
/**
* delete element.
*/
public V remove(K key) {
TreeMap<K, V> map = hashTable[hash(key)];
V element = null;
if (map.containsKey(key)) {
element = map.remove(key);
size--;
// M / 2 >0 即可 。由于我们hashTab有初始容积则可写为M / 2 >= initCapacity
if (size <= lowerTol * M && M / 2 >= initCapacity) {
resize(M / 2);
}
}
return element;
}
/**
* Detect whether the target element exists.
*/
public boolean containKey(K key) {
return hashTable[hash(key)].containsKey(key);
}
/**
* query the target element.
*/
public V get(K key) {
return hashTable[hash(key)].get(key);
}
private void resize(int newM) {
// new array.
TreeMap<K, V>[] newHashTable = new TreeMap[newM];
for (int i = 0; i < newM; i++) {
newHashTable[i] = new TreeMap<>();
}
int oldM = M;
this.M = newM;
for (int i = 0; i < oldM; i++) {
// TreeMap element in old array.
TreeMap<K, V> map = hashTable[i];
// element put into newHashTable
for (K key : map.keySet()) {
newHashTable[hash(key)].put(key, map.get(key));
}
}
// reset pointer
this.hashTable = newHashTable;
}
}
各アドレス競合の平均確率は O(lowerTol) と O(upperTol) の間にあることがわかります。lowerTol と upperTol を制御するため、平均時間計算量を小さい数値内に制御することができ、時間計算量は O(1) に近づきます。
第 3 版: 配列の動的空間の最適化
上記の展開では、M*2 を求めるたびに偶数を取得する必要があり、インデックスの分布が不均一になりますが、容量を素数に動的に設定することで最適化することができます。
/**
* Create by SunnyDay on 2022/05/06 14:23
* custom hashTable base on TreeMap.
*/
public class MyHashTable<K, V> {
// int 范围内素数
private final int capacity[] = {
53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457, 1610612741};
// about resize
private static final int upperTol = 10;
private static final int lowerTol = 2;
// 默认指向 capacity数组中第一个元素
private static int capacityIndex = 0;
private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
private int M;
private int size;
public MyHashTable() {
this.M = capacity[capacityIndex];
this.size = 0;
hashTable = new TreeMap[M];
for (int i = 0; i < M; i++) {
hashTable[i] = new TreeMap<>();
}
}
/**
* calculate index
*/
private int hash(K key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public int getSize() {
return size;
}
/**
* add element.
*/
public void add(K key, V value) {
TreeMap<K, V> map = hashTable[hash(key)];
if (map.containsKey(key)) {
map.put(key, value);
} else {
map.put(key, value);
size++;
// 避免越界
if (size >= upperTol * M && capacityIndex + 1 < capacity.length) {
capacityIndex++;
resize(capacity[capacityIndex]);
}
}
}
/**
* delete element.
*/
public V remove(K key) {
TreeMap<K, V> map = hashTable[hash(key)];
V element = null;
if (map.containsKey(key)) {
element = map.remove(key);
size--;
if (size <= lowerTol * M && capacityIndex - 1 >= 0) {
capacityIndex--;
resize(capacity[capacityIndex]);
}
}
return element;
}
/**
* Detect whether the target element exists.
*/
public boolean containKey(K key) {
return hashTable[hash(key)].containsKey(key);
}
/**
* query the target element.
*/
public V get(K key) {
return hashTable[hash(key)].get(key);
}
private void resize(int newM) {
// new array.
TreeMap<K, V>[] newHashTable = new TreeMap[newM];
for (int i = 0; i < newM; i++) {
newHashTable[i] = new TreeMap<>();
}
int oldM = M;
this.M = newM;
for (int i = 0; i < oldM; i++) {
// TreeMap element in old array.
TreeMap<K, V> map = hashTable[i];
// element put into newHashTable
for (K key : map.keySet()) {
newHashTable[hash(key)].put(key, map.get(key));
}
}
// reset pointer
this.hashTable = newHashTable;
}
}
まとめ
褒美
ハッシュ テーブルの償却時間計算量は O(1) であり、
ハッシュ テーブルは要素の順序を失います。
他のハッシュ競合の解決策
オープンアドレス法: 負荷率の概念を考慮すると、負荷率の選択の時間計算量も O(1)
- リニア検出方式(毎回+1)
- マス目検出方式(毎回+2マス)
- 二次ハッシュ
もう一度ハッシュします:
合体ハッシュ: チェーン アドレス方式とオープン アドレス方式を組み合わせます。