Python里的dict和set的背后小秘密

目录
  • Python里的dict和set的效率有多高?
  • 字典中的散列表
    • 1.散列值和相等性
  • 散列表算法
  • dict的实现及其导致的结果
    • 1.键必须死可散列的
    • 2.字典在内存上的开销巨大
    • 3.键查询很快
    • 4.键的次序取决于添加顺序
    • 5.往字典里添加新键可能会改变已有键的顺序
  • set的实现及其导致的结果
  • 总结
  • Python里的dict和set的效率有多高?
  • 为什么它们是无序的?
  • 为什么并不是所有的Python对象都可以当作dict的键或set里的元素?
  • 为什么dict的键和set的元素的顺序是根据它们被添加的次序而定的,以及为什么在映射对象的生命周期中,这个顺序并不是一成不变的?
  • 为什么不应该在迭代循环dict或是set的同时往里添加元素?

Python里的dict和set的效率有多高?

由实验得知,不管查询有多少个元素的字典或集合,所耗费的时间都能忽略不计(前提是字典或者集合不超过内存大小).

字典中的散列表

散列表其实是一个稀疏数组(总是有空白元素的数组被称为稀疏数组).在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket).

在dict的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用.

因为所有的表元的大小一致,所以可以通过偏移量来读取某个表元.

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

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

1.散列值和相等性

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

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

例如,如果11.0为真,那么hash(1)hash(1.0)也必须为真,但其实这两个数字(整型和浮点)的内部结构是完全不一样的.

既然提到了整型,CPython的实现细节里有一条是:如果有一个整型对象,而且它能被存进一个机器字中,那么它的散列值就是它本身的值.

为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来.这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大.

"""
import sys
# 通过sys.maxsize获取操作系统的整数最大值,转换成二进制,计算位数,加上一个符号位
MAX_BITS = len(format(sys.maxsize, 'b'))
print('%s-bit Python build' % (MAX_BITS + 1))
def hash_diff(o1, o2):
    h1 = '{:>0{}b}'.format(hash(o1), MAX_BITS)  # 计算o1的散列值,并用0补满空位
    h2 = '{:>0{}b}'.format(hash(o2), MAX_BITS)  # 计算o2的散列值,并用0补满空位
    # 比较h1和h2的每一位,用!标识出来,否则用' '表示
    diff = ''.join('!' if b1 != b2 else ' ' for b1, b2 in zip(h1, h2))
    count = '!={}'.format(diff.count('!'))  # 显示不同的总数
    width = max(len(repr(o1)), len(repr(o2)), 8)  # 行头的宽度
    sep = '_' * (width * 2 + MAX_BITS)  # 分割线
    return '{!r:{width}} {}\n{:{width}} {} {}\n{!r:{width}} {}\n{}'.format(
        o1, h1, ' ' * width, diff, count, o2, h2, sep, width=width
    )
print(hash_diff(1, 1.0))
print(hash_diff(1.0, 1.0001))
print(hash_diff(1.0001, 1.0002))
print(hash_diff(1.0002, 1.0003))

从Python3.3开始,str,bytes和datetime对象的散列值计算过程中多了随机的'加盐'这一步.

所加盐值是Python进程内的一个常量,但是每次启动Python解释器都会生成一个不同的盐值.

随机盐值的加入是为了防止DOS攻击而采取的一种安全措施.

散列表算法

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

从字典中取值的算法流程如下:给定一个键,这个算法要么返回一个值,要么抛出KeyError异常

|-------------------------------------------------------------------------|
|计算键的散列值               ________使用散列值的另一部分来定位散列表中的零一行 |
|     |                    |                        ↑                     |
|     |                    |                        | 否 (散列冲突)        |
|     |                    ↓                        |                     |
|使用散列值的一部分         表元                       |                     |
|来定位散列表中的一 ------→ 为空? ---------否-------→ 键相等?                 |
|个表元                     |                        |                     |
|                          |是                       |是                   |
|                          ↓                         ↓                     |
|                    抛出KeyError                返回表元里的值              |
|--------------------------------------------------------------------------|

添加新元素和更新现有键值的操作几乎跟上面一样.只不过对于前者,在发现空表元的时候会放入一个新元素;

对于后者,在找到对应的表元后,原表里值对象会被替换成新值.

另外在插入新值时,Python可能会按照散列表的拥挤程度来决定是否要重新分配内存来为它扩容.如果增加了散列表的大小,那散列值所占的位数和用作索引的位数就会随之增加,这样做的目的是为了减少发生散列冲突的概率.

表面上看,这个算法似乎很费事,而实际上就是dict里有数百万个元素,多数的搜索过程中并不会有冲突发生,平均下来每次搜索可能会有一到两次冲突.

在正常情况下,就算是最不走运的键所遇到的冲突的次数用一只手也能数过来.

dict的实现及其导致的结果

1.键必须死可散列的

一个可散列的对象必须满足以下要求:

1)支持hash()函数,并且通过__hash__()方法所得到的散列值是不变的.

2)支持通过__eq__()方法来检测相等性.

3)若a == b为真,则hash(a) == hash(b)也为真

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

如果你实现了一个类的__eq__()方法,并且希望它是可散列的,那么它一点要有个恰当的__hash__方法,保证a==b为真的情况下hash(a)==hash(b)也必定为真.

否则就会破坏恒定的散列表算法,导致由这些对象所组成的字典和集合完全失去可靠性,这个后果是非常可怕的.

另一方面,如果一个含有自定义__eq__依赖的类处于可变的状态,那就不要在这个类中实现__hash__方法,因为它的实例时不可散列的.

'''
学习中遇到问题没人解答?小编创建了一个Python学习交流群:725638078
寻找有志同道合的小伙伴,互帮互助,群里还有不错的视频学习教程和PDF电子书!
'''
class A:
    def __init__(self, a):
        self.a = a
    def __hash__(self):
        return 1
    def __eq__(self, other):
        return hash_diff(self, other)
    def __repr__(self):
        return str(self.a)
a = A(1)
b = A(2)
d1 = {a: 1, b: 2, 1: 3}
print(d1)  # {1: 3}  会发现里面只有一个键值对

2.字典在内存上的开销巨大

由于字典使用了散列表,而散列表又必须时稀疏的,这导致它在空间上的效率低下.举例而言.如果你需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择;

最好不要根据JSON的风格,用由字典组成的列表来存放这些记录,用元组取代字典能节省空间的原因有两个:

其一是避免了散列表所消耗的空间. 其二是无需把记录中字段的名字在每个元素里都存一遍.

在用户自定义的类型中,__slots__属性可以改变实例属性的存储方式,由dict变成tuple.

3.键查询很快

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

4.键的次序取决于添加顺序

当往dict里添加新键而又发生散列冲突的时候,新键可能会被安排存放到另一个位置.于是下面的这种情况就会发生:

由dict([(key1, value1), (key2, value2)])和dict([(key2, value2), (key1, value1)])得到的两个字典,在进行比较的时候,它们是相等的.

但是如果在key1和key2被添加到字典里的过程中有冲突发生的话,这两个键出现在字典里的顺序是不一样的.

下面的示例展示了这个现橡.这个示例用同样的数据创建了3个字典,唯一的区别就是数据出现的顺序不一样.可以看到,虽然键的次序是乱的,这3个字典仍然被视作相等的.

STUDENTS = [
    (89, '孙悟空'),
    (79, '猪八戒'),
    (69, '沙和尚'),
    (59, '小白龙'),
    (49, '唐僧')
]
d1 = dict(STUDENTS)
print('d1:', d1.keys())
d2 = dict(sorted(STUDENTS))
print('d2:', d2.keys())
d3 = dict(sorted(STUDENTS, key=lambda x: x[1]))
print('d3', d3.keys())
assert d1 == d2 and d2 == d3

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

无论何时往字典里添加新的键,Python解释器都可能做出为字典扩容的决定.扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里.

这个过程可能会发生新的散列冲突,导致新散列表中键的次序变化.

要注意的是,上面提到的这些变化是否会发生以及如何发生,都依赖于字典背后的实现,因此你不能很自信的说自己知道背后发生了什么.

如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很可能会跳过一些键----甚至是跳过那些字典中已经有的键.

由此可知,不要对字典同时进行迭代和修改.如果想扫描并修改一个字典,最好分成两步来进行:

首先对字典迭代,以得出需要添加的内容,把这些内容放在一个新字典里;迭代结束之后再对原字典进行更新.

在Python3中,.keys() .items() .values()方法返回的都是字典视图.也就是说,这些方法返回的值更像集合.

set的实现及其导致的结果

set和frozenset的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用.在set加入到Python之前,我们都是把字典加上无意义的值当作集合来用.

1.集合里的元素必须是可散列的.

2.集合很消耗内存.

3.可以很高效的判断元素是否存在于某个集合.

4.元素的次序取决于被添加到集合里的次序.

5.往集合里添加元素,可能会改变集合里已有元素的次序.

总结

本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注我们的更多内容!

(0)

相关推荐

  • 详解Python中dict与set的使用

    Python中类似于PHP的数组的结构有list,tuple,dict和set, 其中list, tuple和set的类似于PHP的索引数组, 而dict就类似于PHP的关联数组, dict: dict的结构表示了一种映射关系, 与PHP的关联数组类似, 比如要定义个用户信息如下: name: Yi_Zhi_Yu sex: Man country: China 这个使用list,tuple和set都是不能直接表示出来的, 因为没有能够使用字符串做键值的结构,而dict就可以,如下 m = {"n

  • Python中dict和set的用法讲解

    dict Python内置了字典:dict的支持,dict全称dictionary,在其他语言中也称为map,使用键-值(key-value)存储,具有极快的查找速度. 举个例子,假设要根据同学的名字查找对应的成绩,如果用list实现,需要两个list: names = ['Michael', 'Bob', 'Tracy'] scores = [95, 75, 85] 给定一个名字,要查找对应的成绩,就先要在names中找到对应的位置,再从scores取出对应的成绩,list越长,耗时越长. 如

  • Python 中list ,set,dict的大规模查找效率对比详解

    很多时候我们可能要频繁的进行元素的find 或in操作,本人一直天真的以为python的list做了hash,通过红黑树来高效查找···直到今天我真正来测试它和set,dict的查找效率时,才发现自已想太多了!!!! 先看代码: __author__ = 'jmh081701' import numpy import time l=[] sl=set() dl=dict() r=numpy.random.randint(0,10000000,100000) for i in range(0,10

  • python的dict,set,list,tuple应用详解

    本文深入剖析了python中dict,set,list,tuple应用及对应示例,有助于读者对其概念及原理的掌握.具体如下: 1.字典(dict) dict 用 {} 包围 dict.keys(),dict.values(),dict.items() hash(obj)返回obj的哈希值,如果返回表示可以作为dict的key del 或 dict.pop可以删除一个item,clear清除所有的内容 sorted(dict)可以把dict排序 dict.get()可以查找没存在的key,dict

  • 在Python中使用dict和set方法的教程

    dict Python内置了字典:dict的支持,dict全称dictionary,在其他语言中也称为map,使用键-值(key-value)存储,具有极快的查找速度. 举个例子,假设要根据同学的名字查找对应的成绩,如果用list实现,需要两个list: names = ['Michael', 'Bob', 'Tracy'] scores = [95, 75, 85] 给定一个名字,要查找对应的成绩,就先要在names中找到对应的位置,再从scores取出对应的成绩,list越长,耗时越长. 如

  • python--字典(dict)和集合(set)详解

    目录 一.集合 1.集合定义 2.创建集合 3.去重 4.集合增删 5.关系运算 6.排序 7.frozenset 8.练习 9.特性 二.字典 1.字典定义 2.字典打印 3.字典元素删除 4.setdefault 5.defaultdict 总结 一.集合 1.集合定义 集合(set)是一个无序的不重复元素序列. 2.创建集合 使用大括号 { } 或者 set() 函数创建集合; 创建一个空集合必须用 set() 而不是 { } { } 是用来创建一个空字典. s = {1,2,3,4} p

  • Python里的dict和set的背后小秘密

    目录 Python里的dict和set的效率有多高? 字典中的散列表 1.散列值和相等性 散列表算法 dict的实现及其导致的结果 1.键必须死可散列的 2.字典在内存上的开销巨大 3.键查询很快 4.键的次序取决于添加顺序 5.往字典里添加新键可能会改变已有键的顺序 set的实现及其导致的结果 总结 Python里的dict和set的效率有多高? 为什么它们是无序的? 为什么并不是所有的Python对象都可以当作dict的键或set里的元素? 为什么dict的键和set的元素的顺序是根据它们被

  • python里dict变成list实例方法

    python里dict(字典)怎么变成list(列表)? 说明:列表不可以转换为字典 1.转换后的列表为无序列表 a = {'a' : 1, 'b': 2, 'c' : 3} #字典中的key转换为列表 key_value = list(a.keys()) print('字典中的key转换为列表:', key_value) #字典中的value转换为列表 value_list = list(a.values()) print('字典中的value转换为列表:', value_list) 运行结果

  • python里使用正则表达式的组嵌套实例详解

    python里使用正则表达式的组嵌套实例详解 由于组本身是一个完整的正则表达式,所以可以将组嵌套在其他组中,以构建更复杂的表达式.下面的例子,就是进行组嵌套的例子: #python 3.6 #蔡军生 #http://blog.csdn.net/caimouse/article/details/51749579 # import re def test_patterns(text, patterns): """Given source text and a list of pa

  • 详解python里使用正则表达式的分组命名方式

    详解python里使用正则表达式的分组命名方式 分组匹配的模式,可以通过groups()来全部访问匹配的元组,也可以通过group()函数来按分组方式来访问,但是这里只能通过数字索引来访问,如果某一天产品经理需要修改需求,让你在它们之中添加一个分组,这样一来,就会导致匹配的数组的索引的变化,作为开发人员的你,必须得一行一行代码地修改.因此聪明的开发人员又想到一个好方法,把这些分组进行命名,只需要对名称进行访问分组,不通过索引来访问了,就可以避免这个问题.那么怎么样来命名呢?可以采用(?P<nam

  • python通过字典dict判断指定键值是否存在的方法

    本文实例讲述了python通过字典dict判断指定键值是否存在的方法.分享给大家供大家参考.具体如下: python中有两种方法可以判断指定的键值是否存在,一种是通过字典对象的方法 has_key 判断,另外一种是通过 in 方法,下面是详细的范例. d={'site':'http://www.jb51.net','name':'jb51','is_good':'yes'} #方法1:通过has_key print d.has_key('site') #方法2:通过in print 'body'

  • python里使用正则的findall函数的实例详解

    python里使用正则的findall函数的实例详解 在前面学习了正则的search()函数,这个函数可以找到一个匹配的字符串返回,但是想找到所有匹配的字符串返回,怎么办呢?其实得使用findall()函数.如下例子: #python 3. 6 #蔡军生 #http://blog.csdn.net/caimouse/article/details/51749579 # import re text = 'abbaaabbbbaaaaa' pattern = 'ab' for match in r

  • 详解python里使用正则表达式的全匹配功能

    详解python里使用正则表达式的全匹配功能 python中很多匹配,比如搜索任意位置的search()函数,搜索边界的match()函数,现在还需要学习一个全匹配函数,就是搜索的字符与内容全部匹配,它就是fullmatch()函数. 例子如下: #python 3.6 #蔡军生 #http://blog.csdn.net/caimouse/article/details/51749579 # import re text = 'This is some text -- with punctua

  • python中字典dict常用操作方法实例总结

    本文实例总结了python中字典dict常用操作方法.分享给大家供大家参考.具体如下: 下面的python代码展示python中字典的常用操作,字典在python开发中有着举足轻重的地位,掌握字典操作相当重要 #创建一空字典 x = {} #创建包含三个项目的字典 x = {"one":1, "two":2, "three":3} #访问其中的一个元素 x['two'] #返回字典中的所有键列表 x.keys() #返回字典中的所有值列表 x.v

  • Python里disconnect UDP套接字的方法

    UDP 套接字是可以使用 connect 系统调用连接到指定的地址的.从此以后,这个套接字只会接收来自这个地址的数据,而且可以使用 send 系统调用直接发数据而不用指定地址.可以再次调用 connect 来连接到别的地方.但是在 Python 里,一旦调用 connect 之后,就再也回不到最初的能够接收从任意地址来的数据的状态了! 这是 Python 的 API 限制,没办法给 connect 方法传递到 AF_UNSPEC 地址簇(在 C 代码里写死了的).C 里边就可以做到的(代码来自这

随机推荐