文章目录
字典这个数据结构活跃在所有 Python 程序的背后,即便你的源码里并没有直接用到它。——A. M. Kuchling
3.1 泛映射类型
在数学中,我们将集合A对应集合B中的对应法则称为"映射"(Mapping),同样,在python里,我们称"键值对"为映射,这其实也是一种对应法则。而泛映射类型就是在广义上的对应关系。
可散列(hashable)的数据类型:如果一个对象是可散列的,那么在这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现 __ hash__() 方法。另外可散列对象还要有__ qe__() 方法,这样才能跟其他键做比较。如果两个可散列对象是相等的,那么它们的散列值一定是一样的。通俗来说,可散列数据类型在一次程序周期中是不可变的。原子不可变数据类型(str、bytes和数值类型)都是hashable类型;frozenset也是hashable类型;元组中所有元素都是hashable,那么其也是hashable的。
#构造自定义的hashable类型
class Foo:
def __init__(self, name):
self.name = name
def __hash__(self):
print("正在hash...")
return hash(self.name)
def __eq__(self, other):
print("正在比较...")
return self.name == other.name
def __repr__(self):
return self.name
if __name__ == "__main__":
f1 = Foo("小李")
f2 = Foo("小红")
f3 = Foo("小李")
s = set([f1, f2, f3]) # 集合实现不重复的原理正好利用了散列表
print(s) # {小红, 小李}
print( f1 == f3, hash(f1) == hash(f3)) # True True 满足可散列对象的第三个条件
# 只有元组中所有元素可hash,整个元组才可hash
t1 = (1, 2, 3, [1, 2]) # 元组里的列表的值是可变的,所以不可hash
try:
print(hash(t1))
except Exception as e:
print(e) # unhashable type: 'list'
t2 = (1, 2, 3, (1, 2)) # 元组里的元素都是不可变的,并且第二层元组里面的元素也不可变,所以可hash
print(hash(t2)) # 3896079550788208169
t3 = (1, 2, 3, frozenset([1, 2]))
print(hash(t3)) # -5691000848003037416
字典是 Python 语言中唯一的映射类型。映射类型对象里哈希值(键) 和指向的对象(值)是一对多的关系。只有可散列(hashable)的数据类型才能做字典中的键。
# 构造字典的一些方式
>>> 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})
>>> a == b == c == d == e
True
3.2 字典推导
# 字典推导示例,其语法与列表推导相似
>>> 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}
>>> country_code
{'China': 86, 'India': 91, 'Bangladesh': 880, 'United States': 1,
'Pakistan': 92, 'Japan': 81, 'Russia': 7, 'Brazil': 55, 'Nigeria':
234, 'Indonesia': 62}
# item()方法把字典中每对key和value组成一个元组,并把这些元组放在列表中返回。
>>> {code: country.upper() for country, code in country_code.items()
... if code < 66}
{1: 'UNITED STATES', 55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA'}
3.3 常见的映射方法
3.4 映射的弹性键查询
映射在py中非常非常实用,可是有些时候就算键没有值我们也不希望他返回错误信息。py对这种情况提供了两种方法。
3.4.1 defaultdict
defaultdict接受一个工厂函数为参数,返回一个defaultdict类,记录了接受的工厂函数和一个字典。
defaultdict的工作原理简单来说就是如果查询的键不在字典中,那么其将根据输入的工厂函数建立一个空值(如 [ ],turtle()),并返回这个新建的值的引用。如果在创建 defaultdict 的时候没有指定 default_factory,查询不存在的键会触发KeyError。
需要注意的是,defaultdict 里的 default_factory 只会在 __ getitem__ 里被调用,在其他的方法里完全不会发挥作用。
>>> from collections import defaultdict
>>> dd = defaultdict(str)
>>> dd['key'] = 'test'
>>> dd
defaultdict(<class 'str'>, {'key': 'test'})
>>> dd['key']
'test'
>>> dd['newkey']
''
>>> dd.get('newnewkey')
>>>dd['newnewkey']
''
3.4.2 特殊方法 missing
如果有一个类继承了 dict,然后这个继承类提供了__ missing__ 方法,那么在 __ getitem__ 碰到找不到的键的时候,Python 就会自动调用它,而不是抛出一个 KeyError 异常。
我们通过一个简单的例子来演示__ missing__的使用。
class MoreDict(dict):
def __missing__(self, key):#保障d[4]的使用
if isinstance(key, str):
raise KeyError(key)#如果找不到的键本身就是字符串,那就抛出 KeyError 异常。
return self[str(key)]#如果找不到的键不是字符串,那么把它转换成字符串再进行查找。
def get(self, key, default=None):#保障get方法的使用
try:
return self[key]
except KeyError:
return default
def __contains__(self, key):
#先按照传入键的原本的值来查找(我们的映射类型中可能含有非字符串的键),如果
#没找到,再用 str() 方法把键转换成字符串再查找一次。
return key in self.keys() or str(key) in self.keys()
d = MoreDict([('2', 'tow'), ('4', 'four')])
print(d[2], d['4'])
try:
print(d[1])
except KeyError:
print("KeyError")
print(d.get(2), d.get('1', 'N/A'))
print(2 in d)
3.5 字典的变种
collections.OrderedDict
这个类型在添加键的时候会保持顺序,因此键的迭代次序总是一致的。OrderedDict 的 popitem 方法默认删除并返回的是字典里的最后一个元素,但是如果像 my_odict.popitem(last=False) 这样调用它,那么它删除并返回第一个被添加进去的元素。
collections.ChainMap
该类型可以容纳数个不同的映射对象,然后在进行键查找操作的时候,这些对象会被当作一个整体被逐个查找,直到键被找到为止。这个功能在给有嵌套作用域的语言做解释器的时候很有用,可以用一个映射对象来代表一个作用域的上下文。通俗来说,ChainMap可以接受多个字典,并把放入的字典存储在一个队列中,当进行字典的增加删除等操作只会在第一个字典上进行,当进行查找的时候会依次查找。
collections.Counter
这个映射类型会给键准备一个整数计数器。每次更新一个键的时候都会增加这个计数器。所以这个类型可以用来给可散列表对象计数,或者是当成多重集来用——多重集合就是集合里的元素可以出现不止一次。Counter 实现了 + 和 - 运算符用来合并记录,还有像 most_common([n]) 这类很有用的方法。most_common([n]) 会按照次序返回映射里
最常见的 n 个键和它们的计数。
colllections.UserDict
这个类其实就是把标准 dict 用纯 Python 又实现了一遍。相较于直接继承dict类,在自定义dict子类时人们往往更倾向于继承UserDict,因为其实现都是python风格的。
3.6 使用UserDict
为什么继承UserDict要好过继承dict?
一是因为其更加符合python风格,二是虽然UserDict不是dict的子类,但是UserDict有一个叫作 data 的属性,是 dict 的实例,这个属性实际上是 UserDict 最终存储数据的地方。serDict 的子类就能在实现 __ setitem__ 的时候避免不必要的递归,也可以让 __ contains__ 里的代码更简洁。
import collections
class MoreDict(collections.UserDict):
def __missing__(self, key):
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]
def __contains__(self, key):
return str(key) in self.data
def __setitem__(self, key, item):
self.data[str(key)] = item
d = MoreDict({2: 'two', '4': 'four'})
print(d[2], d['4'])
try:
print(d[1])
except KeyError:
print("KeyError")
print(d.get(2), d.get('1', 'N/A'))
print(2 in d)
我们使用UserDict重写了上述类,可以看到代码比继承了dict的更加简洁。
3.7 不可变映射类型
有时候我们想阻止用户对映射进行直接修改,遗憾的是,py并没有直接实现这种功能的数据类型。不过,我们可以通过types中的MappingProxyYType来间接实现。
从 Python 3.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 "<stdin>", 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.8 集合论
本书中“集”或者“集合”既指 set,也指其不可变的姊妹类型frozenset。
集合的本质是许多唯一对象的聚集。因此,集合可以用于去重
>>> l = ['spam', 'spam', 'eggs', 'spam']
>>> set(l)
{'eggs', 'spam'}
>>> list(set(l))
['eggs', 'spam']
集合中的元素必须是可散列的,set 类型本身是不可散列的,但是 frozenset 可以。因此可以创建一个包含不同 frozenset 的 set。
集合还实现了很多基础的中缀运算符。给定两个集合 a 和 b,a | b 返回的是它们的合集,a & b 得到的是交集,而 a - b 得到的是差集。
合理使用集合可以使你的代码更加简洁,同时还能提高可读性。
# needles和haystack均是集合
found = len(needles & haystack)
# 等价于
found = 0
for n in needles:
if n in haystack:
found += 1
对于生成器对象,其只能使用法二,不过对于其他诸如list等可将其转化为集合使用法一
found = len(set(needles) & set(haystack))
# 另一种写法:
found = len(set(needles).intersection(haystack))
3.8.1 集合字面量
集合的字面量和它的数学形式一模一样,如{1}、{2, 3}等等——除了空集!空集必须写成构造方法的形式,即set( )!{ }代表的是一个空字典!
像 {1, 2, 3} 这种字面量句法相比于构造方法(set([1, 2, 3]))要更快且更易读。后者的速度要慢一些,因为 Python 必须先从 set 这个名字来查询构造方法,然后新建一个列表,最后再把这个列表传入到构造方法里。但是如果是像 {1, 2, 3} 这样的字面量,Python 会利用一个专门的叫作 BUILD_SET 的字节码来创建集合。
不过对于range( ),{range(10)}构造的是一个集合,该集合中只存在一个range()对象,而set(range(10))构造的是一个十个元素的数字集合。这可能与函数运行的优先级有关。
对于frozenset,由于python没有对应的BUILD_SET字节码,其必须写成构造方法的形式(frozenset( ))。
3.8.2 集合推导
集合推导和前面提到过的字典推导,列表推导基本一致。
# 这段代码将Unicode字符名字中带有SING的字符都打印出来
>>> from unicodedata import name
>>> {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')}
{'§', '=', '¢', '#', '¤', '<', '¥', 'μ', '×', '$', '¶', '£', '©',
'°', '+', '÷', '±', '>', '¬', '®', '%'}
3.8.3 集合的操作
3.9 dict和set背后
要想理解dict和set背后的优缺点,必须深入了解可散列(hashable)的数据类型。
处理很多数时(1000万)dict和set对查找的操作速度是list的100万倍!
3.9.2 字典中的散列表
散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。在 dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。
因为 Python 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。
-
散列函数(或散列算法,又称哈希函数,英语:Hash Function)是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。百度百科:散列值
从python3.3开始,str、bytes 和 datetime 对象的散列值计算过程中多了随机的“加盐”这一步。所谓的加盐(salt),就是在hash过程中增加一个干扰量,使得通过同一种hash算法所得出来的散列值更加无规律。所加盐值是 Python 进程内的一个常量,但是每次启动Python 解释器都会生成一个不同的盐值。随机盐值的加入是为了防止 DOS 攻击而采取的一种安全措施。
3.9.3 dict的实现及其导致的结果
-
键必须是可散列的(hashable)
-
字典在内存上开销巨大
由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。
-
键查询很快
dict 的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。
-
键的次序取决于添加次序
当往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。就是说,创建字典时,两个键值对的位置可能不同,但是其进行比较时是相等的。
-
往字典中添加新键可能会改变已有键的顺序
无论何时往字典里添加新的键,Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。
如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。由此可知,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原有字典进行更新。
3.9.4 set的实现以及导致的结果
set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。在 set 加入到 Python 之前,我们都是把字典加上无意义的值当作集合来用的。
set和dict实现的特点基本一致,这里不再赘述。
声明
本文来自《流畅的python》以及笔者自己的思考,如有错误,欢迎指正。