《流畅的Python》笔记。python
本篇主要介绍dict和set的高级用法以及它们背后的哈希表。算法
dict
类型不但在各类程序中普遍使用,它也是Python的基石。模块的命名空间、实例的属性和函数的关键字参数等都用到了dict
。与dict
先关的内置函数都在__builtins__.__dict__
模块中。数组
因为字典相当重要,Python对其实现作了高度优化,而散列表(哈希函数,Hash)则是字典性能突出的根本缘由。并且集合(set
)的实现也依赖于散列表。bash
本片的大纲以下:微信
dict
类型的变种;set
和frozenset
类型;和上一篇同样,先来看看collections.abc
模块中的两个抽象基类:Mapping
和MutableMapping
。它们的做用是为dict
和其余相似的类型定义形式接口:app
然而,非抽象映射类型通常不会直接继承这些抽象基类,它们会直接对dict
或者collections.UserDict
进行扩展。函数
首先总结下经常使用的建立字典的方法:性能
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
复制代码
列表推导和生成器表达式能够用在字典上。字典推导(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'}
复制代码
update
和setdefault
update
方法它的参数列表以下:优化
dict.update(m, [**kargs])
复制代码
update
方法处理参数m
的方法是典型的**“鸭子类型”**。该方法首先检测m
是否有keys
方法,若是有,那么update
方法就把m
当作映射对象来处理(即便它并非映射对象);不然退一步,把m
当作包含了键值对(key, value)
元素的迭代器。
Python中大多数映射类的构造方法都采用了相似的逻辑,所以既可用一个映射对象来新建一个映射对象,也能够用包含(key, value)
元素的可迭代对象来初始化一个映射对象。
当更新字典时,若是遇到原字典中不存在的键时,咱们通常最开始会想到以下两种方法:
# 方法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)
复制代码
上述的setdefault
方法在每次调用时都要咱们手动指定默认值,那有没有什么办法能方便一些,在键不存在时,直接返回咱们指定的默认值?两个经常使用的方法是:①使用defaultdict
类;②自定义一个dict
子类,在子类中实现__missing__
方法,而这个方法又有至少两种方法。
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
是新键):
list()
来建立一个新列表;key
做为它的键,放到my_dict
中;注意:
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__
实现字典查询,不过这里并无在找不到键时调用一个可调用对象,而是抛出异常。
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()
复制代码
说明:
isinstance(key, str)
测试是必需的。若是没有这个测试,那么当str(key)
是个不存在的键时便会发生无限递归,由于第4行self[str(key)]
会调用__getitem__
,进而又调用__missing__
,而后一直重复下去。__contains__
方法在这里也是必需的,由于k in d
这个操做会调用该方法。可是从dict
继承到的__contains__
方法在找不到键的时候不会调用__missing__
(间接调用,不会直接调用)。key in my_dict
,由于这样写会使__contains__
也发生递归调用,因此这里采用了更显式的方法key in self.keys
。同时须要注意的是,这里有两个判断,由于咱们本没有强行限制全部的键都必须是str
,因此字典中可能存在非字符串的键(key in self.keys()
)。k in my_dict.keys()
这种操做在Python3中很快,即便映射类型对象很庞大也很快,由于dict.keys()
返回的是一个”视图“,在视图中查找一个元素的速度很快。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
里剩下的映射类型的方法都是从UserDict
、MutableMapping
和Mapping
继承而来,这些方法中有两个值得关注:
MutableMapping.update
:
这个方法不但能够直接用,它还用在__init__
里,使其能支持各类格式的参数。而这个update
方法内部则使用self[key] = value
来添加新值,因此它实际上是在使用咱们定义的__setitem__
方法。
Mapping.get
:
对比StrKeyDict0
和StrKeyDict
的代码能够发现,咱们并无为后者定义get
方法。前者若是不定义get
方法,则会出现以下状况:
>>> d = StrKeyDict0()
>>> d["1"] = one
>>> d[1]
'one'
>>> d.get(1)
None # 和__getitem__的行为不符合,应该返回'one'
复制代码
而在StrKeyDict
中则没有必要,由于UserDict
继承了Mapping
的get
方法,而查看源代码可知,这个方法的实现和StrKeyDict0.get
如出一辙。
collections.OrderedDict
这个类型在添加键的时候会保持原序,即对键的迭代次序就是添加时的顺序。它的popitem
方法默认删除并返回字典中的最后一个元素。值得注意的是,从Python3.6开始,dict
中键的顺序也保持了原序。但出于兼容性考虑,若是要保持有序,仍是推荐使用OrderedDict
。
collections.ChainMap
该类型可容纳多个不一样的映射对象,而后在查找元素时,这些映射对象会被当成一个总体被逐个查找。这个功能在给有嵌套做用域的语言作解释器的时候颇有用,能够用一个映射对象来表明一个做用域的上下文。
import builtins
pylookup = ChainMap(locals(), globals(), vars(builtins))
复制代码
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)]
复制代码
标准库中全部的映射类型都是可变的,但有时候会有这样的须要,好比不能让用户错误地修改某个映射。从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'
复制代码
和前面的字典同样,先来看看集合的超类的继承关系:
集合的本质是许多惟一对象的汇集。即,集合能够用于去重。集合中的元素必须是可散列的,set
类型自己是不可散列的,可是frozenset
能够。也就是说能够建立一个包含不一样frozenset
的set
。
集合的操做
注意两个概念:字面量句法,构造方法:
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), "")}
{'+', '÷', 'µ', '¤', '¥', '¶', '<', '©', '%', '§', '=', '¢', '®', '#', '$', '±', '×',
'£', '>', '¬', '°'}
复制代码
有人作过实验(就在普通笔记本上),在1,000,000个元素中找1,000个元素,dict
与set
二者的耗时比较接近,大约为0.000337s,而使用列表list
,耗时是97.948056s,list
的耗时是dict
和set
的约29万倍。而形成这种差距的最根本的缘由是,list
中找元素是按位置一个一个找(虽然有像折半查找这类的算法,但本质依然是一个位置接一个位置的比较),而dict
是根据某个信息直接计算元素的位置,显而后者速度要比挨个找快不少。而这个计算方法统称为哈希函数(hash
),即hash(key)-->position
。
碍于篇幅,关于哈希算法的原理(哈希函数的选择,冲突的解决等)这里便再也不赘述,相信常常和算法打交道或者考过研的老铁们必定不陌生。
哈希表(也叫散列表)实际上是个稀疏数组(有不少空元素的数组),每一个单元叫作表元(bucket),Python中每一个表元由对键的引用和对值的引用两部分组成。由于全部表元的大小一致,因此当计算出位置后,能够经过偏移量来读取某个元素(变址寻址)。
Python会设法保证大概还有三分之一的表元是空的,当快要达到这个阈值的时候,原有的哈希表会被复制到一个更大的空间中。
若是要把一个对象放入哈希表中,首先要计算这个元素的哈希值。Python中能够经过函数hash()
来计算。内置的hash()
可用于全部的内置对象。若是是自定义对象调用hash()
,实际上运行的是自定义的__hash__
。若是两个对象在比较的时候相等的,那么它们的哈希值必须相等,不然哈希表就不能正常工做。好比,若是1 == 1.0
为真,那么hash(1) == hash(1.0)
也必须为真,但其实这两个数字的内部结构彻底不同。而相等性的检测则是调用特殊方法__eq__
。
补充:从Python3.3开始,为了防止DOS攻击,str
、bytes
和datetime
对象的哈希值计算过程当中多了随机的“加盐”步骤。所加的盐值是Python进程中的一个常量,但每次启动Python解释器都会生成一个不一样的盐值。
为获取my_dict[search_key]
背后的值(不是哈希值),Python首先会调用hash(search_key)
计算哈希值,而后取这个值最低的几位数字看成偏移量(这只是一种哈希算法)去获取所要的值,若是发生了冲突,则再取哈希值的另外几位,知道不冲突为止。
在插入新值的时候,Python可能会按照哈希表的拥挤程度来决定是否要从新分配内存为它扩容。若是增长了散列表的大小,散列值所占的位数和用做索引的位数都会随之增长(目的是为了减小冲突发生的几率)。
这个算法看似费事,但实际上就算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()
方法返回的都是字典视图。
set
和frozenset
也由哈希表实现,但它们的哈希表中存放的只有元素的引用(相似于在字典里只存放了键而没放值)。在set
加入到Python以前,都是把字典加上无心义的值来当集合用。5.3中对字典的几个特色也一样适用于集合。
字典是Python的基石。除了基本的dict
,标准库中还有特殊的映射类型:defaultdict
、OrderedDict
、ChainMap
、Counter
和UserDict
,这些类都在collections
模块中。
大多数映射都提供了两个强大的方法:setdefault
和update
。前者可避免重复搜索,后者可批量更新。
在映射类型的API中,有个很好用的特殊方法__missing__
,能够经过这个方法自定义当对象找不到某个键时的行为。
set
和dict
的实现都用到了哈希表,二者的查找速度都很快,但空间消耗大,典型的以空间换时间的算法。
迎你们关注个人微信公众号"代码港" & 我的网站 www.vpointer.net ~