從Dictionary原始碼看雜湊表
一、基本概念
雜湊:雜湊是一種查詢演算法,在關鍵字和元素的儲存地址之間建立一個確定的對應關係,每個關鍵字對應唯一的儲存地址,這些儲存地址構成了有限、連續的儲存地址。
雜湊函式:在關鍵字和元素的儲存地址之間建立確定的對應關係的函式。
雜湊表是一種利用雜湊函式組織資料,支援快速插入和搜尋的資料結構。
雜湊函式步驟:
1.雜湊:將關鍵字對映到hashcode(.Net中為一個int型別的值),要求儘可能的平均分佈,減少衝突
2.對映:將及其分散的hashcode轉換為有序、連續的儲存地址
雜湊衝突的原因:
1.將關鍵字雜湊為特定長度的整數值時,產生衝突
2.在除留餘數法中,取餘數時產生衝突。
1.構造雜湊函式的要點: 1.1.運算過程簡單高效,以提高雜湊表的查詢、插入效率 1.2.具有較好的雜湊性,以降低雜湊衝突的概率 1.3.雜湊函式應具有較大的壓縮性,以節省記憶體 2.雜湊函式構造方法: 2.1.直接定址法: >>>>取關鍵字的某個線性函式值作為雜湊地址: Hash(K)=α*GetHashCode(K)+C 優點:產生衝突的可能性較小 缺點:空間複雜度可能會很高,佔用大量記憶體 2.2.除留餘數法: >>>>取關鍵字除以某個常數所得的餘數作為雜湊地址: Hash(K)=GetHashCode(K) MOD C。 該方法計算簡單,適用範圍廣泛,是最經常使用的一種雜湊函式。該方法的關鍵是常數的選取,一般要求是接近或等於雜湊表本身的長度,理論研究表明,該常數取素數時效果最好 3.解決雜湊衝突的方法: 3.1.開放定址法:它是一類以發生雜湊衝突的雜湊地址為自變數,通過某種雜湊函式得到一個新的空閒記憶體單元地址的方法,開放定址法的雜湊衝突函式通常是一組; 3.2.連結串列法:當未發生衝突時,則直接存放該資料元素;當衝突產生時,把產生衝突的資料元素另外存放在單鏈表中。
以上參考:
https://zhuanlan.zhihu.com/p/63142005
、https://www.lmlphp.com/user/7277/article/item/355045/
、http://www.nowamagic.net/academy/detail/3008050
二、從 Dictionary<TKey, TValue>
原始碼解讀雜湊表的構建
雜湊表的關鍵思想:通過雜湊函式將關鍵字對映到儲存桶。儲存桶是一個抽象概念,用於儲存相同具有雜湊地址的元素。
陣列在所有程式語言中都是最基本的資料結構,例項化陣列的時候,會在記憶體中分配一段連續的地址空間,用於儲存同一型別的變數。對於雜湊表來講,陣列就是實際儲存元素的資料結構,陣列索引就是其實際的儲存地址,而雜湊函式的功能就是將n個關鍵字唯一對應到到陣列索引 0~m-1(m>=n)。為了兼顧效能,雜湊函式是很難避免雜湊衝突的,也就是說,沒有辦法直接將雜湊地址作為元素的實際地址。
假設以下情況:
- 1.宣告陣列長度為13,現有8個元素需要插入到雜湊表中,該8個元素對應的陣列索引為[0]~[7] (實際儲存地址)
- 2.通過雜湊函式,可以將8個關鍵字對映到雜湊地址(範圍:0~20)
由於雜湊衝突不可避免,如何通過雜湊地址找到對應的實際儲存地址?答案是通過陣列在元素間構建單向連結串列來作為儲存桶,將具有相同雜湊地址的元素在儲存在同一個儲存桶(連結串列)中,並建立一個新的陣列,陣列長度為'雜湊地址範圍長度',該陣列使用雜湊地址作為索引,並儲存連結串列的第一個節點的實際儲存地址。下圖展示了Dictionary<TKey, TValue>
中的實現。
瞭解了大概的原理之後,有兩個問題需要解決:
1.如何通過陣列構建單項鍊表:
自定義一個結構:其包含關鍵字、元素和next。Entry.next
將具有相同雜湊地址的元素構建為一個單向連結串列,Entry.next
用於指向單向連結串列中的下一個元素所在的陣列索引。通過雜湊地址找到對應連結串列的第一個元素所在陣列索引後,就可以找到整個單向連結串列,通過遍歷連結串列對比關鍵字是否相等,來找到元素。
public class Dictionary<TKey, TValue>
{
private struct Entry
{
// 連結串列下一元素索引
// -1:連結串列結束
// -2:freeList連結串列結束
// -3:索引為0 屬於freeList連結串列
// -4:索引為1 屬於freeList連結串列
// -n-3:索引為n 屬於freeList連結串列
public int next;
public uint hashCode;
public TKey key; // Key of entry
public TValue value; // Value of entry
}
private IEqualityComparer<TKey> _comparer;
//儲存Entry連結串列第一個節點的索引,預設為零
//Entry實際索引=_buckets[雜湊地址]-1
private int[] _buckets;
private Entry[] _entries;//組成了n+1個單向連結串列
//n:用於儲存雜湊值相同的元素
//1:用於儲存已釋放的元素
private int _freeCount;//已釋放元素的個數
private int _freeList;//最新已釋放元素的索引
private int _count;//陣列中下一個將被使用的空位
private int _version;//增加刪除容量變化時,_version++
private const int StartOfFreeList = -3;
}
2.如何將具有很多可能的關鍵字對映到有限的的雜湊地址:
該問題分為兩個步驟:
- 1.雜湊函式:將所有可能的關鍵字對映到一個有限的整數值,由於可能性非常非常多,為了減少衝突,所以該整數值範圍也比較大,在.net中是一個
int
型別的整數值,一般稱為GetHashCode()
方法 - 2.
int
值的範圍為-2147483648 ~ 2147483647
,為了節省空間,不可能使用這麼大的陣列去儲存單向連結串列頭部元素的實際索引,所以需要壓縮陣列大小。
如何解決:
- 1.使用直接定址法:
雜湊地址 = (GetHashCode(Ki)*0.000000001 +21) 取整
雖然在係數取很小的情況下,達到了壓縮的效果,但是雜湊衝突非常高,無法實現高效的查詢。如果係數取大,空間複雜度又會特別高。 - 2.使用除留餘數法:
雜湊地址 = GetHashCode(Ki) MOD C
實際證明該方法的雜湊衝突更少,在C為素數的情況下,效果更好。
在Dictionary<TKey, TValue>
內部使用陣列Entry[]
來儲存關鍵字和元素,使用 private int[] _buckets
來儲存單向連結串列頭部元素所在的陣列索引。上面提到,因為雜湊衝突是不可避免的,對於有n個雜湊地址的雜湊表來說,Dictionary<TKey, TValue>
一共構建了n+1個單向連結串列。另外單獨的一個連結串列,用於儲存已經釋放的陣列空位。
增加元素邏輯:
- 1.使用
_count
來作為陣列的空位指標,_count
值永遠指向陣列中下一個將被使用的空位 - 2.使用
_freeList
來儲存釋放連結串列的頭部元素所在陣列(_entries[]
)索引 - 3.如果釋放連結串列為空的情況下,儲存元素到
_entries[_count]
,否則儲存到_entries[_freeList]
- 4.根據關鍵字獲取雜湊地址,如果
_buckets[雜湊地址]
中的值不為-1,則將剛儲存元素的next
置為_buckets[雜湊地址]
值(將元素加到單向連結串列的頭部)。 - 5.更新
_buckets[雜湊地址]
的值為_freeList
或者_count
public bool TryInsert(TKey key, TValue value)
{
if (key == null)
{
throw new ArgumentNullException("TKey不能為null");
}
if (_buckets == null)
{
Initialize(0);
}
Entry[] entries = _entries;
IEqualityComparer<TKey> comparer = _comparer;
uint hashCode = (uint)comparer.GetHashCode(key);
int collisionCount = 0;//雜湊碰撞次數
ref int bucket = ref _buckets[hashCode % (uint)_buckets.Length];//元素所在的實際地址
// Entry連結串列最新索引
// -1:連結串列結束
// >=0:有下一節點
int i = bucket - 1;
//統計雜湊碰撞次數
do
{
if ((uint)i >= (uint)entries.Length)
{
break;
}
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key))
{
entries[i].value = value;
_version++;
return true;
}
i = entries[i].next;
if (collisionCount >= entries.Length)
{
throw new InvalidOperationException("不支援多執行緒操作");
}
collisionCount++;
} while (true);
bool updateFreeList = false;
int index;
//如果FreeList連結串列中長度大於0
//優先使用FreeList
if (_freeCount > 0)
{
index = _freeList;
updateFreeList = true;
_freeCount--;
}
else
{
int count = _count;
//超出陣列大小
if (count == entries.Length)
{
//將陣列長度擴充套件為大於原長度兩倍的最小素數
var forceNewHashCodes = false;
var newSize = HashHelpers.ExpandPrime(_count);
Resize(newSize, forceNewHashCodes);
bucket = ref _buckets[hashCode % (uint)_buckets.Length];
}
index = count;
_count = count + 1;
entries = _entries;
}
ref Entry entry = ref entries[index];
if (updateFreeList)
{
_freeList = StartOfFreeList - entries[_freeList].next;
}
entry.hashCode = hashCode;
// Value in _buckets is 1-based
entry.next = bucket - 1;
entry.key = key;
entry.value = value;
// Value in _buckets is 1-based
bucket = index + 1;
_version++;
// 如果不採用隨機字串雜湊,並達到碰撞次數時,切換為預設比較器(採用隨機字串雜湊)
if (default(TKey) == null && collisionCount > HashHelpers.HashCollisionThreshold && comparer is NonRandomizedStringEqualityComparer) // TODO-NULLABLE: default(T) == null warning (https://github.com/dotnet/roslyn/issues/34757)
{
_comparer = null;
Resize(entries.Length, true);
}
return true;
}
刪除元素邏輯:
- 1.根據關鍵字獲取雜湊地址,連結串列頭部元素索引=
_buckets[雜湊地址]
。 - 2.遍歷連結串列,找到對應關鍵字的元素。
- 3.將元素賦為預設值,並加入到釋放連結串列的頭部。
- 4.構建上一個節點與下一個節點之間的指向關係
lastEle.next = nextEle.index
/// .NetCore3.0 Remove執行之後_version沒有自增
public bool Remove(TKey key)
{
int[] buckets = _buckets;
Entry[] entries = _entries;
int collisionCount = 0;
if (buckets != null)
{
uint hashCode = (uint)(_comparer?.GetHashCode(key) ?? key.GetHashCode());
uint bucket = hashCode % (uint)buckets.Length;
int last = -1;//記錄上一個節點,在刪除中間節點時,將前後節點建立關聯
int i = buckets[bucket] - 1;
while (i >= 0)
{
ref Entry entry = ref entries[i];
if (entry.hashCode == hashCode && _comparer.Equals(entry.key, key))
{
if (last < 0)
{
//刪除的節點為首節點,儲存最新索引
buckets[bucket] = entry.next + 1;
}
else
{
//刪除節點不是首個節點,建立前後關係
entries[last].next = entry.next;
}
// 將刪除節點加入FreeList頭部
entry.next = StartOfFreeList - _freeList;
// 置為預設值
if (RuntimeHelpers.IsReferenceOrContainsReferences<TKey>())
{
entry.key = default;
}
if (RuntimeHelpers.IsReferenceOrContainsReferences<TValue>())
{
entry.value = default;
}
// 儲存FreeList頭部索引
_freeList = i;
_freeCount++;
return true;
}
// 當前節點不是目標節點
last = i;
i = entry.next;
if (collisionCount >= entries.Length)
{
// The chain of entries forms a loop; which means a concurrent update has happened.
// Break out of the loop and throw, rather than looping forever.
// ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
throw new InvalidOperationException("不支援多執行緒操作");
}
collisionCount++;
}
}
return false;
}
三、GitHub原始碼地址
四、String.GetHashCode()方法
不採用隨機字串的方法:原始碼地址
對於某一個確定的字串,返回確定的hashcode
,缺點:容易被雜湊洪水攻擊。
// Use this if and only if 'Denial of Service' attacks are not a concern (i.e. never used for free-form user input),
// or are otherwise mitigated
internal unsafe int GetNonRandomizedHashCode()
{
fixed (char* src = &_firstChar)
{
Debug.Assert(src[this.Length] == '\0', "src[this.Length] == '\\0'"\\0'");
Debug.Assert(((int)src) % 4 == 0, "Managed string should start at 4 bytes boundary");
uint hash1 = (5381 << 16) + 5381;
uint hash2 = hash1;
uint* ptr = (uint*)src;
int length = this.Length;
while (length > 2)
{
length -= 4;
// Where length is 4n-1 (e.g. 3,7,11,15,19) this additionally consumes the null terminator
hash1 = (BitOperations.RotateLeft(hash1, 5) + hash1) ^ ptr[0];
hash2 = (BitOperations.RotateLeft(hash2, 5) + hash2) ^ ptr[1];
ptr += 2;
}
if (length > 0)
{
// Where length is 4n-3 (e.g. 1,5,9,13,17) this additionally consumes the null terminator
hash2 = (BitOperations.RotateLeft(hash2, 5) + hash2) ^ ptr[0];
}
return (int)(hash1 + (hash2 * 1566083941));
}
}
採用隨機字串的方法: 原始碼地址
特點:
- 1.兩個字串相等,返回相同的雜湊值
- 2.不同的字串可以返回相同的雜湊值
- 3.基於不同的.Net實現、.Net平臺、.Net版本、應用程式域,同一個字串可能返回不同的雜湊值
- 4.雜湊值決不能在建立它們的應用程式域的外部使用
public override int GetHashCode()
{
ulong seed = Marvin.DefaultSeed;
// Multiplication below will not overflow since going from positive Int32 to UInt32.
return Marvin.ComputeHash32(ref Unsafe.As<char, byte>(ref _firstChar), (uint)_stringLength * 2 /* in bytes, not chars */, (uint)seed, (uint)(seed >> 32));
}
好文推薦:
- 1.為什麼每次在.NET Core中執行程式時string.GetHashCode()都不同
- 2.瞭解應用程式域和程式集