本文目录
接到一个小需求:记录本周读过的文章 URL、去重、按标题查 metadata、另外存一组不可变的版本号供哈希表键使用。单用一种结构会很别扭——URL 列表要改、版本号不能改、标题查信息要快、重复访问要去掉。Python 内置四种容器正好分工:list 有序可变序列、tuple 有序不可变序列、dict 键值映射、set 无序不重复集合。下面按「该用谁」来记,而不是按 API 表死记。
选型速查
| 场景 | 首选 | 理由 |
|---|---|---|
| 维护有序列表,会增删改 | list | 可变,保序,O(1) 末尾 append |
| 固定一组字段,要当 dict 键或进 set | tuple | 不可变,可哈希(元素也得可哈希) |
| 按 id / 名称查记录 | dict | 键唯一,平均 O(1) 查找 |
| 去重、成员测试、集合运算 | set | 元素唯一,交集并集差集 |
list:有序、可变
queue: list[str] = []
queue.append("first")
queue.append("second")
queue[0] = "updated" # 按下标改
last = queue.pop() # 弹出末尾
nums: list[int] = [3, 1, 4, 1, 5]
nums.sort() # 原地排序
sliced = nums[1:3] # [1, 4],切片得到新 list列表可以嵌套、混合类型(不推荐但合法)。可变性意味着:多个名字指向同一 list 时,通过任一名字修改,大家看到的都是同一份数据。需要副本时用 other = nums.copy() 或 nums[:]。负索引 -1 表示最后一个元素。
常用操作:len(queue)、x in queue(成员测试 O(n))、列表推导 [x * 2 for x in nums if x > 2]。合并两个 list 用 a + b 或 a.extend(b)——后者原地改 a。
tags: list[str] = ["py", "dev"]
tags += ["web"] # 等价 extend,原地追加tuple:有序、不可变
point: tuple[int, int] = (10, 20)
# point[0] = 5 → TypeError
rgb: tuple[int, int, int] = (255, 128, 0)
name, age = ("Ada", 36) # 解包
singleton = (42,) # 单元素 tuple 必须带逗号
not_tuple = (42) # 这只是括号里的 inttuple 创建后不能增删改元素(若元素本身 mutable,元素内部仍可变,但 tuple 的「结构」不变)。因为不可变,可哈希的 tuple 能当 dict 的键:
grid: dict[tuple[int, int], str] = {}
grid[(0, 0)] = "origin"
grid[(1, 0)] = "east"函数返回多个值时,实质是返回一个 tuple:return width, height,调用方用解包接收。命名 tuple(collections.namedtuple 或 3.7+ 的 dataclass)在字段多时可读性更好,但底层仍是 tuple 或类似结构。
dict:键 → 值
article: dict[str, str | int] = {
"title": "容器入门",
"views": 120,
}
article["title"] = "容器类型" # 改值
article.setdefault("tags", "python")
for key, value in article.items():
print(key, value)
title = article.get("missing", "无标题") # 缺键不抛异常
# article["missing"] → KeyError键必须可哈希:不可变且实现了 __hash__ 的类型才行。str、int、tuple(元素均可哈希时)可以;list、dict、set 不能当键——它们可变,哈希值无法稳定。
# bad: {[1, 2]: "x"} → TypeError: unhashable type: 'list'
ok: dict[str, list[int]] = {"nums": [1, 2, 3]} # 值可以是 listPython 3.7+ 保证 dict 插入顺序;遍历 keys() / values() / items() 按插入先后。合并 dict 可用 a | b(3.9+)或 {**a, **b}。删除用 del d[key] 或 d.pop(key, default)。
set:唯一、无序
visited: set[str] = set()
visited.add("https://example.com/a")
visited.add("https://example.com/a") # 重复 add 无效
assert len(visited) == 1
a = {1, 2, 3}
b = {3, 4, 5}
both = a & b # {3} 交集
either = a | b # {1,2,3,4,5} 并集
only_a = a - b # {1, 2} 差集字面量 {1, 2, 3} 是 set;空 set 必须 set(),因为 {} 是空 dict。成员测试 x in visited 平均 O(1),比长 list 扫描快。元素同样要求可哈希,且无重复——靠 == 判同,不保留两份相等元素。set 不支持下标访问,也没有保序保证(3.7+ 实现细节可能保留插入序,但别依赖它写逻辑)。
从 list 去重保序的一种写法:
urls = ["a", "b", "a", "c"]
unique = list(dict.fromkeys(urls)) # ['a', 'b', 'c']场景练习:怎么组合
1. 日志行按序处理,偶尔插队
用 list 存队列;队头 pop(0) 是 O(n),数据量大时考虑 collections.deque。小脚本 list 足够。若只需栈语义,末尾 append/pop 即可。
2. 缓存「(方法, 路径) → 响应体」
键用 tuple[str, str],值用 bytes 或 str。tuple 不可变,适合做复合键。过期策略另说,容器只负责存。
3. 统计独立访客 IP
seen: set[str] = set(),每条日志 seen.add(ip),最后 len(seen)。比 list 去重省内存和时间。若还要保留首次出现顺序,用上面的 dict.fromkeys 技巧或有序结构。
4. 文章 slug 查元数据
catalog: dict[str, dict[str, str]],外层键 slug,内层 dict 存 title、author。嵌套 dict 很常见,注意别用 list 当键。读多写少时,整体替换内层 dict 比逐键 patch 有时更清晰。
5. 标签系统:某文章有哪些 tag,某 tag 下有哪些文章
正向 dict[str, list[str]](slug → tags)够用;反向查询要建索引或再用 dict[str, set[str]](tag → slugs)。set 适合 tag 名去重与快速「是否包含」。
可变性对照
| 类型 | 有序 | 可变 | 可哈希(作 dict 键 / set 元素) |
|---|---|---|---|
| list | 是 | 是 | 否 |
| tuple | 是 | 否 | 元素均可哈希时可 |
| dict | 插入序 | 是 | 否 |
| set | 否 | 是 | 否 |
和基本类型的衔接
容器里装的是对象的引用,不是「拷贝出来的值」(除非元素本身是不可变小对象)。list 里放三个 list,改内层 list,外层一眼能看出来:
matrix: list[list[int]] = [[1, 2], [3, 4]]
matrix[0].append(99)
# matrix 现在是 [[1, 2, 99], [3, 4]]赋值 b = a 让两个名字共享同一容器;要独立副本,list 用 copy(),嵌套结构用 copy.deepcopy。函数默认参数别用 mutable 字面量(如 def f(x=[]))——默认值只创建一次,会在调用间共享同一 list,这是经典坑,留到函数篇再展开。
字面量与构造
四种容器都有字面量写法,也有构造函数:
lst = [1, 2, 3]
tpl = (1, 2, 3)
mapping = {"a": 1}
unique = {1, 2, 3}
empty_list: list[int] = []
empty_dict: dict[str, int] = {}
empty_set: set[int] = set() # 不能写 {},那是空 dict类型标注里的 list[int] 表示「元素为 int 的 list」,3.9+ 可直接写;更早版本用 from typing import List 等。标注不影响运行时,但帮助阅读和静态检查。
浅拷贝与深拷贝
original: list[list[int]] = [[1], [2]]
shallow = original.copy()
shallow[0].append(99)
print(original) # [[1, 99], [2]] — 内层 list 仍共享
import copy
deep = copy.deepcopy(original)
deep[0].append(0)
print(original) # 不受 deep 里对内层的修改影响(在 deep 改的是另一份)「拷贝容器」默认只拷贝最外层壳,里面的 mutable 子对象还是同一引用。嵌套配置、矩阵、树形结构若要独立编辑,得 deepcopy 或自己重建。
何时不必用容器
单个值用基本类型;字段固定且含义清晰时,3.7+ 的 @dataclass 或 3.10+ 的 TypedDict 有时比裸 dict 更可读——那是后续话题。当前原则:一组同类、要改顺序 → list;键查值 → dict;唯一集合 → set;不可变序列 / 复合键 → tuple。
字典推导与集合推导
和 list 推导平行,dict/set 也有推导语法,适合从可迭代对象快速构造:
words = ["apple", "pie", "banana"]
lengths: dict[str, int] = {w: len(w) for w in words}
vowels = {ch for w in words for ch in w if ch in "aeiou"}推导里可以加 if 过滤。可读性下降时改回普通 for 循环,不必强用一行式。
排序:list 的方法与内置 sorted
pairs: list[tuple[str, int]] = [("b", 2), ("a", 3)]
pairs.sort(key=lambda p: p[1]) # 原地,按第二项
by_name = sorted(pairs, key=lambda p: p[0]) # 新 list,原 pairs 不变tuple 本身不能 sort,但「tuple 的 list」可以。键函数 key= 只影响比较键,不改变存进去的元素类型。
遍历与修改的陷阱
遍历 list 时若边迭代边删元素,容易漏项或越界:
items: list[int] = [1, 2, 3, 4]
# 错误示范:for i, v in enumerate(items): ... items.pop(i)
filtered = [v for v in items if v % 2 == 0] # 新建 list 更安全
items[:] = filtered # 或原地替换切片对 dict 在迭代中改大小同样要小心;需要删键时先收集要删的键再统一 del。set 的 add / discard 在迭代中相对安全,但仍建议先想清楚要不要改副本。
嵌套结构的选型
配置文件常见「dict 套 dict 套 list」:
config: dict[str, object] = {
"server": {"host": "127.0.0.1", "port": 8080},
"allowed_tags": ["py", "dev"],
}
host = config["server"]["host"] # type: ignore 或更细标注
tags: set[str] = set(config["allowed_tags"]) # 转 set 做成员测试外层 dict 按名访问;内层 tuple 适合不可变的坐标或版本三元组;内层 list 保序收集;需要快速判「有没有某 tag」时再转 set。一层套一层时,先画清楚「键是什么、值是什么类型」,再选容器,比一上来全用 dict 清晰。
性能直觉(不必死记)
| 操作 | list | dict | set |
|---|---|---|---|
| 按下标 / 键取 | O(1) | O(1) 均摊 | — |
| 成员测试 | O(n) | O(1) 均摊 | O(1) 均摊 |
| 末尾追加 | O(1) | — | add O(1) 均摊 |
数据量小不必纠结;日志去重、权限标签、缓存键集合,该用 set 就别用 list 线性扫。需要有序又去重,上面 dict.fromkeys 或 3.7+ 的 dict 保序特性可以组合使用。
和 JSON 的对应关系
JSON 对象 → dict,数组 → list,字符串/数字/布尔/null → Python 对应类型。JSON 没有 tuple、set;json.loads 得到的「数组」永远是 list。要把 tuple 序列化出去,先转成 list;set 先转成 list 再写 JSON。读回时再按需要转 set 或 tuple。
import json
payload = json.loads('{"ids": [1, 2, 2]}')
unique_ids = set(payload["ids"]) # {1, 2}选型对照:同一数据四种装法
同一组用户 id [1001, 1002, 1001],需求不同,结构不同:
ids_list = [1001, 1002, 1001] # 保留重复与顺序
ids_tuple = tuple(ids_list) # 固定快照,可哈希
ids_unique = list(dict.fromkeys(ids_list)) # 去重保序
ids_set = set(ids_list) # 去重,不关心顺序
lookup: dict[int, str] = {1001: "Ada", 1002: "Bob"}要「按 id 查名字」只有 dict 直接;list 得线性扫。要「有没有重复访问」用 set 长度对比。要「作为另一个 dict 的键的一部分」把 id 放进 tuple。场景决定结构,而不是反过来。
合并与更新
defaults: dict[str, str] = {"theme": "light", "lang": "zh"}
overrides = {"lang": "en"}
merged = defaults | overrides # {"theme": "light", "lang": "en"}
tags: set[str] = {"py", "dev"}
tags |= {"web", "py"} # 并集更新,重复无效dict 的 |=、set 的 |= 都是原地合并。list 没有集合运算,要合并就 extend 或 +。
再练场景:购物车
| 需求 | 结构 | 片段 |
|---|---|---|
| 商品按加入顺序展示 | list[str] | cart.append(sku) |
| 某 SKU 是否已在车里 | set[str] | if sku in seen |
| SKU → 数量 | dict[str, int] | counts[sku] = counts.get(sku, 0) + 1 |
| 不可变的价格快照 (sku, price) | tuple[str, float] | 作 dict 键或 log 记录 |
购物车列表要保序用 list;防重复加同一促销码用 set 辅助;查数量用 dict。tuple 适合「下单那一刻的价格」,避免后面 dict 里价格被改导致历史对不上。
键的可哈希:一张小表
| 可作 dict 键 / set 元素 | 不可 |
|---|---|
int, float, str, bytes | list, dict, set |
tuple(元素均可哈希) | 含 list 的 tuple |
frozenset | 普通 set |
None | — |
值几乎任意:dict 的值可以是 list,set 里也可以放 tuple。限制在「键 / 集合元素」一侧,因为哈希表要靠稳定哈希定位桶。
写代码时若 TypeError: unhashable type 弹出,先看是不是把 list 或 dict 当成了键;改成 tuple 或 str 序列化后再查。反向问题「想用 list 保序又要 O(1) 查找」,通常 dict 存键 + list 存序,或 dict.fromkeys 折中。
日常编码可以记一句:序列用 list,记录用 dict,去重用 set,当键用 tuple。四条足够覆盖八成选型;剩下两成是嵌套组合与性能微调,在真实数据量上来后再改也不迟。
初学阶段先把四种容器写熟,比纠结 specialized 数据结构更重要。标准库里的 collections.deque、Counter 等是在这四种之上的便利层,等遇到瓶颈再换不迟。
把 list、tuple、dict、set 的分工记牢,读别人代码时能一眼看出数据结构在表达什么约束:顺序、唯一、映射、不可变快照。四种容器覆盖了日常大部分数据结构需求;下一篇函数会把对容器的一串操作收成可复用逻辑,作用域则决定这些名字在哪些地方可见,与函数、作用域两篇衔接。