• 深入字典


    一、可散列的数据类型

    字典至关重要,Python 对它的实现做了高度优化,而散列表则是字典类型性能出众的根本原因。集合(set)的实现其实也依赖于散列表,那么什么是可散列的呢?
    标准库里的所有映射类型都是利用 dict 来实现的,因此它们有个共同的限制,即只有可散列的数据类型才能用作这些映射里的键(只有键有这个要求,值并不需要是可散列的数据类型)
    如果一个对象是可散列的,那么在这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现 hash() 方法。另外可散列对象还要有 qe() 方法,这样才能跟其他键做比较。如果两个可散列象是相等的,那么它们的散列值一定是一样的。
    原子不可变数据类型(str、bytes 和数值类型)都是可散列类型,frozenset 也是可散列的,因为根据其定义,frozenset 里只能容纳可散列类型。元组的话,只有当一个元组包含的所有元素都是可散列类型的情况下,它才是可散列的。来看下面的元组tt、tl 和 tf:

    >>> tt = (1, 2, (30, 40))
    >>> hash(tt)
    8027212646858338501
    >>> tl = (1, 2, [30, 40]) # 在这里元组tl中包含其他的不可散列的数据结构list
    >>> hash(tl)
    Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    TypeError: unhashable type: 'list'
    >>> tf = (1, 2, frozenset([30, 40]))
    >>> hash(tf)
    -4118419923444501110
    
    

    上面例子得知,“python中所有的不可变数据类型都是可散列的”这种说法是错误的,因为像元组中可以包含对其他可变数据类型的引用。
    一般来讲用户自定义的类型的对象都是可散列的,散列值就是它们的 id() 函数的返回值,所以所有这些对象在比较的时候都是不相等的。如果一个对象实现了 eq 方法,并且在方法中用到了这个对象的内部转状态的话,那么只有当所有这些内部状态都是不可变的情况下,这个对象才是可散列的。

    二、字典的构建方式

    1、 常用构建方法

    >>> 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
    

    2、 字典推导式

    我们知道列表是可以通过推导式生成的

    >>>ls = [i for i in range(3)]
    >>>ls
    [1, 2, 3]
    

    在python中像字典和集合也是可以通过推导生成的

    dict_list = [
        (1, 'a'),
        (2, 'b'),
        (3, 'c')
    ]
    my_dict = {num: mystr for num, mystr in dict_list}
    print(my_dict)
    {1: 'a', 2: 'b', 3: 'c'}
    

    三、dict查询

    1、 dict内部方法

    我们可以通过python的自省机制dir(dict)来查询到dict的内部方法
    也可dict.mro查询到dict到底继承于谁
    来看一下常见的内部方法的用途

    常用方法 用途
    d.clear() 移除所有元素
    d.contains(k) 查询是否包含k元素
    d.delitem(k) 删除键k,并删除相应的映射
    d.get(k, None) 查找键k并返回相应的映射,如果没有返回None
    d.getitem(k) 让字典 d 能用 d[k] 的形式返回键k 对应的值
    d.missing(k) getitem 找不到对应键的时候,这个方法会被调用
    d.items() 返回 d 里所有的键值对
    d.keys() 获取所有的键
    d.values() 返回字典里的所有值
    d.setitem(k, v) 实现 d[k] = v 操作,把 k 对应的值设为v
    d.setdefault(k,[default] 若字典里有键k,则把它对应的值设置为 default ,然后返回这个值;若无,则让 d[k] =default ,然后返回 default
    d.popitem() 随机返回一个键值对并从字典里移除它
    d.move_to_end(k,[last]) 把键为 k 的元素移动到最靠前或者最靠后的位置( last 的默认值是 True
    d.update(m,[**kargs]) m 可以是映射或者键值对迭代器,用来更新 d 里对应的条目

    注:

    • dict的亲戚有序字典OrderDict,OrderedDict.popitem() 会移除字典里最先插入的元素(先进先出);同时这个方法还有一个可选的 last 参数,若为真,则会移除最后插入的元素(后进先出)
    • update 方法处理参数 m 的方式,是典型的“鸭子类型”。函数首先检查 m 是否有 keys 方法,如果有,那么 update 函数就把它当作映射对象来处理。否则,函数会退一步,转而把 m 当作包含了键值对 (key, value) 元素的迭代器

    当字典 d[k] 不能找到正确的键的时候,Python 会抛出异常,这个行为符合 Python 所信奉的“快速失败”哲学。也许每个 Python 程序员都知道可以用 d.get(k, default) 来代替 d[k],给找不到的键一个默认的返回值(这比处理 KeyError 要方便不少)。但是要更新某个键对应的值的时候,不管使用 getitem 还是 get 都会不自然,而且效率低,dict.get 并不是处理找不到的键的最好方法

    2、setdefault处理查不到的键

    示例一

    dict_list = [
        (1, 'a'),
        (2, 'b'),
        (3, 'c')
    ]
    my_dict = {num: mystr for num, mystr in dict_list}
    my_dict.get(4, 'd') # 当查询不到4这个键的时候,会返回一个‘d’
    my_dict.get(4, 'd') 
    my_dict[4] = 'd'
    print(my_dict) # output{1: 'a', 2: 'b', 3: 'c', 4: 'd'}
    

    上述例子不会抛出异常,但能做的仅此而已先查询,在追加而已,下面用setdefault

    my_dict.setdefault(4,'d')
    

    效果是一样的,但是对于后者来说,可以减少字典的查询次数

    实例二、:查看一个单词在文件中的出现情况

    """创建从一个单词到其出现情况的映射"""
    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) 
    # 以字母顺序打印出结果
    for word in sorted(index, key=str.upper):
        print(word, index[word])
    

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

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

    跟这样写:

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

    效果是一样的,只不过后者至少要进行两次键查询——如果键不存在的话,就是三次,用 setdefault 只需要一次就可以完成整个操作

    3、defaultdict处理查询不到的键

    某个键在映射里不存在,我们也希望在通过这个键读取值的时候能得到一个默认值。有两个途径能帮我们达到这个目的,一个是通过 defaultdict 这个类型而不是普通的 dict,另一个是给自己定义一个 dict 的子类,然后在子类中实现 missing 方法。
    在实例化一个 defaultdict 的时候,需要给构造方法提供一个可调用对象,这个可调用对象会在 getitem 碰到找不到的键的时候被调用,让 getitem 返回某种默认值。
    我们新建了这样一个字典:dd = defaultdict(list),如果键 'new-key' 在 dd 中还不存在的话,表达式 dd['new-key'] 会按照以 下的步骤来行事。
    1 调用 list() 来建立一个新列表。
    2 把这个新列表作为值,'new-key' 作为它的键,放到 dd 中。
    3 返回这个列表的引用。

    import collections
    dc = collections.defaultdict(list)
    dc['c'].append('C')
    dc # defaultdict(list, {'c': ['C']})
    

    4、__missing__方法

    所有的映射类型在处理找不到的键的时候,都会牵扯到 missing__方法。这也是这个方法称作“missing”的原因。虽然基类 dict 并没有定义这个方法,但是 dict 是知道有这么个东西存在的。也就是说,如果有一个类继承了 dict,然后这个继承类提供了 missing 方法,那么在 getitem 碰到找不到的键的时候,Python 就会自动调用它,而不是抛出一个 KeyError 异常。
    missing 方法只会被 getitem 调用(比如在表达式 d[k] 中)。提供 missing 方法对 get 或者__contains
    (in 运算符会用到这个方法)这些方法的使用没有影响。

    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() 
        
    

    上面的类继承了Dict,但是有增加__missing__魔法函数
    1 StrKeyDict0 继承了 dict。
    2 如果找不到的键本身就是字符串,那就抛出 KeyError 异常。
    3 如果找不到的键不是字符串,那么把它转换成字符串再进行查找。
    4 get 方法把查找工作用 self[key] 的形式委托给 getitem,这样在宣布查找失败之前,还能通过 missing 再给某个键一个机会。
    5 如果抛出 KeyError,那么说明 missing 也失败了,于是返回default。
    6 先按照传入键的原本的值来查找(我们的映射类型中可能含有非字符串的键),如果没找到,再用 str() 方法把键转换成字符串再查找一次。
    在上面的例子中,如果在__missing__没有instance判断key的类型,当key不是一个str(key)的键,代码就会无限递归。在进入get函数,return self[str(key)],进入getitem,查询失败,会进入__missing__函数,如果没有instance判断,则直接return self[str(key)],会接着查询调用getitem函数,这样持续查询,不会raise KeyError。所以instance的判断是必须的。
    contains 方法在这里也是必需的。这是因为 kin d 这个操作会调用它,但是我们从 dict 继承到的 __contains__方法不会在找不到键的时候调用 missing 方法。__contains__里还有个细节,就是我们这里没有用更具 Python 风格的方式——k inmy_dict——来检查键是否存在,因为那也会导致 contains 被递归调用。为了避免这一情况,这里采取了更显式的方法,直接在这个self.keys() 里查询。
    在python2中dict.keys()返回是一个列表
    python3中的dict.keys()返回的像是一个视图,更像是一个集合,而像集合列表背后是散列表,典型的以空间换时间,查询会很快。

    5、字典的亲戚

    这些映射类型像defaultdict一样存在collections模块中

    (1)、 collections.OrderedDict

    这个类型在添加键的时候会保持顺序,因此键的迭代次序总是一致的。OrderedDict 的 popitem 方法默认删除并返回的是字典里的最后一个元素,但是如果像 my_odict.popitem(last=False) 这样调用它,那么它删除并返回第一个被添加进去的元素。

    (2)、collections.ChainMap

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

    (3)、collections.Counter

    这个映射类型会给键准备一个整数计数器。每次更新一个键的时候都会增加这个计数器。
    示例

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

    四、不可变的映射类型

    标准库里所有的映射类型都是可变的,但是如果遇到这样的情况呢:不能修改映射
    python中的模块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'
    

    在上面可以看到通过映射类MappingProxyType可以实现查看字典的内容,但是无法通过MappingType修改字典,而d的修改都会反馈到d_proxy上面

    五、字典的背后

    字典和集合类型都有优缺点,这跟背后的散列表有很大原因

    1、效率问题

    字典和集合类型在做查询的时候是非常快的,《fluent python》的作者做了这么一个实验大概测试了这一点>为了对比容器的大小对 dict、set 或 list 的 in 运算符效率的影响,我创建了一个有 1000 万个双精度浮点数的数组,名叫 haystack。另外还有一个包含了 1000 个浮点数的 needles 数组,其中 500 个数字是从haystack 里挑出来的,另外 500 个肯定不在 haystack 里。>

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

    使用上述代码进行测试,将haystack的长度由1000到1000万增长系数10,增长时间由0.000202s 到0.000337s不等
    同样对于set类型,结论相似,对于list类型,消耗的时间却又0.01秒甚至到100秒
    所以对于无论多大的数据量,只要是字典或者集合,查找消耗的时间都可以忽略。

    2、字典中的散列表

    散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组)。在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。在 dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。
    因为 Python 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面,如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值。Python 中可以用 hash() 方法来做这件事情。

    3、散列值和相等性

    内置的 hash() 方法可以用于所有的内置类型对象。如果是自定义对象调用 hash() 的话,实际上运行的是自定义的 hash。如果两个对象在比较的时候是相等的,那它们的散列值必须相等,否则散列表就不能正常运行了。例如,如果 1 == 1.0 为真,那么hash(1) == hash(1.0) 也必须为真,但其实这两个数字(整型和浮点)的内部结构是完全不一样的。

    hash(1) # 1
    hash(1.0) # 1
    

    如果一个对象是整形对象,通常散列值是本身。
    为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来。这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大。注意其中 1和 1.0 的散列值是相同的,而 1.0001、1.0002 和 1.0003 的散列值则非常不同。

    hash(1.0001) #230584300921345
    hash(1.0002) #461168601842689
    hash(1.0003) #691752902764033
    

    4、散列表算法

    (1)、查找时

    为了获取 my_dict[search_key] 背后的值,Python 首先会调用hash(search_key) 来计算 search_key 的散列值,把这个值最低的几位数字当作偏移量,在散列表里查找表元(具体取几位,得看当前散列表的大小)。若找到的表元是空的,则抛出 KeyError 异常。若不是空的,则表元里会有一对 found_key:found_value。这时候 Python 会检验 search_key == found_key 是否为真,如果它们相等的话,就会返回 found_value。
    如果 search_key 和 found_key 不匹配的话,这种情况称为散列冲突。发生这种情况是因为,散列表所做的其实是把随机的元素映射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字的一部分。为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元。若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤。

    (2)、添加元素时

    添加新元素和更新现有键值的操作几乎跟上面一样。只不过对于前者,在发现空表元的时候会放入一个新元素;对于后者,在找到相对应的表元后,原表里的值对象会被替换成新值。
    另外在插入新值时,Python 可能会按照散列表的拥挤程度来决定是否要重新分配内存为它扩容。如果增加了散列表的大小,那散列值所占的位数和用作索引的位数都会随之增加,这样做的目的是为了减少发生散列冲突的概率。

    事实上散列冲突发生的可能性只有百万分之一左右,所以完全不用担心

    六、dict背后的问题

    既然dict使用散列表支撑的,那么又会带来哪些问题呢

    1、对于字典的键必须是可散列的

    所以字典的键必须支持以下几个条件

    • 支持 hash() 函数,并且通过 hash() 方法所得到的散列值是不变的
    • 支持通过 eq() 方法来检测相等性
    • 若 a == b 为真,则 hash(a) == hash(b) 也为真。
      :对于用户自定义的l类:
    • 通常是不需要自定义__eq__,和__hash__等方法,所有由用户自定义的对象默认都是可散列的,,因为它们的散列值由id() 来获取,而且它们都是不相等的
    • 如果__eq__方法,一定要实现一个__hash__方法,保证ab的情况下,hash(a)hash(b)为真,否则定义的对象就是不稳定的,违背了散列表算法。

    2、内存的巨大开销

    字典是典型的以空间换时间的类型,虽然查询很快,但是耗费内存。使用了散列表,而散列表又是稀松数组,空间上的效率低,十分浪费

    3、字典中键的顺序出错

    • 往 dict 里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置。但是相同的键添加到字典中的时候,比较的时候仍然是相等的。
    • 无论何时往字典里添加新的键,Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。

    这样隐含的一个问题是:如果在迭代字典的时候,同时往字典中增加元素会出现什么?
    很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。因为你在添加元素的时候仍然会出现散列冲突,一旦这样,就可能在遍历的时候将发生冲突的键跳过。
    所以,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:
    1 首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里
    2 迭代结束之后再对原有字典进行更新

    下一篇set

  • 相关阅读:
    谈谈站桩
    mysql开发之---使用游标双层嵌套对总表进行拆分为帖子表和回复表
    【Xcode学C-3】if等流程控制、函数的介绍说明标记分组、#include以及LLVM
    hdu5303(2015多校2)--Delicious Apples(贪心+枚举)
    Hadoop最大值的算法中出现的错误(strToDouble)
    利用管道进行通信
    HDU 5308 规律+模拟
    深入浅出理解排序算法之-选择排序
    Jscript 随记
    SharedPreferences具体解释(一)——基础知识
  • 原文地址:https://www.cnblogs.com/welan/p/9612804.html
Copyright © 2020-2023  润新知