Python中Dict兩種實現(xiàn)的原理詳解
前記
在Python
中, Dict是一系列由鍵和值配對組成的元素的集合, 它是一個可變?nèi)萜髂P?,可以存儲任意類型對? Dict的存取速度非常的快, 而這全靠他的哈希算法的功勞, 在Python
3.6之前Dict是無序的, 在Python
3.6中絕大部分是有序的, 在Python
3.7以及之后則是絕對有序的, 而且占用內(nèi)存空間更少, 對于萬物基于Dict的Python
,這算一個大優(yōu)化, 也讓我好奇Python
是如何實現(xiàn)哈希有序的.
1.無序Dict的實現(xiàn)
Dict在查找key時非常的快, 這得益于它的使用空間換時間思路和哈希實現(xiàn)。的在讀取和寫入Key時, 都會對Key進行哈希計算(所以要求Key都是不可變類型,如果是可變類型,就無法計算出他的哈希值了), 然后根據(jù)計算的值, 與當前的數(shù)組空間長度進行取模計算, 得到的值就是當前Key在數(shù)組的下標, 最后通過下標就可以以O(shè)(1)的時間復(fù)雜度讀取值. 這種實現(xiàn)非常棒, 也是分布式的常見做法, 但也有問題, 如果數(shù)組滿了怎么辦或者是不同的Key, 但是哈希結(jié)果是一樣的怎么辦?
針對第一個問題的解決辦法是在合適的時候進行擴容, 在Python
中, 當Dict中放置的數(shù)量占容量的2/3時, Dict就會開始擴容, 擴容后的總?cè)萘渴菙U容之前的一倍, 這是為了減少頻繁擴容, 導(dǎo)致key的遷移次數(shù)變多;
而針對第二個問題則有兩個解法:
鏈接法: 原本數(shù)組里面存的是Key對應(yīng)的值, 而鏈接法的數(shù)組存的是一個數(shù)組, 這個數(shù)組存了一個包含key和對應(yīng)值的數(shù)組, 如下所示, 假設(shè)key1和key2的哈希結(jié)果都是0, 那就會插入到數(shù)組的0下標中, key1在0下標的數(shù)組的第一位, 而key2在插入時,發(fā)現(xiàn)已經(jīng)存在key1了, 再用key2與key1進行對比, 發(fā)現(xiàn)它們的key其實是不一樣的, 那就在0下標進行追加.
array = [ [ # 分別為key, hash值, 數(shù)值 ('key1', 123, 123), ('key2', 123, 123) ], [ ('key3', 123, 123) ] ]
開發(fā)尋址法: 開發(fā)尋址法走的是另外一個思路, 采取借用的思想, 在插入數(shù)據(jù)時, 如果遇到了沖突那就去使用當前下標的下一位, 如果下一位還是沖突, 就繼續(xù)用下一位.在查找數(shù)據(jù)時則會對哈希值對應(yīng)的key進行比較, 如果有值且對不上就找下一位, 直到或者空位找到為止。
上面兩個的方案的實現(xiàn)都很簡單, 對比下也很容易知道他們的優(yōu)缺點:
鏈表法的優(yōu)點:
- 刪除記錄方便, 直接處理數(shù)組對應(yīng)下標的子數(shù)組即可.
- 平均查找速度快, 如果沖突了, 只需要對子數(shù)組進行查詢即可
鏈表法的缺點:
- 用到了指針, 導(dǎo)致了查詢速度會偏慢一點, 內(nèi)存占用可能會較高, 不適合序列化. 而開放尋址法的優(yōu)缺點是跟鏈表法反過來的, 由于Python萬物基于Dict, 且都需要序列化, 所以選擇了開放尋址法.
通過對比鏈表法和開放尋執(zhí)法都可以發(fā)現(xiàn), 他們都是針對哈希沖突的一個解決方案, 如果存數(shù)據(jù)的數(shù)組夠大, 那么哈希沖突的可能性就會很小, 不用頻繁擴容遷移數(shù)據(jù), 但是占用的空間就會很大.所以一個好的哈希表實現(xiàn)初始值都不能太大, 在Python
的Dict的初始值是8. 另外哈希表還需要讓存數(shù)據(jù)的數(shù)組的未使用空位保持在一個范圍值內(nèi)波動, 這樣空間的使用和哈希沖突的概率都會保持在一個最優(yōu)的情況, 但由于每次擴容都會消耗很大的性能, 也不能每次更改都進行一次擴容, 所以需要確定一個值, 當未使用/使用的占比達到這個值時, 就自動擴容, 在Python
的Dict中這個值是2/3. 也就是當Dict里面使用了2/3的空間后, 他就會自動擴容, 使他達到一個新的最優(yōu)平衡. 同時, 為了減少每次擴容時key的遷移次數(shù), 擴容后的總?cè)萘恳欢ㄊ菙U容之前的總?cè)萘康囊槐? 這樣的話, key只需要遷移一半的數(shù)量即可.
哈希表擴容一倍只會遷移一半的key的原因是獲取key在數(shù)組的下標是通過對哈希值取模實現(xiàn)的, 比如一個哈希表容量為8,一個哈希值為20的key取模值為4,哈希表擴容后長度變?yōu)?6, 此時取模結(jié)果還是4。而一個哈希值為11的key取模值為3, 擴容后取模值為11??梢院苊黠@的看出,擴容后原來哈希值大于容量的key都不用做遷移, 而哈希值小于容量的都需要遷移。
但是如果按照上面是說法, 開放尋址法還是有一個問題的, 比如下面的數(shù)組:
arrray = [None, None, None, None, True, True, True, True]
以True代表當前數(shù)組的位置已經(jīng)被占用, None代表未被占用, 可以看出當前并未滿足使用了數(shù)組的2/3空間, 數(shù)組還未到擴容階段。 此時假設(shè)要插入一個Key, 剛好落在數(shù)組下標4, 那么插入的時候就要一直查詢下去, 最后發(fā)現(xiàn)只有數(shù)組下標0的位置的空的, 才可以真正的插入數(shù)據(jù). 對于一個長度只有8的數(shù)組, 需要執(zhí)行5次才能插入數(shù)據(jù), 那也太浪費性能了, 所以Python
要實現(xiàn)一個算法, 盡量讓沖突的數(shù)據(jù)插在別的地方. 在源碼中, Python
用到了公式x = ((5*y) + 1) % 2**i
來實現(xiàn)跳躍插入沖突數(shù)據(jù). 式子中x為數(shù)組的下一個坐標, y為當前發(fā)生沖突的坐標,i為容量系數(shù), 比如初始化時, i為3, 那么容量就是8了, 第一次插入數(shù)據(jù)到坐標0沖突時, y = 0, 帶入公式后, 求得x 等于1, 第二次插入數(shù)據(jù)到坐標0時, y = 1, 求得x 等于 6, 通過這樣算下去, 可以發(fā)現(xiàn)數(shù)字生成軌跡是0>1>6>7>4>5>2>3>0一直循環(huán)著, 這樣跳著插數(shù)據(jù)就能完美解決上面場景的問題.
2.有序Dict的原理
Python
3.6之前說的差不多, 它的數(shù)組大概是長這樣的, 數(shù)組中存了子數(shù)組, 第一項為hash值, 第二項為key, 第三項為值.
array = [ [], [123456, "key1", 1], [], [], [], [234567, "key2", 2], [], [] ]
這種實現(xiàn)的空間大小在初始化時就固定了, 直到下次擴容再發(fā)送變化, 在遍歷字典時, 實際上就是遍歷數(shù)組, 只是把沒有占用的空間進行跳過.可以看出這種遍歷的生成的順序只跟哈希結(jié)果相關(guān), 無法跟插入順序相關(guān), 所以這種方法的實現(xiàn)是無序的(同時由于每次啟動程序時, 他們的哈希計算是不一樣的, 所以每次遍歷的順序也就各不相同了).
而在Python
3.7之后, Python
的Dict使用了新的數(shù)據(jù)結(jié)構(gòu), 使Python
新Dict的內(nèi)存占用也比老的Dict少了, 同時新的Dict在遍歷時是跟插入順序是一致的, 具體的實現(xiàn)是, 初始化時會生成兩個數(shù)組, 插入值時, 在數(shù)組二追加當前的數(shù)據(jù), 并獲得當前追加數(shù)據(jù)所在的下標A, 然后對key進行哈希取模算出下標B, 最后對數(shù)組下標B的值更新為A, 簡單的演示如下:
# 初始的結(jié)構(gòu) # -1代表還未插入數(shù)據(jù) array_1 = [-1, -1, -1, -1, -1, -1, -1, -1] array_2 = [] # 插入值后, 他就會變?yōu)? array_1 = [-1, 0, -1, -1, -1, 1, -1, -1] array_2 = [ [123456, "key1", 1], [234567, "key2", 2], ]
可以看出, 數(shù)組2的容量跟當前放入的值相等的, 數(shù)組1雖然還會保持1/3的空閑標記位, 但他只保存數(shù)組二的下標, 占用空間也不多, 相比之前的方案會節(jié)省一些空間, 同時在遍歷的時候可以直接遍歷數(shù)組2, 這樣Python
的Dict就變?yōu)橛行虻牧? 注: 為了保持操作高效, 在刪除的時候, 只是把數(shù)組1的值設(shè)置為-2, 并把數(shù)組2對應(yīng)的值設(shè)置為None, 而不去刪除它, 在查找時會忽略掉數(shù)組1中值為-2的元素, 在遍歷時會忽略掉值為None的元素.
3.有序字典的實現(xiàn)
通過上面的了解后, 可以使用Python
來寫一個新版Dict的實現(xiàn), 具體說明見注釋:
from typing import Any, Iterable, List, Optional, Tuple class CustomerDict(object): def __init__(self): self._init_seed: int = 3 # 容量因子 self._init_length: int = 2 ** self._init_seed # 初始化數(shù)組大小 self._load_factor: float = 2 / 3 # 擴容因子 self._index_array: List[int] = [-1 for _ in range(self._init_length)] # 存放下標的數(shù)組 self._data_array: List[Optional[Tuple[int, Any, Any]]] = [] # 存放數(shù)據(jù)的數(shù)組 self._used_count: int = 0 # 目前用的量 self._delete_count: int = 0 # 被標記刪除的量 def _create_new(self): """擴容函數(shù)""" self._init_seed += 1 # 增加容量因子 self._init_length = 2 ** self._init_seed old_data_array: List[Tuple[int, Any, Any]] = self._data_array self._index_array: List[int] = [-1 for _ in range(self._init_length)] self._data_array: List[Tuple[int, Any, Any]] = [] self._used_count = 0 self._delete_count = 0 # 這里只是簡單實現(xiàn), 實際上只需要搬運一半的數(shù)據(jù) for item in old_data_array: if item is not None: self.__setitem__(item[1], item[2]) def _get_next(self, index: int): """計算沖突的下一跳,如果下標對應(yīng)的值沖突了, 需要計算下一跳的下標""" return ((5*index) + 1) % self._init_length def _core(self, key: Any, default_value: Optional[Any] = None) -> Tuple[int, Any, int]: """獲取數(shù)據(jù)或者得到可以放新數(shù)據(jù)的方法, 返回值是index_array的索引, 數(shù)據(jù), data_array的索引""" index: int = hash(key) % (self._init_length - 1) while True: data_index: int = self._index_array[index] # 如果是-1則代表沒有數(shù)據(jù) if data_index == -1: break # 如果是-2則代表之前有數(shù)據(jù)則不過被刪除了 elif data_index == -2: index = self._get_next(index) continue _, new_key, default_value = self._data_array[data_index] # 判斷是不是對應(yīng)的key if key != new_key: index = self._get_next(index) else: break return index, default_value, data_index def __getitem__(self, key: Any) -> Any: _, value, data_index = self._core(key) if data_index == -1: raise KeyError(key) return value def __setitem__(self, key: Any, value: Any) -> None: if (self._used_count / self._init_length) > self._load_factor: self._create_new() index, _, _ = self._core(key) self._index_array[index] = self._used_count self._data_array.append((hash(key), key, value)) self._used_count += 1 def __delitem__(self, key: Any) -> None: index, _, data_index = self._core(key) if data_index == -1: raise KeyError(key) self._index_array[index] = -2 self._data_array[data_index] = None self._delete_count += 1 def __len__(self) -> int: return self._used_count - self._delete_count def __iter__(self) -> Iterable: return iter(self._data_array) def __str__(self) -> str: return str({item[1]: item[2] for item in self._data_array if item is not None}) def keys(self) -> List[Any]: """模擬實現(xiàn)keys方法""" return [item[1] for item in self._data_array if item is not None] def values(self) -> List[Any]: """模擬實現(xiàn)values方法""" return [item[2] for item in self._data_array if item is not None] def items(self) -> List[Tuple[Any, Any]]: """模擬實現(xiàn)items方法""" return [(item[1], item[2]) for item in self._data_array if item is not None] if __name__ == '__main__': customer_dict: CustomerDict = CustomerDict() customer_dict["demo_1"] = "a" customer_dict["demo_2"] = "b" assert len(customer_dict) == 2 del customer_dict["demo_1"] del customer_dict["demo_2"] assert len(customer_dict) == 0 for i in range(30): customer_dict[i] = i assert len(customer_dict) == 30 customer_dict_value_list: List[Any] = customer_dict.values() for i in range(30): assert i == customer_dict[i] for i in range(30): assert customer_dict[i] == i del customer_dict[i] assert len(customer_dict) == 0
到此這篇關(guān)于Python中Dict兩種實現(xiàn)的原理詳解的文章就介紹到這了,更多相關(guān)Python Dict內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
python實現(xiàn)的udp協(xié)議Server和Client代碼實例
這篇文章主要介紹了python實現(xiàn)的udp協(xié)議Server和Client代碼實例,需要的朋友可以參考下2014-06-06簡介Python設(shè)計模式中的代理模式與模板方法模式編程
這篇文章主要介紹了Python設(shè)計模式中的代理模式與模板方法模式編程,文中舉了兩個簡單的代碼片段來說明,需要的朋友可以參考下2016-02-02解決Jupyter notebook中.py與.ipynb文件的import問題
這篇文章主要介紹了解決Jupyter notebook中.py與.ipynb文件的import問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-04-04