深入 Python 字典的內部實現

張無忌發表於2016-05-13

字典是通過鍵(key)索引的,因此,字典也可視作彼此關聯的兩個陣列。下面我們嘗試向字典中新增3個鍵/值(key/value)對:

這些值可通過如下方法訪問:

由於不存在 'd' 這個鍵,所以引發了KeyError異常。

雜湊表(Hash tables)

在Python中,字典是通過雜湊表實現的。也就是說,字典是一個陣列,而陣列的索引是鍵經過雜湊函式處理後得到的。雜湊函式的目的是使鍵均勻地分佈在陣列中。由於不同的鍵可能具有相同的雜湊值,即可能出現衝突,高階的雜湊函式能夠使衝突數目最小化。Python中並不包含這樣高階的雜湊函式,幾個重要(用於處理字串和整數)的雜湊函式通常情況下均是常規的型別:

在以下的篇幅中,我們僅考慮用字串作為鍵的情況。在Python中,用於處理字串的雜湊函式是這樣定義的:

如果在Python中執行 hash('a') ,後臺將執行 string_hash()函式,然後返回 12416037344 (這裡我們假設採用的是64位的平臺)。

如果用長度為 x 的陣列儲存鍵/值對,則我們需要用值為 x-1 的掩碼計算槽(slot,儲存鍵/值對的單元)在陣列中的索引。這可使計算索引的過程變得非常迅速。字典結構調整長度的機制(以下會詳細介紹)會使找到空槽的概率很高,也就意味著在多數情況下只需要進行簡單的計算。假如字典中所用陣列的長度是 8 ,那麼鍵'a'的索引為:hash('a') & 7 = 0,同理'b'的索引為 3 ,'c'的索引為 2 , 而'z'的索引與'b'相同,也為 3 ,這就出現了衝突。

深入 Python 字典的內部實現

可以看出,Python的雜湊函式在鍵彼此連續的時候表現得很理想,這主要是考慮到通常情況下處理的都是這類形式的資料。然而,一旦我們新增了鍵'z'就會出現衝突,因為這個鍵值並不毗鄰其他鍵,且相距較遠。

當然,我們也可以用索引為鍵的雜湊值的連結串列來儲存鍵/值對,但會增加查詢元素的時間,時間複雜度也不再是 O(1) 了。下一節將介紹Python的字典解決衝突所採用的方法。

開放定址法( Open addressing )

開放定址法是一種用探測手段處理衝突的方法。在上述鍵'z'衝突的例子中,索引 3 在陣列中已經被佔用了,因而需要探尋一個當前未被使用的索引。增加和搜尋鍵/值對需要的時間均為 O(1)。

搜尋空閒槽用到了一個二次探測序列(quadratic probing sequence),其程式碼如下:

迴圈地5*j+1可以快速放大不影響初始索引的雜湊值二進位的微小差異。變數perturb可使其他二進位也不斷變化。

出於好奇,我們來看一看當陣列長度為 32 時的探測序列,j = 3 -> 11 -> 19 -> 29 -> 5 -> 6 -> 16 -> 31 -> 28 -> 13 -> 2…

關於探測序列的更多介紹可以參閱dictobject.c的原始碼。檔案的開頭包含了對探測機理的詳細介紹。

深入 Python 字典的內部實現

下面我們結合例子來看一看 Python 內部程式碼。

基於C語言的字典結構

以下基於C語言的資料結構用於儲存字典的鍵/值對(也稱作 entry),儲存內容有雜湊值,鍵和值。PyObject 是 Python 物件的一個基類。

下面為字典對應的資料結構。其中,ma_fill為活動槽以及啞槽(dummy slot)的總數。當一個活動槽中的鍵/值對被刪除後,該槽則被標記為啞槽。ma_used為活動槽的總數。ma_mask值為陣列的長度減 1 ,用於計算槽的索引。ma_table為陣列本身,ma_smalltable為長度為 8 的初始陣列。

字典初始化

字典在初次建立時將呼叫PyDict_New()函式。這裡刪掉了原始碼中的部分行,並且將C語言程式碼轉換成了虛擬碼以突出其中的幾個關鍵概念。

新增項

新增新的鍵/值對呼叫的是PyDict_SetItem()函式。函式將使用一個指標指向字典物件和鍵/值對。這一過程中,首先會檢查鍵是否是字串,然後計算雜湊值,如果先前已經計算並快取了鍵的雜湊值,則直接使用快取的值。接著呼叫insertdict()函式新增新鍵/值對。如果活動槽和空槽的總數超過陣列長度的2/3,則需調整陣列的長度。為什麼是 2/3 ?這主要是為了保證探測序列能夠以足夠快的速度找到空閒槽。後面我們會介紹調整長度的函式。

inserdict() 使用搜尋函式 lookdict_string() 來查詢空閒槽。這跟查詢鍵所用的是同一函式。lookdict_string() 使用雜湊值和掩碼計算槽的索引。如果用“索引 = 雜湊值&掩碼”的方法未找到鍵,則會用呼叫先前介紹的迴圈方法探測,直至找到一個空閒槽。第一輪探測,如果未找到匹配的鍵的且探測過程中遇到過啞槽,則返回一個啞槽。這可使優先選擇先前刪除的槽。

現在我們想新增如下的鍵/值對:{‘a’: 1, ‘b’: 2′, ‘z’: 26, ‘y’: 25, ‘c’: 5, ‘x’: 24},那麼將會發生如下過程:

分配一個字典結構,內部表的尺寸為8。

  • PyDict_SetItem: key = ‘a’, value = 1
    • hash = hash(‘a’) = 12416037344
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 12416037344 & 7 = 0
        • slot 0 is not used so return it
      • init entry at index 0 with key, value and hash
      • ma_used = 1, ma_fill = 1
  • PyDict_SetItem: key = ‘b’, value = 2
    • hash = hash(‘b’) = 12544037731
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 12544037731 & 7 = 3
        • slot 3 is not used so return it
      • init entry at index 3 with key, value and hash
      • ma_used = 2, ma_fill = 2
  • PyDict_SetItem: key = ‘z’, value = 26
    • hash = hash(‘z’) = 15616046971
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 15616046971 & 7 = 3
        • slot 3 is used so probe for a different slot: 5 is free
      • init entry at index 5 with key, value and hash
      • ma_used = 3, ma_fill = 3
  • PyDict_SetItem: key = ‘y’, value = 25
    • hash = hash(‘y’) = 15488046584
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 15488046584 & 7 = 0
        • slot 0 is used so probe for a different slot: 1 is free
      • init entry at index 1 with key, value and hash
      • ma_used = 4, ma_fill = 4
  • PyDict_SetItem: key = ‘c’, value = 3
    • hash = hash(‘c’) = 12672038114
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 12672038114 & 7 = 2
        • slot 2 is free so return it
      • init entry at index 2 with key, value and hash
      • ma_used = 5, ma_fill = 5
  • PyDict_SetItem: key = ‘x’, value = 24
    • hash = hash(‘x’) = 15360046201
    • insertdict
      • lookdict_string
        • slot index = hash & mask = 15360046201 & 7 = 1
        • slot 1 is used so probe for a different slot: 7 is free
      • init entry at index 7 with key, value and hash
      • ma_used = 6, ma_fill = 6

以下就是我們目前所得到的:

深入 Python 字典的內部實現

8個槽中的6個已被使用,使用量已經超過了總容量的2/3,因而,dictresize()函式將會被呼叫,用以分配一個長度更大的陣列,同時將舊錶中的條目複製到新的表中。

在我們這個例子中,dictresize()函式被呼叫後,陣列長度調整後的長度不小於活動槽數量的 4 倍,即minused = 24 = 4*ma_used。而當活動槽的數量非常大(大於50000)時,調整後長度應不小於活動槽數量的2倍,即2*ma_used。為什麼是 4 倍?這主要是為了減少呼叫調整長度函式的次數,同時能顯著提高稀疏度。

新表的長度應大於 24,計算長度值時會不斷對當前長度值進行升位運算,直到大於 24,最終得到的長度是 32,例如當前長度為 8 ,則計算過程如8 -> 16 -> 32

這就是長度調整的過程:分配一個長度為 32 的新表,然後用新的掩碼,也就是 31 ,將舊錶中的條目插入到新表。最終得到的結果如下:

深入 Python 字典的內部實現

刪除項

刪除條目時將呼叫PyDict_DelItem()函式。刪除時,首先計算鍵的雜湊值,然後呼叫搜詢函式返回到該條目,最後該槽被標記為啞槽。

假設我們想要從字典中刪除鍵'c',我們最終將得到如下結果:

深入 Python 字典的內部實現

注意,刪除專案後,即使最終活動槽的數量遠小於總的數量也不會觸發調整陣列長度的動作。但是,若刪減後又增加鍵/值對時,由於調整長度的條件判斷基於的是活動槽與啞槽的總數量,因而可能會縮減陣列長度。

好了,文章就到這兒了,希望你會喜歡。如果你有問題需要反饋,請在下方評論。如果你有基於Python的專案需要幫助或者在建立網路服務時遇到問題,作為一位自由職業者我隨時恭候。我的 LinkedIn 簡歷,Twitter: @laurentluce。

相關文章