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

《流暢的Python》筆記。python

本篇主要介紹dict和set的高級用法以及它們背後的哈希表。算法

1. 前言

dict類型不但在各類程序中普遍使用,它也是Python的基石。模塊的命名空間、實例的屬性和函數的關鍵字參數等都用到了dict。與dict先關的內置函數都在__builtins__.__dict__模塊中。數組

因爲字典相當重要,Python對其實現作了高度優化,而散列表(哈希函數,Hash)則是字典性能突出的根本緣由。並且集合(set)的實現也依賴於散列表。bash

本片的大綱以下:微信

  • 常見的字典方法;
  • 如何處理找不到的鍵;
  • 標準庫中dict類型的變種;
  • setfrozenset類型;
  • 散列表工做原理;
  • 散列表帶來的潛在影響(什麼樣的數據能夠做爲鍵、不可預知的順序等)。

2. 字典

和上一篇同樣,先來看看collections.abc模塊中的兩個抽象基類MappingMutableMapping。它們的做用是dict和其餘相似的類型定義形式接口:app

然而,非抽象映射類型通常不會直接繼承這些抽象基類,它們會直接對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. 集合

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

集合的本質是許多惟一對象的彙集。即,集合能夠用於去重。集合中的元素必須是可散列的,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)支持hash()函數,而且經過__hash__()方法獲得的哈希值是不變的;

(2)支持經過__eq__()方法來檢測相等性;

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

全部自定義的對象默認都是可散列的,由於它們的哈希值有id()函數來獲取,並且它們都是不相等的。若是你實現了一個類的__eq__方法,而且但願它是可散列的,那請務必保證這個類知足上面的第3條要求。

二、字典在內存上的開銷巨大

典型的用空間換時間的算法。由於哈希表是稀疏的,這致使它的空間利用率很低。

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

用元組代替字典就能節省空間的緣由有兩個:①避免了哈希表所耗費的空間;②無需把記錄中字段的名字在每一個元素裏都存一遍。

關於空間優化:若是你的內存夠用,那麼空間優化工做能夠等到真正須要的時候再開始,由於優化每每是可維護性的對立面。

三、鍵查詢很快

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

這裏之因此說的是「鍵查詢」,而不是「查詢」,是由於有可能值的數據不在內存,內在磁盤中。一旦涉及到磁盤這樣的低速設備,查詢速度將大打折扣。

四、鍵的次序取決於添加順序

當往dict裏添加新鍵而又發生衝突時,新鍵可能會被安排存放到另外一個位置。而且同一組數據,每次按不一樣順序進行添加,那麼即使是同一個鍵,同一個算法,最後的位置也可能不一樣。最典型的就是這組數據全衝突(全部的hash值都同樣),而後採用的是線性探測再散列解決衝突,這時的順序就是添加時的順序。

五、向字典中添加新鍵可能會改變已有鍵的順序。

不管什麼時候往字典中添加新的鍵,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 ~

相關文章
相關標籤/搜索