Python3.6 的字典爲何會快

做者:青南
連接:https://zhuanlan.zhihu.com/p/73426505
來源:知乎
著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。

在Python 3.5(含)之前,字典是不能保證順序的,鍵值對A先插入字典,鍵值對B後插入字典,可是當你打印字典的Keys列表時,你會發現B可能在A的前面。python

可是從Python 3.6開始,字典是變成有順序的了。你先插入鍵值對A,後插入鍵值對B,那麼當你打印Keys列表的時候,你就會發現B在A的後面。數組

不只如此,從Python 3.6開始,下面的三種遍歷操做,效率要高於Python 3.5以前:數據結構

for key in 字典 for value in 字典.values() for key, value in 字典.items()

從Python 3.6開始,字典佔用內存空間的大小,視字典裏面鍵值對的個數,只有原來的30%~95%。函數

Python 3.6到底對字典作了什麼優化呢?爲了說明這個問題,咱們須要先來講一說,在Python 3.5(含)以前,字典的底層原理。優化

當咱們初始化一個空字典的時候,CPython的底層會初始化一個二維數組,這個數組有8行,3列,以下面的示意圖所示:spa

my_dict = {} ''' 此時的內存示意圖 [[---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---]] '''

如今,咱們往字典裏面添加一個數據:指針

my_dict['name'] = 'kingname' ''' 此時的內存示意圖 [[---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [---, ---, ---], [1278649844881305901, 指向name的指針, 指向kingname的指針], [---, ---, ---], [---, ---, ---]] '''

這裏解釋一下,爲何添加了一個鍵值對之後,內存變成了這個樣子:code

首先咱們調用Python 的hash函數,計算name這個字符串在當前運行時的hash值:orm

>>> hash('name') 1278649844881305901

特別注意,我這裏強調了『當前運行時』,這是由於,Python自帶的這個hash函數,和咱們傳統上認爲的Hash函數是不同的。Python自帶的這個hash函數計算出來的值,只能保證在每個運行時的時候不變,可是當你關閉Python再從新打開,那麼它的值就可能會改變,以下圖所示:blog

 

 

假設在某一個運行時裏面,hash('name')的值爲1278649844881305901。如今咱們要把這個數對8取餘數:

>>> 1278649844881305901 % 8 5

餘數爲5,那麼就把它放在剛剛初始化的二維數組中,下標爲5的這一行。因爲namekingname是兩個字符串,因此底層C語言會使用兩個字符串變量存放這兩個值,而後獲得他們對應的指針。因而,咱們這個二維數組下標爲5的這一行,第一個值爲name的hash值,第二個值爲name這個字符串所在的內存的地址(指針就是內存地址),第三個值爲kingname這個字符串所在的內存的地址。

如今,咱們再來插入兩個鍵值對:

my_dict['age'] = 26 my_dict['salary'] = 999999 ''' 此時的內存示意圖 [[-4234469173262486640, 指向salary的指針, 指向999999的指針], [1545085610920597121, 執行age的指針, 指向26的指針], [---, ---, ---], [---, ---, ---], [---, ---, ---], [1278649844881305901, 指向name的指針, 指向kingname的指針], [---, ---, ---], [---, ---, ---]] '''

那麼字典怎麼讀取數據呢?首先假設咱們要讀取age對應的值。

此時,Python先計算在當前運行時下面,age對應的Hash值是多少:

>>> hash('age') 1545085610920597121

如今這個hash值對8取餘數:

>>> 1545085610920597121 % 8 1

餘數爲1,那麼二維數組裏面,下標爲1的這一行就是須要的鍵值對。直接返回這一行第三個指針對應的內存中的值,就是age對應的值26

當你要循環遍歷字典的Key的時候,Python底層會遍歷這個二維數組,若是當前行有數據,那麼就返回Key指針對應的內存裏面的值。若是當前行沒有數據,那麼就跳過。因此老是會遍歷整個二位數組的每一行。

每一行有三列,每一列佔用8byte的內存空間,因此每一行會佔用24byte的內存空間。

因爲Hash值取餘數之後,餘數可大可小,因此字典的Key並非按照插入的順序存放的。

注意,這裏我省略了與本文沒有太大關係的兩個點: 1. 開放尋址,當兩個不一樣的Key,通過Hash之後,再對8取餘數,可能餘數會相同。此時Python爲了避免覆蓋以前已有的值,就會使用 開放尋址技術從新尋找一個新的位置存放這個新的鍵值對。 2. 當字典的鍵值對數量超過當前數組長度的2/3時,數組會進行擴容,8行變成16行,16行變成32行。長度變了之後,原來的餘數位置也會發生變化,此時就須要移動原來位置的數據,致使插入效率變低。

在Python 3.6之後,字典的底層數據結構發生了變化,如今當你初始化一個空的字典之後,它在底層是這樣的:

my_dict = {} ''' 此時的內存示意圖 indices = [None, None, None, None, None, None, None, None]  entries = [] '''

當你初始化一個字典之後,Python單獨生成了一個長度爲8的一維數組。而後又生成了一個空的二維數組。

如今,咱們往字典裏面添加一個鍵值對:

my_dict['name'] = 'kingname' ''' 此時的內存示意圖 indices = [None, 0, None, None, None, None, None, None]  entries = [[-5954193068542476671, 指向name的指針, 執行kingname的指針]] '''

爲何內存會變成這個樣子呢?咱們來一步一步地看:

在當前運行時,name這個字符串的hash值爲-5954193068542476671,這個值對8取餘數是1:

>>> hash('name') -5954193068542476671 >>> hash('name') % 8 1

因此,咱們把indices這個一維數組裏面,下標爲1的位置修改成0。

這裏的0是什麼意思呢?0是二位數組entries的索引。如今entries裏面只有一行,就是咱們剛剛添加的這個鍵值對的三個數據:name的hash值、指向name的指針和指向kinganme的指針。因此indices裏面填寫的數字0,就是剛剛咱們插入的這個鍵值對的數據在二位數組裏面的行索引。

好,如今咱們再來插入兩條數據:

my_dict['address'] = 'xxx' my_dict['salary'] = 999999 ''' 此時的內存示意圖 indices = [1, 0, None, None, None, None, 2, None]  entries = [[-5954193068542476671, 指向name的指針, 執行kingname的指針],  [9043074951938101872, 指向address的指針,指向xxx的指針],  [7324055671294268046, 指向salary的指針, 指向999999的指針]  ] '''

如今若是我要讀取數據怎麼辦呢?假如我要讀取salary的值,那麼首先計算salary的hash值,以及這個值對8的餘數:

>>> hash('salary') 7324055671294268046 >>> hash('salary') % 8 6

那麼我就去讀indices下標爲6的這個值。這個值爲2.

而後再去讀entries裏面,下標爲2的這一行的數據,也就是salary對應的數據了。

新的這種方式,當我要插入新的數據的時候,始終只是往entries的後面添加數據,這樣就能保證插入的順序。當咱們要遍歷字典的Keys和Values的時候,直接遍歷entries便可,裏面每一行都是有用的數據,不存在跳過的狀況,減小了遍歷的個數。

老的方式,當二維數組有8行的時候,即便有效數據只有3行,但它佔用的內存空間仍是 8 * 24 = 192 byte。但使用新的方式,若是隻有三行有效數據,那麼entries也就只有3行,佔用的空間爲3 * 24 =72 byte,而indices因爲只是一個一維的數組,只佔用8 byte,因此一共佔用 80 byte。內存佔用只有原來的41%

 

青南公衆號

相關文章
相關標籤/搜索