字典是通過鍵(key)索引的,因此,字典也可視作彼此關聯的兩個陣列。下面我們嘗試向字典中新增3個鍵/值(key/value)對:
1 2 3 4 |
>>> d = {'a': 1, 'b': 2} >>> d['c'] = 3 >>> d {'a': 1, 'b': 2, 'c': 3} |
這些值可通過如下方法訪問:
1 2 3 4 5 6 7 8 9 10 |
>>> d['a'] 1 >>> d['b'] 2 >>> d['c'] 3 >>> d['d'] Traceback (most recent call last): File "<stdin>", line 1, in <module> KeyError: 'd' |
由於不存在 'd'
這個鍵,所以引發了KeyError
異常。
雜湊表(Hash tables)
在Python中,字典是通過雜湊表實現的。也就是說,字典是一個陣列,而陣列的索引是鍵經過雜湊函式處理後得到的。雜湊函式的目的是使鍵均勻地分佈在陣列中。由於不同的鍵可能具有相同的雜湊值,即可能出現衝突,高階的雜湊函式能夠使衝突數目最小化。Python中並不包含這樣高階的雜湊函式,幾個重要(用於處理字串和整數)的雜湊函式通常情況下均是常規的型別:
1 2 3 4 |
>>> map(hash, (0, 1, 2, 3)) [0, 1, 2, 3] >>> map(hash, ("namea", "nameb", "namec", "named")) [-1658398457, -1658398460, -1658398459, -1658398462] |
在以下的篇幅中,我們僅考慮用字串作為鍵的情況。在Python中,用於處理字串的雜湊函式是這樣定義的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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),其程式碼如下:
1 2 3 |
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 物件的一個基類。
1 2 3 4 5 |
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 的初始陣列。
1 2 3 4 5 6 7 8 9 10 |
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語言程式碼轉換成了虛擬碼以突出其中的幾個關鍵概念。
1 2 3 4 5 6 7 8 9 |
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 ?這主要是為了保證探測序列能夠以足夠快的速度找到空閒槽。後面我們會介紹調整長度的函式。
1 2 3 4 5 6 7 8 9 10 |
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。
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
- lookdict_string
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
- lookdict_string
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
- lookdict_string
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
- lookdict_string
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
- lookdict_string
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
- lookdict_string
以下就是我們目前所得到的:
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'
,我們最終將得到如下結果:
注意,刪除專案後,即使最終活動槽的數量遠小於總的數量也不會觸發調整陣列長度的動作。但是,若刪減後又增加鍵/值對時,由於調整長度的條件判斷基於的是活動槽與啞槽的總數量,因而可能會縮減陣列長度。
好了,文章就到這兒了,希望你會喜歡。如果你有問題需要反饋,請在下方評論。如果你有基於Python的專案需要幫助或者在建立網路服務時遇到問題,作為一位自由職業者我隨時恭候。我的 LinkedIn 簡歷,Twitter: @laurentluce。