dict 和 set 的背后

泛映射类型

collections.abc 中类的继承关系:

在这里插入图片描述

然而,非抽象映射类型一般不会直接继承这些抽象基类,它们会直接对 dict 或是collections.UserDict 进行扩展。

import collections.abc as abc

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

标准库里的所有映射类型都是利用 dict 来实现的,因此它们有个共同的限制,即只有可散列的数据类型才能用作这些映射里的键。
可散列的数据类型,如果一个对象是可散列的:

  • 在这个对象的生命周期中,它的散列值是不变的
  • 这个对象需要实现 __hash__() 方法
  • 可散列对象还要有 __eq__() 方法, 这样才能跟其他键做比较

元组的话,只有当一个元组包含的所有元素都是可散列类型的情况下,它才是可散列的:

tt = (1,2,(30,40))
print(hash(tt))

tl = (1,2,[30,40])
print(hash(tl))  # TypeError: unhashable type: 'list'

创建 dict 的不同方式:

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)

字典推导

字典推导可以从任何以键值对作为元素的可迭代对象中构建出字典:

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)

country_code = {code : country.upper() for country,code in country_code.items() if code < 66}
print(country_code)

常见的映射方法

当字典 d[k] 不能找到正确的键的时候,python 会抛出异常,这个行为符合 python 所信奉的“快速失败”哲学。
可以用 d.get(k, default) 来代替 d[k],给找不到的键一个默认的返回值。

dict = {}
dict.setdefault('name','Bill')
print(dict)  # {'name': 'Bill'}
dict.setdefault('name','Mike')
print(dict)  # 并没有改变,依然是: {'name': 'Bill'}
print(dict["name"]) # Bill
dict["name"] = "Jim"
print(dict["name"]) # Jim

setdefault 给代码带来简洁性:

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

# 等价于:
if key not in my_dict:
    my_dict[key] = []
    my_dict[key].append(new_value)

映射的弹性键查询

有时候为了方便起见,就算某个键在映射里不存在,我们也希望在通过这个键读取值的时候能得到一个默认值。有两个途径能帮我们达到这个目的:

  • 一个是通过 defaultdict 这个类型而不是普通的 dict
  • 另一个是给自己定义一个 dict 的子类,然后在子类中实现 __missing__ 方法

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

在实例化一个 defaultdict 的时候,需要给构造方法提供一个可调用对象,这个可调用对象会在 __getitem__ 碰到找不到的键的时候被调用,让 __getitem__ 返回某种默认值。defaultdict 接受一个工厂函数作为参数,如下来构造:

dict =defaultdict( factory_function)

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

  • 调用 list() 来建立一个新列表
  • 把这个新列表作为值,'new-key' 作为它的键,放到 dd
  • 返回这个列表的引用
    如果在创建 defaultdict 的时候没有指定 default_factory,查询不存在的键会触发 KeyError
    defaultdict 里的 default_factory 只会在 __getitem__ 里被调用,比如,dd 是个 defaultdictk 是个找不到的键,dd[k] 这个表达式会调用 default_factory 创造某个默认值,而 dd.get(k) 则返回None
from collections import defaultdict
 
dict1 = defaultdict(int)
dict2 = defaultdict(set)
dict3 = defaultdict(str)
dict4 = defaultdict(list)
 
print(dict1[1])  # 0
print(dict2[1])  # set()
print(dict3[1])  # ""
print(dict4[1])  # []

print(dict1.get(0)) # None

特殊方法 __missing__

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

__missing__ 方法只会被 __getitem__ 调用,提供 __missing__ 方法对 get 或者 __contains__ 方法的使用没有影响。

class StrKeyDict0(dict):
    def __missing__(self,key):
        # 如果找不到的键本身就是字符串,那就抛出 KeyError 异常
        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()

字典的变种

collections.OrderedDict

collections.OrderedDict 在添加键的时候会保持顺序,因此键的迭代次序总是一致的。

from collections import OrderedDict
od = OrderedDict()
od['name'] = 'egon'
od['age'] = 18
od['gender'] = 'male'
print(od)

# 删除
del od["age"]
# 重新插入,将被置于末尾
od["age"] = 20
print(od)

popitem 方法会删除并返回 (key, value) 对。如果 last 为真,则以 LIFO(后进先出)顺序返回这些键值对,如果为假,则以 FIFO(先进先出)顺序返回:

from collections import OrderedDict
 
od = OrderedDict()

od['k1'] = 'egon'
od['k2'] = 'tom'
od['k3'] = 'jack'

print(od.popitem(last=False)) # ('k1', 'egon')
print(od.popitem(last=True))  # ('k3', 'jack')
print(od.popitem(last=False)) # ('k2', 'tom')

collections.ChainMap

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

from collections import ChainMap

a = {'a': 1, 'b': 2}
b = {'c': 3}
c = ChainMap(a, b)
print(c)
print(c.maps)

collections.Counter

collections.Counter 类型会给键准备一个整数计数器。每次更新一个键的时候都会增加这个计数器。所以这个类型可以用来给可散列表对象计数,或者是当成多重集来用——多重集合就是集合里的元素可以出现不止一次。

from collections import Counter

ct = Counter('abracadabra')
print(ct)
ct.update("aaaaazzzz")
print(ct)
print(ct.most_common(2))

子类化 UserDict

更倾向于从 UserDict 而不是从 dict 继承的主要原因是,后者有时会在某些方法的实现上走一些捷径,导致我们不得不在它的子类中重写这些方法,但是 UserDict 就不会带来这些问题。
UserDict 并不是 dict 的子类,但是 UserDict 有一个叫作 data 的属性,是 dict 的实例,这个属性实际上是 UserDict 最终存储数据的地方。

from collections import UserDict

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
    
    def __setitem__(self,key,item):
        self.data[str(key)] = item

不可变映射类型

如果给 MappingProxyType 一个映射,它会返回一个只读的映射视图。虽然是个只读视图,但是它是动态的。这意味着如果对原映射做出了改动,我们通过这个视图可以观察到,但是无法通过这个视图对原映射做出修改。

from types import MappingProxyType

d = {1:'A'}
d_proxy = MappingProxyType(d)
print(d_proxy)
# 通过 d_proxy 并不能做任何修改
d_proxy[1] = 'B' # 'mappingproxy' object does not support item assignment

# 对 d 所做的任何改动都会反馈到 d_proxy
d[2] = 'B'
print(d_proxy) # {1: 'A', 2: 'B'}

集合论

集合的本质是许多唯一对象的聚集。因此,集合可以用于去重:

l = ['spam', 'spam', 'eggs', 'spam']
s = set(l)
print(s) # {'eggs', 'spam'}

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

假设有两个集合 haystackneedles,寻找公共元素个数:

found = len(needles & haystack)

# 等价于
found = 0
for n in needles:
    for n in haystack:
        found += 1

集合字面量

如果是空集,那么必须写成 set() 的形式:

s = {1}
print(type(s))
print(s) # {1}
s.pop()
print(s) # set()

{1, 2, 3} 这种字面量句法相比于构造方法 (set([1, 2, 3])) 要更快且更易读。后者的速度要慢一些,因为 python 必须先从 set 这个名字来查询构造方法,然后新建一个列表,最后再把这个列表传入到构造方法里。

from dis import dis

print(dis('{1}'))
"""
 1          0 LOAD_CONST               0 (1)
            2 BUILD_SET                1
            4 RETURN_VALUE
"""

print(dis('set[1]'))
"""
 1          0 LOAD_NAME                0 (set)
            2 LOAD_CONST              0 (1)
            4 BINARY_SUBSCR
            6 RETURN_VALUE
"""

集合推导

from unicodedata import name

s = {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')} 
print(s)

集合的操作

在这里插入图片描述

dict 和 set 的背后

一个关于效率的实验

为了对比容器的大小对 dictsetlistin 运算符效率的影响,我创建了一个有 1000 万个双精度浮点数的数组,名叫 haystack。另外还有一个包含了 1000 个浮点数的 needles 数组,其中 500 个数字是从 haystack 里挑出来的,另外 500 个肯定不在 haystack 里。

found = 0
for n in needles:
    if n in haystack:
        found += 1

在这里插入图片描述

在一个有 1000 万个键的字典里查找 1000 个数,花在每个数上的时间不过是 0.337 微秒。

setlist 同时测试了 found = len(needles & haystack):

在这里插入图片描述

字典中的散列表

散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。

dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为
所有表元的大小一致,所以可以通过偏移量来读取某个表元。

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

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

散列值和相等性

内置的 hash() 方法可以用于所有的内置类型对象。如果是自定义对象调用 hash() 的话,实际上运行的是自定义的 __hash__

如果两个对象在比较的时候是相等的,那它们的散列值必须相等,否则散列表就不能正常运行了。

print(hash(1.0002))
print(hash(1.0001))

散列表算法

在这里插入图片描述

my_dict[search_key] 背后的操作原理:

  • 首先会调用 hash(search_key) 来计算 search_key 的散列值,把这个值最低的几位数字当作偏移量,在散列表里查找表元
  • 若找到的表元是空的,则抛出 KeyError 异常
  • 若不是空的,则表元里会有一对 found_key:found_value。这时候 Python 会检验 search_key == found_key 是否为真,如果它们相等的话,就会返回 found_value
  • 如果 search_keyfound_key 不匹配的话,这种情况称为散列冲突
  • 为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元
  • 若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤

dict 的实现及其导致的结果

键必须是可散列的:

  • 支持 hash() 函数,并且通过 __hash__() 方法所得到的散列值是不变的
  • 支持通过 __eq__() 方法来检测相等性
  • a == b 为真,则 hash(a) == hash(b) 也为真

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

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

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

键的次序取决于添加顺序:
当往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。

# 数据元组的顺序是按照国家的人口排名来决定的
d1 = dict(DIAL_CODES) 
print('d1:', d1.keys())
# 数据元组的顺序是按照国家的电话区号来决定的
d2 = dict(sorted(DIAL_CODES)) 
print('d2:', d2.keys())
# 数据元组的顺序是按照国家名字的英文拼写来决定的
d3 = dict(sorted(DIAL_CODES, key=lambda x:x[1])) 
print('d3:', d3.keys())
# 这些字典是相等的,因为它们所包含的数据是一样的
assert d1 == d2 and d2 == d3

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

set 的实现以及导致的结果

字典和散列表的几个特点,对集合来说几乎都是适用的。为了避免太多重复的内容,这些特点总结如下:

  • 集合里的元素必须是可散列的
  • 集合很消耗内存
  • 可以很高效地判断元素是否存在于某个集合
  • 元素的次序取决于被添加到集合里的次序
  • 往集合里添加元素,可能会改变集合里已有元素的次序
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值