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 ~

相关文章
相关标签/搜索