本节目标:读完这一节,你能在一张表里说清
list、tuple、dict、set的有序性、可变性与查找复杂度差异,能熟练使用列表切片与步长、元组解包、字典的get/setdefault/|合并以及集合的交并差运算,并知道何时该把list换成deque或把dict换成Counter/defaultdict。
适用版本:Python 3.12+(实测 3.14.6)
3.2 列表、元组、字典与集合
上一节我们搞定了「单个值」,但真实程序几乎总是在处理「一组值」:一批订单、一个配置表、一组去重后的用户 ID。Python 内建了四种容器来应对这些需求。它们不是「差不多能用」的替代品——选错容器会让程序慢上几百倍。这一节我们把它们一次讲透。
四种容器一览
先建立全局印象,后面每一节都是对这张表的展开:
| 容器 | 字面量 | 有序 | 可变 | 元素可重复 | 按下标访问 | 查找复杂度 |
|---|---|---|---|---|---|---|
list | [1, 2, 3] | 是 | 是 | 是 | O(1) | O(n) |
tuple | (1, 2, 3) | 是 | 否 | 是 | O(1) | O(n) |
dict | {"a": 1} | 是(3.7+) | 是 | 键唯一 | 按键 | O(1) 均摊 |
set | {1, 2, 3} | 否 | 是 | 否 | 否 | O(1) 均摊 |
一句话选型:要顺序和下标用 list,要只读用 tuple,要键值映射用 dict,要去重和成员判断用 set。
list:有序可变序列
列表是最常用的容器,增删改查都很直接:
nums = [3, 1, 4, 1, 5, 9, 2, 6]
print(nums[0], nums[-1]) # 首尾元素
print(nums[1:4]) # 下标 1~3
print(nums[:3]) # 前三个
print(nums[-3:]) # 后三个
print(nums[::2]) # 每隔一个取一个(步长)
print(nums[::-1]) # 步长为负则整体反转
3 6
[1, 4, 1]
[3, 1, 4]
[9, 2, 6]
[3, 4, 5, 2]
[6, 2, 9, 5, 1, 4, 1, 3]
增删改各有对应方法,注意 remove 按值删、pop 按位置删并返回:
nums.append(7) # 尾部追加
nums.insert(0, 0) # 头部插入
nums.remove(1) # 删除第一个值为 1 的元素
popped = nums.pop() # 弹出尾部
nums[0] = 100 # 按下标赋值
print(nums, popped)
[100, 3, 4, 1, 5, 9, 2, 6] 7
切片还能整体赋值,替换一段区间,长度可以不同:
a = [1, 2, 3, 4, 5]
a[1:4] = [20, 30] # 用两个元素替换三个元素
print(a)
[1, 20, 30, 5]
tuple:不可变的序列
元组和列表长得像,区别是一旦创建就不能修改。尝试赋值会立刻报错:
t = (1, 2, 3)
t[0] = 9
TypeError: 'tuple' object does not support item assignment
不可变带来两个好处:可以当字典的键、可以作为集合元素;语义上也更安全(函数返回「一组固定结果」时不会被人误改)。元组最强大的用法是解包:
point = (3, 4)
x, y = point
print(x, y) # 3 4
first, *rest = [1, 2, 3, 4]
print(first, rest) # 1 [2, 3, 4]
a, b = 1, 2
a, b = b, a # 交换,无需临时变量
print(a, b) # 2 1
星号 * 还能用在函数调用与字面量里做展开:
print([0, *[1, 2], 3]) # [0, 1, 2, 3]
print({"a": 1, **{"b": 2}}) # {'a': 1, 'b': 2}
dict:键值映射
字典是 Python 里最重要的数据结构,几乎所有对象内部都用它。键必须是可哈希的(即不可变),列表和字典本身不能当键,元组可以:
d = {(1, 2): "tuple key"} # 元组作键,合法
print(d)
d2 = {[1, 2]: "list"} # 列表作键,非法
{(1, 2): 'tuple key'}
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
从 3.7 起字典保证插入有序,遍历顺序就是插入顺序:
d = {"b": 2, "a": 1, "c": 3}
print(list(d.keys()))
d["z"] = 99
print(list(d.keys()))
['b', 'a', 'c']
['b', 'a', 'c', 'z']
| 运算符(3.9 起)用于合并字典,右侧键覆盖左侧:
d1 = {"a": 1, "b": 2}
d2 = {"b": 20, "c": 3}
print(d1 | d2)
d1 |= d2 # 原地更新
print(d1)
{'a': 1, 'b': 20, 'c': 3}
{'a': 1, 'b': 20, 'c': 3}
取值时优先用 get 避免 KeyError;setdefault 则是「不存在才写入」:
cfg = {"host": "localhost"}
print(cfg.get("port")) # 不存在返回 None
print(cfg.get("port", 8080)) # 给默认值
print(cfg.setdefault("port", 8080)) # 写入并返回
print(cfg.setdefault("port", 9999)) # 已存在,不覆盖
print(cfg)
None
8080
8080
8080
{'host': 'localhost', 'port': 8080}
遍历用 items() 同时拿到键和值:
for k, v in {"x": 1, "y": 2}.items():
print(k, v)
x 1
y 2
set:去重与集合运算
集合自动去重,成员判断是 O(1),天生适合做「去重」「判断是否包含」「求交集」。需要放进字典当键时,用不可变的 frozenset:
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print("并集", A | B)
print("交集", A & B)
print("差集", A - B)
print("对称差", A ^ B)
print("去重", set([1, 1, 2, 2, 3]))
并集 {1, 2, 3, 4, 5, 6}
交集 {3, 4}
差集 {1, 2}
对称差 {1, 2, 5, 6}
去重 {1, 2, 3}
collections 里的四个好帮手
标准库 collections 提供了针对性的增强容器,很多手写循环都能被它们替代。
deque:两端高效的双向队列。列表在头部插入是 O(n),deque 两端都是 O(1):
from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0)
dq.append(4)
print(dq)
print(dq.popleft(), dq.pop())
deque([0, 1, 2, 3, 4])
0 4
Counter:计数。统计词频、字符出现次数一行搞定:
from collections import Counter
c = Counter("mississippi")
print(c)
print(c.most_common(2))
print(c["s"], c["z"]) # 不存在的键返回 0,不报错
Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})
[('i', 4), ('s', 4)]
4 0
defaultdict:带默认值的字典,省去每次判断键是否存在:
from collections import defaultdict
dd = defaultdict(list)
for k, v in [("a", 1), ("b", 2), ("a", 3)]:
dd[k].append(v)
print(dict(dd))
{'a': [1, 3], 'b': [2]}
namedtuple:带字段名的元组,比裸元组可读得多:
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p, p.x, p.y)
print(p._asdict())
Point(x=3, y=4) 3 4
{'x': 3, 'y': 4}
时间复杂度与选型
选容器本质上是选复杂度。下面这张表是性能优化的第一手依据:
| 操作 | list | deque | dict | set |
|---|---|---|---|---|
| 尾部追加 | O(1) | O(1) | O(1) | O(1) |
| 头部插入 | O(n) | O(1) | — | — |
| 按下标访问 | O(1) | O(n) | — | — |
成员判断 in | O(n) | O(n) | O(1) | O(1) |
| 删除指定值 | O(n) | O(n) | O(1) | O(1) |
实测一下头部插入的差距(各 10 万次):
import timeit
from collections import deque
N = 100_000
def list_head(n):
lst = []
for i in range(n):
lst.insert(0, i)
return lst
def deque_head(n):
dq = deque()
for i in range(n):
dq.appendleft(i)
return dq
print(f"list.insert(0) x{N}: {timeit.timeit(lambda: list_head(N), number=1):.3f}s")
print(f"deque.appendleft x{N}: {timeit.timeit(lambda: deque_head(N), number=1):.4f}s")
list.insert(0) x100000: 2.437s
deque.appendleft x100000: 0.0034s
差了约 700 倍。同样的道理,在 100 万个元素里做成员判断,set 比 list 快到无法比较——前者是常数时间,后者要逐个扫描。当你发现程序在做「列表里找元素」或「列表头部插元素」时,先想想能不能换成 set 或 deque。
小结
四种容器 + 四个增强容器,是日常 Python 的全部武器库:
list有序可变、支持切片与步长;tuple有序不可变、擅长解包,可作字典键。dict的键必须可哈希,3.7 起保证插入有序,|合并、get/setdefault是高频操作。set自动去重、成员判断O(1),支持交并差对称差;frozenset可作键。deque两端O(1),Counter/defaultdict/namedtuple能替掉大量手写循环。- 选型看复杂度:列表头部插入与成员判断是两大性能陷阱。
容器里的元素到底是怎么被「共享」和「复制」的?为什么把列表传给函数后原列表会被改掉?下一节 3.3 可变与不可变、引用语义与拷贝 会揭开 Python 对象模型的这一层,它是避免整类诡异 bug 的关键。
阅读导航:上一节:3.1 数值、字符串与 f-string 格式化 · 下一节:3.3 可变与不可变、引用语义与拷贝 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。