ハッシュテーブルの原理と実装

この記事では、主にハッシュテーブルの一般的なデータ構造の原理と実現方法を紹介します。個人的なレベルが限られているため、記事には必然的に不正確または不明確な部分があります。私を訂正していただければ幸いです:)

概要概要

シンボルテーブルは、キーと値のペアを格納するために使用されるデータ構造です。通常使用する配列は、特別なシンボルテーブルと見なすこともできます。配列の「キー」は配列インデックスであり、値は対応する配列要素です。つまり、シンボルテーブルのすべてのキーが小さい整数の場合、配列のインデックスをキーとして使用して、配列を使用してシンボルテーブルを実装できます。インデックスの配列要素は、に対応する値です。キーですが、これは、すべてのキーが比較的小さい整数である場合にのみ意味します。そうでない場合は、非常に大きな配列が使用される可能性があります。ハッシュテーブルは上記の戦略の「アップグレード」ですが、制限しすぎずに任意のキーをサポートできます。ハッシュテーブルに基づくシンボルテーブルの場合、その中のキーを見つけたい場合は、次の手順を実行する必要があります。

  • まず、ハッシュ関数を使用して、特定のキーを「配列インデックス」に変換します。理想的には、異なるキーが異なるインデックスに変換されますが、実際のアプリケーションでは、同じに変換される異なるキーに遭遇します。インデックス作成の場合、この状況は衝突と呼ばれます。衝突を解決する方法については、後で詳しく説明します。
  • インデックスを取得した後、配列にアクセスするのと同じように、このインデックスを介して対応するキーと値のペアにアクセスできます。

上記は、時空のトレードオフの典型的な例であるハッシュテーブルのコアアイデアです。スペースが無限大の場合、大きな配列を直接使用してキーと値のペアを格納し、キーを配列インデックスとして使用できます。スペースに制限がないため、キーの値は無限大になる可能性があるため、任意のキーを探します。通常のアレイアクセスは1つだけ必要です。逆に、検索操作に時間制限がない場合は、リンクリストを直接使用してすべてのキーと値のペアを保存できます。これにより、スペースの使用が最小限に抑えられますが、順番に検索することしかできません。実際のアプリケーションでは、時間とスペースが限られているため、2つの間でトレードオフを行う必要があります。ハッシュテーブルは、時間とスペースの使用において適切なバランスを見つけました。ハッシュテーブルの利点の1つは、時間とスペースのトレードオフで戦略的な調整を行えるように、コードの他の部分に変更を加えることなく、ハッシュアルゴリズムの対応するパラメーターを調整するだけでよいことです。

ハッシュ関数

ハッシュ関数を紹介する前に、まずいくつかのハッシュテーブルの基本的な概念を紹介しましょう。ハッシュテーブル内では、バケットを使用してキーと値のペアを格納します。前述の配列インデックスはバケット番号であり、特定のキーが格納されるハッシュテーブルのバケットを決定します。ハッシュテーブルが所有するバケットの数は、ハッシュテーブルの容量と呼ばれます。

ここで、ハッシュテーブルにM個のバケットがあり、バケット番号が0からM-1の範囲であるとします。ハッシュ関数の機能は、任意のキーを[0、M-1]の整数に変換することです。ハッシュ関数には2つの基本的な要件があります。1つは計算時間を短縮すること、もう1つは可能な限り異なるバケットにキーを分散することです。さまざまなタイプのキーについて、より良いハッシュ効果を確保するためにさまざまなハッシュ関数を使用する必要があります。
使用するハッシュ関数は、可能な限り均一ハッシュの仮定を満たす必要があります。均一ハッシュの仮定の次の定義は、Sedgewickの「Algorithm」の本に基づいています。

(均一なハッシュの仮定)使用するハッシュ関数は、すべてのキーを0からM –1の間に均等かつ独立して分散させることができます。

上記の定義には2つのキーワードがあります。最初のキーワードは均一です。つまり、各キーに対して計算したバケット数にはM個の「候補値」があり、均一性には、これらのM値が選択される確率が等しい必要があります。 2番目のキーワードは独立しています。つまり、各バケット番号が選択されているかどうかは互いに独立しており、他のバケット番号が選択されているかどうかとは関係ありません。このように、均一性と独立性を満たすことで、ハッシュテーブル内のキーと値のペアの分布が可能な限り均一になり、「多くのキーと値のペアが同じバケットにハッシュされることはありませんが、多くのバケットは空」。。
明らかに、均一なハッシュの仮定を満たすハッシュ関数を設計することは容易ではありません。幸いなことに、多くのような確率統計に基づいていくつかの効率的な実装を直接使用できるため、通常は設計する必要はありません。 Javaで一般的に使用されるクラスは、このタイプのオブジェクトのhashCodeを返すために使用されるhashCodeメソッド(ObjectクラスのhashCodeメソッドはデフォルトでオブジェクトのメモリアドレスを返します)を書き直しました。通常、このhashCodeを次のように除算します。バケット番号Mを取得して、バケット番号を取得します。さまざまなデータ型のハッシュ関数の実装を紹介する例として、Javaのいくつかのクラスを取り上げましょう。

StringクラスのhashCodeメソッド

StringクラスのhashCodeメソッドは次のとおりです。

public int hashCode() { 
  int h = hash; 
  if (h == 0 && value.length > 0) { 
    char val[] = value; 
    for (int i = 0; i < value.length; i++) { 
      h = 31 * h + val[i]; 
    } 
    hash = h; 
  } 
  return h;
}

hashCodeメソッドの値はchar []配列であり、文字列の各文字を格納します。メソッドの最初にハッシュをhに割り当てることがわかります。このハッシュは、以前に計算されたhashCodeを表すため、この文字列オブジェクトのhashCodeが以前に計算されている場合は、これを再度計算する必要はありません。時間、前の計算に戻るだけです。hashCodeをキャッシュするこの戦略は、不変オブジェクトのhashCodeが変更されないため、不変オブジェクトに対してのみ有効です。
上記のコードによると、hがnullの場合、hashCodeを初めて計算していることを意味します。ifステートメントの本体は、hashCodeの特定の計算方法です。文字列オブジェクトstrに4文字が含まれ、ckが文字列のk番目の文字(0から数えて)を表すとすると、strのhashCodeは次のようになります。31*(31 *(31 * c0 + c1)+ c2)+ c3 。

数値型のhashCodeメソッド

ここでは、整数型と倍精度浮動小数点数を例として取り上げ、数値型のhashCodeメソッドの一般的な実装を紹介します。
IntegerクラスのhashCodeメソッドは次のとおりです。

public int hashCode() { 
  return Integer.hashCode(value);
}
public static int hashCode(int value) { 
  return value;
}

値はIntegerオブジェクトによってラップされた整数値を表すため、IntegerクラスのhashCodeメソッドは単に独自の値を返します。

DoubleクラスのhashCodeメソッドをもう一度見てみましょう。

@Override
public int hashCode() { 
  return Double.hashCode(value);
}
public static int hashCode(double value) { 
  long bits = doubleToLongBits(value); 
  return (int)(bits ^ (bits >>> 32));
}

DoubleクラスのhashCodeメソッドは、最初にその値をlong型に変換し、次に下位32ビットと上位32ビットのXOR結果をhashCodeとして返すことがわかります。

DateクラスのhashCodeメソッド

前に紹介したデータ型は数値型と見なすことができ(文字列は整数配列と見なすことができます)、非数値オブジェクトのhashCodeを計算する方法は?ここでは、簡単に紹介する例としてDateクラスを取り上げます。DateクラスのhashCodeメソッドは次のとおりです。

public int hashCode() { 
  long ht = this.getTime(); 
  return (int) ht ^ (int) (ht >> 32);
}

そのhashCodeメソッドの実装は非常に単純であり、Dateオブジェクトによってカプセル化された時間の下位32ビットと上位32ビットのXOR結果を返すだけであることがわかります。DateクラスのhashCodeの実装から、数値以外のタイプのhashCodeを計算するには、各クラスのインスタンスを計算要素として区別できるインスタンスドメインを選択する必要があることがわかります。たとえば、Dateクラスの場合、通常、同じ時刻のDateオブジェクトは等しいと見なされるため、hashCodeは同じになります。ここで、2つの同等のオブジェクト(つまり、equalsメソッドを呼び出してtrueを返す)の場合、それらのhashCodeは同じである必要があり、その逆も同様であることを説明する必要があります。

hashCodeからバケット番号を取得します

以前、オブジェクトのhashCodeを計算するためのいくつかのメソッドを紹介しましたが、hashCodeを取得した後、バケット番号をさらに取得するにはどうすればよいですか?簡単な方法は、取得したhashCodeを容量(バケット数)で直接除算し、残りをバケット数として使用することです。ただし、JavaではhashCodeはint型であり、Javaではint型は符号付きであるため、返されたhashCodeを直接使用すると、負の数になる可能性があります。明らかに、バケット数を負にすることはできません。したがって、最初に返されたhashCodeを負でない整数に変換し、次にそれを容量で除算して、余りをキーの対応するバケット番号とします。具体的なコードは次のとおりです。

private int hash(K key) { return (x.hashCode() & 0x7fffffff) % M;} 

キーによってバケット番号を取得する方法がわかったので、次に、ハッシュテーブルを使用して衝突を検出する2番目のステップを紹介します。

衝突を処理するためにジッパー方式を使用する

さまざまな衝突処理方法を使用して、ハッシュテーブルのさまざまな実装を取得します。最初に紹介したいのは、衝突を処理するためにzipperメソッドを使用するハッシュテーブルの実装です。このように実装されたハッシュテーブルでは、リンクリストが各バケットに格納されます。最初は、すべてのリンクリストが空です。キーがバケットにハッシュされると、このキーは対応するバケットのリンクリストの最初のノードになります。その後、別のキーがこのバケットにハッシュされると(つまり、衝突が発生します)。 )、2番目のキーはリンクリストの2番目のノードになります。このように、バケット数がMで、ハッシュテーブルに格納されているキーと値のペアの数がNの場合、各バケットのリンクリストに含まれるノードの平均数はN / Mになります。したがって、キーを検索するときは、最初にハッシュ関数を使用してそのバケットが入っているバケットを判別します。このステップに必要な時間はO(1)です。次に、バケット内のノードのキーを指定されたキーと順次比較します。 、およびそれらが等しい場合、次のようになります。キーと値のペアを指定するには、このステップに必要な時間はO(N / M)です。したがって、検索操作に必要な時間はO(N / M)であり、通常、NがMの定数倍であることを保証できるため、ハッシュテーブルの検索操作の時間計算量はO(1)であり、挿入操作の複雑さもO(1)です。

上記の説明を理解すると、zipperメソッドに基づくハッシュテーブルを簡単に実装できます。簡単にするために、バケット内のリンクリストとして前のSeqSearchListを直接使用します。参照コードは次のとおりです。

public class ChainingHashMap<K, V> { 
  private int num; //当前散列表中的键值对总数 
  private int capacity; //桶数 
  private SeqSearchST<K, V>[] st; //链表对象数组 
  
  public ChainingHashMap(int initialCapacity) { 
    capacity = initialCapacity; 
    st = (SeqSearchST<K, V>[]) new Object[capacity]; 
    for (int i = 0; i < capacity; i++) { 
      st[i] = new SeqSearchST<>(); 
    } 
  } 
  
  private int hash(K key) { 
    return (key.hashCode() & 0x7fffffff) % capacity; 
  } 
  
  public V get(K key) { 
      return st[hash(key)].get(key); 
  }

  public void put(K key, V value) { 
    st[hash(key)].put(key, value); 
  }
} 

上記の実装では、ハッシュテーブルのバケット数を固定しました。挿入するキーと値のペアの数がバケット数の定数倍にしか到達できないことが明確にわかっている場合、バケット数を固定すると次のようになります。完全に実行可能です。ただし、キーと値のペアの数がバケットの数よりもはるかに多くなる場合は、バケットの数を動的に調整する機能が必要です。実際、ハッシュテーブル内のキーと値のペアの数とバケットの数の比率は、負荷率と呼ばれます。一般に、負荷率が小さいほど、検索に必要な時間が短くなり、スペース使用量が多くなります。負荷率が大きいほど、検索時間は長くなりますが、スペース使用量は減少します。たとえば、Java標準ライブラリのHashMapは、zipperメソッドに基づいて実装されたハッシュテーブルであり、デフォルトの負荷係数は0.75です。HashMapのバケット数を動的に調整する方法は、式loadFactor = maxSize / capacityに基づいています。ここでmaxSizeは、ストレージをサポートするキーと値のペアの最大数であり、loadFactorと容量(バケットの数)が指定されます。初期化中にユーザーによって、またはシステム値によってデフォルト設定されます。HashMapのキーと値のペアの数がmaxSizeに達すると、ハッシュテーブルのバケットの数が増加します。
SeqSearchSTは上記のコードでも使用されています。実際、これはリンクリストに基づくシンボルテーブルの実装です。キーと値のペアの追加をサポートしています。指定されたキーを検索する場合は、順次検索が使用されます。コードは次のとおりです。次のとおりです。

public class SeqSearchST<K, V> { 
  private Node first; 
  
  private class Node { 
    K key; 
    V val; 
    Node next; 
    public Node(K key, V val, Node next) { 
      this.key = key; 
      this.val = val; 
      this.next = next; 
    } 
  } 

  public V get(K key) { 
    for (Node node = first; node != null; node = node.next) { 
      if (key.equals(node.key)) { 
        return node.val; 
      } 
    } 
    return null; 
  } 

  public void put(K key, V val) { 
    //先查找表中是否已存在相应key 
    Node node; 
    for (node = first; node != null; node = node.next) { 
      if (key.equals(node.key)) { 
        node.val = val; 
        return; 
      } 
    } 
    //表中不存在相应key 
    first = new Node(key, val, first); 
  }
}

線形検出を使用して衝突を処理する

基本原則と実装

線形検出法は、ハッシュテーブルの実装戦略のもう1つの特定の方法であり、この戦略はオープンアドレス法と呼ばれます。オープンアドレス法の主なアイデアは次のとおりです:サイズMの配列を使用してN個のキーと値のペアを格納します(M> N)。配列内のスペースは衝突の問題を解決するために使用されます。

線形検出方法の主なアイデアは、衝突が発生したとき(キーがすでにキーと値のペアを持っている配列位置にハッシュされたとき)、配列の次の位置をチェックすることです。このプロセスは線形と呼ばれます検出。線形検出では、次の3つの結果が得られます。

  • ヒット:この位置のキーは、検出されるキーと同じです。
  • ミス:ポジションは空です。
  • この位置のキーは、検索対象のキーとは異なります。

キーを検索するときは、最初にハッシュ関数を使用して配列インデックスを取得し、次に対応する位置のキーが指定されたキーと同じであるかどうかの確認を開始し、異なる場合は検索を続行します(配列の終わりが見つからない場合は、配列の最初に折り返します)キーが見つかるか、空の位置が見つかるまで。線形検出のプロセスから、配列がいっぱいになったときに新しいキーを配列に挿入すると、無限ループに陥ることがわかります。

上記の原則を理解した後、線形検出方法に基づいてハッシュテーブルを実装することは難しくありません。ここでは、配列キーを使用してキーをハッシュテーブルに格納し、配列値を使用して値をハッシュテーブルに格納します.2つの配列の同じ位置にある要素が共同でキーと値のペアを決定しますハッシュテーブル。具体的なコードは次のとおりです。

public class LinearProbingHashMap<K, V> { 
  private int num; //散列表中的键值对数目 
  private int capacity; 
  private K[] keys; 
  private V[] values; 

  public LinearProbingHashMap(int capacity) { 
    keys = (K[]) new Object[capacity]; 
    values = (V[]) new Object[capacity]; 
    this.capacity = capacity; 
  } 

  private int hash(K key) { 
    return (key.hashCode() & 0x7fffffff) % capacity; 
  } 
  
  public V get(K key) { 
    int index = hash(key); 
    while (keys[index] != null && !key.equals(keys[index])) { 
      index = (index + 1) % capacity; 
    } 
    return values[index]; //若给定key在散列表中存在会返回相应value,否则这里返回的是null 
  }
  
 public void put(K key, V value) { 
    int index = hash(key); 
    while (keys[index] != null && !key.equals(keys[index])) { 
      index = (index + 1) % capacity; 
    } 
    if (keys[index] == null) { 
      keys[index] = key; 
      values[index] = value; return; 
    } 
    values[index] = value; num++; 
  }
}

配列サイズを動的に調整します

上記の実装では、配列のサイズはバケット数の2倍であり、配列サイズの動的調整はサポートされていません。実際のアプリケーションでは、負荷係数(配列サイズに対するキーと値のペアの比率)が1に近い場合、検索操作の時間計算量はO(n)に近くなり、負荷係数が1の場合、上記の実装によれば、whileループは無限ループになります。明らかに、検索操作の複雑さをO(n)に減らしたくはありません。もちろん、無限ループに陥ることもありません。したがって、検索操作の一定の時間計算量を維持するために、動的成長配列を実装する必要があります。キーと値のペアの総数が少ない場合、スペースが狭い場合は、実際の状況に応じて、配列を動的に縮小できます。

配列のサイズを動的に変更するには、上記のputメソッドの最初に次の判断を追加するだけです。

if (num == capacity / 2) { 
  resize(2 * capacity); 
}

サイズ変更メソッドのロジックも非常に単純です。

private void resize(int newCapacity) { 
  LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity); 
  for (int i = 0; i < capacity; i++) { 
    if (keys[i] != null) { 
      hashmap.put(keys[i], values[i]); 
    } 
  } 
  keys = hashmap.keys; 
  values = hashmap.values; 
  capacity = hashmap.capacity; 
}

負荷率と検索操作のパフォーマンスの関係について、「アルゴリズム」(Sedgewickなど)からの結論は次のとおりです。

サイズがMでN = a * M(aは負荷係数)の線形検出に基づくハッシュテーブルで、ハッシュ関数が均一ハッシュ仮説を満たしている場合、ヒットとミスの検索に必要な検出時間は:〜1 / 2 *(1 + 1 /(1-a))および〜1 / 2 *(1 + 1 /(1-a)^ 2)

上記の結論に関して、aが約1/2の場合、ヒットとミスを見つけるために必要な検出の数は、それぞれ1.5と2.5であることを知っておく必要があります。もう1つのポイントは、1に近づくと、上記の結論の推定値の精度は低下しますが、実際のアプリケーションでは負荷率を1に近づけないことです。良好なパフォーマンスを維持するには、以下を維持する必要があります。 1/2。

おすすめ

転載: blog.csdn.net/orzMrXu/article/details/102534437