拉鍊法的 unordered_map 和你想象中的不一樣
根據陣列+拉鍊法的描述,我們很快能想到下面這樣的拉鍊法實現的雜湊表,但真的是這樣嗎?一起看下原始碼裡的實現是怎麼樣的。
深入STL原始碼
程式碼不會騙人的,可以寫一個簡單的程式碼研究一下實現,然後通過gdb跟蹤執行:
#include <vector>
#include <unordered_map>
int main() {
std::unordered_map<int, int> hashmap;
hashmap[26] = 26;
}
編譯和開啟gdbgui:
g++ -g hashmap.cc -std=c++11 -o hashmap_test
gdbgui -r -p 8000 ./hashmap_test
gdb 跟進發現程式碼會走到 hashtable_policy.h 的 operator[]
函式中,程式碼我做了一些簡化,只提取了關鍵程式碼:
auto operator[](const key_type& __k) -> mapped_type&
{
__hashtable* __h = static_cast<__hashtable*>(this);
// 根據 key 獲得 hashcode
__hash_code __code = __h->_M_hash_code(__k);
// 根據 key 和 hashcode 獲得 bucket 的 index:n
std::size_t __n = __h->_M_bucket_index(__k, __code);
// 在 bucket n 內嘗試找到節點key為k的節點
__node_type* __p = __h->_M_find_node(__n, __k, __code);
if (!__p)
{
// 如果找到的節點為 nullptr,那麼就重新分配一個節點並且將新節點插入到 hash 表中。
__p = __h->_M_allocate_node(k);
return __h->_M_insert_unique_node(__n, __code, __p)->second;
}
return __p->_M_v().second;
}
operator[]
函式的功能是計算key的hash值,再通過hash值找到對應的bucket n
,最後在這個bucket內查詢是不是有一個key=k
的節點,
如果沒有找到需要的節點,就會新分配並且插入一個新的節點。
那麼這個節點如何插入的呢?跟進下插入函式 _h->_M_insert_unique_node(__n, __code, __p)
:
auto _M_insert_unique_node(__bkt, __code, __node, size_type __n_elt = 1) -> iterator
{
// 判斷是否需要 rehash
const __rehash_state& __saved_state = _M_rehash_policy._M_state();
std::pair<bool, std::size_t> __do_rehash
= _M_rehash_policy._M_need_rehash(_M_bucket_count, _M_element_count,
__n_elt);
if (__do_rehash.first)
{
_M_rehash(__do_rehash.second, __saved_state);
__bkt = _M_bucket_index(this->_M_extract()(__node->_M_v()), __code);
}
this->_M_store_code(__node, __code);
// Always insert at the beginning of the bucket.
// 將節點插入到 bucket 的開始位置
_M_insert_bucket_begin(__bkt, __node);
++_M_element_count;
return iterator(__node);
}
_M_insert_unique_node()
這個插入函式主要作用是判斷如果新插入節點,這個hash表的負載會不會過高?需不需要重新擴容,完成擴容後通過_M_insert_bucket_begin()
再插入到 bucket 的 begin 的位置,這裡 rehash 的過程我們暫時不關注,先看下_M_insert_bucket_begin()
這個函式是怎麼實現的:
_M_insert_bucket_begin(size_type __bkt, __node_type* __node)
{
// 判斷bucket n 是否為空
if (_M_buckets[__bkt])
{
// Bucket is not empty, we just need to insert the new node
// after the bucket before begin.
// 如果 bucket 不為空,用頭插法將節點插入到開頭
__node->_M_nxt = _M_buckets[__bkt]->_M_nxt;
_M_buckets[__bkt]->_M_nxt = __node;
}
else
{
// The bucket is empty, the new node is inserted at the
// beginning of the singly-linked list and the bucket will
// contain _M_before_begin pointer.
// 如果節點不為空,
__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;
if (__node->_M_nxt)
// 如果 __node->_M_nxt 也就是原來的 _M_before_begin._M_nxt 不為空,
// 那麼就要就要把 _M_before_begin._M_nxt 指向新的 node__。
// We must update former begin bucket that is pointing to
// _M_before_begin.
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
// 將 _M_before_begin 賦值給 bucket n。
_M_buckets[__bkt] = &_M_before_begin;
}
}
現在就到了插入節點的精彩部分了,當前 bucket 是否為空將函式劃分成了兩個部分,接下來將用圖例的方式來展示整個插入過程。
插入第一個節點
首先先看為空的情況:
在進入函式前,有:
- 預先建立好的(hashmap 建構函式) buckets
- 一個成員變數
_M_before_begin
- 一個新分配出來的插入節點
__p
當前插入的值為26,做完雜湊計算n = 26 % 7 = 5
,那麼就會在bucket[5]
做插入:
當 bucket[5]
為空的插入程式碼為:
__node->_M_nxt = _M_before_begin._M_nxt; // ①
_M_before_begin._M_nxt = __node; // ②
if (__node->_M_nxt)
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
_M_buckets[__bkt] = &_M_before_begin; // ③
- ①、②兩步就是經典連結串列的頭插法,插入到兩個節點中間。
- 因為這裡
__node->_M_nxt
是指向nullptr
的,具體的邏輯先跳過。 - 然後第③步將
_M_before_begin
的地址賦值給bucket[n]
於是得到了一個頭插法後的連結串列:
插入同bucket的第二個節點
如果嘗試在同一個 bucket 插入一個新的值,因為當前 bucket 有值,程式碼就會走到_M_insert_bucket_begin()
這個函式的前半部分:
if (_M_buckets[__bkt])
{
// Bucket is not empty, we just need to insert the new node
// after the bucket before begin.
// 如果 bucket 不為空,用頭插法將節點插入到開頭
__node->_M_nxt = _M_buckets[__bkt]->_M_nxt;
_M_buckets[__bkt]->_M_nxt = __node;
}
簡化得到:
到目前為止和想象中的雜湊表還是差不多的,不斷的插入到一個 bucket 中,並且用連結串列連在一起,現在嘗試插入一個節點到別的 bucket 中:
在不同的bucket插入一個節點
先會執行 bucket 為空的前兩行,仍然是頭插法後的結果:
__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;
繼續執行接下來的語句:
if (__node->_M_nxt)
// We must update former begin bucket that is pointing to
// _M_before_begin.
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
_M_buckets[__bkt] = &_M_before_begin;
此時,因為 _M_before_begin._M_nxt
不為空,並且賦值到了新節點 __node
的 _M_nxt
上,此時就會執行邏輯:
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
__node->_M_next()
也就是 key 為 12 的那個節點,其bucket_index
應該是5,所以bucket[5]
的指標將會指向新插入的這個節點。
最後再將bucket[1]
指向 _M_before_begin
,得到:
繼續簡化一下,最終其實會形成一個帶哨兵節點的單連結串列,而每個 bucket 只存有一個指向該連結串列相應位置的指標,其中_M_before_begin
就是這個哨兵節點:
最終結構
- 在bucket有值的時候,都是通過前一個指標和頭插法插入到對應的 bucket 內。
- 如果 bucket 沒有值,就會把哨兵節點切換到新的 bucket 中。
如:
這麼複雜,有什麼好處呢?
遍歷的時間複雜度。
假設在這種實現下,遍歷整個 hashmap 只需要從 head
指標不斷的像 head->next
移動至 nullptr
,如果總共有 n 個元素,k個bucket,時間複雜度也只有 O(n)
。
如果是最開始那種實現呢?每個bucket一個連結串列,需要判斷所有 bucket 是否為空,並且遍歷每個 bucket 內的連結串列,時間複雜度會到達 O(n + k)
,而且雜湊表為了避免雜湊衝突,通常會有一個比較大的陣列,表示式中的 k 的影響還是挺大的。
驗證
插入的程式碼已經理解了,驗證一下理解的結構是不是真的是這樣,再看下hashmap.find(key)
的程式碼,find 的過程其實在 hashmap.operator[]
中已經有了,插入前判斷是不是已經有節點了:
auto operator[](const key_type& __k) -> mapped_type&
{
__hashtable* __h = static_cast<__hashtable*>(this);
// 根據 key 獲得 hashcode
__hash_code __code = __h->_M_hash_code(__k);
// 根據 key 和 hashcode 獲得 bucket 的 index:n
std::size_t __n = __h->_M_bucket_index(__k, __code);
// 在 bucket n 內嘗試找到節點key為k的節點
__node_type* __p = __h->_M_find_node(__n, __k, __code);
if (!__p)
{
// ... Do allocate and insert
}
return __p->_M_v().second;
}
跟蹤下函式 __h->_M_find_node(__n, __k, __code)
,會呼叫 _M_find_before_node(__n, __k, __code)
:
auto _M_find_before_node(size_type __n, const key_type& __k, __hash_code __code) const -> __node_base*
{
// _M_buckets[__n] 儲存了該 bucket 的 prev,如果不存在,那麼這個 bucket 就是空的
__node_base* __prev_p = _M_buckets[__n];
if (!__prev_p)
return nullptr;
// 從 prev->next 開始,迴圈到 prev->next 為nullptr 或者 prev->next 的bucket號不是當前bucket 為止。
for (__node_type* __p = __prev_p->_M_nxt; ; __p = __p->_M_next())
{
if (this->_M_equals(__k, __code, __p))
return __prev_p;
// 迴圈結束判斷
if (!__p->_M_nxt || _M_bucket_index(__p->_M_next()) != __n)
break;
__prev_p = __p;
}
return nullptr;
}
現在判斷當前 bucket[n]
是否有值,如果有值,就開始從 prev->next
開始遍歷到 nullptr,或者 bucket 號不是當前bucket的節點。
比如,找一個bucket[2]
內的節點的開始和結束:
總結
標準庫內的 STL 的實現還是非常 Amazing 的,它的實現關鍵詞有三個,陣列、單連結串列和哨兵節點,在支援分桶的情況下,還支援了O(n)
的遍歷複雜度。
另外附上我的參考連線:
- 幫助我理解了
_M_before_begin
節點的作用 https://szza.github.io/2021/03/01/C++/2_/