python進階(24)Python字典的底層原理以及字典效率

Silent丿丶黑羽發表於2022-02-11

前言

  • 問題1:python中的字典到底是有序還是無序
  • 問題2:python中字典的效率如何
     

python字典底層原理

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

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

  不僅如此,從Python 3.6開始,下面的三種遍歷操作,效率要高於Python 3.5之前:

for key in dict1
 
for value in dict1.values()
 
for key, value in dict1.items()

從Python 3.6開始,字典佔用記憶體空間的大小,是字典裡面鍵值對的個數,只有原來的30%~95%。
Python 3.6到底對字典做了什麼優化呢?為了說明這個問題,我們需要先來說一說,在Python 3.5之前,字典的底層原理。
 

python3.5之前字典的底層原理

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

my_dict = {}
 
'''
此時的記憶體示意圖
[
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---]
]
'''

現在,我們往字典裡面新增一個資料:

my_dict['name'] = 'jkc'
 
'''
此時的記憶體示意圖
[
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [1278649844881305901, 指向name的指標, 指向jkc的指標],
  [---, ---, ---],
  [---, ---, ---]
]
'''

這裡解釋一下,為什麼新增了一個鍵值對以後,記憶體變成了這個樣子:
首先我們呼叫Python 的hash函式,計算name這個字串在當前執行時的hash值:

In [1]: hash('name')
Out[1]: 1278649844881305901

特別注意,我這裡強調了『當前執行時』,這是因為,Python自帶的這個hash函式,和我們傳統上認為的Hash函式是不一樣的。Python自帶的這個hash函式計算出來的值,只能保證在每一個執行時的時候不變,但是當你關閉Python再重新開啟,那麼它的值就可能會改變,如下圖所示:

假設在某一個執行時裡面,hash('name')的值為1278649844881305901。現在我們要把這個數對8取餘數:

In [2]: 1278649844881305901 % 8
Out[2]: 5

餘數為5,那麼就把它放在剛剛初始化的二維陣列中,下標為5的這一行。由於name和jkc是兩個字串,所以底層C語言會使用兩個字串變數存放這兩個值,然後得到他們對應的指標。於是,我們這個二維陣列下標為5的這一行,第一個值為name的hash值,第二個值為name這個字串所在的記憶體的地址(指標就是記憶體地址),第三個值為jkc這個字串所在的記憶體的地址。

現在,我們再來插入兩個鍵值對:

my_dict['age'] = 26
my_dict['salary'] = 999999
 
'''
此時的記憶體示意圖
[
  [-4234469173262486640, 指向salary的指標, 指向999999的指標],
  [1545085610920597121, 執行age的指標, 指向26的指標],
  [---, ---, ---],
  [---, ---, ---],
  [---, ---, ---],
  [1278649844881305901, 指向name的指標, 指向jkc的指標],
  [---, ---, ---],
  [---, ---, ---]
]
'''

那麼字典怎麼讀取資料呢?首先假設我們要讀取age對應的值。
此時,Python先計算在當前執行時下面,age對應的Hash值是多少:

In [2]: hash('age')
Out[2]: 1545085610920597121

現在這個hash值對8取餘數:

In [2]: 1545085610920597121 % 8
Out[2]: 1

餘數為1,那麼二維陣列裡面,下標為1的這一行就是需要的鍵值對。直接返回這一行第三個指標對應的記憶體中的值,就是age對應的值26。
當你要迴圈遍歷字典的Key的時候,Python底層會遍歷這個二維陣列,如果當前行有資料,那麼就返回Key指標對應的記憶體裡面的值。如果當前行沒有資料,那麼就跳過。所以總是會遍歷整個二維陣列的每一行。
每一行有三列,每一列佔用8byte的記憶體空間,所以每一行會佔用24byte的記憶體空間。
由於Hash值取餘數以後,餘數可大可小,所以字典的Key並不是按照插入的順序存放的。
注意,這裡我省略了與本文沒有太大關係的兩個點:

  • 1.開放定址,當兩個不同的Key,經過Hash以後,再對8取餘數,可能餘數會相同。此時Python為了不覆蓋之前已有的值,就會使用開放定址技術重新尋找一個新的位置存放這個新的鍵值對。
  • 2.當字典的鍵值對數量超過當前陣列長度的2/3時,陣列會進行擴容,8行變成16行,16行變成32行。長度變了以後,原來的餘數位置也會發生變化,此時就需要移動原來位置的資料,導致插入效率變低。
     

python3.6之後字典的底層原理

在Python 3.6以後,字典的底層資料結構發生了變化,現在當你初始化一個空的字典以後,它在底層是這樣的:

my_dict = {}
 
'''
此時的記憶體示意圖
indices = [None, None, None, None, None, None, None, None]
 
entries = []
'''

當你初始化一個字典以後,Python單獨生成了一個長度為8的一維陣列。然後又生成了一個空的二維陣列。

現在,我們往字典裡面新增一個鍵值對:

my_dict['name'] = 'jkc'
 
'''
此時的記憶體示意圖
indices = [None, 0, None, None, None, None, None, None]
 
entries = [[-5954193068542476671, 指向name的指標, 執行jkc的指標]]
'''

為什麼記憶體會變成這個樣子呢?我們來一步一步地看:

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

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

所以,我們把indices這個一維陣列裡面,下標為1的位置修改為0。

這裡的0是什麼意思呢?0是二位陣列entries的索引。現在entries裡面只有一行,就是我們剛剛新增的這個鍵值對的三個資料:name的hash值、指向name的指標和指向jkc的指標。所以indices裡面填寫的數字0,就是剛剛我們插入的這個鍵值對的資料在二位陣列裡面的行索引。

好,現在我們再來插入兩條資料:

my_dict['address'] = 'xxx'
my_dict['salary'] = 999999
 
'''
此時的記憶體示意圖
indices = [1, 0, None, None, None, None, 2, None]
 
entries = [
  [-5954193068542476671, 指向name的指標, 執行jkc的指標],
  [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%。
 

字典的用法總結

  • 1.鍵必須可雜湊
    • (1) 數字、字串、元組,都是可雜湊的。
    • (2) 自定義物件需要支援下面三點:
      • ①支援 hash()函式
      • ②支援通過__eq__()方法檢測相等性。
      • ③若 a==b 為真,則 hash(a)==hash(b)也為真。
  • 2.字典在記憶體中開銷巨大,典型的空間換時間。
  • 3.鍵查詢速度很快
  • 4.往字典裡面新增新建可能導致擴容,導致雜湊表中鍵的次序變化。因此,不要在遍歷字 典的同時進行字典的修改。

參考:https://www.cnblogs.com/songyifan427/p/11198719.html

相關文章