Java 配列から HashMap へのアルゴリズムの説明

Java 配列から HashMap へのアルゴリズムの説明

1. 配列とは何ですか?

どの本にこのような文章があったか忘れましたが、「すべてのデータ構造は配列の進化形である」。よく考えてみると、これは実際に理にかなっています。なぜなら、コンピューターのメモリは実際には線形のストレージ スペースだからです。

Java サンプルコード:

 int []配列=新しいint [ 5 ]

オブジェクト ヘッダーと配列の長さの情報を無視すると、JVM は実行時にヒープ内に 20 バイトのメモリを割り当てます。これは次のようになります。

このようなデータ構造は、配列の添え字を通じてデータに簡単にアクセスできますが、検索時に配列をトラバースする必要があり、平均時間計算量は O(n/2) です。

データ量が大きい場合や検索操作が頻繁に行われる場合、このようなトラバーサル操作はほとんど受け入れられません。では、どうすればより短時間でデータを素早く見つけることができるのでしょうか?

2.二分探索

配列内の要素がすでにソートされている場合、自然な方法はバイナリ検索を使用することです。

たとえば、長さが 1000 の整数配列があり、配列内の整数は小さいものから大きいものの順に並べられています。この配列に 6000 があるかどうかを知りたいとします。次に、まず配列[500]の数字が6000かどうかを確認します。配列[500]の数字が6000より小さい場合は、配列[750]の数字を確認します...。これを最大10回繰り返した後、結果を判定できます。

このアルゴリズムの時間計算量は O(logN) です。

ただし、ほとんどの場合、配列要素は順序付けられておらず、ソートに必要な時間計算量 O(NlogN) は通常、トラバースに必要な時間よりもはるかに長くなります。

つまり、問題は振り出しに戻ってしまいます。乱雑なデータの中からデータを素早く見つけるにはどうすればよいでしょうか?

3. 無知な思考

プログラミングを学び始めて間もなく、「Programming Pearls」を読んだことを今でも覚えています。そこには、次のような記述がありました。1970 年代に、マイク・レスクは電話会社の電話番号検索機能を作成しました。たとえば、数字キー 5375*6* に対応する LESK*M* を入力すると、エラー一致率はわずか 0.2% で、正しい電話番号またはオプションのリストが返されます。

これはどうすればできるのでしょうか?

当時はデータ構造やアルゴリズムについて何も知りませんでした。自分の無謀な思考の過程を思い出すと、今でもとても興味深いです。

1. 追加

すべての数字を足し合わせると(* キーは 11)、5375*6* の合計は 48 になります。ほとんどの入力の合計は 100 を超えません。つまり、数万の電話番号が配列の最初の 100 の位置に集中することになり、これは実現不可能です。

2. 掛け算

すべての数字を掛け合わせると、その積は 381150 になります。これは実現可能であるように思われますが、問題があります。lesk、lsek、slke... の積は同じであり、各数字キーは 3 つの文字に対応するため、重複の可能性が非常に高くなります。

3. 乗算の改善

① 同じ文字だが文字の順序が異なる文字列は、各数字にシーケンス番号を掛け、次に他の値を掛けることで競合を回避できます。

② 各数字キーは3つの英語の文字に対応しています。ユーザーが数字キーに文字の連続番号を入力しないと、それ以上正確に計算することはできません。考えられる唯一の方向性は、与えられた単語リストに基づいて確率統計を作成することであるため、考慮されません。

4. 場所の競合

改良された乗算方法を使用しても、異なる名前の文字を使用して計算された積が重複する可能性があります。競合が発生した場合はどうすればよいですか?

次の空いている位置に連続して保存しますか?考えてみれば、これはうまくいかないでしょう。次の空白位置が別の文字セットの産物である場合、二次的な競合が発生します。

幸いなことに、素数は存在します。

素数は 1 とそれ自身でしか割り切れないので、上記の乗算によって得られる積は素数にはならず、素数の位置に電話番号は格納されません。

したがって、現在の積から始めて、右側にある次の素数を検索します。素数の位置がまだ使用されている場合は、次に最も近い素数の検索を続けます...

今のところ、問題は解決したようです。

ユーザーが数字の文字列を入力すると、数式に従って積が計算され、添え字の位置にある電話番号が返されます。正しくない場合は、素数の添え字を持つ配列要素が空になるまで、次の素数を順番に検索し、最後に見つかった電話番号をすべて返します。ほとんどの場合、電話番号を見つけるのにかかる時間は O(1) だけです。

5. 配列が大きすぎる

唯一の問題は、上記のアイデアに従って作成された配列が大きすぎることです。名前は 10 文字です。各文字に対応する数字が 9 の場合、最終的な積はおよそ 9 の 17 乗になります。これは、サイズが 9^17 の配列を作成することを意味しますが、これは完全に不可能です。

6. 後日

配列の係数が大きすぎることを考慮しなくても、当時の私のプログラミング レベルでは、このようなプログラムを書くことはできなかったでしょう。

私がこの思考プロセスを復活させた理由は、自立して考えるプロセスがとても興味深く、価値があると考えているからです。考えてみてください。実際、既存のアルゴリズムはすべて、実際のエンジニアリング プロジェクトで段階的に解決策を模索した優れた専門家によって導き出されたものです。

したがって、工学において難しい問題に遭遇したとき、問題を細分化して考え、それぞれの小さな問題に対する解決策を模索する意欲があれば、多くのいわゆる難しい問題は解決できるのです。

その後、「データ構造とアルゴリズム分析。Java 言語の説明」を読んで、私のアイデアは実はオープン アドレッシングだったことに気付きました。

JDK の HashMap は個別のチェーンを使用します。相違点: 競合するデータを保存するためのバケットの概念が追加され、配列サイズを縮小するためにモジュロ演算が実行されました。

それでは、Java の HashMap について説明しましょう。

4. HashMapの構造とアルゴリズムの詳細な説明

HashMap の本質は依然として配列であり、以下に示す構造に似た、長さが不定の多次元配列です。

1. 簡単な紹介

HashMap 内の配列は、リンク リストのヘッド ノードを保持します。

データを保存:

キーの hashCode を計算し、配列の長さでモジュロ演算を実行して、配列の添え字 (リンク リストのヘッド ノード) を取得します。

位置が空の場合は、新しいリンク リスト ノードを生成し、配列に保存します。

位置が空でない場合は、リンク リスト内の各ノードをループします。同じキーを持つノードがすでに存在する場合は、古いノードを上書きします。同じキーを持つノードが存在しない場合は、新しいノードをリンク リストの末尾ノードとして使用します(注: JDK1.8 のソース コードを参照してください。新しいノードは、JDK1.6 のようにリンク リストの先頭ノードとして追加されるのではなく、常にリンク リストの末尾に追加されます)

データを検索:

キーの hashCode を計算し、配列の長さでモジュロ演算を実行して、配列の添え字 (リンク リストのヘッド ノード) を取得します。リンク リストが空でない場合は、まず最初のノードを比較します。最初のノード キーが同じ (hashCode が同じで equals が true) 場合は、最初のノードの値を直接返します。最初のノード キーが異なる場合は、リンク リスト内の他のノードを順番に走査し、同じキーを持つノードの値を返します (同じキーを持つノードが見つからない場合は null を返します)。

HashMap にリンク リストを導入する目的は、前のセクションで説明した重複競合の問題を解決することです。

注記:

すべてのキーのハッシュコードが同じで、すべて 0 であると仮定すると、0%4 = 0 となり、すべての要素がリンク リスト 0 に保存されます。各データを保存および検索するには、リンク リスト 0 を走査する必要があります。そして、この時点で、HashMap は本質的にリンク リストに退化し、時間の計算量も設計された O(1) から O(n/2) に増加します。

HashMap の検索と保存の時間計算量を O(1) に可能な限り抑えるには、各リンク リスト内の要素を均等に分散する必要があります。つまり、各リンク リストには 1 つの要素のみが格納されます。

では、影響を与える要因は何でしょうか?

まず、キーのハッシュコードは重複できません。重複する場合は、少なくとも 2 つの要素を格納するリンク リストが必要です。
2 つ目はハッシュ関数の設計です。単純に剰余を求めると、剰余の中に多くの繰り返しが生まれます。
3 番目は、リンク リストのサイズです。長さ 10 の配列に 100 個の要素を分散する場合、計算方法に関係なく、複数の要素を格納するリンク リストが存在します。最適なケースは、各リンク リストに 10 個の要素が格納されることです。

これら 3 つの要素のアルゴリズム設計については、以下で詳しく説明します。

2. ハッシュコード生成

これは、String クラスの hashCode 生成コードです。

パブリックintハッシュコード() {
int h = ハッシュ;
h == 0 &&.長さ > 0の場合{
char val[] =;
( int i = 0 ; i <.長さ; i++) {
        h = 31 * h + val[i];
      }
      ハッシュ = h;
    }
hを返します。
  }

Stringクラスの値はchar[]であり、charはUTF-8エンコーディングに変換できます。たとえば、「a」、「b」、「c」の UTF-8 コードはそれぞれ 97、98、99 です。上記のコードに従って「abc」を変換する式は次のとおりです。

h = 31 * 0 + 97 = 97;
h = 31 * 97 + 98 = 3105;
h = 31 * 3105 + 99 = 96354;

31 は比較的適切なサイズの素数であるため、乗算係数として使用されます。値が小さすぎると、ハッシュコードの計算に関係する項目の数が少ない場合に積が小さすぎます。偶数の場合は左シフトに相当し、乗算がオーバーフローすると、通常のビット情報の損失が発生します。これらは両方とも繰り返し率の増加につながります。

乗算係数として 32 (<< 5 に相当) を使用する場合、文字列 "abcabcabcabcabcabc" を例にとると、そのハッシュコードの各ステップの計算結果は次のようになります。

上図に示すように、文字列の末尾の 3 文字ごとに繰り返しハッシュコードが生成されます。これは偶然ではありません。他の英語の文字を使用した場合でも、重複が発生する可能性があります。素数を使用すると、重複の可能性が大幅に減少します。興味があれば、上の図を参考にして左シフト操作を実行すると、これが偶然ではないことがわかります。

ハッシュコードの生成ルールや注意点については、「Effective Java」という書籍に詳しく解説されていますので、興味のある方はご覧ください。

ソースコードの hashCode() メソッドから、String クラスのオブジェクトが計算された hashCode を保存していることがわかります。hashCode() メソッドが呼び出された場合、2 回目の呼び出しでは再生成されず、計算された hashCode が直接返されます。

String オブジェクトは常に、String クラスによってプライベートに管理される定数プールに格納されます。new キーワードが明示的に使用されていない場合、定数プール内に同じ値を持つオブジェクトが既に存在する場合は、既存のオブジェクトへの参照が常に返されます。したがって、多くの場合、hashCode を生成するというコストのかかる操作を実際に実行する必要はありません。

3. ハッシュ関数の設計

これで、重複率が非常に低い hashCode を取得できました。何か足りないものはありますか?

摂動関数

静的最終intハッシュ(オブジェクトキー) {
整数h;
戻り値(key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 );
  }

次の図は、文字列「abcabcabcabcabc」を例として、上記の方法を使用して計算されたプロセスと結果を示しています。

最初に 16 ビットを符号なし右シフトしてから XOR 演算を実行するのはなぜですか?下の図のバイナリ AND 演算を見ると理解できるでしょう。

2 進数の最後の 4 ビットが変更されない限り、前の 2 進ビットがどのように変更されても同じ結果が表示されることがわかります。上位ビットが変化しても下位ビットが変化しないため、計算結果が同じになる状況を防ぐには、上位ビットの変化を含める必要があります。この効果は、整数のバイナリ ビットの上位半分と下位半分に対して XOR 演算を実行することで実現できます。

AND 演算を使用する理由は何ですか?

ハッシュ関数の計算式はhashCode % tableSizeなので、tableSizeが2のn乗(n ≥ 1)のときはhashCode & (tableSize – 1)と等しくなります。

摂動関数の最適化前: 1954974080 % 16 = 1954974080 & (16 – 1) = 0
摂動関数が最適化された後: 1955003654 % 16 = 1955003654 & (16 – 1) = 6

これが摂動関数が必要な理由です。

ソースコードの説明

コードを説明する前に、JDK 1.8 では、競合が多数発生した場合の検索効率を解決するために赤黒木が導入されたことを付け加えておきます。したがって、リンク リスト内のデータが一定のサイズに達すると、リンク リストは赤黒木に変換されます。したがって、jdk1.8 では、HashMap の検索とデータ保存の最大時間計算量は、実際には赤黒木の時間計算量 O(logN) になります。

以下は、HashMap にデータを保存するメソッドのソースコードです。上記の説明の後では、このコードを理解するのは非常に簡単だと思います。

最終的な V putVal ( intハッシュ、 K キー、 V、 boolean onlyIfAbsent、 boolean evict ) {
    Node<K,V>[] tab; //HashMap配列
    Node<K,V> p; //保存するデータを初期化する
int n; //配列の容量
int i; //配列の添え字

/* 配列が空または 0 の場合、容量を 16 に初期化します */
if ((tab = table) == null || (n = tab.length) == 0 ){
      n = (タブ = resize()).length;
    }

/* ハッシュ関数を使用して配列の位置を取得します (空の場合は、新しいノードを配列に保存します) */
if ((p = tab[i = (n - 1 ) & hash]) == null ){
      tab[i] = newNode(ハッシュ、キー、null );
    }

/* 配列の位置にすでに値がある場合は、次のメソッドを使用してデータを保存します*/
それ以外{
      Node<K,V> e; //新しい値を保存するための一時ノード
      K k; // キーを比較するために使用する一時変数

//ヘッドノードのハッシュ値と新しいノードのキーが同じで、キー値が等しい場合、eは古いノードに割り当てられます
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){の場合
        e = p;
      }

//ヘッドノードが赤黒ツリーノードの場合、eはツリーノードとして保存されます
そうでない場合( p TreeNode のインスタンス) {
      e = ((TreeNode<K,V>)p).putTreeVal( this 、タブ、ハッシュ、キー、);
      }

// ヘッドノードが新しいノードと異なり、ヘッドノードが赤黒木ノードでない場合は、リンクリスト内のすべてのノードをループします。
それ以外{
( int binCount = 0 ; ; ++ binCount ) {
((e = p.next) == null の場合) {
//リンクリストの最後まで同じキーを持つノードが見つからない場合は、新しいノードをリンクリストの最後のノードとして追加します
            p.next = newNode(ハッシュ、キー、null );

//リンクリスト内のノード数が 8-1 以上の場合は、赤黒木に変換します。赤黒木に変換されるノードの最小数は 8 です。
binCount >= TREEIFY_THRESHOLD - 1 の場合{
              treeifyBin(タブ、ハッシュ);
            }
壊す;
          }
//ループ中に新しいキーと古いキーの値が同じ場合は、次の場所にジャンプします: 古い値を上書きするかどうか
e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){の場合
壊す;
          }
          p = e;
        }
      }

//古い値を上書きするかどうか
e != null の場合
        V 古い値 = e.value ;
//新しい値が空でない場合(古い値を変更できるか、古い値が空の場合)、古いノードの値を上書きします
if (!onlyIfAbsent || oldValue == null ) {
          e.=; 	
        }
        afterNodeAccess(e); //コールバック関数。ここでは空の関数ですが、このメソッドはlinkedHashMapで実装されています。
return oldValue; //古い値を返す
      }
    }

//コレクションのトラバース中に他のスレッドがコレクションを変更するかどうかを比較して判断するために使用されます。詳細については、オンラインで「fail-fast fast failure メカニズム」を検索してください。
    ++modCount;

// キーの数が許容容量を超える場合は、容量を拡張します。 HashMap で許容される容量は配列の長さ * フィルファクターです (デフォルトは 0.75)
if (++サイズ > 閾値){
    サイズを変更します。
    }

//コールバック関数。ここでは空の関数ですが、このメソッドはlinkedHashMapで実装されています。
    afterNodeInsertion(削除);
nullを返します。
  }

簡略化された擬似コード

  putval(キー、){
    インデックス = キー.ハッシュコード % テーブルサイズ;
if ( null == テーブル[インデックス]){
      table[index] =新しいノード(キー、);
    }それ以外{
      firstNode = テーブル[インデックス];
      次のノード = firstNode.next;
nextNode.hasNextNode() が次のノードにある間
//同じキーを持つ古いノードが見つかった場合は、古いノードを上書きします
if (キー == nextNode.key){
          nextNode =新しいノード(キー、); 
壊す;
        }
//キューの最後に同じキーを持つ古いノードが見つからない場合は、最後のノードの最後に新しいノードを追加します
(nextNode.next == null )の場合{
          nextNode.next =新しいノード(キー、);
壊す;
        }
        次のノードを次に示します。
      }
    }
  }

リンクリストのサイズ設計

これについてはコードのコメントで簡単に触れられているので、ここで詳しく説明します。

①容量選択

HashMap の初期容量は 1 << 4 で、16 です。それ以降の拡張はサイズ << 1 となり、容量は元の容量の 2 倍に拡張されます。この設計は、2^n-1 (n≥1) のバイナリ表現が常に 1 を繰り返すため、モジュロ演算に便利だからです。

書籍「データ構造とアルゴリズム分析。Java 言語の説明」では、競合を減らすために容量を素数にすべきであると示唆されていますが、証明や実験データは示されていません。

素数は魔法の数ですが、個人的にはここでは特に役に立たないと感じています。

式「インデックス = ハッシュコード % サイズ」によれば、サイズが素数か非素数かに関係なく、インデックスの値の範囲は 0 から (サイズ - 1) の間です。実際の決定要因はハッシュコードの優れたランダム性であると思われます。

ここではまだ結論を出さないでください。素数と非素数の繰り返し率を比較するためのランダム関数を後で作成します。

② 荷重係数

デフォルトの充填係数は 0.75 です。つまり、配列の容量が 16 の場合、キーの数が 12 を超えると、HashMap が拡張されます。

時間と空間のパフォーマンスのバランスをとるために、パッキング係数は 0.75 になるように設計されています。小さすぎると、配列がまばらになり、スペースの使用率が低下します。大きすぎると、競合が増加し、時間の複雑さが増します。

その他について

赤黒木は JDK 1.8 で導入されました。赤黒木のデータ構造、追加、削除、変更、クエリ操作、および時間計算量分析を簡単な言葉で説明するのは複雑で難しい作業です。個別に説明する方が適切なので、後回しにしましょう。

5. 最小完全ハッシュ関数 (MPHF)

JDK の HashMap は、あらゆるデータ セットの時間計算量の問題を解決し、設計されたハッシュ関数は、未知のデータ セットの場合でも適切に機能します。

しかし、既知のデータ セット (Java キーワード セットなど) がある場合、次の 2 つの要件を同時に満たすハッシュ関数をどのように設計すればよいでしょうか。

⑴ コンテナの容量はデータセットのサイズとまったく同じです。
⑵ 矛盾はない。

つまり、あるデータセットが与えられたときに、ハッシュ関数によってコンテナの各ノードに 1 つのデータ要素のみを持たせることができる場合、そのようなハッシュ関数は最小完全ハッシュ関数と呼ばれます。

私は最近コンパイラ理論を勉強していて、キーワードセットの O(1) 時間計算量検索問題を解決する方法について言及しました。最小完全ハッシュ関数を使用できると述べました。このような名詞を見ると、とても素晴らしくて高貴なものだということをすぐに感じます。

<<:  ビッグデータなどの最も中核的なキーテクノロジー:32のアルゴリズム

>>:  SKU多次元属性状態判定アルゴリズム

ブログ    
ブログ    
ブログ    

推薦する

障害検出におけるデータ機械学習の応用

はじめに: 従来の産業では、故障診断が機械メンテナンスで最も困難な段階であるため、メンテナンスに費や...

2023年の人工知能に関する6つの予測

現在の AI ブームと展望に基づいて、2023 年の AI に関して専門家が予測する 6 つの点を紹...

AIは単なるコードかもしれないが、それは私たちのコードだ

AI に対する期待は高すぎるのでしょうか? また、企業とその経営陣は AI が提供する成果にどの程度...

AIを活用して、ナスダックは金融業界向けのSaaSプロバイダーに変革したいと考えている

ナスダックがAIGCに対して強気であることは疑いの余地がない。 Nasdaq の CIO 兼 CTO...

...

...

MetaGPTが人気に! 2ドルでボスになれる、GitHubには11.2万個のスターがつき、AIエージェント「オールラウンダー」が誕生

インテリジェントエージェントは未来です!最近、別の AI エージェント プロジェクト MetaGPT...

人工知能の安全で制御可能な開発について議論するために、AIセキュリティと産業ガバナンスフォーラムが正式に開催されました。

第四次科学技術革命をリードする戦略的技術として、人工知能は社会構築と経済発展に重大かつ広範囲な影響を...

方向を理解し、座標を伝える、Shikraはマルチモーダルな大規模モデル参照ダイアログの新しい次元を開きます

人間の日常的なコミュニケーションでは、場面内のさまざまな領域や物体に焦点が当てられることが多く、これ...

...

NeRFは過去のものになるのか?立体復元は3D GSの新時代へ! (復旦大学からの最新レビュー)

この記事は、Heart of Autonomous Driving の公開アカウントから許可を得て転...

...

ディープラーニングモデルアーキテクチャを視覚化する6つの一般的な方法の概要

視覚化は、ディープラーニング モデルの内部構造を説明し、理解するのに役立ちます。 モデル計算グラフの...

オープンソースの Gemma モデル: Google の言語の奇跡。命令チューニング、低ランク適応、Switch Transformer を使用して小さなモデルで遊ぶことができます。

言語は人間にとって最も重要なコミュニケーションツールであり、人工知能の分野における最も挑戦的な研究対...