5.7 字典和集合

深入学习Python字典(dict)和集合(set)的使用方法,包括dict的key-value存储结构、哈希算法原理、set的无序不重复特性,掌握dict和set的增删改查操作,理解不可变对象在dict和set中的重要性。

dict - Python里的"智能电话簿"

Python有个很实用的内置功能叫dict(dictionary的缩写),你可以把它想象成一本智能电话簿。

别的编程语言里也有类似的东西,例如Java,叫map。

它的工作原理很简单:用键(key)来存储和查找值(value),速度快得惊人。

来看个实际场景。假如你要记录班上同学的成绩,用传统的list来做会是这样:

names = ['Michael', 'Bob', 'Tracy']
scores = [95, 75, 85]

想知道某个同学的成绩?得先在names里找到他的位置,再去scores里取对应的分数。这还只是三个人,要是有一百个、一千个同学呢?查找时间会越来越长,简直是个噩梦。

dict就不一样了。它直接建立"姓名"和"成绩"的对应关系:

>>> d = {'Michael': 95, 'Bob': 75, 'Tracy': 85}
>>> d['Michael']
95

为什么dict能这么快?道理其实跟我们查纸质字典一样。

想象一下,你手里有本一万字的汉语字典,要查某个字。笨办法是从第一页开始一页页翻,直到找到为止——这就是list的查找方式,字典越厚越费时间。

聪明的做法呢?先看部首索引,找到对应的页码,直接翻过去。不管查哪个字,速度都差不多,这就是dict的工作方式。

dict内部会根据key(比如’Michael’)直接计算出value(95)存放的内存地址,然后一步到位取出来。这种通过key计算存储位置的算法,有个专业名字叫哈希算法(Hash)。

往dict里添加数据很灵活。除了初始化时就设定好,还可以随时添加:

>>> d['Adam'] = 67
>>> d['Adam']
67

有个特点需要注意:每个key只能对应一个value。如果你给同一个key赋值多次,后面的会覆盖前面的:

>>> d['Jack'] = 90
>>> d['Jack']
90
>>> d['Jack'] = 88
>>> d['Jack']
88

访问不存在的key会报错:

>>> d['Thomas']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'Thomas'

怎么避免这种尴尬?有两个办法。一个是用in先确认key存不存在:

>>> 'Thomas' in d
False

另一个是用dict自带的get()方法,找不到key时它会返回None,或者返回你指定的默认值:

>>> d.get('Thomas')
>>> d.get('Thomas', -1)
-1

(Python交互环境下,None不会显示出来,这是正常的)

删除某个key用pop(key),对应的value也会一起消失:

>>> d.pop('Bob')
75
>>> d
{'Michael': 95, 'Tracy': 85}

有件事可能会让你意外:dict里元素的顺序跟你添加它们的顺序没什么关系。

dict和list各有各的特点。dict查找和插入超快,不管数据量多大速度都很稳定,但代价是占用内存比较多。list正好相反,查找和插入会随着数据量增加而变慢,不过它省内存。这就是典型的"用空间换时间"。

Python代码里到处都能看到dict的身影,用好它很关键。有个铁律必须记住:dict的key必须是不可变对象

为什么?因为dict要根据key来计算存储位置,如果同一个key每次计算出来的结果都不一样,整个系统就乱套了。在Python里,字符串、整数这些都是不可变的,放心拿来当key。但list是可变的,就不行:

>>> key = [1, 2, 3]
>>> d[key] = 'a list'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

set - key的集合体

set跟dict是近亲,它也是一组key的集合,只不过不存value。既然key不能重复,set里自然也不会有重复元素。

创建set很简单,直接用花括号把元素列出来:

>>> s = {1, 2, 3}
>>> s
{1, 2, 3}

或者把一个list转换成set:

>>> s = set([1, 2, 3])
>>> s
{1, 2, 3}

注意看,传进去的[1, 2, 3]是list(方括号),显示出来的{1, 2, 3}是set(花括号)。这个显示顺序并不代表set内部是有序的。

重复的元素会被自动过滤掉:

>>> s = {1, 1, 2, 2, 3, 3}
>>> s
{1, 2, 3}

add(key)可以往set里加元素。重复添加同一个元素不会报错,只是没有效果而已:

>>> s.add(4)
>>> s
{1, 2, 3, 4}
>>> s.add(4)
>>> s
{1, 2, 3, 4}

remove(key)删除元素:

>>> s.remove(4)
>>> s
{1, 2, 3}

set就是数学意义上的集合:无序、不重复。两个set可以做交集、并集这些数学运算:

>>> s1 = {1, 2, 3}
>>> s2 = {2, 3, 4}
>>> s1 & s2
{2, 3}
>>> s1 | s2
{1, 2, 3, 4}

set和dict的唯一区别就是不存value,但实现原理是一样的。这意味着set也不能放可变对象,因为没法判断两个可变对象是不是真的相等,就没法保证"不重复"这个特性。你可以试试把list放进set,看看会发生什么。

关于不可变对象的深入理解

前面说过,str是不可变对象,list是可变对象。

对list操作时,它的内容会真的改变:

>>> a = ['c', 'b', 'a']
>>> a.sort()
>>> a
['a', 'b', 'c']

但对str操作呢:

>>> a = 'abc'
>>> a.replace('a', 'A')
'Abc'
>>> a
'abc'

咦,replace()确实生成了'Abc',但变量a还是'abc'?怎么回事?

我们换个写法就清楚了:

>>> a = 'abc'
>>> b = a.replace('a', 'A')
>>> b
'Abc'
>>> a
'abc'

关键要理解:a只是个变量名,真正的字符串对象是'abc'。我们常说"对象a的内容是’abc’",其实是指变量a指向了内容为’abc’的字符串对象:

┌───┐     ┌───────┐
 a │────▶│ 'abc' 
└───┘     └───────┘

当你调用a.replace('a', 'A')时,实际上是在字符串对象'abc'上调用replace方法。虽然这方法叫"replace",但它并没有改变'abc'本身。它做的是创建一个新字符串'Abc'然后返回。如果我们用变量b接住这个新字符串,就很好理解了:变量a还指向原来的'abc',变量b指向新的'Abc':

┌───┐     ┌───────┐
 a │────▶│ 'abc' 
└───┘     └───────┘
┌───┐     ┌───────┐
 b │────▶│ 'Abc' 
└───┘     └───────┘

这就是不可变对象的特性:不管你调用什么方法,对象本身的内容永远不会变。这些方法只会创建并返回新对象,原对象始终保持不变。

小结

dict这种key-value存储结构在Python里用处大得很,记住要用不可变对象做key,其中字符串是最常见的选择。

tuple虽然也是不可变对象,但你可以试试把(1, 2, 3)(1, [2, 3])分别放进dict或set,看看会有什么结果,想想为什么会这样。