流畅的python读书笔记③:字典和集合


字典这个数据结构活跃在所有 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 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。

  1. 散列函数(或散列算法,又称哈希函数,英语:Hash Function)是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。百度百科:散列值

    从python3.3开始,str、bytes 和 datetime 对象的散列值计算过程中多了随机的“加盐”这一步。所谓的加盐(salt),就是在hash过程中增加一个干扰量,使得通过同一种hash算法所得出来的散列值更加无规律。所加盐值是 Python 进程内的一个常量,但是每次启动Python 解释器都会生成一个不同的盐值。随机盐值的加入是为了防止 DOS 攻击而采取的一种安全措施。

    在这里插入图片描述
    在这里插入图片描述

3.9.3 dict的实现及其导致的结果

  1. 键必须是可散列的(hashable)

  2. 字典在内存上开销巨大

    由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。

  3. 键查询很快

    dict 的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。

  4. 键的次序取决于添加次序

    当往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。就是说,创建字典时,两个键值对的位置可能不同,但是其进行比较时是相等的

  5. 往字典中添加新键可能会改变已有键的顺序

    无论何时往字典里添加新的键,Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。

    如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。由此可知,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原有字典进行更新。

3.9.4 set的实现以及导致的结果

set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。在 set 加入到 Python 之前,我们都是把字典加上无意义的值当作集合来用的。

set和dict实现的特点基本一致,这里不再赘述。


声明

本文来自《流畅的python》以及笔者自己的思考,如有错误,欢迎指正。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值