深入Python字典的內部實現
字典是通過鍵(key)索引的,因此,字典也可視作彼此關聯的兩個數組。下面我們嘗試向字典中添加3個鍵/值(key/value)對:
>>> d = {'a': 1, 'b': 2}
>>> d['c'] = 3
>>> d
{'a': 1, 'b': 2, 'c': 3}
這些值可通過如下方法訪問:
>>> d['a']
1
>>> d['b']
2
>>> d['c']
3
>>> d['d']
Traceback (most recent call last):
File "<stdin>", line 1, in
KeyError: 'd'
由于不存在 'd' 這個鍵,所以引發了KeyError異常。
哈希表(Hash tables)
在Python中,字典是通過哈希表實現的。也就是說,字典是一個數組,而數組的索引是鍵經過哈希函數處理后得到的。哈希函數的目的是使鍵均勻地分布在數組中。由于不同的鍵可能具有相同的哈希值,即可能出現沖突,高級的哈希函數能夠使沖突數目最小化。Python中并不包含這樣高級的哈希函數,幾個重要(用于處理字符串和整數)的哈希函數通常情況下均是常規的類型:
>>> map(hash, (0, 1, 2, 3))
[0, 1, 2, 3]
>>> map(hash, ("namea", "nameb", "namec", "named"))
[-1658398457, -1658398460, -1658398459, -1658398462]
在以下的篇幅中,我們僅考慮用字符串作為鍵的情況。在Python中,用于處理字符串的哈希函數是這樣定義的:
arguments: string object
returns: hash
function string_hash:
if hash cached:
return it
set len to string's length
initialize var p pointing to 1st char of string object
set x to value pointed by p left shifted by 7 bits
while len >= 0:
set var x to (1000003 * x) xor value pointed by p
increment pointer p
set x to x xor length of string object
cache x as the hash so we don't need to calculate it again
return x as the hash
如果在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的哈希函數在鍵彼此連續的時候表現得很理想,這主要是考慮到通常情況下處理的都是這類形式的數據。然而,一旦我們添加了鍵'z'就會出現沖突,因為這個鍵值并不毗鄰其他鍵,且相距較遠。
當然,我們也可以用索引為鍵的哈希值的鏈表來存儲鍵/值對,但會增加查找元素的時間,時間復雜度也不再是 O(1) 了。下一節將介紹Python的字典解決沖突所采用的方法。
開放尋址法( Open addressing )
開放尋址法是一種用探測手段處理沖突的方法。在上述鍵'z'沖突的例子中,索引 3 在數組中已經被占用了,因而需要探尋一個當前未被使用的索引。增加和搜尋鍵/值對需要的時間均為 O(1)。
搜尋空閑槽用到了一個二次探測序列(quadratic probing sequence),其代碼如下:
j = (5*j) + 1 + perturb;
perturb >>= PERTURB_SHIFT;
use j % 2**i as the next table index;
循環地5*j+1可以快速放大不影響初始索引的哈希值二進位的微小差異。變量perturb可使其他二進位也不斷變化。
出于好奇,我們來看一看當數組長度為 32 時的探測序列,j = 3 -> 11 -> 19 -> 29 -> 5 -> 6 -> 16 -> 31 -> 28 -> 13 -> 2…
關于探測序列的更多介紹可以參閱dictobject.c的源碼。文件的開頭包含了對探測機理的詳細介紹。
下面我們結合例子來看一看 Python 內部代碼。
基于C語言的字典結構
以下基于C語言的數據結構用于存儲字典的鍵/值對(也稱作 entry),存儲內容有哈希值,鍵和值。PyObject 是 Python 對象的一個基類。
typedef struct {
Py_ssize_t me_hash;
PyObject *me_key;
PyObject *me_value
} PyDictEntry;
下面為字典對應的數據結構。其中,ma_fill為活動槽以及啞槽(dummy slot)的總數。當一個活動槽中的鍵/值對被刪除后,該槽則被標記為啞槽。ma_used為活動槽的總數。ma_mask值為數組的長度減 1 ,用于計算槽的索引。ma_table為數組本身,ma_smalltable為長度為 8 的初始數組。
typedef struct _dictobject PyDictObject;
struct _dictobject {
PyObject_HEAD
Py_ssize_t ma_fill;
Py_ssize_t ma_used;
Py_ssize_t ma_mask;
PyDictEntry *ma_table;
PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
PyDictEntry ma_smalltable[PyDict_MINSIZE];
};
字典初始化
字典在初次創建時將調用PyDict_New()函數。這里刪掉了源代碼中的部分行,并且將C語言代碼轉換成了偽代碼以突出其中的幾個關鍵概念。
returns new dictionary object
function PyDict_New:
allocate new dictionary object
clear dictionary's table
set dictionary's number of used slots + dummy slots (ma_fill) to 0
set dictionary's number of active slots (ma_used) to 0
set dictionary's mask (ma_value) to dictionary size - 1 = 7
set dictionary's lookup function to lookdict_string
return allocated dictionary object
添加項
添加新的鍵/值對調用的是PyDict_SetItem()函數。函數將使用一個指針指向字典對象和鍵/值對。這一過程中,首先會檢查鍵是否是字符串,然后計算哈希值,如果先前已經計算并緩存了鍵的哈希值,則直接使用緩存的值。接著調用insertdict()函數添加新鍵/值對。如果活動槽和空槽的總數超過數組長度的2/3,則需調整數組的長度。為什么是 2/3 ?這主要是為了保證探測序列能夠以足夠快的速度找到空閑槽。后面我們會介紹調整長度的函數。
arguments: dictionary, key, value
returns: 0 if OK or -1
function PyDict_SetItem:
if key's hash cached:
use hash
else:
calculate hash
call insertdict with dictionary object, key, hash and value
if key/value pair added successfully and capacity over 2/3:
call dictresize to resize dictionary's table
inserdict() 使用搜尋函數 lookdict_string() 來查找空閑槽。這跟查找鍵所用的是同一函數。lookdict_string() 使用哈希值和掩碼計算槽的索引。如果用“索引 = 哈希值&掩碼”的方法未找到鍵,則會用調用先前介紹的循環方法探測,直至找到一個空閑槽。第一輪探測,如果未找到匹配的鍵的且探測過程中遇到過啞槽,則返回一個啞槽。這可使優先選擇先前刪除的槽。
現在我們想添加如下的鍵/值對:{‘a’: 1, ‘b’: 2′, ‘z’: 26, ‘y’: 25, ‘c’: 5, ‘x’: 24},那么將會發生如下過程:
分配一個字典結構,內部表的尺寸為8。
以下就是我們目前所得到的:
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 ,將舊表中的條目插入到新表。最終得到的結果如下:
刪除項
刪除條目時將調用PyDict_DelItem()函數。刪除時,首先計算鍵的哈希值,然后調用搜詢函數返回到該條目,最后該槽被標記為啞槽。
假設我們想要從字典中刪除鍵'c',我們最終將得到如下結果:
注意,刪除項目后,即使最終活動槽的數量遠小于總的數量也不會觸發調整數組長度的動作。但是,若刪減后又增加鍵/值對時,由于調整長度的條件判斷基于的是活動槽與啞槽的總數量,因而可能會縮減數組長度。
來自:http://developer.51cto.com/art/201705/540517.htm