流畅的python:集合

第三章 字典与集合(下)


接上一篇: 流畅的python:字典

我们高中数学的第一节课就是集合,所以你对他不可能会陌生。python中除了内置的集合set外,还有另一个不可变的姊妹类型frozenset,接下来我们就看看她们。

1. 集合

  • 基本定义

集合的本质是许多唯一对象的聚集。因此,集合可以用于去重,就算你给了她重复的元素,她也不会仅保留其中的一个:

s = {'1','2','2'}
# s = {'1', '2'}

而且集合中的元素必须是可散列的,或者说是可哈希的:

s={'1','2',[1,2]}

像上面存在不哈希的元素,会报出TypeError: unhashable type: ‘list’,真是可散列的存在保证了集合快速的查找功能。

还有一点需要注意的是,如果要创建一个空集合,必须使用set(),如果你知道{}是创建一个空字典的话。

  • 集合推导式

集合推导式用到的地方不多,我们就用一个例子简单看一下:

a = 'qwertaaswqe'
s = {i for i in a}
>>>s
{'a', 'e', 'q', 'r', 's', 't', 'w'}
  • 集合运算

常用集合运算

集合比较

当然,大多数的方法你可能并不用熟记,我到现在也只用过求交集(&)、并集(|)、属于(in)、子集(<)这四个。

2. 一个关于效率的实验

一个关于效率的实验

我想上面的数据一目了然,在数据增加了一万倍以后,dict和set的查找时间增长不超过5。由于列表的背后没有散列表来支持in运算符,每次搜索都需要扫描一次完整的列表,导致所需的时间根据haystack的大小呈线性增长。

现在,我想我们很容易得到这个结论,字典与集合的查找运行效率非常快,接下来让我们往深处挖,发现效率背后的原因。

3. 散列表

散列表其实是一个稀疏数组

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

4. 散列表算法

为了获取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;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则再增加几位重复以上的步骤。

散列表算法

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

上面说的是不是晦涩难懂?简单点说,在查找表元时,python往往会根据后几位散列值作为偏移量进行查找,这样查询确实很快,不是吗?在获取了散列值以后相当于告诉你了表元的地址,你直接对对应地址的表元读取就行了。所以:key对应了唯一的散列值,散列值反映了key的地址,查找是我得到的表元散列值就得到了地址。不过由于使用的不是全部散列值,对于大的散列表难免就会出现冲突现象,比如有10个表元,你每次取最后一位3作为地址肯定可以查到,但是有20个表元的时候,就可能对应到3或者13,想避免冲突,首先位数必须要增加了,不然都不够描述这20个表元,而查找的时候也不能只取一位,至少两位才能分清是03还是13。

是不是觉得这个算法很费事,这破玩意儿咋还有冲突呢,实际上就算dict里有数百万个元素,多数的搜索过程中并不会有冲突发生,平均下来每次搜索可能会有一到两次冲突。在正常情况下,就算是最不走运的键所遇到的冲突的次数用一只手也能数过来。

5. dict的实现及其导致的后果

散列表算法能够正确实施,那么必须保证:

​ 1)键是可散列的

​ 2)支持hash函数获得唯一的散列值

​ 3)__eq__方法检测相等性

​ 4)若a=b,则hash(a)=hash(b)

所有用户自定义对象都是可散列的,因为散列值就是由id()函数获取,id反映了内存地址,同一对象必然有同一id,不同对象id必然不同。不过如果你实现了一个类__eq__方法,并且希望它是可散列的,那么它一定要有个恰当的__hash__方法,保证在a==b为真的情况下hash(a)==hash(b)也必定为真。否则就会破坏恒定的散列表算法,导致由这些对象所组成的字典和集合完全失去可靠性,这个后果是非常可怕的。另一方面,如果一个含有自定义的__eq__依赖的类处于可变的状态,那就不要在这个类中实现__hash__方法,因为它的实例是不可散列的。

  • 字典在内存上开销巨大

    由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。举例而言,如果你需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择。用元组取代字典就能节省空间的原因有两个:其一是避免了散列表所耗费的空间,其二是无需把记录中字段的名字在每个元素里都存一遍。

  • 键查询很快

    我想这个不用多说,就像2中的例子一样。dict的实现是典型的空间换时间:字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。

  • 键的次序取决于添加顺序

    d1 = {1: 's', 2: 'l'}
    d2 = {2: 'l', 1: 's'}
    print(d1 == d2)  # True
    print(d1.keys()) # dict_keys([1, 2])
    print(d2.keys()) # dict_keys([2, 1])
    

    尽管顺序是乱的,但是两者还是相等的。

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

    无论何时往字典里添加新的键,Python解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。这个过程中可能会发生新的散列冲突,导致新散列表中键的次序变化。要注意的是,上面提到的这些变化是否会发生以及如何发生,都依赖于字典背后的具体实现,因此你不能很自信地说自己知道背后发生了什么。如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。

    由此可知,不要对字典同时进行迭代和修改。如果想扫描并修改一个字典,最好分成两步来进行:首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原有字典进行更新。

6. set的实现以及导致的后果

set和frozenset的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用(就像在字典里只存放键而没有相应的值)。set出现的现象dict是一样的,我们不再多说了:

  • 集合里的元素必须是可散列的。
  • 集合很消耗内存。
  • 可以很高效地判断元素是否存在于某个集合。
  • 元素的次序取决于被添加到集合里的次序。
  • 往集合里添加元素,可能会改变集合里已有元素的次序。

——本章完——
欢迎关注我的微信公众号
扫码关注公众号

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值