著者:Zeng Zhiwei
リンク:https://zhuanlan.zhihu.com/p/96633352
出典:Zhihu
C# を 2 ~ 3 年ほど使っているのですが、ある日突然 C# Dictionary の基本的な実装について聞かれ、これまで自分は借り物、使えばいい、何も考えずにいたのだと反省しました。考えてみると、頭皮がチクチクするような感覚だった。普段当たり前のように使っていることを学んでいきましょう 今日はまず辞書のソースコードを学びます。
1. 辞書ソースコードの学習
Dictionaryの実装では主にソースコードと比較して解析を行いますが、ソースコードと比較した現在のバージョンは .Net Framwork 4.8 です。
ソースコードアドレス: dictionary.cs
ここでは主に Dictionary のいくつかの主要なクラスとオブジェクトを紹介します。
次に、コードに従って、挿入、削除、展開のプロセスを実行します。
1. エントリー構造
まず、Entryなどの構造体を導入します。その定義は次のコードに示されています。これは辞書にデータを格納する最小単位であり、Add(Key,Value)
メソッドの呼び出しによって追加された要素はこの構造にカプセル化されます。
private struct Entry {
public int hashCode; // Lower 31 bits of hash code, -1 if unused
public int next; // Index of next entry, -1 if last
public TKey key; // Key of entry
public TValue value; // Value of entry
}
2. その他の重要なプライベート変数
private int[] buckets; // Hash桶
private Entry[] entries; // Entry数组,存放元素
private int count; // 当前entries的index位置
private int version; // 当前版本,防止迭代过程中集合被更改
private int freeList; // 被删除Entry在entries中的下标index,这个位置是空闲的
private int freeCount; // 有多少个被删除的Entry,有多少个空闲的位置
private IEqualityComparer<TKey> comparer; // 比较器
private KeyCollection keys; // 存放Key的集合
private ValueCollection values; // 存放Value的集合
3.辞書の構造
private void Initialize(int capacity)
{
int prime = HashHelpers.GetPrime(capacity);
this.buckets = new int[prime];
for (int i = 0; i < this.buckets.Length; i++)
{
this.buckets[i] = -1;
}
this.entries = new Entry<TKey, TValue>[prime];
this.freeList = -1;
}
Dictionary が構築されるときに次のことを行うことがわかります。
- this.buckets = new int[prime] を初期化します。
- this.entries = new Entry<TKey, TValue>[prime] を初期化します。
- バケットとエントリの容量は、辞書の容量を超える最小の素数です。
このうち、this.buckets は主にハッシュ衝突に使用され、 this.entries は辞書の内容を格納し、次の要素の位置を識別するために使用されます。
4. Dictionary – Add操作
public void Add(TKey key, TValue value) {
Insert(key, value, true);
}
int targetBucket = hashCode % buckets.Length;
Dictionary<int,string> を例として、Dictionary に要素を追加する方法を示します。
まず、辞書を構築します。その後、バケットとエントリの容量は、辞書の容量より大きい最小素数 7 になります。
Dictionary<int, string> test = new Dictionary<int, string>(6);
Test.Add(4,"4")
ハッシュ アルゴリズムによると: int targetBucket = hashCode %buckets.Length;buckets.Length は7 , 4.GetHashCode()%7= 4に等しいため、バケット内の添え字 4 を持つスロットと衝突します。 Count が 0 なので、Entries の 0 番目の要素に要素が配置され、加算後の Count は 1 になります。
Test.Add(11,"11")
ハッシュ アルゴリズム 11.GetHashCode()%7= 4によると、バケット内の添え字 4 のスロットと再び衝突します。このスロットの値は -1 ではなくなったため、この時点では Count=1 となり、この新しい値は次のようになります。要素は、エントリ内の添字 1 を持つ配列に配置され、バケット スロットは添字 1 を持つエントリを指し、添字 1 を持つエントリは添字 0 を持つエントリの下にあります。
Test.Add(18,"18")
Test.Add(19,"19")
5. Dictionary – Remove操作
Test.Remove(4)
要素を削除するときは、衝突を使用してリンク リストに沿って 3 回検索して、キー 4 を持つ要素の位置を見つけ、現在の要素を削除します。そして、FreeList の位置を現在削除されている要素の位置にポイントし、FreeCount を 1 に設定します。
削除されたデータは FreeList リンク リストを形成します。データを追加する場合は、まず FreeList リンク リストにデータが追加されます。FreeList が空の場合は、件数順に並べられます。
6. 辞書 – リサイズ操作(容量拡張)
注意深い人は、Addbuckets、entries
操作を見た後で「配列が 2 つだけではないのですか? 配列がいっぱいだったらどうなるのですか?」と尋ねるかもしれません。次に、容量を拡張するために導入するリサイズ (容量拡張)操作です。buckets、entries
6.1 拡張操作のトリガー条件
まず、どのような状況で拡張操作が発生するかを知る必要があります。
最初の状況は当然、配列がいっぱいで新しい要素を格納する方法がないことです。以下の図に示すように。
次に、ディクショナリ内で発生する衝突が多すぎるため、パフォーマンスに重大な影響を及ぼし、展開操作がトリガーされます。
ハッシュ演算では必ず競合が発生するため、辞書では競合問題を解決するためにジッパーメソッドが使用されていますが、下の図の状況を見てください。すべての要素はバケット[3]に正確に当てはまります。その結果、時間計算量は O(n)となり、検索パフォーマンスが低下します。
6.2 容量拡張操作の実行方法
明確なデモを行うために、衝突しきい値が 2 であると仮定して、サイズ 2 のディクショナリである次のデータ構造がシミュレートされ、ハッシュ衝突拡張がトリガーされます。
- 1.現在のサイズの 2 倍のバケットとエントリを申請します
- 2.既存の要素を新しいエントリにコピーします
- 3. ハッシュ衝突拡張の場合は、新しい HashCode 関数を使用してハッシュ値を再計算します。
- 4. エントリの各要素について、bucket = newEntries[i].hashCode % newSize によって新しいバケットの位置が決まります。
- 5、重建ハッシュ链,newEntries[i].next=buckets[bucket]; バケット[バケット]=i;
フォーカスポイント
Dictionary の実装原理に関しては、2 つの主要なアルゴリズムがあります。
- 1つはハッシュアルゴリズムです。
- 1 つは、ハッシュ衝突競合解決アルゴリズムを処理するために使用されます。
2. ハッシュアルゴリズム
ハッシュ アルゴリズムは、 可変長のバイナリデータ セットをより短いバイナリ長のデータ セットに マッピングする デジタル ダイジェスト アルゴリズム です。
ハッシュ アルゴリズムを実装する関数は、ハッシュ関数と呼ばれます。ハッシュ関数には次のような特徴があります。
同じデータをハッシュ演算した場合、得られる結果は同じでなければなりません。HashFunc(key1) == HashFunc(key1)
異なるデータに対してハッシュ操作を実行すると、結果が同じになる場合があります ( ハッシュの衝突が発生します )。key1 != key2 => HashFunc(key1) == HashFunc(key2)
.
ハッシュ演算は不可逆であり、キーによって元のデータを取得することはできません。key1 => hashCode
しかしhashCode ==> key1
。
ハッシュの衝突については次の図でわかりやすく説明されており、この図から、ハッシュ演算後Sandra Dee
と両方が位置に落ち、その結果、衝突と競合が発生することがわかります。John Smith
02
ハッシュ関数を構築するための一般的なアルゴリズムには次のものがあります。
- 1. 直接アドレス指定方法:キーワードまたはキーワードの一次関数値をハッシュ アドレスとして取得します。つまり、H(key)=key または H(key) = a・key + b (a と b は定数) (このようなハッシュ関数を独自の関数と呼びます)、
これを応用すると、たとえば、世界地図のマスクの場合、座標 x * 1000 + 座標 y を直接使用してキーを取得します。
- 2. 数値分析方法:数値のパターンを見つけ出し、それらのデータを可能な限り使用して競合の可能性が低いハッシュ アドレスを構築します。
従業員グループの生年月日などの一連のデータを分析すると、生年月日の最初の数桁がほぼ同じであることがわかります。この場合、競合が発生する可能性が非常に高くなりますが、次のことがわかります。年、月、日 月を表す最後の数桁と特定の日付は大きく異なりますが、後者の桁を使用してハッシュ アドレスを作成すると、競合の可能性が大幅に減少します。
- 3. 中央を 2 乗する方法: 2 乗したキーワードの中央の数字をハッシュ アドレスとして取得します。
- 4. 折り畳む方法:キーワードを同じ桁数で複数の部分に分割し、最後の部分は異なる桁でも構いませんが、これらの部分の重ね合わせ和 (桁上げを削除したもの) をハッシュ アドレスとして取得します。
- 5. 乱数法:ランダム関数を選択し、キーワードのランダムな値をハッシュ アドレスとして取得します。通常、キーワードの長さが異なる場合に使用されます。
- 6. 除算余り法:キーワードをハッシュテーブル長 m 以下の数 p で除算した余りをハッシュアドレスとする。
つまり、H(key) = key MOD p、p<=m です。キーワードは直接モジュロ化できるだけでなく、折り畳み、二乗、その他の演算の後にモジュロ化することもできます。pの選択は非常に重要で、一般的には素数かmが使われますが、pの選択を間違えると衝突が起こりやすくなります。
7. ハッシュバケットアルゴリズム
ハッシュ アルゴリズムというと、誰もが ハッシュ テーブル を思い浮かべます。キーはハッシュ関数によって計算された後、すぐにハッシュ コードを取得できます。ハッシュ コードのマッピングを通じて、値を直接取得できます。ただし、ハッシュ コードの値は一般的
に非常に大きく、多くの場合 2^32 上記では、各 hashCode のマッピングを指定することは不可能です。
このような問題があるため、生成された HashCode をセグメント化した形式でマッピングし、各セグメントを Bucket と呼びます。一般的な Hash バケットは、結果の残りの部分を直接取得することです。
生成された hashCode が 2^32 の値を持つと仮定し、それをセグメントに分割し、 マッピングに
8 つの
バケット を使用する
bucketIndex = HashFunc(key1) % 8
と、そのようなアルゴリズムを使用して、hashCode がどの特定のバケットにマッピングされるかを決定できます。
辞書はハッシュ バケット アルゴリズムを使用します
int hashCode =comparer.GetHashCode(key)&0x7FFFFFFF;
int targetBucket = hashCode %buckets.Length;
3. ハッシュ衝突競合解決アルゴリズム
ハッシュアルゴリズムの場合、必ず競合が発生するため、競合が発生した後にどう対処するかが非常に重要なポイントとなる 現在、一般的な競合解決アルゴリズムとしては、Zipper法(Dictionary実装で使用)、Open Addressing法、re-Hash法、一般的な流出区域規制法
1. ジッパー方式 ( オープン ハッシュ ): 競合する要素の単一リンク リストを作成し、ヘッド ポインタ アドレスをハッシュ テーブル内の対応するバケットの位置に保存します。このようにして、ハッシュ テーブル バケットの場所を特定した後、単一リンク リストをトラバースすることで要素を見つけることができます。
2. オープン アドレッシング方式 (クローズド ハッシュ): ハッシュの競合が発生したときに、ハッシュ テーブルがいっぱいでない場合は、ハッシュ テーブルに空の位置が存在する必要があり、キーを競合位置に格納できます。 「空の位置。
3. 再ハッシュ法: 名前が示すように、競合しない位置が見つかるまで、他のハッシュ関数を使用してキーが再度ハッシュされます。
1. ジッパー方式
2. オープンアドレッシング方式
キーコードセット {1,4,5,6,7,9} があり、ハッシュ構造の容量が 10、ハッシュ関数が Hash(key)=key%10 であるとします。図に示すように、すべてのキーをハッシュ構造に挿入します。
構造体に挿入するキーコード24がある場合、ハッシュ関数を使用して得られるハッシュアドレスは4であるが、このアドレスには既に要素が格納されており、ハッシュ競合が発生する。
線形検出: ハッシュの競合が発生した位置から開始し、次の空の位置が見つかるまで逆方向に探索します。例えば、上記の例でキーコード 24 を挿入すると、挿入後は以下のように線形検出が行われます。
制限:
1. この方法を使用するには、変調する前にキー コードが整数である必要があるため、非整数型を整数型に変換する必要があります。
2. モジュールの数値は素数であることが望ましいため、素数テーブルを作成する必要があります。
3. 容量拡張の問題。