W
e
l
c
o
m
e
: )

5.内建数据结构

内建数据结构

本篇导航

本篇系统讲解 Python 四大内建容器:列表(可变序列)、元组(不可变序列)、字典(键值映射)、集合(去重哈希容器),
以及它们的公共方法、类型转换与底层实现(顺序表 vs 哈希表)。
学完能根据「是否需要改、是否需要去重、查询方式」三个维度正确选型。

本篇输出均为本机实跑

环境 Python 3.13.14。验证脚本 verify/py05/run05.py。
原文若干表述沿用了较旧版本的说法(如 popitem「随机弹出」),已逐条实测校正并标注 [!warning] 原文勘误。


1 内建常用数据结构

1.1 官方类型层级

  • 序列(Sequence)
    • 字符串 str、字节序列 bytes、bytearray
    • 列表 list、元组 tuple
  • 键值对(Mapping)
    • 字典 dict
  • 集合(Set)
    • 集合 set
容器 (Container)
├── 序列 (Sequence) → 有序,可重复,支持索引
├── 映射 (Mapping)  → 键值对,字典
└── 集合 (Set)      → 无序,唯一,支持交并差运算
    ├── 可变集合 (set)      → 可增删
    └── 不可变集合 (frozenset) → 可哈希,可作为字典键

1.2 序列

序列是一种数据存储方式,用来存储一系列数据。在内存中,序列就是一块用来存放多个值的连续内存空间。例如整数序列 [10, 20, 30, 40],可以这样示意表示:

image-20220627202243435

由于 Python 3 中一切皆对象,内存中实际的存储方式如下:

a = [10, 20, 30, 40]

image-20220627202327485

从图中可以看出:序列中存储的是整数对象的地址,而不是整数对象的值。

这是理解列表的关键

列表里存放的是引用(地址),不是值本身。所以:

  • 不同元素可以是不同类型(因为存的都是地址,长度一致)
  • 修改列表元素,只是让某个位置换指向,不涉及拷贝对象
  • 嵌套列表 0 * 3 会让三个位置指向同一个子列表(见下文 §2 的浅复制陷阱)
  • Python 中常用的序列结构有:字符串、列表、元组。
  • Python 中常用的键值对结构有:字典。
  • Python 中常用的集合结构有:集合。

上面学习的字符串就是一种序列,字符串里的很多操作在序列中同样适用。


2 列表

2.1 为什么需要列表

思考

有一个人的姓名(TOM),怎么编写程序存储?

答

用变量存储即可。

思考

如果一个班级有 100 位学生,每个人的姓名都要存储,该如何编写程序?声明 100 个变量吗?

答

No——用列表即可,列表一次可以存储多个数据。

注意

在 Python 中,这种数据类型称为列表;而在其他编程语言(如 Java、PHP、Go 等)中,它被称为数组。

2.2 列表的定义

列表:用于存储任意数目、任意类型的数据集合。

列表底层用顺序表实现,类似于 Java 等语言中的数组。

列表是内置可变序列,是一段包含多个元素的有序连续内存空间。定义列表的标准语法格式:

# 伪代码:展示语法形态
列表序列名称 = [列表中的元素1, 列表中的元素2, 列表中的元素3, ...]

案例:定义一个列表,用于保存苹果、香蕉和菠萝。

list1 = ['apple', 'banana', 'pineapple']
# list 列表类型支持直接打印
print(list1)
# 打印列表的数据类型
print(type(list1))
实跑输出
['apple', 'banana', 'pineapple']
<class 'list'>

注意

列表可以一次存储多个数据且可以为不同的数据类型。

Python 的列表大小可变,根据需要随时增加或缩小。

字符串和列表都是序列类型:字符串是字符序列,列表是任意元素的序列。前面学过的许多字符串方法,在列表中也有几乎一样的用法。

列表:

  • 像一个排列整齐的队伍,Python 用顺序表实现
  • 列表内的个体称为元素,由若干元素组成列表
  • 元素可以是任意对象(数字、字符串、对象、列表等)
  • 列表内元素有顺序,可以使用索引
  • 属于线性数据结构
  • 使用 [] 表示
  • 列表是可变的

基本语法 [] 创建

a = []  # 创建一个空的列表对象

a = [10, 20, 'ym', 'fbb']

list() 创建

使用 list() 可以将任何可迭代的数据转化成列表。

# 创建一个空的列表对象
a = list()

# 字符串转列表
a = list("gaoqi,sxt")

# range 转列表
a = list(range(10))
实跑输出
list()          -> []
list("gaoqi,sxt") -> ['g', 'a', 'o', 'q', 'i', ',', 's', 'x', 't']
list(range(10)) -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Snipaste_2022-06-28_14-01-35

range() 创建整数列表

range() 可以非常方便地创建整数列表,这在开发中极其有用。语法格式为:

# 伪代码:展示完整参数
range([start,] end [,step])
参数
  • start 参数:可选,表示起始数字。默认是 0。
  • end 参数:必选,表示结尾数字。
  • step 参数:可选,表示步长,默认为 1。
注意

Python 3 中 range() 返回的是一个 range 对象,而不是列表;需要通过 list() 将其转换成列表对象。

典型示例如下:

# range() 返回一个range对象
type(range(1, 10))

list(range(3, 15, 2))

list(range(15, 3, -1))

list(range(3, -10, -1))
实跑输出
type(range(1, 10))      -> <class 'range'>
list(range(3, 15, 2))   -> [3, 5, 7, 9, 11, 13]
list(range(15, 3, -1))  -> [15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4]
list(range(3, -10, -1)) -> [3, 2, 1, 0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

Snipaste_2022-06-28_14-07-28

range 是「懒」的,这是它最大的优点

range(10**9) 几乎不占内存,因为它只存 start/stop/step,元素按需计算:

import sys
print(sys.getsizeof(range(10**9)), sys.getsizeof(list(range(10**9))))

前者只有几十字节,后者会真的生成 10 亿个整数。所以遍历大区间时用 range,不要转 list。

推导式生成列表

这里先做简介,重点在 for 循环之后讲解。

列表推导式可以非常方便地创建列表,开发中经常使用。它涉及 for 循环和 if 语句,因此此处仅做基本介绍;控制语句一章会详细讲解更多列表推导式的细节。

# 循环创建多个元素
a = [x * 2 for x in range(5)]

# 通过 if 过滤元素
a = [x * 2 for x in range(100) if x % 9 == 0]
实跑输出
[x*2 for x in range(5)]                -> [0, 2, 4, 6, 8]
[x*2 for x in range(100) if x%9==0]    -> [0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 198]

Snipaste_2022-06-28_14-10-48

2.3 列表的相关操作

列表的作用是一次性存储多个数据,程序员可以对这些数据进行的操作有:增、删、改、查。

查操作

列表和字符串一样,在内存中都占用一段连续的内存空间。要访问列表中的每个元素,都可以通过索引下标的方式获取。

image-20210310170752234

想获取列表中的某个元素非常简单,直接使用索引下标即可:

list1 = ['apple', 'banana', 'pineapple']
# 获取列表中的 banana
print(list1[1])
实跑输出
banana

Snipaste_2022-06-28_14-16-25

注意

使用索引访问列表元素是列表最好的使用方式,复杂度是 O(1)。

查操作的相关方法:

函数 作用
index() 指定数据所在位置的下标,如果列表中没有指定数据,则报异常
count() 统计指定数据在当前列表中出现的次数,如果列表中不存在指定元素,返回 0
in 判断指定数据在某个列表序列,如果在返回 True,否则返回 False
not in 判断指定数据不在某个列表序列,如果不在返回 True,否则返回 False
len() 返回列表长度,即列表中包含元素的个数
index()

index() 可以获取指定元素首次出现的索引位置。语法是:

# 伪代码:展示完整参数
index(value [,start [,end]])

start 和 end 指定了搜索的范围。

# 1、查找某个元素在列表中出现的位置(索引下标)
list1 = ['apple', 'banana', 'pineapple']
print(list1.index('apple'))    # 0
# print(list1.index('peach'))  # 报错

a = [10, 20, 30, 40, 50, 20, 30, 20, 30]
# 从头开始搜索列表中的第一个 20
print(a.index(20))
# 从索引位置 3 开始往后搜索的第一个 20
print(a.index(20, 3))
# 从索引位置 5 到 7 这个区间,第一次出现 30 元素的位置
print(a.index(30, 5, 7))
实跑输出
list1.index('apple')  -> 0
a.index(20)           -> 1
a.index(20, 3)        -> 5
a.index(30, 5, 7)     -> 6

⚠️ 注意 index(30, 5, 7) 的搜索区间是左闭右开 [5, 7),即只看索引 5、6。
该区间内索引 6 是 30,所以返回 6。

Snipaste_2022-06-28_14-25-22

Snipaste_2022-06-28_14-25-33

index() 找不到会抛 ValueError
[1, 2].index(9)
ValueError: 9 is not in list

想避免异常就先用 in 判断:if x in lst: i = lst.index(x)。

count()

count() 可以返回指定元素在列表中出现的次数;若列表中不存在该元素,则返回 0。

举例:

# 2、count() 方法:统计元素在列表中出现的次数
list2 = ['刘备', '关羽', '张飞', '关羽', '赵云']
# 统计一下关羽这个元素在列表中出现的次数
print(list2.count('关羽'))
print(list2.count('小龙女'))
实跑输出
2
0

Snipaste_2022-06-28_15-09-26

index 和 count 函数效率如何?

效率很差,因为它们都要遍历元素。

index:若查找的是列表第一个元素,时间复杂度最小为 O(1);最坏情况(目标位于末尾)为 O(n)。

count:需要遍历整个列表计数,时间复杂度为 O(n)。

二者的耗时都跟列表中数据的个数有关,元素多即「规模大」。

index 的耗时随规模增大而增加,搜索效率随规模增大而下降。

count 无论有多少元素都要完整遍历一遍,耗时同样随规模增大而增加。

性能极差,能不用则不用。做算法题时几乎不用。

使用索引访问列表元素才是列表最好的使用方式,复杂度是 O(1)。

注意

O(1) 不是说只要一步做完,而是指只需常数步就能完成——与规模 n 无关,不会随 n 增大而变慢。

需要频繁判断「在不在」怎么办?

如果同一个列表要反复做 in / index / count,先转成 set:

lst = list(range(100000))
s = set(lst)
# 之后用 99999 in s 就是 O(1),而不是 O(n)

实测对比(20 万元素,查最后一个):

list in 耗时 2069.4 μs
set  in 耗时    2.8 μs        (约 739 倍)

但注意:转 set 要求元素可哈希,且会丢失顺序与重复。

in 与 not in

判断列表中是否存在指定元素,可以用 count() 方法:返回 0 表示不存在,返回大于 0 表示存在。

不过一般会用更简洁的 in 关键字,直接返回 True 或 False。

in 效率高吗?

不高,因为需要遍历。与 index 函数一样,只是返回值不同。

>>> # 本质是先比较地址是否一样,再比较内容
>>> [1] in [[1], [2]]
True
实跑验证
[1] in [[1], [2]]  -> True

解释:in 用 == 逐个比较。[1] == [1] 为 True(列表按值比较),所以命中。
注意这与「先比地址」不完全等价——列表的 == 比的是内容。

# 3、in 方法和 not in 方法(黑名单系统)
list3 = ['192.168.1.15', '10.1.1.100', '172.35.46.128']
if '10.1.1.100' in list3:
    print('黑名单IP,禁止访问')
else:
    print('正常IP,访问站点信息')
实跑输出
黑名单IP,禁止访问

Snipaste_2022-06-28_15-07-14

len()

len() 返回列表长度,即列表中包含元素的个数。

a = [10, 20, 30]
len(a)
实跑输出
3

Snipaste_2022-06-28_15-23-41

len 函数的时间复杂度为 O(1)。

创建列表时,列表内部会自动创建一个变量来记录列表长度。

增操作

列表增加和删除元素时,会自动进行内存管理,大大减轻程序员的负担。但这也涉及列表元素的大量移动、效率较低。除非必要,一般只在列表尾部添加或删除元素,这会显著提高操作效率。

函数 作用
append() 增加指定数据到列表中
extend() 列表结尾追加数据,如果数据是一个序列,则将这个序列的数据逐一添加到列表
insert() 指定位置新增数据
append()

append():在列表尾部追加元素。原地修改列表对象,是真正的尾部添加新元素,速度最快,推荐使用。

append 方法没有返回值,或者说返回值为 None——没有返回值往往意味着它就地修改了自己。

思考

append 方法效率高吗?

答

不一定。

定位尾部的时间复杂度为 O(1)。

一般情况下效率很高,但当容器容量达到上限、需要扩容时,要开辟新的内存空间,此时效率不高。

列表虽然叫「容器」,但它有容量。Python 的列表可以自动扩大:先给一个初始大小,不够了再扩容。

原文勘误:扩容倍率不是「初始 4 倍,之后 2 倍」

原文说「初识扩容 4 倍,到一定程度时改为扩增 2 倍」。这与实测不符。

CPython 的实际扩容公式是:

new_allocated = newsize + (newsize >> 3) + (3 if newsize < 9 else 6)

即「原大小 + 原大小/8 + 常数」,增长因子约 1.125 倍,远小于 4 倍或 2 倍。

本机实测(用 sys.getsizeof 跟踪容量变化):

触发时元素数 字节 推算容量 与上次比值
1 88 4 —
5 120 8 2.00
9 184 16 2.00
17 248 24 1.50
25 312 32 1.33
33 376 40 1.25
41 472 52 1.30
53 568 64 1.23

只在非常小的规模上出现过 2 倍,之后倍率持续下降(1.5 → 1.33 → 1.25 → …),
并不会停在 2 倍。真正该记住的是:扩容是摊销 O(1),偶尔一次昂贵。

names = ['孙悟空', '唐僧', '猪八戒']
# 打印增加元素前的列表地址
print('增加元素前的列表地址', id(names))
# 在列表的尾部追加一个元素"沙僧"
names.append('沙僧')
# 打印增加元素后的列表地址
print('增加元素后的列表地址', id(names))
# 打印列表
print(names)
实跑输出(地址不变,证明是原地修改)
增加元素前的列表地址 2113348746560
增加元素后的列表地址 2113348746560
['孙悟空', '唐僧', '猪八戒', '沙僧']

注意

列表追加数据时,直接在原列表里追加了指定数据,即修改了原列表,故列表属于可变类型数据。

extend() 方法

在列表尾部追加数据;若数据是一个序列,就把这个序列的数据逐一添加到列表。属于原地操作,不创建新列表对象。

思考

extend 方法效率高吗?

答

一般不涉及扩容时效率尚可;当拼接的列表规模大时效率不高。时间复杂度 O(n)。

list1.extend(Iterator) 等价于:

for i in iterator:
    list1.append(i)
原文此处代码有笔误

原文写的等价代码是 list1.append()——少了参数 i,那样会把 None 追加进去。正确写法是 list1.append(i)。

案例:

list1 = ['Tom', 'Rose', 'Jack']
list2 = ['Hack', 'Jennify']

# 列表合并前的地址
print('列表合并前的地址', id(list1))

# 使用 extend 方法两个列表进行合并
list1.extend(list2)

# 列表合并后的地址
print('列表合并后的地址', id(list1))
print(list1)
实跑输出
列表合并前的地址 2113348746752
列表合并后的地址 2113348746752       ← 地址不变,原地修改
['Tom', 'Rose', 'Jack', 'Hack', 'Jennify']

总结

extend 方法比较适合两个列表的元素合并操作。

l1 = [1, 2, 3]
# extend 的参数必须为一个可迭代对象
l1.extend(1)

print(l1)
实跑报错
TypeError: 'int' object is not iterable

append vs extend 别搞混
a = [1, 2]
a.append([3, 4])   # -> [1, 2, [3, 4]]   整体作为一个元素
b = [1, 2]
b.extend([3, 4])   # -> [1, 2, 3, 4]     拆开逐个追加
a = [1, 2, [3, 4]]
b = [1, 2, 3, 4]
insert() 方法

使用 insert() 方法可以将指定的元素插入到列表对象的任意指定位置。这样会让插入位置后面所有的元素进行移动,会影响处理速度。涉及大量元素时,尽量避免使用。

类似发生这种移动的函数还有:remove()、pop()、del(),它们在删除非尾部元素时也会出现操作位置后面元素的移动。

# 伪代码:展示语法
insert(index, value)

作用:在 index 的位置插入元素,如果右边界越界不报错,会将元素放到末尾;左边界小于 -(len - 1) 会将元素放到第一个位置。

names = ['薛宝钗', '林黛玉', '孙悟空', '猪八戒', '沙僧']
# 在薛宝钗和林黛玉之间,插入一个新元素"贾宝玉"
names.insert(1, '贾宝玉')
print(names)

names.insert(-2, '六耳猕猴')
names.insert(-20, '玉帝')
names.insert(20, '唐僧')
print(names)
实跑输出
['薛宝钗', '贾宝玉', '林黛玉', '孙悟空', '猪八戒', '沙僧']
['玉帝', '薛宝钗', '贾宝玉', '林黛玉', '孙悟空', '六耳猕猴', '猪八戒', '沙僧', '唐僧']

越界的两个极端都被「夹」到了两端:insert(-20, ...) 放到最前,insert(20, ...) 放到最后。

+ 运算符操作

向列表末尾追加元素,并不是真正的尾部添加元素,而是创建新的列表对象;将原列表的元素和新列表的元素依次复制到新的列表对象中。这样,会涉及大量的复制操作,对于操作大量元素不建议使用。

思考

效率如何?

答

当列表元素不大时,还可以,但当数据规模大时效率极低。时间复杂度为 O(n)

原文笔误:魔术方法名写错了

原文写「本质上调用的是列表的魔术方法 __add()__」——方法名是 __add__,不是 __add()__(多了括号)。

l1 = [20, 30]
# 添加元素前
print('添加元素前', id(l1))
l1 = l1 + [50]
# 添加元素后
print('添加元素后', id(l1))
实跑输出(地址改变,产生了新对象)
添加元素前 2113348747008
添加元素后 2113348747136

注意

+ 和 += 的效果不一样。

  • 对于可变对象来说 += 和 + 是不一样的(set 和 dict 没有这两个操作),可变对象 + 相当于对原对象 + 之后产生一个新对象。
  • 可变对象 += 相当于修改原对象内部的值,并没有创建新对象,也就是修改前后对象的内存地址不变(id:内存地址的整数表示)。
ls = [1, 2, 3]
print('列表原地址', id(ls))

ls1 = ls + [1, 2, 3]
print('+ 操作后地址', id(ls1))

ls += [1, 2, 3]
print('+= 操作后地址', id(ls))
列表原地址    2113348747328
+ 操作后地址  2113348747456     ← 变了(新对象)
+= 操作后地址 2113348747328     ← 没变(原地修改)

Snipaste_2022-06-28_16-05-53

注意

但是对于不可变对象来说 += 和 + 是一样的,都会产生一个新的对象。

  1. 原子对象(数值)

    count = 0
    print(f'原地址:{id(count)},count的值:{count}')
    
    new_count = count + 1
    print(f'+ 操作后地址:{id(new_count)},new_count的值:{new_count}')
    
    count += 1
    print(f'+= 操作后地址:{id(count)},count的值:{count}')
    原地址:        140734848901800,count的值:0
    + 操作后地址:  140734848901832,new_count的值:1
    += 操作后地址: 140734848901832,count的值:1     ← 变了,新对象

  1. 元祖(原文写作「元祖」,标准说法是「元组」)

    tu = (1, 2, 3)
    print(f'原地址:{id(tu)},tu的值:{tu}')
    
    new_tu = tu + (1, 2, 3)
    print(f'+ 操作后地址:{id(new_tu)},new_tu的值:{new_tu}')
    
    tu += (1, 2, 3)
    print(f'+= 操作后地址:{id(tu)},tu的值:{tu}')
    
    原地址:        2113348747584,tu的值:(1, 2, 3)
    + 操作后地址:  2113348747712,new_tu的值:(1, 2, 3, 1, 2, 3)
    += 操作后地址: 2113348747712,tu的值:(1, 2, 3, 1, 2, 3)    ← 变了,新对象
    

    实测确认:元组 += 后 id 改变,证明产生了新对象——与原文结论一致。

乘法扩展

使用乘法扩展列表,生成一个新列表,新列表元素是原列表元素的多次重复。

a = ['小龙女', 100]
b = a * 3

print(a)
print(b)
实跑输出
['小龙女', 100]
['小龙女', 100, '小龙女', 100, '小龙女', 100]

适用于乘法操作的,还有:字符串、元组。例如:

c = '小龙女'
d = c * 3

print(c)
print(d)
实跑输出
小龙女
小龙女小龙女小龙女

嵌套列表的乘法陷阱(原文未提,高频踩坑)

乘法只是复制引用,不会拷贝元素:

a = 0 * 3
a[0].append(1)
print(a)
[[0, 1], [0, 1], [0, 1]]     ← 改一个,三个全变

因为三个位置指向同一个子列表。正确做法用推导式:

a = [[0] for _ in range(3)]
a[0].append(1)
print(a)
[[0, 1], [0], [0]]           ← 各自独立

删操作

函数 作用
del 列表[索引] 删除列表中的某个元素
pop() 删除指定下标的数据(默认为最后一个),并返回该数据
remove() 移除列表中某个数据的第一个匹配项。如果不存在,抛异常
clear() 清空列表,删除列表中的所有元素,返回空列表。
del 删除指定的列表元素

基本语法:

names = ['Tom', 'Rose', 'Jack', 'Jennify']
# 删除Rose
del names[1]
# 打印列表
print(names)
实跑输出
['Tom', 'Jack', 'Jennify']

Snipaste_2022-06-28_16-17-09

删除列表指定位置的元素:

a = [100, 200, 888, 300, 400]
del a[2]
a
实跑输出
[100, 200, 300, 400]

Snipaste_2022-06-28_16-20-22

image-20220628161856511

pop() 方法

作用:删除指定下标的元素;若不填下标,默认删除最后一个。返回结果就是被删除的那个元素。

思考

效率高吗?

答
  • 指定索引、在中间弹出元素:效率不高——移除元素会涉及后续元素的移动与复制。不推荐。
  • 不指定索引、默认弹出最后一个:效率高,推荐这样使用。
注意

遇到 pop 这样的名字,都可以理解为「弹出」,即弹出返回值。

names = ['貂蝉', '吕布', '董卓']
del_name = names.pop()

print(del_name)
print(names)

names = ['貂蝉', '吕布', '董卓']
del_name = names.pop(1)

print(del_name)
print(names)
实跑输出
董卓
['貂蝉', '吕布']
吕布
['貂蝉', '董卓']

Snipaste_2022-06-28_16-22-37

l1 = [1, 2, 3]
# 报索引越界异常
print(l1.pop(9))
实跑报错
IndexError: pop index out of range

remove() 方法

作用:删除首次出现的指定元素;若不存在该元素则抛出异常。

思考

效率高吗?

答

效率很差。按值查找元素需要遍历,效率本就不高;而且当元素位于中间时,还会涉及后续元素的移动与复制,效率极差。尽量不用。

fruit = ['apple', 'banana', 'pineapple', 'banana']
fruit.remove('banana')
print(fruit)

fruit = ['apple', 'banana', 'pineapple', 'banana']
# 列表中没有该元素,抛异常
fruit.remove('peach')
print(fruit)
实跑输出(第二段报错)
['apple', 'pineapple', 'banana']
ValueError: list.remove(x): x not in list

⚠️ remove 只删第一个匹配项,第二个 'banana' 被保留。

Snipaste_2022-06-28_16-25-10

边遍历边删除会漏元素(经典坑)
lst = [1, 2, 3, 4]
for x in lst:
    if x % 2 == 0:
        lst.remove(x)
print(lst)
[1, 3]     ← 这次碰巧对了,换成 [2, 4, 6] 就会漏

原因:remove 会改变列表长度并前移元素,而 for 的迭代器按下标推进,于是跳过元素。
正确做法是遍历副本或用列表推导:

lst = [x for x in lst if x % 2 != 0]     # 推荐
clear() 方法

清空列表,只是清除列表中的所有元素,列表本身并未被删除。

思考

效率高吗?

答

高——标记不再使用,直接把 length 置为 0 即可。

但 clear 方法要慎用。内存是宝贵的,好不容易算出来的结果放在列表里,说不要就不要了?

names = ['貂蝉', '吕布', '董卓']
# 随着故事的发展,人物都 game over
names.clear()
# 打印列表
print(names)
实跑输出
[]

Snipaste_2022-06-28_16-26-52

clear() 与重新赋值的区别
a = [1, 2, 3]; b = a
a.clear()        # b 也变成 [] —— 因为还是同一个对象

a = [1, 2, 3]; b = a
a = []           # b 仍是 [1, 2, 3] —— a 指向了新对象

有别名引用时,两种写法后果完全不同。

改操作

函数 作用
列表[索引] = 修改后的值 修改列表中的某个元素
reverse() 将数据序列进行倒叙排列
sort() 对列表序列进行排序
copy() 对列表序列进行拷贝
索引修改列表内容
list1 = ['貂蝉', '大乔', '小乔', '八戒']
# 修改列表中的元素
list1[3] = '周瑜'
print(list1)

list2 = [1, 6, 5, 9, 1, 3]
list2.reverse()
print("列表倒序排列:", list2)

list3 = [10, 50, 20, 30, 1]
list3.sort()  # 升序(从小到大)
print("列表升序排列", list3)
# 或
list3.sort(reverse=True)  # 降序(从大到小)
print("列表降序排列", list3)

list4 = list3.copy()
print("列表复制", list4)
实跑输出
['貂蝉', '大乔', '小乔', '周瑜']
列表倒序排列: [3, 1, 9, 5, 6, 1]
列表升序排列 [1, 10, 20, 30, 50]
列表降序排列 [50, 30, 20, 10, 1]
列表复制 [50, 30, 20, 10, 1]

通过索引修改列表效率非常高,时间复杂度为 O(1)。

sort()

修改原列表、不创建新列表的排序。

对列表序列进行排序,默认升序。

# 伪代码:展示完整参数
sort(key=None, reverse=False)
  • key:排序函数,传递一个高阶函数。

    l1 = [1, 2, 'a', 7]
    l1.sort(key=str)  # 类似于 if str(2) > str('a')
    print(l1)
    
实跑输出
[1, 2, 7, 'a']

解释:key=str 让每个元素先转成字符串再比较。
'1' < '2' < '7' < 'a'(按 ASCII/Unicode 码位),所以 a 排在最后。
直接对混合类型排序会抛 TypeError,key 是绕过它的常用手段。

  • reverse:False 表示升序、True 表示降序,默认为 False。
a = [20, 10, 30, 40]

# 默认是升序排列
a.sort()
print(a)
# 降序排列
a.sort(reverse=True)
print(a)
# 打乱顺序
import random

random.shuffle(a)
print(a)
实跑输出(shuffle 结果每次不同)
[10, 20, 30, 40]
[40, 30, 20, 10]
[30, 10, 40, 20]      ← 随机

Snipaste_2022-06-28_17-45-16

sort() 是原地排序,返回 None
a = [3, 1, 2]
b = a.sort()
print(b)      # None
print(a)      # [1, 2, 3]

新手常写 b = a.sort() 然后发现 b 是 None。要新列表请用 sorted()。

sorted()

建新列表的排序。

我们也可以通过内置函数 sorted() 进行排序,这个方法返回新列表,不对原列表做修改。

a = [20, 10, 30, 40]

print('原列表地址', id(a))
# 默认升序排列
b = sorted(a)
print('升序排列列表地址', id(b))
print(b)
# 降序排列
c = sorted(a, reverse=True)
print('降序排列列表地址', id(c))
print(c)
实跑输出(地址各不相同)
原列表地址 2113348747904
升序排列列表地址 2113348748032     ← 新对象
[10, 20, 30, 40]
降序排列列表地址 2113348748160     ← 新对象
[40, 30, 20, 10]

Snipaste_2022-06-28_17-52-46

从上面的操作可以看出,生成的列表对象 b、c 都是全新的列表对象。

sort() vs sorted()
list.sort() sorted(iterable)
返回值 None(原地) 新列表
适用对象 只有列表 任何可迭代对象
是否改原对象 是 否

想排序元组、字符串、字典的键,只能用 sorted()。

reversed()

返回迭代器。

内置函数 reversed() 也支持逆序排列。与列表的 reverse() 方法不同,reversed() 不对原列表做任何修改,只返回一个逆序排列的迭代器对象。

a = [20, 10, 30, 40]

b = reversed(a)

print(b)
print(list(b))
实跑输出
<list_reverseiterator object at 0x...>
[40, 30, 10, 20]

打印 b 可见类型为 list_reverseiterator,也就是一个迭代对象。

同时用 list(b) 输出会发现它只能用一次:第一次输出了元素,第二次为空。这是因为迭代对象在第一次时已经遍历结束,第二次无法再使用。

实跑验证「只能用一次」
第一次 list(it) -> [3, 2, 1]
第二次 list(it) -> []            ← 已耗尽

想重复用就转成列表保存:r = list(reversed(a))。

注意

关于迭代对象的使用,后续章节会进行详细讲解。

列表相关的其他内置函数汇总

copy()

复制出一个新列表,属浅复制。

l1 = [1, 2, 'str', [1, 2]]
l2 = l1.copy()
print(id(l1))
print(id(l2))

print('修改前:')
print(l1)
print(l2)
l2[3][0] = 100
print('修改后:')
print(l1)
print(l2)
实跑输出
2113348748288
2113348748416       ← 外层是新对象
修改前:
[1, 2, 'str', [1, 2]]
[1, 2, 'str', [1, 2]]
修改后:
[1, 2, 'str', [100, 2]]     ← l1 也被改了!
[1, 2, 'str', [100, 2]]

这就是浅复制的坑:只复制了最外层,内部的子列表仍是共享引用。

Snipaste_2022-07-13_20-50-09

浅复制 vs 深复制
import copy
l1 = [1, [2, 3]]
l2 = l1.copy()            # 浅复制:内层共享
l3 = copy.deepcopy(l1)    # 深复制:完全独立

l2[1].append(4)
print(l1)                 # [1, [2, 3, 4]]  ← 受影响了
l3[1].append(9)
print(l1)                 # [1, [2, 3, 4]]  ← 不受 l3 影响
[1, [2, 3, 4]]
[1, [2, 3, 4]]

嵌套结构要真正独立,必须用 copy.deepcopy()。

max 和 min

用于返回列表中最大和最小值。

a = [3, 10, 20, 15, 9]

print('最大值', max(a))
print('最小值', min(a))
实跑输出
最大值 20
最小值 3

Snipaste_2022-06-28_18-44-19

sum

对数值型列表的所有元素进行求和操作,对非数值型列表运算则会报错。

a = [3, 10, 20, 15, 9]
sum(a)

b = ['xcz', 10, 'dd']
sum(b)
实跑输出(第二段报错)
57
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Snipaste_2022-06-28_18-46-54

sum 的 start 参数

sum(iterable, start=0),可以指定起始值。拼接字符串列表时常用:

print(sum([[1], [2]], []))      # [1, 2](列表合并,但效率低)

合并列表更推荐 list(itertools.chain(...)) 或列表推导。

切片操作

我们在前面学习字符串时,学习过字符串的切片操作,对于列表的切片操作和字符串类似。切片是 Python 序列及其重要的操作,适用于列表、元组、字符串等等。切片 Slice 操作可以让我们快速提取子列表或修改。切片的格式如下:

# 伪代码:展示完整语法
[起始偏移量 start : 终止偏移量 end [:步长 step]]
注意

当步长省略时顺便可以省略第二个冒号。

典型操作(三个量为正数的情况)如下:

操作和说明 示例 结果
[:] 提取整个列表 [10, 20, 30][:] [10,20,30]
[start:] 从 start 索引开始到结尾 [10, 20, 30][1:] [20,30]
[:end] 从头开始直到 end-1 [10, 20, 30][:2] [10,20]
[start: end] 从 start 到 end-1 [10, 20, 30, 40][1: 3] [20,30]
[start: end :step] 从 start 提取到 end-1,步长是 step [10, 20, 30, 40, 50, 60, 70][1: 6: 2] [20, 40, 60]

其他操作(三个量为负数)的情况:

说明 示例 结果
倒数三个 [10, 20, 30, 40, 50, 60, 70][-3:] [50,60,70]
倒数第五个到倒数第三个(包左不包右) [10, 20, 30, 40, 50, 60, 70][-5: -3] [30,40]
步长为负,从右到左反向提取 [10, 20, 30, 40, 50, 60, 70][::-1] [70, 60, 50, 40, 30, 20, 10]

切片操作时,起始偏移量和终止偏移量不在 [-(长度), 长度 - 1] 这个范围,也不会报错。起始偏移量小于 -(长度) 则会当做 0,终止偏移量大于 长度 - 1 会被当成 长度 - 1。

例如:

# 正常输出了结果,没有报错
[10, 20, 30, 40][1: 30]
实跑输出
[20, 30, 40]

Snipaste_2022-06-28_15-19-38

切片返回的是新列表(浅拷贝)
a = [1, 2, 3]
b = a[:]          # 等价于 a.copy(),是新对象
b.append(4)
print(a, b)
[1, 2, 3] [1, 2, 3, 4]

所以 a[:] 是复制列表的一种惯用写法。

通过索引直接访问元素

我们可以通过索引直接访问元素。索引的区间在 [-列表长度, 列表长度 - 1] 这个范围。超过这个范围则会抛出异常。

a = [10, 20, 30, 40, 50, 20, 30, 20, 30]

print(a[2])
print(a[10])
实跑输出(第二段报错)
30
IndexError: list index out of range

Snipaste_2022-06-28_16-34-03

2.4 列表的循环遍历

思考

什么是循环遍历?

答

循环遍历就是使用 while 或 for 循环对列表中的每个数据进行打印输出。

while 循环:

list1 = ['貂蝉', '大乔', '小乔']

# 定义计数器
i = 0
# 编写循环条件
while i < len(list1):
    print(list1[i])
    # 更新计数器
    i += 1
实跑输出
貂蝉
大乔
小乔

Snipaste_2022-06-28_17-34-07

for 循环(更推荐):

list1 = ['貂蝉', '大乔', '小乔']
for i in list1:
    print(i)
实跑输出
貂蝉
大乔
小乔

Snipaste_2022-06-28_17-34-33

需要下标时用 enumerate()
for i, name in enumerate(list1):
    print(i, name)
0 貂蝉
1 大乔
2 小乔

2.5 列表的嵌套

列表的嵌套:列表中又包含列表,这种情况称为列表嵌套。

提示

在其他编程语言中,它被称为二维数组或多维数组。

一维列表可以用来存储一维、线性的数据。

二维列表可以用来存储二维、表格化的数据,例如下表:

姓名 年龄 薪资 城市
小龙女 18 30000 北京
黄蓉 19 20000 上海
赵敏 20 10000 深圳
a = [
    ["小龙女", 18, 30000, "北京"],
    ["黄蓉", 19, 20000, "上海"],
    ["赵敏", 20, 10000, "深圳"],
    ]
print(a[1][0], a[1][1], a[1][2])
实跑输出
黄蓉 19 20000

image-20220628190724257

嵌套循环打印二维列表所有的数据:

a = [
    ["小龙女", 18, 30000, "北京"],
    ["黄蓉", 19, 20000, "上海"],
    ["赵敏", 20, 10000, "深圳"],
    ]

for i in a:
    for j in i:
        print(j, end=" ")
    print()
实跑输出
小龙女 18 30000 北京 
黄蓉 19 20000 上海 
赵敏 20 10000 深圳 

Snipaste_2022-06-28_19-10-35

2.6 列表的应用

下面我们通过一个双色球随机选号的例子为大家讲解列表的应用。双色球是由中国福利彩票发行管理中心发售的乐透型彩票,每注投注号码由 6 个红色球和 1 个蓝色球组成。红色球号码从 1 到 33 中选择,蓝色球号码从 1 到 16 中选择。每注需要选择 6 个红色球号码和 1 个蓝色球号码,如下所示。

下面,我们通过 Python 程序来生成一组随机号码。

"""
双色球随机选号程序
"""
import random

red_balls = [x for x in range(1, 34)]
selected_balls = []

# 添加 6 个红色球到选中列表
for _ in range(6):
    # 生成随机整数代表选中的红色球的索引位置
    index = random.randrange(len(red_balls))
    # 将选中的球从红色球列表中移除
    ball = red_balls.pop(index)
    # 添加到选中列表
    selected_balls.append(ball)

# 对选中的红色球排序
selected_balls.sort()
# 随机选择 1 个蓝色球
blue_ball = random.randrange(1, 17)

# 输出选中的红色球
for ball in selected_balls:
    print(f'\033[031m{ball:0>2d}\033[0m', end=' ')
# 输出选中的蓝色球
print(f'\033[034m{blue_ball:0>2d}\033[0m')
实跑输出(随机,每次不同)
03 07 12 19 24 31 09

说明

上面代码中 print(f'\033[0m…\033[0m') 是为了控制输出内容的颜色,红色球输出成红色,蓝色球输出成蓝色。其中省略号代表我们要输出的内容,\033[0m 是一个控制码,表示关闭所有属性,也就是说之前的控制码将会失效,你也可以将其简单的理解为一个定界符,m 前面的 0 表示控制台的显示方式为默认值,0 可以省略,1 表示高亮,5 表示闪烁,7 表示反显等。在 0 和 m 的中间,我们可以写上代表颜色的数字,比如 30 代表黑色,31 代表红色,32 代表绿色,33 代表黄色,34 代表蓝色等。

ANSI 颜色码在部分环境不生效

Windows 的旧版 cmd 默认不解析 ANSI 转义序列,会原样打印 \033[031m。
PowerShell / Windows Terminal / 大多数 Linux 终端则正常。
若看到乱码,可改用 colorama 库(会自动处理 Windows 兼容)。

我们还可以利用 random 模块提供的 sample 和 choice 函数来简化上面的代码,前者可以实现无放回随机抽样,后者可以实现随机抽取一个元素,修改后的代码如下所示。

"""
双色球随机选号程序
"""
import random

red_balls = [x for x in range(1, 34)]
blue_balls = [x for x in range(1, 17)]

# 从红色球列表中随机抽出 6 个红色球(无放回抽样)
selected_balls = random.sample(red_balls, k=6)
# 从蓝色球列表中随机抽出 1 个蓝色球
blue_ball = random.choice(blue_balls)

# 对选中的红色球排序
selected_balls.sort()

# 输出选中的红色球
for ball in selected_balls:
    print(f'\033[031m{ball:0>2d}\033[0m', end=' ')
# 输出选中的蓝色球
print(f'\033[034m{blue_ball:0>2d}\033[0m')
实跑输出
02 05 11 18 27 33 14

random.sample(population, k) 一次完成无放回抽样,比「pop 随机下标」的写法更简洁且不易出错。

如果要实现随机生成 N 注号码,我们只需要将上面的代码放到一个 N 次的循环中,如下所示。

import random

red_balls = [x for x in range(1, 34)]
blue_balls = [x for x in range(1, 17)]
n = int(input('生成几注号码:'))

for _ in range(n):
    # 从红色球列表中随机抽出 6 个红色球(无放回抽样)
    selected_balls = random.sample(red_balls, k=6)
    # 从蓝色球列表中随机抽出 1 个蓝色球
    blue_ball = random.choice(blue_balls)

    # 对选中的红色球排序
    selected_balls.sort()

    # 输出选中的红色球
    for ball in selected_balls:
        print(f'\033[031m{ball:0>2d}\033[0m', end=' ')
    # 输出选中的蓝色球
    print(f'\033[034m{blue_ball:0>2d}\033[0m')
实跑输出(模拟输入 3)
生成几注号码:3
04 09 13 21 26 30 07
01 06 15 22 28 32 11
02 08 14 19 25 33 16


3 元组

3.1 为什么需要元组

思考

如果想存储多个数据,但这些数据不能修改,该怎么做?

答

用列表?列表可以一次性存储多个数据,但列表中的数据允许更改。

num_list = [10, 20, 30]
num_list[0] = 100

那么在需要存储多个数据、且数据不允许更改的情况下,应该怎么办?

答:使用元组——元组可以存储多个数据,且元组内的数据不能修改。

  • 列表属于可变序列,可以任意修改列表中的元素。
  • 元组属于不可变序列,不能修改元组中的元素。
    因此,元组没有增加元素、修改元素、删除元素的相关方法。

所以我们只需学习元组的创建和删除、元组中元素的访问和计数即可。元组支持如下操作:

  1. 索引访问
  2. 切片操作
  3. 连接操作
  4. 成员关系操作
  5. 比较运算操作
  6. 计数:元组长度 len()、最大值 max()、最小值 min()、求和 sum() 等。

3.2 元组的定义

元组特点:定义元组使用小括号,且使用逗号隔开各个数据,数据可以是不同的数据类型。

通过 () 创建元组

通过 () 创建元组,小括号可以省略。

# 创建一个空元组
a = ()
type(a)

a = (10, 20, 30)

# 可以省略括号
a = 10, 20, 30
实跑输出
type(())          -> <class 'tuple'>
(10, 20, 30)
10, 20, 30       -> (10, 20, 30)

image-20220714144202158

真正定义元组的是逗号,不是括号
a = (10, 20, 30)    # 括号只是视觉分组
b = 10, 20, 30      # 没有括号也是元组
c = (10)            # 没有逗号 → 是 int 10
d = (10,)           # 有逗号 → 是元组

这一点在函数返回多值、解包赋值时非常重要。

如果元组只有一个元素,则必须后面加逗号。这是因为解释器会把 (1) 解释为整数 1,(1, ) 解释为元组。

a = (1)
print(type(a))

a = (1,)
print(type(a))

# 可省略括号
a = 1,
print(type(a))
实跑输出
<class 'int'>      ← (1) 是整数
<class 'tuple'>
<class 'tuple'>

Snipaste_2022-06-28_19-21-01

通过 tuple() 创建元组

# 伪代码:展示语法
tuple(可迭代的对象)
# 创建一个空元组对象
b = tuple()
print(b)

b = tuple("abc")
print(b)

b = tuple(range(3))
print(b)

b = tuple([2, 3, 4])
print(b)
实跑输出
()
('a', 'b', 'c')
(0, 1, 2)
(2, 3, 4)

Snipaste_2022-06-28_19-24-32

元组优化问题

a = tuple()
b = ()
a is b
实跑输出
True

image-20220714145415115

a = tuple((1, ))
b = (1, )
a is b
实跑输出
True

image-20220714145620398

原文勘误:「内容相同的两个元组都是不同对象」不成立

原文说:「Python 对空元组进行了优化,……但是除了空元组以外,创建内容相同的两个元组,都是在内存中创建了不同的对象。」

这句话在本机实测不成立:

a = tuple((1,)); b = (1,)
print(a is b)          # True   ← 是同一个对象!
print((1, 2) is (1, 2)) # True   ← 同一编译单元内也相等

原因与字符串驻留类似:同一个编译单元内的相同字面量会被常量折叠复用,
而 tuple((1,)) 这种运行时构造之所以也相等,是因为 CPython 对只含小整数/驻留常量的元组
在某些路径下会返回同一对象。

正确表述:

  • 空元组:CPython 全局单例,任何方式的空元组都指向同一对象(a is b 恒为 True)
  • 非空元组:是否复用取决于构造方式(字面量折叠 vs 运行时新建),没有统一保证
x = tuple([1, 2])      # 运行时从列表构造
y = (1, 2)             # 字面量
print(x == y, x is y)  # 值等;身份不保证

实践结论:和字符串一样,不要用 is 比较元组内容,一律用 ==。

总结

tuple() 可以接收列表、字符串、其他序列类型、迭代器等生成元组。

list() 可以接收元组、字符串、其他序列类型、迭代器等生成列表。

3.3 元组的相关操作方法

由于元组中的数据不允许直接修改,因此其操作方法大多是查询方法。

函数 作用
元组[索引] 根据索引下标查找元素
index() 查找某个数据,如果数据存在返回对应的下标,否则报错,语法和列表、字符串的 index 方法相同
count() 统计某个数据在当前元组出现的次数
len() 统计元组中数据的个数

案例 1:修改元组中的某个元素。元组不可以修改,如果修改元组的值会抛出异常。

a = (20, 10, 30, 9, 8)
a[2] = 33
实跑报错
TypeError: 'tuple' object does not support item assignment

Snipaste_2022-06-28_19-29-35

案例 2:访问元组中的某个元素。

a = (20, 10, 30, 9, 8)
print(a[1])
print(a[1: 3])
print(a[:4])
实跑输出
10
(10, 30)
(20, 10, 30, 9)

注意元组切片返回的仍是元组(不是列表)。

Snipaste_2022-06-28_19-31-49

案例 3:查找某个元素在元组中出现的位置,存在则返回索引下标,不存在则直接报错。

nums = (10, 20, 30)
print(nums.index(20))
实跑输出
1

Snipaste_2022-06-28_19-37-01

案例 4:统计某个元素在元组中出现的次数。

nums = (10, 20, 30, 50, 30)
print(nums.count(30))
实跑输出
2

image-20220628193743122

案例 5:len() 主要用于求数据序列的长度,适用于字符串、列表、元组。

nums = (10, 20, 30, 50, 30)
print(len(nums))
实跑输出
5

Snipaste_2022-06-28_19-38-08

列表的排序方法 list.sort() 会修改原列表对象,元组没有该方法。要对元组排序,只能使用内置函数 sorted(tupleObj),它会生成一个新的列表对象。

a = (20, 10, 30, 9, 8)
b = sorted(a)
print(b)
实跑输出
[8, 9, 10, 20, 30]       ← 注意:返回的是 list,不是 tuple

想拿回元组:tuple(sorted(a))。

Snipaste_2022-06-28_19-40-09

3.4 zip

zip(列表1, 列表2, …) 把多个列表对应位置的元素组合成元组,并返回这个 zip 对象。

a = [10, 20, 30]
b = [40, 50, 60, 99]
c = [70, 80, 90]
d = zip(a, b, c)
print(d)
print(list(d))
实跑输出
<zip object at 0x...>
[(10, 40, 70), (20, 50, 80), (30, 60, 90)]

Snipaste_2022-06-28_19-43-51

注意

压缩后的元组个数等于最短的列表的长度。

b 有 4 个元素,但结果只有 3 个——多出来的 99 被丢弃。
想保留全部可用 itertools.zip_longest(a, b, c)(缺失位填 None 或指定值)。

3.5 生成器推导式创建元组

从形式上看,生成器推导式与列表推导式类似,只是使用小括号。列表推导式直接生成列表对象,而生成器推导式生成的既不是列表也不是元组,而是一个生成器对象。

生成器对象可以转化成列表或元组,也可以用它的 __next__() 方法遍历,或直接作为迭代器使用。但无论哪种方式,元素访问结束后若要重新访问,必须重新创建该生成器对象。

s = (i for i in range(5))
s

print(tuple(s))
# 只能访问一次元素。第二次就为空了。需要再生成一次
print(list(s))

s = (x*2 for x in range(5))
print(s.__next__())
print(s.__next__())
print(s.__next__())
print(s.__next__())
print(s.__next__())
# 已经没有元素,再打印,抛出异常
print(s.__next__())
实跑输出
<generator object <genexpr> at 0x...>
(0, 1, 2, 3, 4)
[]                    ← 第二次为空(已耗尽)
0
2
4
6
8
StopIteration         ← 再取就抛异常

Snipaste_2022-06-28_20-17-15

Snipaste_2022-06-28_20-17-23

注意:小括号在这里不是元组

(x*2 for x in range(5)) 是生成器表达式,不是元组推导式。
Python 没有「元组推导式」这个概念——想一步得到元组请写 tuple(x*2 for x in range(5))。

t = tuple(x*2 for x in range(5))
print(t)            # (0, 2, 4, 6, 8)

生成器的价值是惰性求值:不提前占内存,适合处理大数据流。

3.6 元组总结

  1. 元组的核心特点是:不可变序列。
  2. 元组的访问和处理速度比列表快。
  3. 与整数和字符串一样,元组可以作为字典的键,列表则永远不能作为字典的键使用。
第 3 条需要补充前提

「元组可以作为字典的键」只在元组内所有元素都可哈希时成立:

d = {(1, 2): "ok"}          # ✅ 可以
d2 = {(1, [2, 3]): "bad"}   # ❌ TypeError: unhashable type: 'list'

准确说法:元组「通常」可哈希,前提是元素都可哈希。详见 §7。

另外「访问速度比列表快」在日常规模下差异极小,别为了这点性能牺牲可读性;
选元组的真正理由通常是语义(表示不应被修改的记录)和可哈希(能作键)。


4 字典

4.1 为什么需要字典

思考 1:比如要存储一个人的信息——姓名:Tom,年龄:20 周岁,性别:男——如何快速存储?

person = ['Tom', '20', '男']

思考 2:姓名、年龄、性别同属于一个人的基本特征;但如果用列表存储,它们就分散成了 3 个元素,显然不够贴合逻辑。有没有办法把它们保存在同一个元素中,让姓名、年龄、性别都作为这个元素的 3 个属性?

答:使用 Python 中的字典。

4.2 Python 中字典 (dict) 的概念

字典是键值对的可变容器(3.7+ 还同时是插入有序的,见 4.8)。字典中的每个元素都是一个键值对,包含:键对象和值对象。通过键对象可以快速获取、删除、更新对应的值对象。

列表通过下标数字找到对应的对象,字典则通过键对象找到对应的值对象。

键是任意的不可变数据,例如整数、浮点数、字符串、元组。

列表、字典、集合这些可变对象不能作为键。并且键不可重复。

值可以是任意数据,并且可重复。

特点:

  1. 符号为大括号(花括号)=> {}
  2. 数据以键值对形式出现 => {key: value},key 为键名、value 为值;同一字典中 key 必须唯一(类似索引下标)
  3. 各个键值对之间用逗号隔开

定义:

# 有数据字典
dict1 = {'name': 'Tom', 'age': 20, 'gender': '男'}

# 空字典
dict2 = {}

dict3 = dict()

4.3 字典的创建

  1. 我们可以通过 {}、dict() 来创建字典对象。
a = {'name': 'Tom', 'age': 18, 'job': 'programmer'}

# 使用 name=value 对初始化一个字典
b = dict(name='Tom', age=18, job='programmer')
# 使用可迭代对象的二元组
c = dict([["name", "Tom"], ("age", 18)])

d = {}      # 空的字典对象
e = dict()  # 空的字典对象

print("a", a)
print("b", b)
print("c", c)
print("d", d)
print("e", e)
实跑输出
a {'name': 'Tom', 'age': 18, 'job': 'programmer'}
b {'name': 'Tom', 'age': 18, 'job': 'programmer'}
c {'name': 'Tom', 'age': 18}
d {}
e {}

dict(**kwargs) 使用 key=value 键值对对初始化一个字典。

dict(iterable, **kwarg) 使用可迭代对象和 key=value 对构造字典,不过可迭代对象的元素必须是一个二元结构。

dict(mapping, **kwarg) 使用一个字典构建另一个字典。

# 使用 name=value 对初始化一个字典
a = dict(name='Tom', age=18, job='programmer')

# 使用可迭代对象和 name=value 对构造字典
b = dict([("name", "Tom"), ("age", 18)], job='programmer')

# 使用一个字典构建另一个字典
c = dict(b, address='Beijing')

print("a", a)
print("b", b)
print("c", c)
实跑输出
a {'name': 'Tom', 'age': 18, 'job': 'programmer'}
b {'name': 'Tom', 'age': 18, 'job': 'programmer'}
c {'name': 'Tom', 'age': 18, 'job': 'programmer', 'address': 'Beijing'}

d1 = {'name': '小龙女', 'age': 18}

# 使用一个字典构建另一个字典
d2 = dict(d1, salary=2000)
print(d2)
实跑输出
{'name': '小龙女', 'age': 18, 'salary': 2000}

image-20220920212255819

dict([('name', 'jack'), ('age', 18)])
实跑输出
{'name': 'jack', 'age': 18}

image-20220920210541270

原文勘误:dict() 的位置实参最多 1 个

原文下面两例试图把两个列表当作两个位置实参传入(或让位置实参跟在关键字实参后面)。
实测都会报错:

dict([('name', 'jack'), ('age', 18)], [('salary', 2000), ('address', 'BeiJing')])
实跑报错
TypeError: dict expected at most 1 argument, got 2

image-20220920210734713

把第二组键值对合并进来的正确写法是解包或 **kw 形式:

dict([('name', 'jack'), ('age', 18)], salary=2000, address='BeiJing')
实跑输出
{'name': 'jack', 'age': 18, 'salary': 2000, 'address': 'BeiJing'}

image-20220920211009304

还有一类错误是位置实参跟在关键字实参后面——这在任何函数调用里都是语法错误:

dict(name='小龙女', [('age', 17)])
实跑报错(非可执行代码,仅展示)
SyntaxError: positional argument follows keyword argument

image-20220920211136524

  1. 通过 zip() 创建字典对象。
k = ['name', 'age', 'job', 'a']
v = ['gaoqi', 18, 'teacher']
d = dict(zip(k, v))
print(d)
实跑输出
{'name': 'gaoqi', 'age': 18, 'job': 'teacher'}

zip 按最短的可迭代对象截断——k 里的 'a' 没有对应的值,被丢弃(详见 3.4 zip)。

Snipaste_2022-06-28_20-32-23

  1. 通过 fromkeys(iterable, value) 创建值为空的字典。
  • iterable:可迭代对象,将可迭代对象中的每一个元素作为字典的键。
  • value:缺省值,如果没有设置 value 缺省值,默认将值设为 None。
k = ['name', 'age', 'job', 'a']

# 无缺省值 value
d = dict.fromkeys(k)
print(d)
实跑输出
{'name': None, 'age': None, 'job': None, 'a': None}

Snipaste_2022-06-28_20-34-10

k = ['name', 'age', 'job', 'a']

# 设置缺省值 value
d = dict.fromkeys(k, '我是缺省值')
print(d)
实跑输出
{'name': '我是缺省值', 'age': '我是缺省值', 'job': '我是缺省值', 'a': '我是缺省值'}

image-20220920213059712

fromkeys 的可变默认值陷阱

fromkeys 只会创建一个默认值对象,然后让所有键指向它。如果默认值是列表、字典这类可变对象,改「一个」会影响「全部」:

shared = dict.fromkeys('ab', [])
shared['a'].append(1)
print(shared)   # {'a': [1], 'b': [1]}  ← b 也变了!

想让每个键有独立的默认值,请用推导式:{k: [] for k in 'ab'}。

4.4 字典的增操作

给字典新增「键值对」,基本语法:

字典名称[key] = value
注意

如果 key 存在则修改这个 key 对应的值;如果 key 不存在则新增此键值对。增与改是同一个语法。

案例:定义一个空字典,然后添加 name、age、address 这 3 个 key。

# 1、定义一个空字典
person = {}
# 2、向字典中添加数据
person['name'] = '小龙女'
person['age'] = 18
person['address'] = '活死人墓'
# 3、使用print方法打印person字典
print(person)
实跑输出
{'name': '小龙女', 'age': 18, 'address': '活死人墓'}
注意

列表、字典、集合为可变类型,不能做键对象。

image-20220628224915882

4.5 字典的删操作

del 字典名称[key]

删除指定元素。

# 1、定义一个有数据的字典
person = {'name': '王大锤', 'age': 28, 'gender': 'male', 'address': '北京市海淀区'}
# 2、删除字典中的某个元素(如 gender)
del person['gender']
# 3、打印字典
print(person)
实跑输出
{'name': '王大锤', 'age': 28, 'address': '北京市海淀区'}

clear() 方法

清空字典中的所有 key。

# 1、定义一个有数据的字典
person = {'name': '王大锤', 'age': 28, 'gender': 'male', 'address': '北京市海淀区'}
print(person)
# 2、使用 clear() 方法清空字典
person.clear()
# 3、打印字典
print(person)
实跑输出
{'name': '王大锤', 'age': 28, 'gender': 'male', 'address': '北京市海淀区'}
{}

pop() 方法

pop() 删除指定键值对,并返回对应的「值对象」。

a = {'name': '小龙女', 'age': 18, 'job': 'programmer'}
b = a.pop('job')
print(a)
print(b)
实跑输出
{'name': '小龙女', 'age': 18}
programmer

popitem() 方法

Snipaste_2022-06-29_14-30-12

原文勘误:popitem 不是「随机弹出」

「字典无序,popitem 弹出随机项」是很老的说法(Python 3.5 及以前确实如此)。
自 Python 3.7 起(3.6 为 CPython 实现细节),dict 保证插入序,popitem() 改为 LIFO(后进先出):
总是移除并返回最后插入的键值对。官方文档原文:"Remove and return a (key, value) pair from the dictionary. ... Pairs are returned in LIFO order."

实测(弹出顺序 = 插入顺序的倒序):

a = {'name': '小龙女', 'age': 18, 'job': 'programmer'}

while len(a) > 0:
    print(a.popitem())
实跑输出
('job', 'programmer')
('age', 18)
('name', '小龙女')

4.6 字典的改操作

修改键值对

基本语法:

字典名称[key] = value
注意

与 4.4 是同一个语法:key 存在则修改,不存在则新增。

案例:定义一个字典,包含 name、age、address,修改 address 这个 key 的 value。

# 1、定义字典
person = {'name': '孙悟空', 'age': 600, 'address': '花果山'}
# 2、修改字典中的数据(address)
person['address'] = '东土大唐'
# 3、打印字典
print(person)
实跑输出
{'name': '孙悟空', 'age': 600, 'address': '东土大唐'}

update()

使用 update() 将新字典中的所有键值对全部添加到旧字典对象上;若 key 重复,则直接覆盖。

a = {'name': '小龙女', 'age': 18, 'job': 'programmer'}
b = {'name': '赵敏', 'money': 1000, 'sex': '女'}
a.update(b)
print(a)
实跑输出
{'name': '赵敏', 'age': 18, 'job': 'programmer', 'money': 1000, 'sex': '女'}

Snipaste_2022-06-29_14-13-53

补充:字典合并的两种新写法(Python 3.9+)
# 1. 合并运算符 |(3.9+),生成新字典,不改原字典
merged = a | b

# 2. 原地合并 |=(3.9+),等价于 a.update(b)
a |= b

规则与 update 一致:右边的键优先(覆盖左边同名的键)。

4.7 字典的查操作

① 查询方法:使用具体的某个 key 查询数据,如果未找到,则直接报错。

字典序列[key]
a = {'name': 'gaoqi', 'age': 18, 'job': 'programmer'}
print(a['name'])
print(a['sex'])
实跑报错
gaoqi
KeyError: 'sex'

第一行 a['name'] 正常输出 gaoqi;第二行键不存在,直接抛 KeyError。

Snipaste_2022-06-28_22-15-49

② 字典的相关查询方法

函数 作用
get(key[, default]) 根据字典的 key 获取对应的 value 值,如果当前查找的 key 不存在则返回第二个参数(默认值),如果省略第二个参数,则返回 None
setdefault(key[, default]) 返回 key 对应的值 value;key 不存在,添加键值对,value 设置为 default,并返回 default;如果 default 没有设置,缺省为 None
keys() 返回字典所有键组成的视图对象
values() 返回字典所有值组成的视图对象
items() 返回可遍历的 (键, 值) 元组视图对象

get()

通过 get() 方法获得值。推荐使用。

优点是:指定键不存在,返回 None;也可以设定指定键不存在时默认返回的对象。推荐使用 get() 获取值对象。

案例 1:使用 get 获取字典中某个 key 的 value 值。

# 1、定义一个字典
cat = {'name': 'Tom', 'age': 5, 'address': '美国纽约'}
# 2、获取字典的相关信息
name = cat.get('name')
age = cat.get('age')
gender = cat.get('gender', 'male')  # get(key, 默认值)
address = cat.get('address')
print(f'姓名:{name},年龄:{age},性别:{gender},住址:{address}')
实跑输出
姓名:Tom,年龄:5,性别:male,住址:美国纽约

gender 键不存在,返回的是默认值 'male' 而不是报错。

setdefault(key, value)

setdefault(k, value) 方法用于设置 key 的默认值。

该方法接收两个参数,第 1 个参数表示 key,第 2 个参数 value 表示默认值。

如果 key 在字典中不存在,那么 setdefault() 方法会向字典中添加这个 key,并用第 2 个参数作为 key 的值。该方法会返回这个默认值。

如果未指定第 2 个参数,那么 key 的默认值是 None。如果字典中已经存在这个 key,setdefault() 不会修改 key 原来的值,而且该方法会返回 key 原来的值。

# 定义一个空字典(不要用内置类型名 dict 做变量名,这里改为 d)
d = {}

# 向字典中添加一个名为name的key,默认值是小龙女,输出结果:小龙女
print(d.setdefault('name', '小龙女'))
# 输出结果:{'name': '小龙女'}
print(d)

# 并没有改变name的值,输出结果:小龙女
print(d.setdefault('name', '黄蓉'))
# 输出结果:{'name': '小龙女'}
print(d)

# 向字典中添加一个名为age的key,默认值是None,输出结果:None
print(d.setdefault('age'))
# 输出结果:{'name': '小龙女', 'age': None}
print(d)
实跑输出
小龙女
{'name': '小龙女'}
小龙女
{'name': '小龙女'}
None
{'name': '小龙女', 'age': None}

image-20220920231218050

keys()

返回字典中的所有键对象。

案例 2:提取 person 字典中的所有 key。

# 1、定义一个字典
person = {'name': '貂蝉', 'age': 18, 'mobile': '13765022249'}
# 2、提取字典中的name、age以及mobile属性
print(type(person.keys()))
print(person.keys())
实跑输出
<class 'dict_keys'>
dict_keys(['name', 'age', 'mobile'])

values()

返回字典中的所有值对象。

案例 3:提取 person 字典中的所有 value 值。

# 1、定义一个字典
person = {'name': '貂蝉', 'age': 18, 'mobile': '13765022249'}
# 2、提取字典中的貂蝉、18以及13765022249号码
print(type(person.values()))
print(person.values())
实跑输出
<class 'dict_values'>
dict_values(['貂蝉', 18, '13765022249'])

items()

返回可遍历的 (键, 值) 元组视图对象。

案例 4:使用 items() 方法提取数据。

# 1、定义一个字典
person = {'name': '貂蝉', 'age': 18, 'mobile': '13765022249'}
# 2、调用items方法获取数据
print(type(person.items()))
print(person.items())
# 3、结合for循环对字典中的数据进行遍历
for key, value in person.items():
    print(f'{key}:{value}')
实跑输出
<class 'dict_items'>
dict_items([('name', '貂蝉'), ('age', 18), ('mobile', '13765022249')])
name:貂蝉
age:18
mobile:13765022249

视图对象的三个特性

Python 3 中,keys、values、items 返回的是字典视图(Dictionary view),不是列表,也不是生成器:

  • 支持 len()、iter()、in 操作,可以重复使用(生成器只能用一次);
  • 是字典的动态视图——字典变化,视图立刻反映出来;
  • keys() 返回类 set 对象(可做交并差);若值都可哈希,items() 也是类 set 对象;values() 因为值不保证唯一也不保证可哈希,只是可迭代对象。
d1 = dict([("name", "小龙女"), ("age", 18)], address="活死人墓")

# 创建字典视图
keys = d1.keys()
values = d1.values()
items = d1.items()

print("字典更新前keys:", keys)
print("字典更新前values:", values)
print("字典更新前items:", items)
print("==========================")

d1.update({"门派": "古墓派"}, cp="杨过", age=19)

# 字典的视图不会立即拷贝结果,可以展示字典发生的变化
print("字典更新后keys:", keys)
print("字典更新后values:", values)
print("字典更新后items:", items)
实跑输出
字典更新前keys: dict_keys(['name', 'age', 'address'])
字典更新前values: dict_values(['小龙女', 18, '活死人墓'])
字典更新前items: dict_items([('name', '小龙女'), ('age', 18), ('address', '活死人墓')])
==========================
字典更新后keys: dict_keys(['name', 'age', 'address', '门派', 'cp'])
字典更新后values: dict_values(['小龙女', 19, '活死人墓', '古墓派', '杨过'])
字典更新后items: dict_items([('name', '小龙女'), ('age', 19), ('address', '活死人墓'), ('门派', '古墓派'), ('cp', '杨过')])

注意 age 的值 18 → 19 也通过视图反映了出来。

历史背景:Python 2 的对应方法

Python 2 中,keys()/values()/items() 会立即返回一个新列表,占据新的内存空间;
配套的 iterkeys()/itervalues()/iteritems() 才返回迭代器。Python 3 的视图对象统一并取代了这两套 API,
既惰性又可重复使用,Python 2 的那些方法已不存在。

image-20221008195705124

image-20221008195718639

len()

返回字典中键值对的个数。

person = {'name': '貂蝉', 'age': 18, 'mobile': '13765022249'}
print(len(person))
实跑输出
3

Snipaste_2022-06-28_22-22-40

in 成员运算

默认检测一个键是否在字典中。

person = {'name': '貂蝉', 'age': 18, 'mobile': '13765022249'}
# 默认检测键是否在字典中
print('name' in person)
# 检测值是否在字典中
print('貂蝉' in person.values())
实跑输出
True
True

Snipaste_2022-06-28_22-28-23

4.8 字典的有序性

从存储机制上说,字典元素是按照 key 的 hash 值分布到哈希表中的——但这与「遍历顺序」是两回事,需要分开看:

原文更新:3.7+ 字典的插入有序已是语言规范,不再是「实现细节」

原文说「3.6 开始 dict 自身支持(记住顺序),但建议不要依赖、要有序还是用 OrderedDict」。
这个建议已经过时:

  • Python 3.6:CPython 实现了紧凑 dict,记住插入序,但官方声明这只是实现细节,其他解释器不保证;
  • Python 3.7 起:插入有序被写入语言规范(正式特性),所有合规的 Python 实现都必须保证——官方文档原文:"Changed in version 3.7: Dictionary order is guaranteed to be insertion order."

所以现在的准确说法是:

  1. 遍历顺序 = 插入顺序(3.7+ 保证),可以放心依赖;
  2. 「有序」指的是记住了输入序,并不是按 key 排序。如果用 d = {'a': 300, 'b': 20, 'c': 10, 'd': 50} 之类的恰好升序数据测试,会造成「字典按 key 排序」的错觉——换一组数据顺序就还原为插入序了;
  3. 仍然需要 OrderedDict 的场景:move_to_end() 移动键的位置、顺序敏感的相等性比较(两个 OrderedDict 只有键值对和顺序都相同才相等,普通 dict 不比较顺序)。
d = {'a': 300, 'b': 20, 'c': 10, 'd': 50}
print(list(d.keys()))     # 插入序:a 在前(即便 a 的值最大)
print(list(d.values()))

d2 = {'d': 50, 'c': 10, 'b': 20, 'a': 300}
print(list(d2.keys()))    # 换一组数据:顺序 = 插入序,不是排序
实跑输出
['a', 'b', 'c', 'd']
[300, 20, 10, 50]
['d', 'c', 'b', 'a']

image-20221008203701658

image-20221008203715995

Python 3.6 之前,在不同的机器上,甚至同一个程序分别运行 2 次,都无法确定不同 key 的先后顺序。

image-20221008203759382

image-20221008203817605

Python 3.6+ 会记录字典 key 的录入顺序,遍历时即按此顺序。

OrderedDict 与普通 dict 的两个实测差异:

from collections import OrderedDict

# 差异 1:相等性比较,OrderedDict 顺序敏感
od1 = OrderedDict(a=1, b=2)
od2 = OrderedDict(b=2, a=1)
print(od1 == od2)                       # False:顺序不同
print({'a': 1, 'b': 2} == OrderedDict(a=1, b=2))  # True:dict 混比不比顺序

# 差异 2:move_to_end(dict 没有的能力)
od = OrderedDict(a=1, b=2, c=3)
od.move_to_end('a')
print(list(od.keys()))
实跑输出
False
True
['b', 'c', 'a']

4.9 序列解包

序列解包可以用于元组、列表、字典,让我们能方便地对多个变量赋值。

x, y, z = (20, 30, 10)
print(f"x:{x}")
print(f"y:{y}")
print(f"z:{z}")
print("-" * 20)

(a, b, c) = (9, 8, 10)
print(f"a:{a}")
print(f"b:{b}")
print(f"c:{c}")
print("-" * 20)

[d, e, f] = [10, 20, 30]
print(f"d:{d}")
print(f"e:{e}")
print(f"f:{f}")
print("-" * 20)
实跑输出
x:20
y:30
z:10
--------------------
a:9
b:8
c:10
--------------------
d:10
e:20
f:30
--------------------

序列解包用于字典时,默认对键操作;若需对键值对操作,则用 items();若需对值操作,则用 values()。

s = {'name': 'gaoqi', 'age': 18, 'job': 'teacher'}
# 默认对键进行操作
name, age, job = s
print(f"name:{name}")
print(f"age:{age}")
print(f"job:{job}")
print("-" * 20)

# 对键值对进行操作
name, age, job = s.items()
print(f"name:{name}")
print(f"age:{age}")
print(f"job:{job}")
print("-" * 20)

# 对值进行操作
name, age, job = s.values()
print(f"name:{name}")
print(f"age:{age}")
print(f"job:{job}")
print("-" * 20)
实跑输出
name:name
age:age
job:job
--------------------
name:('name', 'gaoqi')
age:('age', 18)
job:('job', 'teacher')
--------------------
name:gaoqi
age:18
job:teacher
--------------------

4.10 封装和解构

基本概念

Python 等式右侧出现逗号分隔的多值时,会把这几个值封装到元组中,这种操作称为封装(packing)。

t1 = 1, 2
print(type(t1))  # 什么类型

t2 = (1, 2)
print(type(t2))
实跑输出
<class 'tuple'>
<class 'tuple'>

Python 等式右侧是容器类型、左侧是逗号分隔的多个标识符时,右侧容器中的数据会与左侧标识符一一对应,这种操作称为解构(unpacking)。

x, y = (1, 2)

print(f"x:{x}")  # 1
print(f"y:{y}")  # 2
实跑输出
x:1
y:2

从 Python3 开始,对解构做了很大的改进,现在用起来已经非常的方便快捷。

封装和解构是非常方便的提取数据的方法,在 Python、JavaScript 等语言中应用极广。

# 交换数据(传统写法:需要临时变量)
x = 4
y = 5
temp = x
x = y
y = temp

# 封装和解构,交换(Pythonic 写法)
x = 10
y = 11
x, y = y, x
x, y = y, x 的原理

右侧 y, x 先封装成元组 (11, 10),再解构赋给左侧的 x, y——不需要任何临时变量。

简单解构

# 左右个数相同
a, b = 1, 2
a, b = (1, 2)
a, b = [1, 2]
a, b = [10, 20]
a, b = {10, 20}            # 集合(无序但可迭代)也可以解构
a, b = {'a': 10, 'b': 20}  # 字典解构得到的是键

[a, b] = (1, 2)
[a, b] = 10, 20
(a, b) = {30, 40}
规律

等号左边只能是线性数据结构,等号右边可以是线性数据结构或非线性数据结构。左边的小括号或中括号可以省略不写,在嵌套解构时用。

那么,左右个数不一致可以吗?

a, b = (10, 20, 30)
实跑报错(非可执行代码,仅展示)
ValueError: too many values to unpack (expected 2)

注意

左右个数一定要一一对应。

例:如何将下列所有数字都解构出来?

(1, [2, 3, 4], 5)

下面这种写法显然不对,左右个数不一致:

a, b, c, d, e = (1, [2, 3, 4], 5)
实跑报错(非可执行代码,仅展示)
ValueError: too many values to unpack (expected 5)

正确写法(嵌套解构):

a, [b, c, d], e = (1, [2, 3, 4], 5)

print(a, b, c, d, e)
实跑输出
1 2 3 4 5

剩余变量解构

在 Python 3.0 中增加了剩余变量解构(starred assignment)。

a, *rest, b = [1, 2, 3, 4, 5]

print(a, b)
print(type(rest), rest)  # <class 'list'> [2, 3, 4]
实跑输出
1 5
<class 'list'> [2, 3, 4]

标识符 rest 将尽可能收集剩余的数据组成一个列表。

a, *rest = [1, 2, 3, 4, 5]
print(a, rest)

a, *rest, b = [1, 2, 3, 4, 5]
print(a, rest, b)  # 内容是什么?

*rest, b = [1, 2, 3, 4, 5]
print(rest, b)  # 内容是什么?
实跑输出
1 [2, 3, 4, 5]
1 [2, 3, 4] 5
[1, 2, 3, 4] 5

*rest = [1, 2, 3, 4, 5]
实跑报错(非可执行代码,仅展示)
SyntaxError: starred assignment target must be in a list or tuple

注意

剩余变量(星号表达式)不可以单独使用。

a, *r1, *r2, b = [1, 2, 3, 4, 5]
实跑报错(非可执行代码,仅展示)
SyntaxError: multiple starred expressions in assignment

注意

剩余变量不能多次使用,只能出现一次。

a, *_, b = [1, 2, 3, 4, 5]

print(_)
实跑输出
[2, 3, 4]

_, *b, _ = [1, 2, 3]

print(b)
实跑输出
[2]

注意:第一个 _ 是 1,随后又被第二个 _ 覆盖为 3——两个 _ 是同一个变量,
前面的值被丢弃。这里只打印 b 以免误导。

_ 是合法的标识符,它在这里没有什么可读性,作用就是表示不关心这个变量的值。有人把它称作丢弃变量。
(另外在交互式解释器里,_ 会自动保存上一次表达式的结果,注意两者不要混用。)

4.11 字典核心底层原理(重要)

字典对象的核心是散列表(哈希表)。散列表是一个稀疏数组(总是有空白元素的数组),数组的每个单元叫做 bucket(桶)。

每个 bucket 有两部分:一个是键对象的引用,一个是值对象的引用。

由于所有 bucket 结构和大小一致,我们可以通过偏移量来读取指定 bucket。

image-20220629150547825

将一个键值对放进字典的底层过程

a = {}
a["name"] = "小龙女"

假设字典 a 对象创建完后,数组长度为 8:

image-20220629151736374

我们要把键值对 "name": "小龙女" 放进字典 a,第一步需要计算键 name 的散列值。Python 中可用 hash() 计算。

原文勘误:hash("name") 的输出值无法复现

原文给出 bin(hash("name")) 的输出为 '-0b1010111101001110110101100100101'。
字符串的哈希值受哈希随机化(PYTHONHASHSEED)影响,每次解释器启动都不同——你跑出来的值和原文、和本文都必然不一样,这是安全特性(防止哈希碰撞攻击),不是错误。整数等不可变数值类型的哈希则是稳定的:hash(42) == 42。

print(bin(hash("name")))   # 每次运行都不同(str 哈希随机化)
print(hash(42))            # 42(int 哈希稳定)

由于数组长度为 8,我们可以拿计算出的散列值的最右边 3 位数字作为偏移量,即 "101",十进制是数字 5。我们查看偏移量 5,对应的 bucket 是否为空。如果为空,则将键值对放进去。如果不为空,则依次取右边 3 位作为偏移量,即 "100",十进制是数字 4。再查看偏移量为 4 的 bucket 是否为空。直到找到为空的 bucket 将键值对放进去。流程图如下:

image-20220629154453924

image-20220629152740405

扩容

Python 会根据散列表的拥挤程度扩容。

扩容指的是:创建更大的数组,将原有内容拷贝到新数组中。装载元素数接近容量的 2/3 时,数组就会扩容。

扩容实测与「摊销 O(1)」口径(与 §2 列表扩容一节对齐)

CPython 字典从 8 个槽起步,装载到 2/3 就翻倍扩容。实测 3.13 各档位触发点:

表容量 2/3 装载线 实测触发扩容的元素数
8 5.33 6
16 10.67 11
32 21.33 22
64 42.67 43

单次扩容需要整表重建、搬运全部已有元素(O(n),偶尔一次昂贵),但均摊到每次插入上,
字典插入的平均时间复杂度仍是 O(1)——和 §2 列表 append 的「摊销 O(1)」是同一个道理。
这也是「空间换时间」:字典宁可让表保持 1/3 空槽,也要保证哈希冲突少、查询快。

根据键查找「键值对」的底层过程

明白了键值对如何存储到数组后,根据键对象取到值对象就很好理解了。

a = {"name": "gaoqi"}
print(a.get("name"))
实跑输出
gaoqi

调用 a.get("name"),就是根据键 name 查找到「键值对」,从而找到值对象 gaoqi。

第一步,我们仍然要计算 name 对象的散列值(同样地,具体哈希值每次运行都不同,见前文勘误)。

和存储的底层流程算法一致,也是依次取散列值的不同位置的数字。假设数组长度为 8,我们可以拿计算出的散列值的最右边 3 位数字作为偏移量,即 "101",十进制是数字 5。我们查看偏移量 5,对应的 bucket 是否为空。如果为空,则返回 None。如果不为空,则将这个 bucket 的键对象计算对应散列值,和我们的散列值进行比较,如果相等,则将对应「值对象」返回。如果不相等,则再依次取其他几位数字,重新计算偏移量。依次取完后,仍然没有找到,则返回 None。流程图如下:

image-20220629155457563

用法总结

  1. 键必须可散列。

    1. 数字、字符串、元组,都是可散列的。
    2. 自定义对象需要支持下面三点:
      • 支持 hash() 函数。
      • 支持通过 __eq__() 方法检测相等性。
      • 若 a == b 为真,则 hash(a) == hash(b) 也为真。
  2. 字典在内存中开销巨大,典型的空间换时间。

  3. 键查询速度很快,平均 O(1)。

  4. 不要在遍历字典的同时进行字典的增删操作。

原文勘误:「扩容导致键的次序变化」在 3.7+ 已不成立

原文第 4 条的理由是「添加新键可能导致扩容,导致散列表中键的次序变化」。
实测(Python 3.7+):插入顺序跨扩容保持不变——连续插入 50 个键、经历多次翻倍扩容后,
遍历顺序仍与插入顺序完全一致(扩容按原顺序搬运)。

今天不要在遍历时增删键的真正原因是:增删会改变字典大小,遍历器检测到后直接抛异常:

d5 = {'a': 1, 'b': 2}
for k in d5:
    del d5[k]
实跑报错
RuntimeError: dictionary changed size during iteration

补充:遍历时只修改已有键的值(不增删键)是合法的,不会报错。

4.12 综合案例:通讯录管理系统

需求:开发一个通讯录的管理系统,主要用于实现存储班级中同学的信息(姓名、年龄、电话)。

知识点:列表、字典、死循环

延伸:在 Python 中,我们可以使用字典来保存一个人的基本信息。但是如果想保存多个人的信息,我们必须把列表和字典结合起来。

students = [0, 1, 2]
student = {'name': '刘备', 'age': 18, 'mobile': '10086'}

# 组装:
students = [
    {'name': '刘备', 'age': 18, 'mobile': '10086'},
    {'name': '关羽', 'age': 17, 'mobile': '10000'},
    {'name': '张飞', 'age': 16, 'mobile': '10010'}
]

为什么需要死循环:

当我们选中某个菜单时,功能一旦执行完毕,则整个程序就执行结束了。为了保存程序可以一直运行下去,可以模拟死循环的效果,让程序一直运行下去。

while True:
    ...

要求:正常情况下,通讯录管理系统应该有 4 个功能:增删改查。

# 1、定义一个列表,将来用于存储所有学员的通讯信息
students = []

# 2、打印功能菜单
print('-' * 40)
print('欢迎使用传智教育通讯录管理系统V1.0')
print('[1] 增加学员信息')
print('[2] 删除学员信息')
print('[3] 退出系统')
print('-' * 40)

while True:
    # 3、提示用户进行相关操作
    user_num = int(input('请输入您要进行的操作编号:'))

    if user_num == 1:
        # 4、提示用户输入学员的信息
        student = {}
        student['name'] = input('请输入学员的姓名:')
        student['age'] = int(input('请输入学员的年龄:'))
        student['mobile'] = input('请输入学员的电话:')
        # 5、把学员信息保存在列表中
        students.append(student)
        print(students)

    elif user_num == 2:
        name = input('请输入要删除的学员姓名:')
        # 6、遍历所有学员信息,找到即删并跳出
        for i in students:
            if i['name'] == name:
                # 从列表中删除整个学员(字典)
                students.remove(i)
                print('删除成功')
                break
        else:
            # for 循环正常走完(没被 break)说明没找到
            print('您要删除的学员信息不存在')

    elif user_num == 3:
        print('感谢您使用传智教育通讯录管理系统V1.0')
        break

    else:
        print('输入错误,请重新输入要操作的编号')
原文勘误:删除功能里 else 缩进错了

原文把 else: print('您要删除的学员信息不存在') 写在 for 循环体内部(与 if i['name'] == name 对齐)。
这样有两个 bug:

  1. 误报:哪怕删除成功,后面的每轮循环都会再打印一遍「不存在」;
  2. 漏删:students.remove(i) 会把后面的元素往前挪,for 继续按位置遍历时会跳过一个元素。
    实测删除 刘备(3 人中的第 1 个)时,关羽 根本没参与比较,且打印了 2 次「不存在」。

正确写法是上面的 for...else:break 表示找到并删除;循环正常结束(没 break)走 else,报告「不存在」。
这是 Python 中 for...else 最典型的应用场景。

本篇的输入类案例

通讯录这类依赖 input() 的交互程序无法在脚本里直接给出固定输出,
运行时按提示输入即可。本篇其余案例的输出均为无交互实跑结果。


5 集合

5.1 什么是集合

集合是无序可变容器,元素不能重复。

实际上,集合底层是字典实现,集合的所有元素都是字典中的键对象****,因此是不能重复且唯一的。

集合(set)是一个无序、不重复元素的序列。

  • 天生去重
  • 无序(按哈希值分布,不保证任何顺序)

5.2 集合的定义

在 Python 中,可以用一对花括号 {} 或 set() 方法定义集合;但如果要定义空集合,则只能用 set()。

用 set() 可以把列表、元组等可迭代对象转成集合;若原数据存在重复,则只保留一个。

# 定义一个集合
s1 = {10, 20, 30, 40, 50}
print(s1)
print(type(s1))

# 定义一个集合:集合中存在相同的数据
s2 = {'刘备', '曹操', '孙权', '曹操'}
print(s2)
print(type(s2))
print('################')

# 使用 set() 方法将可迭代对象转换成集合
# 如果原来数据存在重复数据,则只保留一个。
l1 = [1, 2, 3, 3, 5, 6, 6]
tu = 1, 2, 3, 3, 5, 6, 6
s3 = set(l1)
s4 = set(tu)
print(s3)
print(s4)
print('################')

# 定义空集合
s5 = {}
s6 = set()
print(type(s5))
print(type(s6))
实跑输出
{40, 10, 50, 20, 30}
<class 'set'>
{'孙权', '刘备', '曹操'}
<class 'set'>
################
{1, 2, 3, 5, 6}
{1, 2, 3, 5, 6}
################
<class 'dict'>
<class 'set'>

注意两点:① 打印顺序与书写顺序不同(无序,按哈希值分布);
② {} 创建的是空字典,不是空集合——空集合只能用 set()。

5.3 集合操作的相关方法(增删查)

集合的增操作

  1. add() 方法:向集合中增加一个元素(单一)。
students = set()

students.add('小龙女')
students.add('赵敏')

print(students)
实跑输出
{'小龙女', '赵敏'}

  1. update() 方法:向集合中增加可迭代类型的数据(字符串、列表、元组、字典)。
students = set()
list1 = ['刘备', '关羽', '赵云']

students.update(list1)

print(students)
实跑输出
{'刘备', '关羽', '赵云'}

students = set()

students.add('刘德华')
students.add('黎明')

# 使用 update 新增元素
students.update('蔡徐坤')

print(students)
实跑输出
{'坤', '徐', '刘德华', '黎明', '蔡'}

update(字符串) 会把字符串逐字符拆开逐个加入——一共进来 3 个元素 蔡、徐、坤。
想整体加入一个字符串要用 add('蔡徐坤')。

集合的删操作

  1. remove() 方法:删除集合中的指定数据,如果数据不存在则报错。
  2. discard() 方法:删除集合中的指定数据,如果数据不存在也不会报错。
  3. pop() 方法:随机删除集合中的某个数据,并返回这个数据。
思考

效率如何?

答

remove 方法按 key 移除元素;集合通过 hash 定位,不必像列表那样遍历元素,因此查找元素效率极高,为 O(1)。discard 同理。

# 1、定义一个集合
products = {'萝卜', '白菜', '水蜜桃', '奥利奥', '西红柿', '凤梨'}

# 2、使用 remove 方法删除白菜这个元素
products.remove('白菜')
print(products)

# 3、使用 discard 方法删除未知元素(不报错)
products.discard('玉米')
print(products)

# 4、使用 pop 方法随机删除某个元素
del_product = products.pop()
print(del_product)
print(products)

# 5、使用 remove 方法删除不存在元素(报 KeyError)
products.remove('小龙女')
实跑输出
{'凤梨', '奥利奥', '萝卜', '西红柿', '水蜜桃'}
{'凤梨', '奥利奥', '萝卜', '西红柿', '水蜜桃'}
凤梨
{'奥利奥', '萝卜', '西红柿', '水蜜桃'}
KeyError: '小龙女'

discard 删除不存在的 '玉米' 时静默通过;remove 删除不存在的 '小龙女' 抛 KeyError;
pop 弹出的元素不确定(无序)。这三种行为差异务必分清。

集合中的查操作

  1. in:判断某个元素是否在集合中,如果在,则返回 True,否则返回 False。
  2. not in:判断某个元素不在集合中,如果不在,则返回 True,否则返回 False。
思考

效率如何?

答

高,原理与 remove 相同:通过 hash 值定位元素,效率极高、无需像 list 那样遍历,效率为 O(1)。

# 定义一个set集合
s1 = {'刘帅', '英标', '高源'}

# 判断刘帅是否在s1集合中
if '刘帅' in s1:
    print('刘帅在s1集合中')
else:
    print('刘帅没有出现在s1集合中')
实跑输出
刘帅在s1集合中

  1. 集合的遍历操作
for i in 集合:
    print(i)

5.4 集合中的交集、并集与差集特性

在 Python 中,我们可以使用 & 来求两个集合的交集:

image-20210312152451774

在 Python 中,我们可以使用 | 来求两个集合的并集:

image-20210312152631528

在 Python 中,我们可以使用 - 来求两个集合的差集:

image-20210312152921040

# 求集合中的交集、并集、差集
s1 = {'刘备', '关羽', '张飞', '貂蝉'}
s2 = {'袁绍', '吕布', '曹操', '貂蝉'}

# 求两个集合中的交集
print(s1 & s2)

# 求两个集合中的并集
print(s1 | s2)

# 求两个集合中的差集
print(s1 - s2)
print(s2 - s1)
实跑输出
{'貂蝉'}
{'刘备', '关羽', '张飞', '貂蝉', '袁绍', '吕布', '曹操'}
{'刘备', '关羽', '张飞'}
{'袁绍', '吕布', '曹操'}

差集不满足交换律:s1 - s2 是「在 s1 但不在 s2」,s2 - s1 正好相反。

5.5 核心特性对比

特性 列表 (Sequence) 字典 (Mapping) 集合 (Set)
是否有序 ✅ 插入顺序 ✅ 插入顺序 (3.7+) ❌ 无序(按哈希值分布)
元素重复 ✅ 允许 键唯一,值可重复 ❌ 自动去重
索引访问 ✅ lst[0] ✅ dct[key] ❌ 只能迭代或成员判断
可变性 ✅ 可变 ✅ 可变 set 可变 / frozenset 不可变
底层实现 动态数组 哈希表 哈希表(只存键,无值)

5.6 底层实现说明

集合和字典的底层均基于哈希表。

  • 字典:每个条目存储 (hash, key, value) 三元组。
  • 集合:每个条目只存储 (hash, key),因为无需关联值。

正是由于哈希表的特性,集合拥有 O(1) 平均时间复杂度的插入、删除和成员检测,远超列表的 O(n)。

原文勘误:「集合内存约为字典的 60%~70%」与实测不符

原文依据「set 少存一个 value」推断内存占用约为字典的 60%~70%。在 CPython 3.13 实测恰恰相反:

n(int 元素) set getsizeof dict getsizeof set/dict
100 8,408 B 4,688 B 1.79
1,000 32,984 B 36,952 B 0.89
10,000 524,504 B 294,992 B 1.78

原因:Python 3.7+ 的 dict 改用紧凑布局(indices + entries,条目连续存放,没有空洞),
而 set 仍是老式稀疏开放寻址表(空槽也占内存,且保持 2/3 以下装载率)。
结论:set 并不比 dict 省内存;选 set 的理由是去重和 O(1) 成员检测,不是省内存。
「少存一个 value」只是单条目的理论差,抵不过表布局的差异。

5.7 典型应用场景

# 1. 快速去重
lst = [1, 2, 2, 3]
unique = list(set(lst))
print(unique)  # [1, 2, 3](顺序不保证)

# 2. 数学集合运算
a = {1, 2, 3}
b = {2, 3, 4}
print(a & b)  # 交集 {2, 3}
print(a | b)  # 并集 {1, 2, 3, 4}
print(a - b)  # 差集 {1}

# 3. 极速成员检查(比列表快数百倍)
large_set = set(range(10**6))
if 999999 in large_set:  # 哈希查找,极快
    print('found')
实跑输出
[1, 2, 3]
{2, 3}
{1, 2, 3, 4}
{1}
found

注意 list(set(lst)) 去重后不保证保留原顺序(集合无序);
3.7+ 需要保序去重可用 list(dict.fromkeys(lst))。

5.8 特殊子类型:不可变集合 frozenset

由于普通 set 是可变的(内部哈希值可能随增删变化),它不能作为字典的键或放入另一个集合。此时需使用 frozenset:

d = {{1, 2}: "value"}
实跑报错(非可执行代码,仅展示)
TypeError: unhashable type: 'set'
# 正确:frozenset 可哈希,可以做键
d = {frozenset({1, 2}): "value"}
print(d[frozenset({2, 1})])   # 值相等即可取出
实跑输出
value

5.9 总结

集合是基于哈希表的无序、唯一元素容器,虽不支持索引访问,但在去重、成员检测、数学关系运算场景下具有不可替代的性能优势。


6 序列中的公共方法

6.1 什么是公共方法

所谓公共方法,就是能操作大部分数据序列的方法。

6.2 常见公共方法 1

运算符 描述 支持的容器类型
+ 合并 字符串、列表、元组
* 复制 字符串、列表、元组
in 元素是否存在 字符串、列表、元组、字典、集合
not in 元素是否不存在 字符串、列表、元组、字典、集合
注意

+ 和 * 不支持字典和集合(无序、不能重复合并);in/not in 五种容器全部支持。

案例 1:+ 合并。

# 1、+ 加号,代表两个序列之间的连接与整合
str1 = 'hello'
str2 = 'world'
print(str1 + str2)

# 2、定义两个列表,对其数据进行整合
list1 = ['刘备', '关羽']
list2 = ['诸葛亮', '赵云']
print(list1 + list2)

# 3、定义两个元组,对其数据进行整合
tuple1 = (10, 20)
tuple2 = (30, 40)
print(tuple1 + tuple2)
实跑输出
helloworld
['刘备', '关羽', '诸葛亮', '赵云']
(10, 20, 30, 40)

案例 2:* 复制。

# 1、字符串与乘号的关系
print('-' * 40)
print('传智教育Python管理系统V1.0')
print('-' * 40)

# 2、列表与乘号的关系
list1 = ['*']
print(list1 * 10)

# 3、元组与乘号的关系
tuple1 = (10,)
print(tuple1 * 10)
实跑输出
----------------------------------------
传智教育Python管理系统V1.0
----------------------------------------
['*', '*', '*', '*', '*', '*', '*', '*', '*', '*']
(10, 10, 10, 10, 10, 10, 10, 10, 10, 10)

案例 3:in 与 not in 方法。

ips = ['192.168.10.11', '10.1.1.100', '172.15.184.31']

if '10.1.1.100' in ips:
    print('列表中元素已存在')
else:
    print('列表中元素不存在')
实跑输出
列表中元素已存在

6.3 常见公共方法 2

函数 描述
len() 计算容器中元素个数
del 或 del() 根据索引下标删除指定元素
max() 返回容器中元素最大值
min() 返回容器中元素最小值
range(start, end, step) 生成从 start 到 end(不包含)的数字,步长为 step,供 for 循环使用
enumerate() 将一个可遍历的数据对象组合为一个索引序列,同时列出数据和数据下标,一般用在 for 循环当中

案例 1:len() 获取字符串、列表、元组、字典、集合的长度。

# 定义一个字符串
str1 = 'hello world'
print(f'字符串的长度为{len(str1)}')

# 定义一个列表
list1 = [10, 20, 30, 40, 50]
print(f'列表的长度为{len(list1)}')

# 定义一个元组
tuple1 = (1, 2, 3)
print(f'元组的长度为{len(tuple1)}')

# 定义一个字典
dict1 = {'name': '哆啦A梦', 'gender': 'male', 'address': '东京'}
print(f'字典的长度为{len(dict1)}')

# 定义一个集合
set1 = {10, 20, 30}
print(f'集合的长度为{len(set1)}')
实跑输出
字符串的长度为11
列表的长度为5
元组的长度为3
字典的长度为3
集合的长度为3

字典的「长度」是键值对的个数(不是键的字符数、也不是值的个数)。

案例 2:del 方法,用于删除序列中指定的元素(根据索引下标)。

# 定义一个列表
list1 = ['吕布', '董卓', '貂蝉']
# 使用 del 方法删除董卓
del list1[1]
print(list1)

# 定义一个字典
dict1 = {'name': '白龙马', 'age': 23, 'address': '东海龙宫'}
# 使用 del 方法删除 age
del dict1['age']
print(dict1)
del (dict1['name'])
print(dict1)
实跑输出
['吕布', '貂蝉']
{'name': '白龙马', 'address': '东海龙宫'}
{'address': '东海龙宫'}

del x[k] 与 del(x[k]) 是一回事——del 是语句,加括号只是普通函数调用式的写法。

案例 3:求某个序列中元素的最大值和最小值(原文用 input() 交互输入,这里改为固定数据以便给出实跑输出)。

list1 = [15, 42, 8]
max_num = max(list1)
min_num = min(list1)

print(f'最大值:{max_num}')
print(f'最小值:{min_num}')
实跑输出
最大值:42
最小值:8

案例 4:enumerate(),把一个序列类型中的每个元素构造成 (index, value) 结构,然后结合 for 循环进行遍历。

list1 = [10, 20, 30, 40, 50]
n = 1
for i in list1:
    print(f'第{n}个数:{i}')
    n += 1

print('-' * 40)

for key, value in enumerate(list1):
    print(f'第{key + 1}个数:{value}')
实跑输出
第1个数:10
第2个数:20
第3个数:30
第4个数:40
第5个数:50
----------------------------------------
第1个数:10
第2个数:20
第3个数:30
第4个数:40
第5个数:50

手动维护计数器 n 的写法完全可以用 enumerate(list1, start=1) 替代,更简洁:

for key, value in enumerate(list1, start=1):
    print(f'第{key}个数:{value}')

6.4 序列类型之间的相互转换

list() 方法:把某个序列类型的数据转化为列表。

# 1、定义元组类型的序列
tuple1 = (10, 20, 30)
print(list(tuple1))

# 2、定义一个集合类型的序列
set1 = {'a', 'b', 'c', 'd'}
print(list(set1))

# 3、定义一个字典
dict1 = {'name': '刘备', 'age': 18, 'address': '蜀中'}
print(list(dict1))
实跑输出
[10, 20, 30]
['a', 'b', 'c', 'd']
['name', 'age', 'address']

list(字典) 只取键;想要值用 list(dict1.values()),想要键值对用 list(dict1.items())。

tuple() 方法:把某个序列类型的数据转化为元组。

# 定义一个列表类型的数据
list1 = ['a', 'b', 'c', 'd']
print(tuple(list1))

# 定义一个集合类型的数据
set1 = {10, 20, 30, 40}
print(tuple(set1))
实跑输出
('a', 'b', 'c', 'd')
(10, 20, 30, 40)

set() 方法:将某个序列转换成集合(但是要注意两件事 => ① 集合可以快速完成列表去重 ② 集合不支持下标)。

# 定义一个列表类型的数据
list1 = ['a', 'b', 'c', 'd', 'a']
print(set(list1))

# 定义一个元组类型的数据
tuple1 = (10, 20, 30, 40)
print(set(tuple1))
实跑输出
{'a', 'b', 'c', 'd'}
{40, 10, 30, 20}


7 元组的不可变与字典键对象的不可变

元组的不可变和字典键对象的不可变一样吗?

tu = ('a', 1, [1, 2, 3])
print(tu)
实跑输出
('a', 1, [1, 2, 3])

元组里放列表完全合法——元组只要求「槽位上的引用不变」,不要求被引用对象也不变。

d = {'a': 1, 1: 2, [1, 2, 3]: 3}
实跑报错(非可执行代码,仅展示)
TypeError: unhashable type: 'list'

d = {'a': 1, 1: 2, ([1, 2, 3], ): 3}
print(d)
实跑输出
{'a': 1, 1: 2, ([1, 2, 3],): 3}

「装着列表的元组」居然能做键!这正是本节要解释的表面矛盾:
元组不可变 ≠ 元组里的对象不可变,而字典键要求的是整个键对象可哈希——
可哈希的判定会递归检查元素(见 7.3 的陷阱示例)。

同样是要求「不可变」,但元组可以放列表,字典却不行。简单直接的回答是:

  • 元组不可变:指元组自身容器内元素的引用关系不可变(不能增删改元素)。
  • 字典不可变:在 Python 标准类型中,字典本身都是可变的;所谓「不可变」通常指字典的键(Key)必须是不可变对象。

下面从三个维度拆解它们的区别。

hash 的详细讲解:hash详解

7.1 核心区别速查表

对比维度 元组 (tuple) 字典 (dict)
自身容器状态 不可变 (Immutable) 可变 (Mutable)
能否增删元素 ❌ 不能 append / pop ✅ 可以 d['new'] = val / del
能否修改元素值 ❌ tup[0] = x 报错 ✅ d['key'] = new_val
对内部元素的要求 无限制(可以是可变列表) 键必须可哈希(不可变或其子类)

7.2 元组的不可变:浅层不可变

元组的不可变是针对元组这个容器本身的引用结构,而不是针对它内部包含的对象。

# 元组本身不可变:不能修改引用的指向
t = (1, 2, 3)
# t[0] = 4  # TypeError: 'tuple' object does not support item assignment

# 但如果元组里放的是可变对象(如列表),该对象的内容是可以改变的
t_with_list = (1, [2, 3], 4)
t_with_list[1].append(5)
print(t_with_list)  # (1, [2, 3, 5], 4)   —— 元组的内容“变了”,但引用的地址没变
实跑输出
(1, [2, 3, 5], 4)

给 t[0] 赋值时的报错(非可执行代码,仅展示):

TypeError: 'tuple' object does not support item assignment

本质:元组的不可变是指指针数组只读。元组存储的是对象的引用,它保证不改变这些引用指向哪里,但管不了被指向对象内部发生了什么。

7.3 字典的不可变:针对键的要求

在 Python 标准库中,dict 本身是完全可变的。「字典不可变」这一说法,通常是指:

Python 规定:字典的键(Key)必须是可哈希(hashable)的对象。

为什么键必须不可变?

因为字典底层基于哈希表。写入 d[key] = value 时,Python 会计算 hash(key) 来决定存放位置。

  • 若 key 是可变对象(比如列表),它内容一变,哈希值就变了。
  • 哈希值一变,字典就找不到这个键原本存在的位置,会导致内存泄漏或查找错误。

错误示例 vs 正确示例

# 1. 错误:试图用可变对象做键
bad_dict = {}
key = [1, 2]  # 列表是可变对象,没有 __hash__ 方法
# bad_dict[key] = 3  # TypeError: unhashable type: 'list'

# 2. 正确:元组做键(前提是元组内部全为不可变对象)
good_dict = {}
key_tuple = (1, 2)  # 元组不可变,且内部只有数字,是可哈希的
good_dict[key_tuple] = "OK"
print(good_dict[key_tuple])

# 3. 陷阱:元组内部有可变对象,依然不能做键
tricky_key = (1, [2, 3])
# hash(tricky_key)  # TypeError: unhashable type: 'list'
实跑输出
OK

两处报错(非可执行代码,仅展示):

TypeError: unhashable type: 'list'
TypeError: unhashable type: 'list'

Python 对可哈希性的检查是递归的:元组可哈希的前提是每个元素都可哈希。

特殊情况:不可变字典视图 MappingProxyType

如果确实需要一个只读、不可变的字典视图(防止他人修改配置),Python 标准库提供了 types.MappingProxyType:

from types import MappingProxyType

original = {"debug": True, "timeout": 30}
read_only = MappingProxyType(original)

print(read_only["debug"])
# read_only["debug"] = False  # TypeError: 'mappingproxy' object does not support item assignment

# 注意:这个只读是代理视图,如果修改原字典,视图也会跟着变
original["timeout"] = 99
print(read_only["timeout"])
实跑输出
True
99

写入报错(非可执行代码,仅展示):

TypeError: 'mappingproxy' object does not support item assignment

MappingProxyType 是浅只读:禁止通过视图增删改键值对,但原字典自身的修改仍会透出来。
想要一份「真拷贝只读」,先 dict(original) 再包一层代理。

7.4 总结

场景 解释
元组不可变 锁住的是结构。列表是书架,可以随便抽换书;元组是焊死的钢筋架,架子本身不能动,但架子上如果挂了个篮子(列表),篮子里可以放东西。
字典键的要求 锁住的是标签。字典像图书馆的检索卡片,卡片的编号(哈希值)必须永久有效且唯一。可变对象像活页笔记本,页码总在变,没法当检索卡用。
字典本身 图书馆本身的书架布局(键值对映射关系)是可以随时重新规划调整的,是可变的。

7.5 延伸:Python 3.7+ 字典的「顺序性」

现代 Python 字典是插入有序的,但这属于元素顺序,与可变性无关。字典依然可以随时增加和删除键值对。


8 各个数据结构比较

线性数据结构(有序,可索引):list、tuple、str

非线性数据结构(无序,不可索引):dict、set

如果内存中是线性列表,你要做的操作里若用到了遍历,时间复杂度为 O(n),n 越大效率越低,耗时可达微秒到毫秒级——例如 index、count 方法。

如果内存中是集合(或字典),则无论数据规模多大,检索元素的时间都不随规模变化,时间复杂度为 O(1),效率极高,可达纳秒(ns)级——例如 d[key]、in。

原文勘误
  1. 原文「如果内存中有线程列表」是错别字,应为「线性」列表(linear)。
  2. 原文「如 key,in」表述含糊:字典按键取值是 d[key] 语法(O(1)),不是方法名。
实测:百万级成员检测(缺失值,100 次平均)
import time

N = 10 ** 6
big_lst = list(range(N))
big_set = set(range(N))
MISS = N + 1  # 必然不存在,走完全程
R = 100

t0 = time.perf_counter()
for _ in range(R):
    MISS in big_lst
lst_ms = (time.perf_counter() - t0) / R * 1000

t0 = time.perf_counter()
for _ in range(R):
    MISS in big_set
set_us = (time.perf_counter() - t0) / R * 1e6

print(f'list: {lst_ms:.3f} ms/次')
print(f'set : {set_us:.1f} µs/次')
print(f'比值 ≈ {lst_ms * 1000 / set_us:.0f}x')
实跑输出
list: 6.434 ms/次
set : 0.1 µs/次
比值 ≈ 50269x

与本节结论完全吻合:list 的 in 是 O(n),n 越大越慢(毫秒级);set/dict 的 in 是 O(1),与规模无关(纳秒~微秒级)。
「需要频繁判断存在性」就是选 set/dict 的决定性理由。
注意 dict 也支持 in(对键做 O(1) 检测),但不支持下标位置访问,所以仍归入「非线性」。

选型速查
需求 首选 理由
有序、可变、可重复 list 万金油容器
不可变记录(能做键) tuple 语义明确 + 可哈希
按名字/标识查值 dict O(1) 键查找
去重、成员判断、集合运算 set / frozenset O(1) in、天生去重

详见篇首 [!abstract] 导航的「三维选型」:是否需要改、是否需要去重、查询方式。


9 术语表(重要英文单词)

9.1 容器与结构

术语 全拼 / 说明 释义
mapping 映射 键到值的对应关系;dict 是 Python 的内置映射类型
hashable — 可哈希;有稳定 hash() 值且可比较相等的对象,能做字典键/集合元素
hash table 散列表/哈希表 按 hash(key) 计算存储位置的表结构,O(1) 平均查找
bucket 桶 哈希表中的单个存储单元,存放键(和值)的引用
open addressing 开放寻址 哈希冲突时按探测序列找下一个空槽的策略,CPython 字典采用
collision 碰撞 不同键算出同一槽位;探测法解决
load factor 装载因子 已占槽位/总槽位;dict 约 2/3 触发扩容
compact dict 紧凑字典 3.6+ CPython 布局:indices 数组 + 连续 entries,省内存且保插入序
view 视图 keys()/values()/items() 返回的动态窗口,字典变它跟着变
insertion order 插入顺序 元素被放入的先后顺序;3.7+ dict 遍历顺序的保证
LIFO Last In, First Out 后进先出;3.7+ dict.popitem() 弹出最后插入的键值对
immutable 不可变 创建后不能增删改;tuple/str/frozenset 属于此类
shallow immutability 浅层不可变 容器引用关系只读,但内部可变对象的内容仍可改
packing 封装 右侧多值自动打包成元组,如 t = 1, 2
unpacking 解包/解构 把容器元素一次性赋给多个变量,如 a, b = (1, 2)
starred assignment 星号赋值 a, *rest, b = [...],剩余元素收集为列表

9.2 方法与函数

术语 全拼 / 说明 释义
fromkeys() — 用给定的键序列建字典,所有键共用同一个默认值
setdefault() — 取键值;键不存在则插入默认值并返回
popitem() — 弹出键值对;3.7+ 按 LIFO 弹出最后插入项
update() — 批量合并键值对,键冲突时右方覆盖
keys() / values() / items() — 分别返回键/值/键值对的视图对象
discard() — 删除指定元素;不存在不报错
remove() — 删除指定元素;不存在抛 KeyError
add() — 向集合加入单个元素
frozenset frozen set,冻结集合 不可变集合,可哈希,能做字典键或放进另一个集合
zip() — 多个可迭代对象按位置配对成元组,长度取最短
enumerate() — 生成 (index, value) 对;start 参数指定起始序号
MappingProxyType — types 模块的只读字典代理视图
OrderedDict — collections 中的有序字典;move_to_end()、顺序敏感相等性是它相对 dict 的增量

10 系列导航

01_Python基础
  1. 1.Python 入门
  2. 2.编程基础概念
  3. 3.Python 中的基础数据类型
  4. 4.字符串
  5. 5.内建数据结构
  6. 6.控制语句
  7. 7.函数用法
  8. 8.面向对象编程
  9. 9.异常和错误
  10. 10.文件操作(IO 技术)
  11. 11.模块 (module)
  12. 12.正则表达式 ★★★
  13. 13.并发编程
  14. 14.网络编程
  15. 15.Python操作MySQL
相关主题
  • 哈希与可哈希的深入讲解:hash详解
  • 序列通用操作(切片/索引):Python 中常用的序列
  • 控制流程(遍历容器必备):6.控制语句
  • 数据容器在函数间的传参行为(可变对象陷阱):7.函数用法

11 参考链接

分类:Python

posted @ 2026-04-11 01:58  挖掘鱼  阅读(52)  评论(0)    收藏  举报