首先來看看構建dict的基礎設施:
typedef struct {
Py_ssize_t me_hash;
PyObject *me_key;
PyObject *me_value;
} PyDictEntry;
這個結構體為dict中key-value,其中的me_hash為me_key的hash值,[空間換時間]。除此之外,我們發現me_key與me_value都是PyObject指針類型,這也說明了為什么dict中的key與value可以為python中的任何類型數據。
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];
};
這個結構體便是dict了。按照我們通常的理解,dict應該是可變長對象?。槭裁催@里還有PyObject_HEAD,而不是PyObject_VAR_HEAD。仔細一看,dict的可變長與string,list,tuple仍有不同之外,后者可以通過PyObject_VAR_HEAD中的ob_size來指明其內部有效元素的個數。但dict不能這樣做,所以dict干脆繞開PyObject_VAR_HEAD,而且除了有ma_used這個字段來交代出其有效元素的個數,還需要ma_fill來交代清楚曾經有效元素的個數(用來計算加載率)。
ma_mask,則牽扯到hash中的散列函數;
ma_smalltable,python一向的有限空間換時間,一個小池子來應付大多數的小dict(不超過PyDict_MINSIZE)
ma_lookup,則是一次探測與二次探測函數的實現。
在展開dict實現細節前,先把dict使用的解決沖突的開放定址法介紹一下。我們知道哈希,就是將一個無限集合映射到一個有限集,如果選擇理想的hash函數,能夠將預期處理到的元素均勻分布到有限集中即可在O(1)時間內完成元素查找。但理想的hash函數是不存在的,且由于映射的本質(無限到有限)必然出出現一個位置有多個元素要‘占據’,這就需要解決沖突?,F有的解決沖突的方法:
- 開放定址法
- 鏈地址法
- 多哈希函數法
- 建域法
其中建域法基本思想為假設哈希函數的值域為[0,m-1],則設向量HashTable[0..m-1]為基本表,另外設立存儲空間向量OverTable[0..v]用以存儲發生沖突的記錄。
其中前兩種方法實現最為簡單高效,下面回顧下開放定址與鏈地址法。
開放定址法:
形成hash表時,某元素在第一次探測其應該占有的位置時,如果發現此處(記為A)已經被別人占了,那就在從A開始,再次探測(當然這次探測使用的hash函數與第一次已經不一樣了),如果發現還是被別人占了,那么繼續探測,至到找到一個可用位置(也有可能在當下條件下永遠找不到)。開放地址法有一個至關重要的問題需要解決,那就是在一個元素離開hash表時,如何處理離開后的位置狀態。如果設置為原始空狀態,那么后續的有效元素就無法識別了,因為在查找時同樣是依據上面的探測規則進行查找,所以必須告訴探測函數某個位置雖然無有效元素了,但后續的探測可能會出現有效元素。我們可以發現,開放定址法很容易發生沖突(主要是一次探測以上成功的元素占取其它元素應該在第一次探測成功的位置),所以就需要加大hash有效空間。
鏈地址法:
鏈地址法的思想很簡單,你不是可能會出現多個元素對應同一個位置,那么我就在這個位置拉出一個鏈表來存放所以hash到這個位置的元素。很簡單吧,還節約內存呢!很遺憾,python的設計者沒有選它。
那為什么python發明者選擇了開放定址而不是鏈地址法,在看python源碼時看到這么一段話:
Open addressing is preferred over chaining since the link overhead for chaining would be substantial (100% with typical malloc overhead).
由于鏈地址法需要動態的生成鏈表結點(malloc),所以時間效率不如開放定址法(但開放定址法的裝載率不能高于2/3,相對于鏈地址法的空間開銷也是毋庸置疑的),由此可以看出python的設計時代已經不是那個內存只有512k可供使用的時代了,對內存的苛刻已經讓步于效率。當然這需要考慮到python由于實現動態而必須靠自身的設計將損失的時間效率盡可能地補回來。
好了,交待完開放定址法與為什么python設計者選擇它后,我們來看看dict如何實現這個算法的。前面已經看到每個key-value由一個Entry結構體實現,python就是利用entry自身的信息來指明每個位置的狀態:原始空狀態、有效元素離去狀態、有效元素占據狀態。
- 原始空:me_key:Null ;me_value:Null
- 有效元素離去:me_key:dummy; me_value:Null
- 有效元素占據:me_key:not Null and not dummy ;me_value:not Null
其中dict的hash方法與沖突解決方法的思路如下:
lookdict(k,v)
index <- hash1(k),freeslot<-Null,根據me_key與me_value選擇2、3、4一個執行;
查看index處的值處于’有效元素占據‘狀態,判斷data[index]與v是否一致(地址或內容),一致,則返回查找成功;轉5
index所指向的位置處于’原始空‘狀態,查找失敗,若freeslot==Null返回index;否則返回freeslot;轉5
index所指向的位置處于’有效元素離去‘狀態,freeslot<-index, 轉5
index <- hash2(index),,轉2
dict的lookdict方法實現充分體現了python對內存的利用率與空間換時間提高效率上,表現為如下方面:
內存利用率:當找到原始空狀態時,如果前面已經找到dummy態的entry,則會將其返回。
提高效率:ma_table始終指向有效散列空間的開始位置,在開辟新空間后,small_table就棄之不用了,ma_table改指向新開辟空間的首位置。
another one
本文描述了Python是如何實現dictionary。dictionary是由主鍵key索引,可以被看作是關聯數組,類似于STL的map。有如下的基本操作。
d = {'a': 1, 'b': 2}
d['c'] = 3
d
{'a': 1, 'b': 2, 'c': 3}
hash table
Python中dictionary是以hash表實現,而hash表以數組表示,數組的索引作為主鍵key。Hash函數的目標是key均勻的分布于數組中,良好的hash函數能夠最小化減少沖突的次數。
在本文中,我們采用string作為key為例子。
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
如果你執行hash(‘a’),函數將會執行string_hash()返回12416037344,在此我們假設采用的是64位系統。
假設數組的大小是x, 該數組用來存儲key/value, 我們用掩碼x-1計算這個數組的索引。例如,如果數組的大小是8,‘a’的索引是hash(‘a’)&7=0, ‘b’的索引是3, ‘c’的索引是2。’z’的索引是3與’b’的索引一致,由此導致沖突。

我們看到Python中的hash函數在key是連續的時候表現很好的性能,原因是它對于處理這種類型的數據有通用性。但是,一旦我們加入了’z’,由于數據的不連貫性產生了沖突。
當產生沖突的時候,我們可以采用鏈表來存儲這對key/value,但是這樣增加了查找時間,不再是O(1)的復雜度。在下一節中,我們將要具體描述一下Python中dictionary解決這種沖突的方法。
Open addressing
散列地址方法是解決hash沖突的探測策略。對于’z’,由于索引3已經被占用,所以我們需要尋找一個沒有使用的索引,這樣添加key/value的時候肯能花費更多的時間,但是查找時間依然是O(1),這是我們所期望的結果。
這里采用quadratic探測序列來尋找可用的索引,代碼如下:
i is the current slot index
set perturb to hash
forever loop:
set i to i << 2 + i + perturb + 1
set slot index to i & mask
if slot is free:
return it
right shift perturb by 5 bits
讓我們來看看它是如何工作的,令i=33 -> 3 -> 5 -> 5 -> 6 -> 0…
索引5將被用作’z’的索引值,這不是一個很好的例子,因為我們采用了一個大小是8的數組。對于大數組,算法顯示它的優勢。
出于好奇,我們來看看當數組大小是32,mask是31時,探測序列為3 -> 11 -> 19 -> 29 -> 5 -> 6 -> 16 -> 31 -> 28 -> 13 -> 2…
如果你想了解更多,可以察看dictobject.c的源代碼。

接下來,讓我們看看Python內部的代碼是如何實現的?
Dictionary C structures
如下的C語言結構體用來存儲一個dictionary條目:key/value對,結構體內有Hash值,key值和value值。PyObject是Python對象的基類。
1
typedef struct {
2
Py_ssize_t me_hash;
3
PyObject *me_key;
4
PyObject *me_value;
5
} PyDictEntry;
如下的結構體代表了一個dictionary。ma_fill是已使用slot與未使用slot之和。當一對key/value被刪除了,該slot被標記為未使用。ma_used是已使用slot的數目。ma_mask等于數組大小減一,用來計算slot的索引。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];
};
Dictionary initialization
當我們第一次創建一個dictionary的時候,將會調用PyDict_New()。我們以偽碼表示該函數的主要功能。
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
Adding items
當增加一對新的key/value時,將調用PyDict_SetItem()。該函數的參數有dictionary對象的指針和key/value對。它檢測key是否為字符串以及計算hash值或者重復使用已經存在的index。Insertdict()用來增加新的key/value,在dictionary的大小被重新調整之后,而且已使用slot的數量超過數組大小的2/3.
為什么是2/3?這樣能夠保證probing序列能夠快速地找到空閑的slot。稍后我們將看看調整數組大小的函數。
arguments: dictionary, key, value
returns: 0 if OK or -1
function PyDict_SetItem:
set mp to point to dictionary object
if key's hash cached:
use hash
else:
calculate hash
set n_used to dictionary's number of active slots (ma_used)
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
Insertdict()使用查找函數來找到一個未使用的slot。接下來我們來看看這個函數,lookdict_string()利用hash值和掩碼來計算slot的索引。如果它找不到slot索引的key等于hash&mask,它會使用擾碼來探測。
arguments: dictionary object, key, hash
returns: dictionary entry
function lookdict_string:
calculate slot index based on hash and mask
if slot's key matches or slot's key is not set:
returns slot's entry
if slot's key marked as dummy (was active):
set freeslot to this slot's entry
else:
if slot's hash equals to hash and slot's key equals to key:
return slot's entry
set var freeslot to null
we are here because we couldn't find the key so we start probing
set perturb to hash
forever loop:
set i to i << 2 + i + perturb + 1
calculate slot index based on i and mask
if slot's key is null:
if freeslot is null:
return slot's entry
else:
return freeslot
if slot's key equals to key or slot's hash equals to hash
and slot is not marked as dummy:
return slot's entry
if slot marked as dummy and freeslot is null:
set freeslot to slot's entry
right shift perturb by 5 bits
我們在dictionary中增加如下的key/value:{‘a’: 1, ‘b’: 2′, ‘z’: 26, ‘y’: 25, ‘c’: 5, ‘x’: 24}。流程如下:
- 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 - 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
This is what we have so far:

由于8個slot中的6已經被使用了即超過了數組容量的2/3,dictresize()被調用來分配更大的內存,該函數同時拷貝舊數據表的數據進入新數據表。
在這個例子中,dictresize()調用時,minused是24也就是4×ma_used。當已使用slot的數目很大的時候(超過50000)minused是2×ma_used。為什么是4倍?這樣能夠減少調整步驟的數目而且增加稀疏度。
v新數據表的大少應該大于24,它是通過把當前數據表的大小左移一位實現的,直到她大于24.例如 8 -> 16 -> 32.
arguments: dictionary object, (2 or 4) * active slots
returns: 0 if OK, -1 otherwise
function dictresize:
calculate new dictionary size:
set var newsize to dictionary size
while newsize less or equal than (2 or 4) * active slots:
set newsize to newsize left shifted by 1 bit
set oldtable to dictionary's table
allocate new dictionary table
set dictionary's mask to newsize - 1
clear dictionary's table
set dictionary's active slots (ma_used) to 0
set var i to dictionary's active + dummy slots (ma_fill)
set dictionary's active + dummy slots (ma_fill) to 0
copy oldtable entries to dictionary's table using new mask
return 0
}
當數據表大小進行調整的時候所發生的:分配了一個大小是32的新數據表

removing items
函數PyDict_DelItem()用來刪除一個條目。計算key的hash值,然后調用查找函數返回這個條目。該條目的key設置為啞元。這個啞元包含了過去使用過的key值但現在還是未使用的狀態。
arguments: dictionary object, key
returns 0 if OK, -1 otherwise
function PyDict_DelItem:
if key's hash cached:
use hash
else:
calculate hash
look for key in dictionary using hash
if slot not found:
return -1
set slot's key to dummy
set slot's value to null
decrement dictionary active slots
return 0
如果我要從dictionary中刪除key ’c’,將以如下的數組結束
