《Fluent Python》- 03 字典和集合

字典这个数据结构活跃在所以Python程序背后,即便你没有直接用到它

泛映射类型

非抽象映射类型通常是不会继承抽象基类的。它们会直接对dict或者是collection.UserDict进行扩展。而抽象基类通常的作用是形式化的文档,它们还可以跟isinstance一起被用来判断某个数据是不是广义上的映射类型。

这里用isintance而不是type来检查,是因为这个参数有可能不是dict。

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

标准库里的所有映射都是利用dict来实现的,英雌有个限制,只有可散列的数据类型才能用作这些映射里的键。

关于可散列:如果一个对象是可散列的,那么在这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现__hash__()方法。另外可散列对象还要有__eq__()方法,这样才能和其他键做比较。

关于Python中“Python里所有的不可变类型都是可散列的”这句话其实是不太准确的,比方说元祖,它本身是不可变的,但是内部元素却可能是可变的。

字典推导

自Python2.7以来,列表推导和生成器表达式的概念就移植到了字典上,从而有了字典推导。字典推导可以从任何以建值对作为元素的可迭代对象中构建出字典。

DIAL_CODES = [(86, 'China'),
              (91, 'India')]
country_code = {country:code for code, country in DIAL_CODES}  # 把DIAL_CODES内容反一下

用setdefault处理找不到的键

当字典d[k]不能找到正确的键的时候,Python会抛出异常,这个行为符合Python所信奉的“快速失败”哲学。也许每个Python程序员都知道可以用d.get(k,default)来替代d[k],给找不到的k一个返回默认值。但这不是处理找不到键最好的方法。采用setdefault处理:

index = {}
word = "location"
location = "China"
index.setdefault(word, []).append(location)  
# 如果word不存在就会创建一个空列表进去,简单来说就是创建了  word-[] 这样的键值对,所以之后的append是可以直接追加的,因为这时其实word已经出现了
# 如果word存在的话,就直接append,index.setdefault(word, []).append(location)  这种写法等价替代于:
# if key not in my_dict:
#     my_dict[key] = []
# my_dict[key].append(new_value)
print(index)  #  {'location': ['China']}

映射的弹性键查询

如果某个键在映射里不存在,我们也希望在通过这个键读取值的时候能得到一个默认值。通常有两种方法:

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

# defaultdict,如果查找不存在的key会自动生成 key - 'list'(你所定义的类型)
# 但是如果一开始没有指定类型,查找不存在的key则会报错
dd = defaultdict(list)
res = dd.get(list) # 返回none
print(res)
ans = dd["new"] # 返回空列表
print(ans)

如果在创建defaultdict的时候没有指定default_factory,查询不存在的键会触发KeyError

特殊方法__missing__

class StrKeyDict(dict):  # 继承dict类

    def __missing__(self, key): # d[k] 找不到会走这个函数
        if isinstance(key, str):  # 如果key是字符串且又没有找到,抛出KeyError
            raise KeyError(key)
        return self[str(key)]  # 如果不是字符串就转成字符串再找

    def get(self, key, default = None):
        try:
            return self[key]  # 把get改造成了self[key],如果找不到会走missing
        except KeyError: # 说明确实没有
            return default # 返回默认值

    def __contains__(self, key): # k in d 这个操作会调用它
        return key in self.keys() or str(key) in self.keys() # 先按原来的找,找不到再转成str找

字典的变种

说一些其它不同映射类型

collection.OrderedDict

这个类型在添加键的时候会保持顺序,因此键的迭代次序是一定的。其中的popitem默认返回并删除最后一个元素但是如果my_order.popitem(last = False)这样去用它的话,则会返回并删除第一个元素。

collection.ChainMap

容纳数个不同的映射对象,然后在进行键查找操作的时候,这些对象会被当做一个整体逐个查找,直到键被找到。

collection.Counter

这个映射类型会给键准备一个整数计数器,简单来说就是方便统计

nums = [9, 8, 2, 2, 6, 9, 4, 9, 7, 4, 5, 2, 5, 9, 6, 8, 6, 2,
                     5, 9, 6, 9, 5, 5, 7, 5, 1, 3, 1, 1, 9, 9, 1, 6, 2, 8,
                     8, 2, 5, 2, 2, 7, 9]
nums_str = ""
for num in nums:
    nums_str += str(num)
print(nums_str)
ct = collections.Counter(nums_str)
print(ct) # Counter({'9': 9, '2': 8, '5': 7, '6': 5, '8': 4, '1': 4, '7': 3, '4': 2, '3': 1})

子类化UserDict

创建自定义映射类其实通常以UserDict为基类。

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

# 继承UserDict重写StrKeyDict
# UserDict并没有继承dict,而是封装了一个dict的实例
class StrKeyDict(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 # 这些操作其实都是交给UserDict去完成的

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

对比于我们之前写的StrKeyDict类,这次写的就比较简单,清爽。

不可变映射类型

从Python3.3开始,types模块中引入了一个封装类名叫MappingProxyType。如果给这个类一个映射,它会返回一个只读的映射视图,虽然是个只读视图,但它是动态的。简单来说就是你改了原来的,这个也会跟着变。但是只读就意味着不能对其修改。

d = {1:'A'}
d_proxy = MappingProxyType(d)
print(d_proxy)
# d_proxy[2] = 'x' 会报错
d[2] = 'B'
print(d_proxy) # d_proxy也会跟着变

集合论

“集”这个概念在Python中算是比较年轻的,使用率也不高。通常所说的集以及集合代指set或者frozenset。

集合是许多唯一对象的聚集,因此集合可以去重。

address1 = ['BeiJing', 'Tokyo', 'NewYork', 'NewYork']
address1_set = set(address1) # set 去重,无序,概念上和数据结构里的大同小异  frozenset 不可变set
print(address1_set)
address2 = ['Tokyo', 'Paris', 'London']
address2_set = set(address2)
print(address1_set | address2_set) # | 取并集  & 取交集  - 取差集
str_set = {'a', 'a'}  # 简单构造set,使用{} 会自动去重
print(str_set)

集合推导

前面说了字典推导,这里在简单了解一下集合推导

un_set = {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i), '')}  #  把32-255之间的字符其中包含 ‘SIGN’ 的取出来
print(un_set)

关于集合就说到这里,还有一些其他关于集合的操作方法,其实就是离散数学里对集合的操作罢了,这个就查阅官方文档吧。

dict和set的背后

主要解决以下几个问题:

Python里dict和set的效率有多高

为什么它们是无序的

为什么并不是所有的Python对象都可以当做dict的键或者set里的元素

为什么dict的键和set元素的顺序是根据它们被添加的次序而定的,以及为什么在映射对象的生命周期里这个顺序并不是一成不变的

为什么不应该在迭代循环dict或是set的同时往里添加元素

其实只有你懂数据结构,这些问题即使你不懂Python也能回答,因为精髓是一样的。

我们一点一点处理吧,为什么dict和set效率高,因为它们是O(1)的复杂度,这个是牺牲空间换来的,为什么这么说,因为这个和散列表有关系。

散列表其实是一个稀疏的数组,这里先拿Java举个例子,Java中的散列表中有个loadFactor的装载因子,这个值为0.75,也就是说,创建16个元素的表,最多只能容纳12个元素,当13个元素添加时,它就会扩容了。而在Python中这个因子大概是0.66,大体上是保证1/3是空的。

关于散列有一个很重要的关系,就是散列值和相等性,必须要满足的条件是,如果值相同,那么散列值必须相同,散列值不同,值必须不同,但是散列值相同,可以让值不同。尤其要注意一下,如果1.0=1 那么 hash(1.0)也就必须等于hash(1)即便它们内部构造不同。

接着来说散列表中的散列算法。当然这个可能会因为不同的语言或者其他而不同,在Python中,首先会调用hash(search_key)来计算search_key的散列值,把这个值最低的几位数字当作偏移量,在散列表里查找表元(具体取几位,得看当前散列表的大小)

当然这里就会发生一个问题?如果不同对象产生相同的散列值怎么办,这个就被称为散列冲突。这个时候就要用特殊方法处理,在Java中是一种拉链法去处理的,简单来说就是在这个hash值的位置向下组成链表,当然这有个链表过长的问题,当一定程度长的时候,Java会把它转成红黑树。扯远了,在Python中则是会在散列值中另再取几位,然后用特殊方法处理一下,把新得到的数字再当做索引来寻,这个特殊方法通常又被称为扰动函数。如果发现还是冲突就继续上述步骤。(这也就能解释为什么Python中的负载因子会比较低,如果太高的话,会出现反复计算的情况)

现在我们来解决最开始的问题:

为什么是无序的,因为它是散列的,位置肯定不是顺序的。

为什么不是所有对象都可以用,因为如果拿可变对象当key,会出现key的散列值变化的问题,也就是可能出现找不到的问题,明明就存在却找不到。

第四个问题,感觉有点绕,这里有两个问题,第一个为什么dict的键和set元素的顺序是根据它们被添加的次序而定的(注意,这里的次序并非是顺序),因为存在冲突,如果k1和k2的散列值是一样的,那么先存k1就会存在这个位置,而k2就会重新散列,反过来,如果先存k2,那么位置就会倒过来。为什么在映射对象的生命周期里这个顺序并不是一成不变的,因为有扩容的存在,在Python中扩容是重新开一个空间,然后复制过去,所以可能会发生变化(散列表变大了,它的散列函数是和散了表长度有关的)。

最后一个问题,为什么不应该在迭代循环dict或是set的同时往里添加元素,因为如果你在迭代的过程中添加,就可能产生扩容问题,就可能导致漏元素问题。(这里是指迭代dict,而并非是在循环中使用dict,注意理解上不要有偏差)

杂谈(非正式向)

其实上一次我也有说过,数据结构的重要性,这一节主要的核心部分其实是散列表,如果你理解散列表,其实你很快就能解决最后那5个问题。这再一次充分说明了数据结构的重要性。当然其实在实际的开发中,你可能一辈子都不会去自己写个散列表。但如果有天你要改动时,你就能充分明白可改的位置,以及怎么改,而且也能帮助你更好的学习和充分使用这些底层采用散列表的数据结构。

猜你喜欢

转载自www.cnblogs.com/Moriarty-cx/p/12447311.html