Python學習之路22-字典和集合

VPointer發表於2019-02-16

《流暢的Python》筆記。

本篇主要介紹dict和set的高階用法以及它們背後的雜湊表。

1. 前言

dict型別不但在各種程式中廣泛使用,它也是Python的基石。模組的名稱空間、例項的屬性和函式的關鍵字引數等都用到了dict。與dict先關的內建函式都在__builtins__.__dict__模組中。

由於字典至關重要,Python對其實現做了高度優化,而雜湊表(雜湊函式,Hash)則是字典效能突出的根本原因。而且集合(set)的實現也依賴於雜湊表。

本片的大綱如下:

  • 常見的字典方法;
  • 如何處理找不到的鍵;
  • 標準庫中dict型別的變種;
  • setfrozenset型別;
  • 雜湊表工作原理;
  • 雜湊表帶來的潛在影響(什麼樣的資料可以作為鍵、不可預知的順序等)。

2. 字典

和上一篇一樣,先來看看collections.abc模組中的兩個抽象基類MappingMutableMapping。它們的作用是dict和其他類似的型別定義形式介面:

Python學習之路22-字典和集合

然而,非抽象對映型別一般不會直接繼承這些抽象基類,它們會直接對dict或者collections.UserDict進行擴充套件。

2.1 建立字典

首先總結下常用的建立字典的方法:

a = dict(one=1, two=2, three=3)
b = {"one": 1, "two": 2, "three": 3}
c = dict(zip(["one", "two", "three"], [1, 2, 3]))
d = dict([("two", 2), ("one", 1), ("three", 3)])
e = dict({"three": 3, "one": 1, "two": 2})
print(a == b == c == d == e)

# 結果
True
複製程式碼

2.2 字典推導

列表推導和生成器表示式可以用在字典上。字典推導(dictcomp)可從任何以鍵值對作為元素的可迭代物件中構建出字典。

DIAL_CODES = [
    (86, 'China'),
    (91, 'India'),
    (1, 'United States'),
    (62, 'Indonesia'),
    (55, 'Brazil'),
    (92, 'Pakistan'),
    (880, 'Bangladesh'),
    (234, 'Nigeria'),
    (7, 'Russia'),
    (81, 'Japan'),
]

country_code = {country: code for code, country in DIAL_CODES}
print(country_code)
code_country = {code: country.upper() for country, code in country_code.items() if code < 66}
print(code_country)

# 結果:
{'China': 86, 'India': 91, 'United States': 1, 'Indonesia': 62, 'Brazil': 55, 
 'Pakistan': 92, 'Bangladesh': 880, 'Nigeria': 234, 'Russia': 7, 'Japan': 81}
{1: 'UNITED STATES', 62: 'INDONESIA', 55: 'BRAZIL', 7: 'RUSSIA'}
複製程式碼

2.3 兩個重要的對映方法updatesetdefault

2.3.1 update方法

它的引數列表如下:

dict.update(m, [**kargs])
複製程式碼

update方法處理引數m的方法是典型的**“鴨子型別”**。該方法首先檢測m是否有keys方法,如果有,那麼update方法就把m當做對映物件來處理(即使它並不是對映物件);否則退一步,把m當做包含了鍵值對(key, value)元素的迭代器。

Python中大多數對映類的構造方法都採用了類似的邏輯,因此既可用一個對映物件來新建一個對映物件,也可以用包含(key, value)元素的可迭代物件來初始化一個對映物件。

2.3.2 setdefault處理不存在的鍵

當更新字典時,如果遇到原字典中不存在的鍵時,我們一般最開始會想到如下兩種方法:

# 方法1
if key not in my_dict:
    my_dict[key] = []  # 如果字典中不存在該鍵,則為該鍵建立一個空list
my_dict[key].append(new_value)

# 方法2
temp = my_dict.get(key, []) # 去的key對應的值,如果key不存在,則建立空list
temp.append(new_value)
my_dict[key] = temp  # 把新列表放回字典
複製程式碼

以上兩種方法至少進行2次鍵查詢,如果鍵不存在,第一種方法要查詢3次,非常低效。但如果使用setdefault方法,則只需一次就可以完成上述操作:

my_dict.setdefault(key, []).append(new_value)
複製程式碼

2.4 對映的彈性鍵查詢

上述的setdefault方法在每次呼叫時都要我們手動指定預設值,那有沒有什麼辦法能方便一些,在鍵不存在時,直接返回我們指定的預設值?兩個常用的方法是:①使用defaultdict類;②自定義一個dict子類,在子類中實現__missing__方法,而這個方法又有至少兩種方法。

2.4.1 defaultdict類

collections.defaultdict能優雅的解決3.3.2中的問題:

import collections
my_dict = collections.defaultdict(list)
my_dict[key].append(new_value)  # 我們不需要判斷鍵key是否存在於my_dict中
複製程式碼

在例項化defaultdict時,需要給構造方法提供一個可呼叫物件(實現了__call__方法的物件),這個可呼叫物件儲存在defaultdict類的屬性default_factory中,當__getitem__找不到所需的鍵時就會通過default_factory來呼叫這個可呼叫物件來建立預設值。

上述程式碼中my_dict[key]的內部過程如下(假設key是新鍵):

  1. 呼叫list()來建立一個新列表;
  2. 把這個新列表作為值,key作為它的鍵,放到my_dict中;
  3. 返回這個列表的引用

注意

  • 如果在例項化defaultdict時未指定default_factory,那麼在查詢不存在的鍵時則會觸發KeyError
  • defaultdict中的default_factory只會在__getitem__裡被呼叫,在其它的方法裡完全不會發揮作用!比如,dd是個defaultdict,k是個不存在的鍵,dd[k]這個表示式則會呼叫default_factory,並返回預設值,而dd.get(k)則會返回None

特殊方法__missing__

其實上述的功能都得益於特殊方法__missing__,實際呼叫default_factory的就是該特殊方法,且該方法只會被__getitem__呼叫。即:__getitem__呼叫__missing____missing__呼叫default_factory

所有的對映型別在處理找不到鍵的情況是,都會牽扯到該特殊方法。基類dict沒有定義這個方法,但dict有該方法的宣告。

下面通過編寫一個繼承自dict的類來說明如何使用__missing__實現字典查詢,不過這裡並沒有在找不到鍵時呼叫一個可呼叫物件,而是丟擲異常。

2.4.2 自定義對映類:繼承自dict

某些情況下可能希望在查詢字典時,對映裡的鍵通通轉換成str類,但為了方便,也允許使用非字串作為建,比如我們希望實現如下效果:

>>> d = StrKeyDict0([("2", "two"), ("3", "three")])
>>> d["2"]
'two'
>>> d[3]
'three'
>>> d[1]
Traceback (most recent call last):
	...
KeyError: "1"
複製程式碼

以下便是這個類的實現:

class StrKeyDict0(dict):
    def __missing__(self, key):
        if isinstance(key, str):   # 必須要由此判斷,否則無限遞迴
            raise KeyError(key)
        return self[str(key)]
	
    # 為了和__getitem__行為一致,所以必須實現該方法,例子在3.4.3中
    def get(self, key, default=None):
        try:
            return self[key]
        except KeyError:
            return default

    def __contains__(self, key):
        return key in self.keys() or str(key) in self.keys()
複製程式碼

說明:

  • 第3行:這裡的isinstance(key, str)測試是必需的。如果沒有這個測試,那麼當str(key)是個不存在的鍵時便會發生無限遞迴,因為第4行self[str(key)]會呼叫__getitem__,進而又呼叫__missing__,然後一直重複下去。
  • 第13行:為了保持一致性,__contains__方法在這裡也是必需的,因為k in d這個操作會呼叫該方法。但是從dict繼承到的__contains__方法在找不到鍵的時候不會呼叫__missing__(間接呼叫,不會直接呼叫)。
  • 第14行:這裡並沒有使用更具Python風格的寫法:key in my_dict,因為這樣寫會使__contains__也發生遞迴呼叫,所以這裡採用了更顯式的方法key in self.keys。同時需要注意的是,這裡有兩個判斷,因為我們本沒有強行限制所有的鍵都必須是str,所以字典中可能存在非字串的鍵(key in self.keys())。
  • k in my_dict.keys()這種操作在Python3中很快,即使對映型別物件很龐大也很快,因為dict.keys()返回的是一個”檢視“,在檢視中查詢一個元素的速度很快。

2.4.3 子類化UserDict

如果要自定義一個對映型別,更好的策略是繼承collections.UserDict。它是把標準dict用純Python又實現了一遍。之所以更傾向於從UserDict而不是從dict繼承,是因為後者有時會在某些方法的實現上走一些捷徑,導致我們不得不在它的子類中重寫這些方法,而UserDict則沒有這些問題。也正是由於這個原因,如果上個例子要實現將所有的鍵都轉換成字串,還需要做很多工作,而從UserDict繼承則能很容易實現。

注意:如果我們想在上個例子中實現__setitem__,使其將所有的鍵都轉換成str,則會發生無限遞迴

-- snip -- 
    def __setitem__(self, key, value):
        self[str(key)] = value

if __name__ == "__main__":
    d = StrKeyDict0()
    d[1] = "one"
    print(d[1])

# 結果:
  File "test.py", line 17, in __setitem__
    self[str(key)] = value
  [Previous line repeated 329 more times]
RecursionError: maximum recursion depth exceeded while calling a Python object
複製程式碼

下面使用UserDict來實現一遍StrKeyDict,它實現了__setitem__方法,將所有的鍵都轉換成str。注意這裡並沒有自行實現get方法,原因在後面。

import collections

class StrKeyDict(collections.UserDict):
    def __missing__(self, key):
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]

    def __contains__(self, key):
        # 相比於StrKeyDict0,這裡只有一個判斷,因為鍵都被轉換成字串了
        # 而且查詢是在self.data屬性上查詢,而不是在self.keys()上查詢。
        return str(key) in self.data

    def __setitem__(self, key, value):
        # 把具體實現委託給了self.data屬性
        self.data[str(key)] = value

if __name__ == "__main__":
    d = StrKeyDict()
    d[1] = "one"
    print(d[1])
    print(d)

# 結果
one
{'1': 'one'}
複製程式碼

因為UserDict繼承自MutableMapping,所以StrKeyDict裡剩下的對映型別的方法都是從UserDictMutableMappingMapping繼承而來,這些方法中有兩個值得關注:

MutableMapping.update

這個方法不但可以直接用,它還用在__init__裡,使其能支援各種格式的引數。而這個update方法內部則使用self[key] = value來新增新值,所以它其實是在使用我們定義的__setitem__方法。

Mapping.get

對比StrKeyDict0StrKeyDict的程式碼可以發現,我們並沒有為後者定義get方法。前者如果不定義get方法,則會出現如下情況:

>>> d = StrKeyDict0()
>>> d["1"] = one
>>> d[1]
'one'
>>> d.get(1)
None   # 和__getitem__的行為不符合,應該返回'one'
複製程式碼

而在StrKeyDict中則沒有必要,因為UserDict繼承了Mappingget方法,而檢視原始碼可知,這個方法的實現和StrKeyDict0.get一模一樣。

2.5 其他字典

2.5.1 collections.OrderedDict

這個型別在新增鍵的時候會保持原序,即對鍵的迭代次序就是新增時的順序。它的popitem方法預設刪除並返回字典中的最後一個元素。值得注意的是,從Python3.6開始,dict中鍵的順序也保持了原序。但出於相容性考慮,如果要保持有序,還是推薦使用OrderedDict

2.5.2 collections.ChainMap

該型別可容納多個不同的對映物件,然後在查詢元素時,這些對映物件會被當成一個整體被逐個查詢。這個功能在給有巢狀作用域的語言做直譯器的時候很有用,可以用一個對映物件來代表一個作用域的上下文。

import builtins
pylookup = ChainMap(locals(), globals(), vars(builtins))
複製程式碼

2.5.3 collections.Counter

這個類會給鍵準備一個整數計數器,每次更新一個鍵時就會自動增加這個計數器。所以這個型別可以用來給可雜湊物件計數,或者當成多重集來使用(相同元素可以出現不止一次的集合)。

>>> import collections
>>> ct = collections.Counter("abracadabra")
>>> ct
Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.update("aaaaazzz")
>>> ct
Counter({'a': 10, 'z': 3, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.most_common(2)
[('a', 10), ('z', 3)]
複製程式碼

2.5.4 不可變對映型別

標準庫中所有的對映型別都是可變的,但有時候會有這樣的需要,比如不能讓使用者錯誤地修改某個對映。從Python3.3開始,types模組中引入了一個封裝類MappingProxyType。如果給這個類一個對映,它返回一個只讀的對映檢視。雖然是個只讀檢視,但它是動態的,如果原對映被修改,我們也能通過這個檢視觀察到變化。以下是它的一個例子:

>>> from types import MappingProxyType
>>> d = {1: "A"}
>>> d_proxy = MappingProxyType(d)
>>> d_proxy
mappingproxy({1: 'A'})
>>> d_proxy[1]
'A'
>>> d_proxy[2] = "x"
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment
>>> d[2] = "B"
>>> d_proxy
mappingproxy({1: 'A', 2: 'B'})
>>> d_proxy[2]
'B'
複製程式碼

3. 集合

和前面的字典一樣,先來看看集合的超類的繼承關係:

Python學習之路22-字典和集合

集合的本質是許多唯一物件的聚集。即,集合可以用於去重。集合中的元素必須是可雜湊的,set型別本身是不可雜湊的,但是frozenset可以。也就是說可以建立一個包含不同frozensetset

集合的操作

注意兩個概念:字面量句法,構造方法:

s = {1, 2, 3}  # 這叫字面量句法
s = set([1, 2, 3]) # 這叫構造方法
s = set() # 空集, 不是s = {},這是空字典!
複製程式碼

字面量句法相對於構造方法更快更易讀。後者速度之所以慢是因為Python必須先從set這個名字來查詢構造方法,然後新建一個列表,最後再把這個列表傳入到構造方法裡。而對於字面量句法,Python會利用一個專門的叫做BUILD_SET的位元組碼來建立集合。

集合的字面量——{1}{1, 2}等——看起來和它的數學形式一模一樣。但要注意空集,如果要建立一個空集,只能是temp = set(),而不是temp = {},後者建立的是一個空字典。

frozenset的標準字串表示形式看起來就像構造方法呼叫一樣:

>>> frozenset(range(10))
frozenset({0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
複製程式碼

對於frozenset,一旦建立便不可更改,常用作字典的鍵的集合。

除此之外,集合還實現了很多基礎的中綴運算子,如交集a & b,合集a | b,差集a - b等,還有子集,真子集等操作,由於這類操作太多,這裡不再一一列出。下面程式碼得到兩個可迭代物件中共有的元素個數,這是一個常用操作:

found = len(set(needles) & set(haystack))
# 另一種寫法
found = len(set(needles).intersection(haystack))
複製程式碼

集合推導

和列表推導,字典推導一樣,集合也有推導(setcomps):

>>> from unicodedata import name
>>> {chr(i) for i in range(32, 256) if "SIGN" in name(chr(i), "")}
{'+', '÷', 'µ', '¤', '¥', '¶', '<', '©', '%', '§', '=', '¢', '®', '#', '$', '±', '×', 
'£', '>', '¬', '°'}
複製程式碼

4. dict和set的背後

有人做過實驗(就在普通筆記本上),在1,000,000個元素中找1,000個元素,dictset兩者的耗時比較接近,大約為0.000337s,而使用列表list,耗時是97.948056s,list的耗時是dictset的約29萬倍。而造成這種差距的最根本的原因是,list中找元素是按位置一個一個找(雖然有像折半查詢這類的演算法,但本質依然是一個位置接一個位置的比較),而dict是根據某個資訊直接計算元素的位置,顯然後者速度要比挨個找快很多。而這個計算方法統稱為雜湊函式(hash),即hash(key)-->position

礙於篇幅,關於雜湊演算法的原理(雜湊函式的選擇,衝突的解決等)這裡便不再贅述,相信經常和演算法打交道或者考過研的老鐵們一定不陌生。

雜湊表(也叫雜湊表)其實是個稀疏陣列(有很多空元素的陣列),每個單元叫做表元(bucket),Python中每個表元由對鍵的引用和對值的引用兩部分組成。因為所有表元的大小一致,所以當計算出位置後,可以通過偏移量來讀取某個元素(變址定址)。

Python會設法保證大概還有三分之一的表元是空的,當快要達到這個閾值的時候,原有的雜湊表會被複制到一個更大的空間中。

4.1 雜湊值和相等性

如果要把一個物件放入雜湊表中,首先要計算這個元素的雜湊值。Python中可以通過函式hash()來計算。內建的hash()可用於所有的內建物件。如果是自定義物件呼叫hash(),實際上執行的是自定義的__hash__。如果兩個物件在比較的時候相等的,那麼它們的雜湊值必須相等,否則雜湊表就不能正常工作。比如,如果1 == 1.0為真,那麼hash(1) == hash(1.0)也必須為真,但其實這兩個數字的內部結構完全不一樣。而相等性的檢測則是呼叫特殊方法__eq__

補充:從Python3.3開始,為了防止DOS攻擊,strbytesdatetime物件的雜湊值計算過程中多了隨機的“加鹽”步驟。所加的鹽值是Python程式中的一個常量,但每次啟動Python直譯器都會生成一個不同的鹽值。

4.2 Python中的雜湊演算法

為獲取my_dict[search_key]背後的值(不是雜湊值),Python首先會呼叫hash(search_key)計算雜湊值,然後取這個值最低的幾位數字當作偏移量(這只是一種雜湊演算法)去獲取所要的值,如果發生了衝突,則再取雜湊值的另外幾位,知道不衝突為止。

在插入新值的時候,Python可能會按照雜湊表的擁擠程度來決定是否要重新分配記憶體為它擴容。如果增加了雜湊表的大小,雜湊值所佔的位數和用作索引的位數都會隨之增加(目的是為了減少衝突發生的概率)。

這個演算法看似費事,但實際上就算dict中有數百萬個元素,多數的搜尋過程中並不會發生衝突,平均下來每次搜尋可能會有一到兩次衝突。

4.3 dict的優劣

1、鍵必須是可雜湊的

一個可雜湊物件必須滿足一下要求:

(1)支援hash()函式,並且通過__hash__()方法得到的雜湊值是不變的;

(2)支援通過__eq__()方法來檢測相等性;

(3)若a == b為真,則hash(a) == hash(b)也必須為真。

所有自定義的物件預設都是可雜湊的,因為它們的雜湊值有id()函式來獲取,而且它們都是不相等的。如果你實現了一個類的__eq__方法,並且希望它是可雜湊的,那請務必保證這個類滿足上面的第3條要求。

2、字典在記憶體上的開銷巨大

典型的用空間換時間的演算法。因為雜湊表是稀疏的,這導致它的空間利用率很低。

如果需要存放數量巨大的記錄,那麼放在由元組或命名元組構成的列表中會是比較好的選擇;最好不要根據JSON的風格,用由字典組成的列表來存放這些記錄。

用元組代替字典就能節省空間的原因有兩個:①避免了雜湊表所耗費的空間;②無需把記錄中欄位的名字在每個元素裡都存一遍。

關於空間優化:如果你的記憶體夠用,那麼空間優化工作可以等到真正需要的時候再開始,因為優化往往是可維護性的對立面。

3、鍵查詢很快

本節最開始的實驗已經證明,字典的查詢速度非常快。如果再簡單計算一下,上面的實驗中,在有1000萬個元素的字典裡,每秒能進行200萬次鍵查詢。

這裡之所以說的是“鍵查詢”,而不是“查詢”,是因為有可能值的資料不在記憶體,內在磁碟中。一旦涉及到磁碟這樣的低速裝置,查詢速度將大打折扣。

4、鍵的次序取決於新增順序

當往dict裡新增新鍵而又發生衝突時,新鍵可能會被安排存放到另一個位置。並且同一組資料,每次按不同順序進行新增,那麼即便是同一個鍵,同一個演算法,最後的位置也可能不同。最典型的就是這組資料全衝突(所有的hash值都一樣),然後採用的是線性探測再雜湊解決衝突,這時的順序就是新增時的順序。

5、向字典中新增新鍵可能會改變已有鍵的順序。

無論何時往字典中新增新的鍵,Python直譯器都有可能做出擴容的決定。擴容時,在將原有的元素新增到新表的過程中就有可能改變原有元素的順序。如果在迭代一個字典的過程中同時對修改字典,那麼這個迴圈就很有可能會跳過一些鍵。

補充:Python3中,.keys().items().values()方法返回的都是字典檢視。

4.4 set的實現

setfrozenset也由雜湊表實現,但它們的雜湊表中存放的只有元素的引用(類似於在字典裡只存放了鍵而沒放值)。在set加入到Python之前,都是把字典加上無意義的值來當集合用。5.3中對字典的幾個特點也同樣適用於集合。

5. 總結

字典是Python的基石。除了基本的dict,標準庫中還有特殊的對映型別:defaultdictOrderedDictChainMapCounterUserDict,這些類都在collections模組中。

大多數對映都提供了兩個強大的方法:setdefaultupdate。前者可避免重複搜尋,後者可批量更新。

在對映型別的API中,有個很好用的特殊方法__missing__,可以通過這個方法自定義當物件找不到某個鍵時的行為。

setdict的實現都用到了雜湊表,兩者的查詢速度都很快,但空間消耗大,典型的以空間換時間的演算法。


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路22-字典和集合

相關文章