CHAP.III 字典和集合

CHAP.III 字典和集合

正因为字典至关重要,Python对它的实现做了高度优化,而散列表 则是字典类型性能出众的根本原因。集合(set)的实现其实也依赖于散列表。想要进一步理解集合和字典,就要先理解散列表的原理。

3.1 泛映射类型

collections.abc 模块中有 MappingMutableMapping 这两个抽象基类,它的作用是为 dict 和 其他类似的类型定义形式接口。这些抽象基类的主要作用是作为形式化的文档,它们定义了构建一个映射类型所需要的最基本的接口。然后它们还可以跟isinstance一起被用来判定某个数据是不是广义上的映射类型:

from collections import abc

mydict = {}
print(isinstance(mydict,abc.Mapping))
#	True

标准库里所有的映射类型都是利用dict来实现的,因此它们有个共同的限制,即 只有 可散列 的数据类型才能用做这些映射的键(只有键有这个要求)

什么是可散列的数据类型

如果一个对象是可散列的,那么在这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现_hash_()。另外可散列对象还要有_eq_()方法,这样才能跟其他键作比较。如果两个可散列对象是相等的,那么它们的散列值一定是一样的。

原子不可变数据类型(str、bytes和数据类型)都是可散列类型,frozenset也是可散列的,因为根据其定义,frozenset里只能容纳可散列类型。元组的话,只有当一个元组包含的所有元素都是可散列类型的情况下,它才是可散列的。(如果元组里有各种引用类型的,那么则不可散列)

3.2 字典推导

DIAL_CODES = [
    (86, 'China'),
    (91, 'India'),
    (55, 'Brazil')
]

country_code = {country: code for code, country in DIAL_CODES}
print(country_code)
{'China': 86, 'India': 91, 'Brazil': 55}

3.3 常见的映射方法

常见API

d.popitem()随机返回一个键值对并删除它,而OrderedDict.popitem()会移除字典中最先出入的元素(先进先出);同时这个方法还有一个可选的last参数,若为真,则会移除最后插入的元素(后进先出)。

update方法也很有意思。d.update(m,[**kwargs])m可以是映射或者键值对的迭代器,用来更新d里面的条目。“鸭子方法

举个例子如下。

DIAL_CODES = [
    (86, 'China'),
    (91, 'India'),
    (55, 'Brazil')
]

country_code = {country: code for code, country in DIAL_CODES}
print(country_code)
#   第一种,m包含keys方法,update将它当作映射对象处理。
exp1 = {'UK': 77,
        'IS': 43
        }
country_code.update(exp1)
print(country_code)
#   第二种,包含key,value的可迭代对象。
exp2 = (('China', 90), ('US', 1))
country_code.update(exp2)
print(country_code)
{'China': 86, 'India': 91, 'Brazil': 55}
{'China': 86, 'India': 91, 'Brazil': 55, 'UK': 77, 'IS': 43}
{'China': 90, 'India': 91, 'Brazil': 55, 'UK': 77, 'IS': 43, 'US': 1}

setdefault是个比较微妙的一个。一旦发挥作用,就可以节省不少次键查询,让程序更高效。

用setdefault处理找不到的键

当字典d[k]不能找到正确的键时,Python会抛出异常,这个行为符合Python所信奉的“快速失败”哲学

也许每个程序员都知道可以用d.get(k,default)来代替d[k],给找不到的值一个默认的返回值。但不管用_getitem_ 还是 get 都不自然,而且效率低。dict.get 并不是处理找不到键的最好方法。

示例3-2 下面这段程序从索引中获取单词出现的频率信息,并将它们写进对应的列表里。

书中提取的txt我没有,所以只能写一下代码但是运行不了。

import sys
import re

WORD_RE = re.compile(r'\w+')

index = {}
with open(sys.argv[1],encoding='utf-8') as fp:
    for line_no, line in enumerate(fp,1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start()+1
            location = (line_no,column_no)
			#	以下3行是一种很不好的实现,这样写只是为了证明论点。
            occurences = index.get(word,[])#	1
            occurences.append(location)	   #	2
            index[word] = occurences	   #	3
            
for word in sorted(index,key=str.upper):   #	4
    print(word,index[word])

运行 python3 index0.py ../../data/zen.txt

1.提取word出现的情况,如果还没有记录就返回[ ]。

2.把单词新出现的位置添加到列表的后面。

3.把新的列表放回字典中,这又涉及到一次查询操作。

14,15,16行是处理单词的3行,通过dict.setdefault可以只用一行解决。示例3-4 更接近Alex Martelli自己举的例子。

示例3-4 index.py 用一行就解决了获取和更新单词的出现情况列表。

import sys
import re

WORD_RE = re.compile(r'\w+')

index = {}
with open(sys.argv[1],encoding='utf-8') as fp:
    for line_no, line in enumerate(fp,1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start()+1
            location = (line_no,column_no)

            index.setdefault(word,[]).append(location)#	1
# 以字母顺序打印出结果            
for word in sorted(index,key=str.upper):
    print(word,index[word])

1.获取单词的出现情况列表,如果单词不存在,把单词和一个空列表放进映射,然后返回这个空列表,这样就能在不进行第二次查找的情况下更新列表了。

也就是说,这样写:

my_dict.setdefault(key,[]).append(new_value)

跟这样写:

if key not in my_dict:
    my_dict[key] = []
my_dict[key].append(new_value)

那么,在单纯地查找取值(而不是通过查找来插入新值)的时候,该怎么处理找不到的键呢?

3.4 映射的弹性键查询

有时候为了方便起见,就算某个键在映射里不存在,我们也希望在通过这个键读取值得时候能得到一个默认值。有两个途径能帮我们达到这个目的,一个是通过defaultdict这个类型而不是普通的dict,另一个是给自己定义一个dict的子类,然后在子类中实现_missing_方法。下面将介绍这两种方法。

3.4.1 defaultdict:处理找不到的键的一个选择

示例3-5 在collections.defaultdict的帮助下优雅地解决了示例3-4里的问题。在用户创建defaultdict对象的时候,就需要给它配置一个为找不到的键创建默认值的方法。

具体而言,在实例化一个default的时候,需要给构造对象提供一个可调用对象,这个可调用对象会在_getitem_碰不到找不到的键的时候被调用,让_getitem_返回某种默认值。

比如,我们新建这样一个字典:dd = defaultdict(list),如果键'new-key'在dd中还不存在的话,表达式dd['new-key']会按照以下的步骤来行事。

  1. 调用list( ) 来建立一个新列表。
  2. 把这个新列表作为值,'new-key'作为它的键,放在dd中。
  3. 返回这个列表的引用。

而这个生成默认值的可调用对象存放在名为default_factory的实例属性里。

def reflect():
    return dict()
dd = defaultdict(reflect)
print(dd.default_factory)
<function reflect at 0x0000029E0D856280>

示例 3-5 利用defaultdict 实例而不是setdefault 方法

import sys
import re
from collections import defaultdict

WORD_RE = re.compile(r'\w+')

index = defaultdict(list)
with open(sys.argv[1],encoding='utf-8') as fp:
    for line_no, line in enumerate(fp,1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start()+1
            location = (line_no,column_no)

            index[word].append(location)

for word in sorted(index,key=str.upper):
    print(word,index[word])

有意思的一点,defaultdict里面的default_factory 只会在 _getitem_里被调用,在其他的方法里完全不会发挥作用。比如dd是个defaultdict,k是一个找不到的键,dd[k]这个表达式会调用default_factory创造某个默认值,而dd.get(k)则会返回None

3.4.2 特殊方法_missing_

所有的映射类型在处理找不到的键的时候,都会牵扯到_missing_ 方法。这也是这个方法称作"missing"的原因。虽然基类dict并没有定义这个方法,但是dict是知道有这么个东西存在的。也就是说,如果有一个类继承了dict,然后这个继承类提供了_missing_方法,那么在_getitem_碰到找不到的键的时候,Python会自动调用它,而不是抛出KeyError异常。

_missing_方法只会被_getitem_调用(比如表达式d[k]中)。提供_missing_方法对get或者_contains_这些方法的使用没有影响。

如果要定义一个映射类型,更合适的策略其实是继承collections.UserDict类。

示例3-7 实现了将非字符串的键转换为字符串的字典

class StrKeyDict0(dict):
    
    def __missing__(self, key):
        if isinstance(key,str):
            raise KeyError(key)
        return self[str(key)]
    
    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(k)返回的是一个存在的键,那么_missing_方法是没问题的,但是如果str(k)不是一个存在的键,那么代码会无限递归。因为self[str(key)]会调用_getitem_,而这个str(key)又不存在,那么又会调用一次missing

为了保持一致性,_contains_也是必需的,因为k in d 这个操作会调用它,但是我们从dict继承来的_contains_方法不会在找不到键的时候调用_missing_方法。_contains_里还要一个细节,就是我们这里没有用更具Python风格的——k in my_dict——来检查键是否存在,因为那样也会导致_contains_循环调用。为了1避免这种情况,这里采取了更显式的方法,直接在这个self.keys()方法里查询。

像 k in my_dict.keys() 这种操作在Python3 中 是很快的,而且即便映射类型对象很庞大也没关系。这是因为dict.keys() 的返回值是一个“视图”。视图就像一个集合,在视图里查找一个元素的速度很快。

3.5 字典的变种

总结了collections模块中,除了defaultdict之外的不同映射类型。

collections.OrderedDict

​ 这个类型在添加键的时候会保持顺序。popitem()有一些特别,前面已经说过了。

Collections.ChainMap

​ 该类型可以容纳数个不同的映射对象,然后在进行键查找操作时候,这些对象会被当成一个整体逐个查找,直到键被找到为止。这个功能在给嵌套作用域的语言做解释器的时候很好用,可以用一个映射对象来代表作用域的上下文。Python变量查询规则的代码片段:

import builtins
from collections import ChainMap

pylookup = ChainMap(locals(),globals(),vars(builtins))

Collections.Counter

​ 这个映射类型会给键准备一个整数计数器。每次更新一个键的时候都会增加这个计数器。所以这个类型可以用来给可散列对象计数,或者是当成多重集来用——多重集合就是集合中的元素可以出现不止一次,Counter实现了 + 和 - 运算符用来合并记录,还有像most_common([n])这类很有用的方法。most_common([n])会按照次序返回映射里最常见的n个键和它们的计数。下面的小例子利用Counter来计算单词中各个字母出现的次数。

from collections import Counter

ct = Counter('abracadabra')
print(ct)
ct.update('aaaaazzz')
print(ct)
print(ct.most_common(2))
Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
Counter({'a': 10, 'z': 3, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
[('a', 10), ('z', 3)]

Collections.UserDict

​ 这个类其实就是把标准dict用纯Python又实现了一遍。UserDict是让用户继承写子类的。下面就来试试。

3.6 子类化UserDict

​ 就创造自定义映射类型来说,以UserDict为基类,总比以普通的dict为基类来得方便。这体现在,我们能改进示例3-7 中定义得StrKeyDict0 类,使得所有键都存储为字符串类型。

​ 而更倾向于从UserDict而不是从dict继承的主要原因是,后者有时会在某些方法上走捷径,导致我们不得不在它的子类中重写这些方法,但是UserDict就不会带来这些问题。

​ 另外一个值得注意的地方,UserDict并不是dict的子类,但是UserDict有一个叫做data的属性,是dict的实例,这个属性实际上是UserDict最终存储数据的地方。这样做的好处是,比起示例3-7 ,UserDict的子类就能在实现_setitem_的时候避免不必要的递归,也能让_contains_里的代码更简洁。

​ 多亏了UserDict,示例3-8里的StrKeyDict的代码比示例3-7里的StrKeyDict0 要短一些,功能却更完善。

示例3-8 无论是添加、更新好还是查询操作,StrKeyDict都会把非字符串的键转换为字符串。

import collections


class StrKeyDict(collections.UserDict):	# 1
    def __missing__(self, key):			# 2
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]

    def __contains__(self, key):
        return str(key) in self.data	# 3

    def __setitem__(self, key, value):
        self.data[str(key)] = value		# 4

3._contains_方法比较简洁了,因为现在键都是字符串形式了,而且不会发生循环调用,所以只用在self.data上查询就好了,并不需要去查询self.keys()

4.setitem 会把所有的键都转换成字符串。

因为UserDict继承的是 MutableMapping,所以 StrKeyDict里剩下的那些映射方法都是从UserDict、MutableMappingMapping 这些超类继承而来。特别是最后的Mapping类,虽然是一个抽象基类(ABC),但是却提供了好几个实用的方法。以下两个方法值得关注。

MutableMapping.update

​ 这个方法不但可以为我们所直接利用,它还可以用在_init_ 里,让构造方法可以利用传入的各种参数(其他映射类型、元素是(key,value)对的可迭代对象和键值对象)来新建实例。因为这个方法在背后是self[key] = value 来添加新值的,所以它其实是在使用_setitem_方法。

Mapping.get

​ 在实例3-7中,我们不得不改写get方法,好让它的表现跟_getitem_一致。而在示例3-8中就没这个必要了,因为继承了Mapping.get方法,继承的方法和示例3-7的get是一模一样的。

有没有不可变的字典类型?在标准库里没有,但是可用替身来代替。

3.7 不可变映射类型

从Python3.3 开始,types模块中引入了一个封装类名叫MappingProxyType。如果给这个类一个映射,它会返回一个只读映射视图。虽然是个只读视图,但是它是动态的。也就是说,如果原映射发生了变化,那么我们可以通过视图看到,但是我们不能通过映射对其进行修改。下面简单演示。

from types import MappingProxyType

d = {1: 'A'}
d_proxy = MappingProxyType(d)
print(d_proxy)
print(d_proxy[1])
#   d_proxy[2] = 'B' 报错
d[2] = 'B'
print(d_proxy)
print(d_proxy[2])
{1: 'A'}
A
{1: 'A', 2: 'B'}
B

3.8 集合论

集合的本质是许多唯一对象的聚集。集合中的元素必须是可散列的。set 类型本身是不可散列的,但是frozenset 可以。因此可以创建一个包含不同 frozensetset

除了保证唯一性,集合还是吸纳了很多基础的中缀运算符。给定两个集合 a 和 b,a | b返回的是它们的合集,a & b得到的是交集,而 a - b得到的是差集。

3.8.1 集合字面量

除空集之外,集合的字面量——{1}、{1,2},等等——看起来跟它的数学形式一模一样。如果是空集,不那么必须写成set() 的形式。

{1,2,3}这种字面量句法相比于构造方法set([1,2,3])要更快且更易读。后者的速度要慢一些,因为Python必须先从set这个方法来查询构造方法,然后新建一个列表,最后再把这个列表传入到构造方法。但是如果是像{1,2,3}这样的字面量,Python会利用一个专门的叫做BUILD_SET的字节码来创建集合。

由于Python里没有针对frozenset的特殊字面量句法,只能采用构造方法。

frozenset(range(10))

3.8.2 集合推导

{chr(i) for i in range(32,256)}

3.8.3 集合的操作

很多。用的时候查就好了。

3.9 dict和set的背后

想要理解Python里字典和集合类型的长处和弱点,背后的散列表是绕不开的一环。

3.9.1 一个关于效率的实验

字典和集合操作很快。

3.9.2 字典中的散列表

散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫做表元(bucket)。在dict的散列表中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,一个是对值的引用。因为所有表元大小一致,所以可以通过偏移量读取某个表元。

因为Python会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。

如果要把一个对象放入散列表,那么首先要计算这个元素值的散列值。Python用*hash()*方法来做这件事情。

1.散列值和相等性

内置的hash()方法可以用于所有的内置类型对象。自定义对象调用hash()的话,实际上运行的是自定义的_hash_。如果两个对象在比较的时候是相等的,那它们的散列值必须相等,否则散列表就不能正常运行了。

散列值在索引空间中尽量分散开,越是相似但不相等的对象,它们的散列值差别应该越大。

从Python3.3 开始,str、bytes 和 datetime对象的散列值计算过程中多了随机的“加盐”这一步。所加的盐是Python进程内的一个常量,但是每次启动Python解释器都会生成一个不同的盐值。随机盐值的加入为了防止DOS攻击而采取的一种安全措施。

CPython的实现细节里有一条是:如果有一个整型对象,而且它能被存进一个机器字中,那么它的散列值就是它本身的值。

2.散列表算法

为了获取my_dict[search_key]背后的值,Python首先会调用hash(search_key)来计算search_key的散列值,把这个值的最低几位数字当作偏移量,在散列表里查找表元(具体查几位,得看当前散列表的大小)。若找到的表元是空的,则抛出KeyError异常。如哦不是空的,则表元里会存在一对found_key:found_value。这时候Python会检验search_key == found_key 是否为真,如果它们相等的话,就会返回found_value.

如果 search_keyfound_key 不匹配的话,这种情况叫 散列冲突。发生这种情况是因为散列表所做的其实只是把随机的元素映射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字的一部分。为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元。若这次找到的表元是空的,则同样抛出KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复上述步骤。

在这里插入图片描述

添加新元素和更新现有键值的操作几乎跟上面一样,只不过对于前者,在发现空表元的时候会放入一个新元素;对于后者,在找到相对应的表元后,原表里的值对象会被替换成新值。

另外在插入新值时,Python可能会按照散列表的拥挤程度来决定是否要重新分配内存为它扩容。如果增加了散列表的大小,那散列值所占的位数和用作索引的位数都会增加,这样做的目的就是为了减少发生散列冲突的概率。

下面来看看dict这些特点背后的原因。

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

1.键必须是可散列的

一个可散列的对象必须满足以下要求。

  1. 支持hash()函数,并且通过_hash_()方法所得到的散列值是不变的。
  2. 支持通过_eq_()方法来检测相等性。
  3. a == b 为真 ,则 hash(a) == hash(b)也为真。

所有由用户自定义的对象默认都是可散列的,因为它们的散列值由id()来获取,而且它们都是不相等的。

如果你实现了一个类的_eq_方法,并且希望它是可散列的,那么它一定要有个恰当的_hash_方法,保证 a==b 为真的情况下,hash(a) == hash(b)也必定为真。否则就会破坏恒定的散列表算法。

另一方面,如果一个含有自定义_eq_依赖的类处于可变状态,那就不要在这个类实现_hash_方法,因为实例是不可散列。

2.字典在内存中的开销巨大

散列表空间效率低下,如果需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择;最好不要根据JSON的风格,用由字典组成的列表来存放这些记录。

用元组取代自带你就能节省空间的原因有两个:其一是避免了散列表耗费的空间,其二是无需把记录中字段的名字在每个元素里都存一遍。

_slots_属性可以改变实例属性的存储方式,由dict变成tuple。相关细节以后再说。

空间优化。

3.键查询很快

无需多言。

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

dict[(key1,value1),(key2,value2)]dict[(key2,value2),(key1,value1)]得到的两个字典,再进行比较的时候,它们是相等的;但是如果在key1 和 key2 被添加到字典的过程中有冲突发生的话,这两个键出现在字典里的顺序是不一样的。(Python3.x 具体是几记不住了,之后的dict都是有序的,完全根据添加顺序

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

字典背后的实现深不可测,添加新键可能会扩容散列表,散列表扩容可能会导致新的散列冲突。对字典同时进行迭代和修改可能会跳过一些键,很危险。如果想扫描修改字典,最后分为两部。

  1. 首先对字典迭代,以得出需要添加的内容,把这些内容存到一个新字典里。
  2. 迭代之后,再对原有字典进行更新。

Python3中,.keys().items() 和 *.values()*方法返回的都是字典视图,不可修改。视图还有动态的特性,可以实时反馈字典的变化。

dict1 = dict([('us', '1'), ('china', '2')])
dict2 = dict([('china', '2'), ('us', '1')])

dict1_keys = dict1.keys()

dict1['India'] = 4
print(dict1_keys)

即使是后续改的字典,之前获取的视图能显示。

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

set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用。特点总结如下:

  1. 集合里的元素必须是可散列的。
  2. 集合很消耗内存。
  3. 可以很高效地判断元素是否存在于某个集合里。
  4. 元素的次序却决于被添加到集合中的次序。
  5. 往集合里添加元素,可能会改变。

3.10 本章小结

setdefault 方法可用来更新字典里存放的可变值(比如list)

3.11 延伸阅读

《Python cookbook》

《代码之美》

简单而正确。

ct1 = dict([(‘us’, ‘1’), (‘china’, ‘2’)])

dict2 = dict([(‘china’, ‘2’), (‘us’, ‘1’)])

dict1_keys = dict1.keys()

dict1[‘India’] = 4
print(dict1_keys)


即使是后续改的字典,之前获取的视图能显示。

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

set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用。特点总结如下:

  1. 集合里的元素必须是可散列的。
  2. 集合很消耗内存。
  3. 可以很高效地判断元素是否存在于某个集合里。
  4. 元素的次序却决于被添加到集合中的次序。
  5. 往集合里添加元素,可能会改变。

3.10 本章小结

setdefault 方法可用来更新字典里存放的可变值(比如list)

3.11 延伸阅读

《Python cookbook》

《代码之美》

简单而正确。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值