C++ STL:std::unorderd_map 物理結構詳解

北極烏布發表於2022-01-30

拉鍊法的 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; // ③
  1. ①、②兩步就是經典連結串列的頭插法,插入到兩個節點中間。
  2. 因為這裡 __node->_M_nxt 是指向nullptr的,具體的邏輯先跳過。
  3. 然後第③步將_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 中:

在不同的bucket插入一個節點

不同bucket插入第一個節點

先會執行 bucket 為空的前兩行,仍然是頭插法後的結果:

__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;

不同bucket插入第一個節點後前兩句

繼續執行接下來的語句:

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;

不同bucket插入第一個節點後後幾句

此時,因為 _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)的遍歷複雜度。

另外附上我的參考連線:

  1. 幫助我理解了_M_before_begin節點的作用 https://szza.github.io/2021/03/01/C++/2_/

相關文章