流畅的python笔记(三)字典和集合

目录

一、泛映射类型

二、字典构建与字典推导式

三、常见泛映射类型的方法

常见方法

用setdefault处理找不到的键

四、映射的弹性键查询

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

特殊方法__missing__

五、字典的变种

collections.OrderedDict

collections.ChainMap

collections.Counter

collections.UserDict

六、子类化UserDict 

继承UserDict重写StrKeyDict0

MutableMapping.update

Mapping.get

七、不可变映射类型

八、集合论

set & frozenset

集合字面量

集合推导式

集合的操作

九、dict与set的背后

散列表

散列表算法

dict实现导致的后果

set的实现以及导致的结果


一、泛映射类型

collections.abc模块中有Mapping和MutableMapping两个抽象基类,他们的作用是为dict和其它泛映射类型定义形式接口。

可以用isinstance函数来判定某个对象是不是泛映射类型

import collections

my_dict = {}
print(isinstance(my_dict, collections.Mapping))
# 输出True

所谓泛映射类型即键值对类型。泛映射类型中键必须是可散列的,可散列对象有三点要求:

第一:在此对象的生命周期中,其散列值不变

第二:需要实现特殊方法__hash__

第三:要有__qe__方法

        若两个可散列对象相等,则其散列值一定相等。

        原子不可变数据类型,如str、bytes和数值类型都是可散列类型,frozenset也可散列。当元组类型中的元素都是可散列类型时,元组也可散列,如果元组中有不可散列的元素,则元组不可散列。

        一般自定义类型可散列,散列值即id()返回值,即其在内存中的地址。

二、字典构建与字典推导式

字典构建方式有很多种,可以用{}也可以用dict()。

可以用字典推导式来构建字典,基本形式类似列表推导式和生成器表达式。字典推导式可以从任何以键值对为元素的可迭代对象中构建出字典。

如上例子中,用于列表推导式的可迭代对象是一个列表,其元素是含有两个元素的元组,当然这两个元素可以一个用来做键,一个做值。

三、常见泛映射类型的方法

常见方法

用setdefault处理找不到的键

当使用d[k]时如果字典d没有键k,python会抛出异常。可以用d.get(k, default)来代替d[k],这样当找不到键k的时候就返回默认值default。但是当我们的目的不是打印出键k对应的值,而是为了更新值得时候,用get()方法就会显得很不自然,d.get(k, default) = x,相当于给一个默认值default赋值了。因此get()并不是处理找不到的键的最好方法。

        下面看一个例子。

这个例子的作用是获取一个文件中各个单词出现的位置。字典index中的元素是各个单词及其出现位置的映射,由于单词重复出现位置可能有很多个,因此每个单词都映射到一个列表,这个列表中的元素是该单词出现的位置元组。如下所示,Although出现的位置有三个。

明确目的,我们这个例子是为了观察字典的setdefault方法相比get方法在处理不存在的键时优在哪。

        word即单词,location即当次遍历单词出现的位置。首先我们看上图中用get方法时,在标号1处会先进行一次对word的查找,如果word不存在,则标号1处会返回一个空列表关联到occurrences,然后标号2处把word此次出现的位置添加到列表occurrences中,然后在标号3处,实际上又对word进行了一次查找工作,然后把word和occurrences映射存入index。相当于进行了两次键的查询。产生两次查找的主要原因是在标号1处用get方法时只是返回了一个默认的空列表,但是没有把单词和列表之间的映射直接存到index中。

        再来看看用setdefault方法时的代码。只需要把上边标号1 2 3处的三行代码替换成如下一行代码:

index.setdefault在第一次查找word时直接就将 "word" : [ ] 的映射加到index字典中了,因此只要再把location放进空列表中即可,省去了第二次查找。即上边用setdefault的写法跟下边等价:

 如果键不存在,则把键值对加入字典,然后更新键对应的值。

四、映射的弹性键查询

上一节中sefdefault方法主要的用处是在于查找并插入新值,如果只查找的话,希望给不存在的键一个默认的返回值,本节说了两种方法,一是通过一种特殊的字典类型defaultdict,一种是自定义一个dict的子类,并子类中实现特殊方法__missing__。(我寻思直接用get方法也行啊)

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

defaultdict类在collections模块中。

现在用defaultdict类来解决上一节中的问题。如下图所示,只需要在标号1中把原先的字典替换成defaultdict,注意在定义这个类时需要给其构造函数传入一个可调用对象,如图中传递的是list(defaultdict类型的对象有一个对象属性default_factory = list)。则如下所示,如果index[word]的时候特殊方法__getitem__找不到word,则index会默认调用default_factory,下图实例中即调用传递来的可调用对象list会word创造一个默认的值,list函数创造出来的默认值当然就是一个空列表,然后这个空列表被赋值给index[word](本来就是为键word创造出来的值),然后再用append方法就能成功加入location了。

可以看到创建defaultdict的时候一般必须指定default_factory,如果不指定的话,如果查询了一个不存在的键则会触发KeyError。 

        需要注意的是default_factory只在__getitem__中调用,即用index[word]时如果word查不到则会自动调用default_factory,但是如果用index.get(word)时会直接返回None,不会调用default_factory。

        本质上以上机制是由特殊方法__missing__实现的,defaultdict实现了__missing__方法,则方键找不到时会自动调用default_factory,这个特性一般所有映射类型都会去支持。

特殊方法__missing__

映射类型在通过__getitem__找不到键的时候(比如用方括号时d[k]找不到键k),就会自动调用__missing__,但是在其它找键的情况比如get或者__contains__(in运算符底层调用__contains__)找不到键的时候不会调用__missing__,还是会返回异常,即明确__missing__只被__getitem__调用。

        虽然键可以是很多类型,比如数字,字符串等,但是有些情况下我们希望在查询的时候,把键全部转化成str。

        下边这个例子中,虽然字典StrKeyDict0的对象d中的键都是字符串,但是当我们用数值2或者4来查找时还是能找到正确的值。

 下面是StrKeyDict0的定义。

class StrKeyDict0(dict): # StrKeyDict0继承自dict
    def __missing__(self, key):
        if isinstance(key, str):
            raise KeyError(key) # __missing__是用来处理找不到的键的,如果找不到的键本身就是字符串,那么会触发KeyError异常
        return self[str(key)] # 如果找不到的键本身不是字符串,那么就转成字符串再查找一次

    def get(self, key, default=None):
        try:
            return self[key] # get方法这里实际上把查找工作委托给__getitem__了,因为这里用了【】索引
        except KeyError: # 如果抛出了KeyError,那么说明__missing__也失败了,就直接返回default了
            return default

    def __contains__(self, key): # 先按照传入的键来查找,如果找不到就转换成字符串再找一次
        return key in self.keys() or str(key) in self.keys() 

上边定义中可以看到在__missing__中一定要先判断传入的键是不是字符串,如果是字符串还找不到,那就直接抛出异常。这样做是为了防止无限递归调用。试想如果__missing__中只有

return self[str(key)] 这一行,那么当StrKeyDict0对象传入一个键key,就会调用__getitem__方法,__getitem__找不到这个键,就会调用__missing__,__missing__中返回self[str(key)]又会调用__getitem__,这样就会导致无限递归调用了。

        还有__contains__中的实现,直接判定key或者str(key)是否在self.keys()中,而不是用 k in my_dict这种python风格的代码,因为 in 运算符会调用__contains__,因此如果那样用的话又会形成无限递归调用。

五、字典的变种

标准库模块collections中,除了defaultdict外还有很多不同的字典类型。

collections.OrderedDict

顾名思义,有序字典,但是这个有序不是说按照键的字典序排序之类的,而是说保持插入顺序,即在迭代的时候,先插入的就先访问到,后插入的就后访问到。OrderedDict的popitem方法默认删除并返回插入的最后一个元素,但是如果popitem(last=Falst)则删除并返回第一个插入的元素。

        普通字典是没办法保存插入顺序的。

collections.ChainMap

没用到过,用到再说。

collections.Counter

用到再说。

collections.UserDict

就是把标准dict用纯python又实现了一遍

六、子类化UserDict 

继承UserDict重写StrKeyDict0

collections.UserDict是专门用于自定义映射类型的基类。

 继承自UserDict主要是为了方便,本例中对于__missing__的实现跟上例一样没有改变,因为当__getitem__找不到键的时候一定会调用__missing__,所以if语句不能避免,否则还是会无线递归。

        __contains__的实现直接判断key字符串之后是否在self.data中,这里的self.data实质上是一个dict类型的字典对象,是UserDict的一个属性,也是UserDict真正存储数据的地方。 这里就可以直接用python风格判断str(key)是不是在self.data中,不用担心递归调用的问题,因为self.data找不到键会调用自己的__contains__,而不是我们自定义类的__contains__。

        __setitem__把所有键都转换成了字符串,这样在查询时候和储存时的键就都是字符串了。    

        UserDict不是从dict继承,而是继承自MutableMapping。MutableMapping又继承自Mapping,Mapping是一个抽象基类,但是提供了好几个使用方法。如下所示。

MutableMapping.update

此方法不但能直接用,还默认用在__init__中,当构造函数中传入各种参数(键值对序列就行)时会用这个方法新建实例。这个方法用self[key] = value来添加新值,本质上是在使用__setitem__方法。

Mapping.get

其源码实现跟StrKeyDict一样。目的是为了让get的行为跟__getitem__保持一致。

七、不可变映射类型

标准库中的所有映射类型都是可变的。types模块中有一个封装类型MappingProxyType,其初始化参数接受一个映射对象,返回一个只读的映射视图,这个映射视图是动态的,这个映射视图不能更改,但是原初始化参数的更改可以反应到该映射视图上。

        如下图例子,用MappingProxyType返回一个字典d的映射视图d_proxy,d_proxy不能更改,但是更改d的话可以反映到d_proxy上。 

八、集合论

set & frozenset

在python中集合类主要是set和frozenset。

集合的本质是许多唯一对象的集合,因此集合可以用于去重。且因为集合的背后是散列表,因此查询速度极快。

集合中的元素必须可散列,如果我们要定义嵌套集合对象,里边的集合不能是set对象,因此set对象是不可散列的,但是frozenset是可散列的,因此可以创建包含不同frozenset的set。

       集合实现了交(a & b)、并(a | b),差(a - b)等操作。

集合字面量

定义集合有两种方式。

        一种是直接用{ }来定义,如 s = {1, 2, 3}。

        一种是用set([1, 2, 3])。

        前者比后者要快一点,因为用set()定义的时候,python会先去查询set的构造函数,然后新建一个列表,最后把列表传入到构造函数里,但是直接用 { }的话python会利用一个专门的叫做BUILD_SET的字节码来创建集合。

        需要注意的一点是空集合的定义,应该用 s = set() 而不是 s = { },因为后者实际上是定义了一个空字典。

        frozenset只能用构造函数来定义。

集合推导式

学了前边的列表推导式和生成器表达式、字典推导式等,再看集合推导式就是水到渠成了。 

集合的操作

 

 

 

 

 

 

九、dict与set的背后

散列表

散列表是一个稀疏数组,其中的单元叫做表元(bucket),在dict中,每个键值对占用一个表元,每个表元分为两个部分,一个是对键的引用,一个是对值的引用。python会保证三分之一的表元是空的,因此快达到这个阈值的时候,原有散列表会被复制到一个更大的空间中去。散列表中每个表元的大小都是一样的,因此可以根据键的散列表来确定一个偏移量,通过偏移量来定位到某个表元。

        要把一个对象放入散列表,首先要计算这个元素键的散列值,python中用hash()方法实现。

        内置hash()方法可以用于所有的内置类型对象,如果对自定义类型对象调用hash(),则实际运行的是自定义的特殊方法__hash__,在这个特殊方法里边应该还是计算内置类型的hash吧。如果两个对象在比较的时候是相等的,那么他们的散列值必须相等。如果 1 == 1.0为真,那么hash(1) == hash(1.0)也必须为真。

        为了让散列值能够充当散列表索引,散列值在索引空间中应该尽量分散开来。最理想的情况下越是相似但不想等的对象,其散列值差别应该越大。

散列表算法

  1. 给定一个search_key,为了获取其对应的值my_dict[search_key]
  2. python首先调用hash(search_key)计算键search_key的散列值,以这个值的最低几位数字(位数跟当前散列表大小有关,散列表越大,需要的位数越多)作为偏移量,在散列表中查找表元。
  3. 若找到表元为空,则返回KeyError,即不存在该键search_key
  4. 若找到表元非空,则表元中存储了两个引用,found_key和found_value。检验search_key == found_key是否为真,如果为真则返回found_value。如果为假,则发生了散列冲突
  5. 散列冲突是因为两个键的散列值的最后几位相等,因此造成在散列表中偏移量相同。解决散列冲突有很多种方法,一般可以在散列值上再取几位,然后处理一下得到新的偏移量再次寻找表元。

        如果是插入新值而非简单查找,则python会根据散列表的拥挤程度决定是否扩容,扩容以后散列表增大,则散列值位数与用作索引的位数都会增加,这样做可以减少发生散列冲突的概率。值得注意的是散列冲突并不经常发生,是很偶然的事件。

dict实现导致的后果

  1. 键必须是可散列的。可散列对象要满足三个条件:(1)支持hash()函数,并且通过__hash__方法得到的散列值是不变的。(2)支持通过 __eq__方法来检测相等性。(3)若a == b 为真,则hash(a) == hash(b)也为真。
  2. 字典在内存上开销巨大。字典使用散列表,而散列表必须是稀疏的,因此空间效率低下。要节省空间可以用元组取代字典,优化原因有两个:其一是避免了散列表所耗费的空间,其二是无需把记录中字段的名字在每个元素里存一遍。在用户自定义的类中,通过__slots__属性可以改变实例属性的存储方法,由dict变成tuple。
  3. 键查询很快。dict实现是典型空间换时间,无视数据量大小常数时间复杂度访问。
  4. 键的次序取决于添加顺序。当往字典里添加新键,而且发生散列冲突,则新键会被安排到另一个位置。于是当两个键被添加到字典中的顺序不同,虽然得到的是相同的字典,但是这两个键出现在字典中的顺序是不一样的。
  5. 往字典里添加新键会改变已有键的顺序。无论何时往字典里添加新键,python解释器都可能为字典扩容,导致新建一个更大的散列表,这个过程可能发生新的散列冲入,导致新散列表中键的次序变化。因此不要在循环字典的时候同时修改,因为修改以后字典中键的顺序可能已经变了,因此可能循环会跳过一些键。

set的实现以及导致的结果

set和frozenset也是基于散列表实现的,知识其表元中只存储有键的引用,没有值的引用。

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值