📑 本页目录(点开跳转)
09 · 容器、哈希与顺序
⏱ 112 分钟 | ⭐ 种子全固定了、环境也一样,结果还是每次不同 —— 因为你在某处遍历了一个 set
🎯 一句话
dict 从 3.7 起保证插入序,set 从来不保证;而 set 的实际顺序取决于字符串哈希,字符串哈希每次进程启动都不一样。
上一章解决的是「两台机器不一样」,这一章解决的是同一台机器、同一个环境、同一份代码、同一个随机种子,跑两次还是不一样。
🧩 一、同一批元素,两种容器,两种顺序
# order.py —— 同一批元素,dict 和 set 的迭代顺序
items = ["banana", "apple", "cherry", "date", "elderberry"]
d = dict.fromkeys(items) # 只借它的键
s = set(items)
print("放进去的顺序:", items)
print("dict 迭代出来:", list(d))
print("set 迭代出来:", list(s))
print()
print("dict 和插入序一致吗:", list(d) == items)
print("set 和插入序一致吗:", list(s) == items)
实跑输出:
对照
放进去的顺序: ['banana', 'apple', 'cherry', 'date', 'elderberry']
dict 迭代出来: ['banana', 'apple', 'cherry', 'date', 'elderberry']
set 迭代出来: ['apple', 'elderberry', 'cherry', 'date', 'banana']
dict 和插入序一致吗: True
set 和插入序一致吗: False
⭐ dict 那一行是有语言级保证的:3.6 是 CPython 的实现细节,3.7 起写进了语言规范 ——
dict、**kwargs、json.load() 出来的对象、类的 __dict__,迭代顺序都等于插入顺序,可以放心依赖。
⚠️ set 那一行不但没保证,而且你跑出来大概率和上面不一样 —— 我自己连跑三次就是三种顺序。
下一节整节都在讲这一条的后果。
💡 顺手记一个替代品:想要「去重 + 保序」,别用 set,用 dict.fromkeys(x) ——
它就是上面第一行,list(dict.fromkeys(items)) 一步到位,而且是 O(n)。
🧩 二、⭐ 连跑两次,set 的顺序就变了
# crossproc.py —— 同一行代码,连跑 5 个进程,看 set 的迭代顺序变不变
import os
import subprocess
import sys
CODE = "print(list({'a', 'b', 'c', 'd'}))"
print("① 默认(哈希随机化开着):")
for i in range(5):
r = subprocess.run([sys.executable, "-c", CODE],
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
print("\n② 钉死 PYTHONHASHSEED=0:")
env = dict(os.environ, PYTHONHASHSEED="0")
for i in range(5):
r = subprocess.run([sys.executable, "-c", CODE], env=env,
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
print("\n③ 换成整数元素(默认环境):")
CODE_INT = "print(list({1, 2, 3, 4}))"
for i in range(5):
r = subprocess.run([sys.executable, "-c", CODE_INT],
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
print("\n④ 同一个进程里建 5 次(默认环境):")
CODE_SAME = "print([list({'a','b','c','d'}) for _ in range(5)])"
r = subprocess.run([sys.executable, "-c", CODE_SAME],
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
print("\n⑤ hash('a') 在 5 个进程里:")
for i in range(5):
r = subprocess.run([sys.executable, "-c", "print(hash('a'))"],
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
实跑输出:
操作步骤
- 默认(哈希随机化开着):
- ['c', 'b', 'd', 'a']
- ['c', 'd', 'b', 'a']
- ['d', 'b', 'a', 'c']
- ['c', 'b', 'a', 'd']
- ['c', 'd', 'b', 'a']
- 钉死 PYTHONHASHSEED=0:
- ['d', 'c', 'a', 'b']
- ['d', 'c', 'a', 'b']
- ['d', 'c', 'a', 'b']
- ['d', 'c', 'a', 'b']
- ['d', 'c', 'a', 'b']
- 换成整数元素(默认环境):
- [1, 2, 3, 4]
- [1, 2, 3, 4]
- [1, 2, 3, 4]
- [1, 2, 3, 4]
- [1, 2, 3, 4]
- 同一个进程里建 5 次(默认环境):
- [['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a']]
- hash('a') 在 5 个进程里:
- 9020997369174567764
- -6646560285226559150
- 2012689219294902623
- -8178510508689186273
- -7163299598626686116
⭐ 五段输出各回答一个问题:
| 段 | 回答了什么 |
|---|---|
| ① | set 的顺序跨进程不稳 —— 5 次 5 个样子 |
| ② | 不稳的来源是哈希种子:钉死 PYTHONHASHSEED=0,5 次完全一致 |
| ③ | ⭐ 只有 str / bytes / datetime 的哈希被随机化。整数的 hash(n) 就是 n 本身,所以整数 set 反而稳 |
| ④ | ⭐ 同一个进程里怎么建都一样 —— 顺序在进程内是确定的,只在进程之间变 |
| ⑤ | 根子在这:hash('a') 每个进程给出完全不同的 64 位数 |
⚠️⚠️ 第 ④ 段是这个坑之所以致命的原因:你在本地反复跑同一个脚本, 每次结果都不一样 → 一眼看出问题;但你在一个 notebook 里反复跑同一个 cell, 结果永远一致 → 你会以为它是确定的。等到明天重启内核、或者 CI 上跑一遍,数字就变了。
⭐ 为什么 Python 要随机化字符串哈希: 这是 CPython 3.3 起的安全默认值(3.3 起默认开启,源自 oCERT-2011-003 的哈希碰撞攻击修复;3.4 换成 SipHash,那才是 PEP 456)。不随机的话,攻击者可以构造 一批哈希值全部相同的键(比如 HTTP 表单字段名),把字典退化成链表, 一个请求就能打满 CPU —— 这类攻击叫 hash-flooding DoS。 所以它不是 bug,是你换来的安全性;代价就是迭代顺序不再跨进程稳定。
🚦 PYTHONHASHSEED 必须在解释器启动前就设好
# hashseed_late.py —— 在脚本里设 PYTHONHASHSEED,太晚了
import os
import subprocess
import sys
LATE = "import os; os.environ['PYTHONHASHSEED'] = '0'; print(hash('a'))"
print("① 脚本里 os.environ['PYTHONHASHSEED']='0' 之后 hash('a'):")
for _ in range(3):
r = subprocess.run([sys.executable, "-c", LATE],
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
print("② 解释器启动前就设好:")
env = dict(os.environ, PYTHONHASHSEED="0") # ⭐ 传给子进程,启动时就在
for _ in range(3):
r = subprocess.run([sys.executable, "-c", "print(hash('a'))"], env=env,
capture_output=True, text=True, encoding="utf-8")
print(" ", r.stdout.strip())
实跑输出:
操作步骤
- 脚本里 os.environ['PYTHONHASHSEED']='0' 之后 hash('a'):
- 922772475798321245
- -2360262693598893259
- 2801284360798806853
- 解释器启动前就设好:
- 4644417185603328019
- 4644417185603328019
- 4644417185603328019
💀 第 ① 段是最容易白干的一件事:set_seed() 里加一行 os.environ["PYTHONHASHSEED"] = "0"
看起来很对,但哈希种子是解释器启动时读一次就定死的,等你的代码跑起来早就晚了 ——
hash('a') 三次三个值,这行代码一点用都没有,还会让你以为已经修好了。
⭐ 正确的三种设法,都在解释器之外:
| 怎么设 | 写法 |
|---|---|
| ⭐ 命令行(最直接) | PYTHONHASHSEED=0 python train.py(Windows PowerShell:先 $env:PYTHONHASHSEED=0) |
| Dockerfile / CI 配置 | ENV PYTHONHASHSEED=0 |
| ⭐ 脚本自己重启一次 | 检测到没设就 os.execve 带着 env 重新起自己(复现要求极严时才值得) |
💀 三、这就是「种子固定了、结果还是不一样」的那个洞
# repro.py —— 种子全固定了,结果照样每次不同:因为中间遍历了一个 set
import os
import subprocess
import sys
import tempfile
SCRIPT = '''
import random
random.seed(42) # 种子固定死了
feats = {"age", "income", "city", "clicks", "device", "tenure"} # 一个 set
top3_bad = [f for f in feats][:3] # 直接遍历 set —— 不稳
top3_good = sorted(feats)[:3] # 先排序再取 —— 稳
print(round(random.random(), 6), "|", top3_bad, "|", top3_good)
'''
d = tempfile.mkdtemp()
path = os.path.join(d, "train.py")
with open(path, "w", encoding="utf-8") as fh:
fh.write(SCRIPT)
print("random.random() | 直接遍历 set 取前3 | 先 sorted 再取前3")
for i in range(4):
r = subprocess.run([sys.executable, path],
capture_output=True, text=True, encoding="utf-8")
print(f"第 {i + 1} 次: {r.stdout.strip()}")
实跑输出:
要点
random.random() | 直接遍历 set 取前3 | 先 sorted 再取前3
第 1 次: 0.639427 | ['city', 'device', 'age'] | ['age', 'city', 'clicks']
第 2 次: 0.639427 | ['income', 'clicks', 'age'] | ['age', 'city', 'clicks']
第 3 次: 0.639427 | ['tenure', 'device', 'city'] | ['age', 'city', 'clicks']
第 4 次: 0.639427 | ['age', 'city', 'tenure'] | ['age', 'city', 'clicks']
⭐ 这张输出的三列缺一不可:
- 第一列
0.639427四次完全相同 —— 证明random.seed(42)确实生效了,随机数发生器没问题。 - 第二列四次四个样子 —— 同一个种子、同一份代码、同一个环境,结果就是不一样。
- 第三列四次完全相同 —— 只加了一个
sorted(),当场稳了。
💀 为什么这个 bug 特别难查:你会去查种子、查 shuffle、查 GPU 的非确定性算子、
查数据加载的多进程 —— 唯独不会怀疑那一行看起来毫无随机性的 for f in feats。
它里面没有一个随机相关的词。
⚠️ 本站《机器学习与深度学习基础》第 11 章的可复现检查单第一项写的是
「固定所有随机种子(python / numpy / torch / cuda)」,配的 set_seed() 里是
random.seed / np.random.seed / torch.manual_seed / torch.cuda.manual_seed_all。
⭐ 这四个全设了,上面这段代码照样每次不同 —— 那张清单缺的就是 PYTHONHASHSEED 这一格,
本章第二、三节补的正是它。
⭐ 实践中最常见的四个触发点(都不带「随机」二字):
| 写法 | 为什么会漂 |
|---|---|
for col in set(a) - set(b): |
特征差集,顺序决定了列的排列,进而决定模型输入 |
features = list({...}) 去重后建特征列表 |
列顺序变 → 有的模型(树模型的 tie-break、特征重要性排名)结果就变 |
json.dumps(some_set) / 拿它算配置指纹 |
缓存 key 每次不同 → 缓存永远命中不了,还查不出原因 |
for k in some_dict_built_from_set: |
⚠️ dict 自己是保序的,但它的插入序来自一个 set —— 污染传递过来了 |
⭐ 修法只有一句话:任何一个 set 在离开集合运算、变成「一串东西」的那一刻,套一个 sorted()。
# setfix.py —— 集合运算的结果落地成「一串东西」时,三种写法
a = ["zeta", "yak", "xray", "wolf"]
b = ["yak"]
bad = list(set(a) - set(b)) # ❌ 顺序跨进程漂
ok = sorted(set(a) - set(b)) # ✅ 首选:确定、可读,一次 O(n log n)
keep = [x for x in a if x not in set(b)] # ✅ 次选:想保留 a 的原始顺序时用
print("❌ list(set) →", bad)
print("✅ sorted →", ok)
print("✅ 保留原顺序 →", keep)
连跑两次的实跑输出:
流程图
⭐ 只有第一行在两次之间变了,后两行纹丝不动。⚠️ 注意两个 ✅ 给出的是不同的顺序 ——
sorted 是字典序,keep 是 a 的原始顺序。两个都是确定的,选哪个看你要不要保留原始顺序。
💡 PYTHONHASHSEED=0 和 sorted() 该选哪个:
sorted() 治本(代码在哪跑都对),PYTHONHASHSEED=0 治标但覆盖面广(连你没读过的第三方库都管)。
⭐ 生产上两个都上:sorted() 写进代码,PYTHONHASHSEED=0 写进 Dockerfile 兜底。
🛑 读到这里可以停 —— 前半章讲完了(约 38 分钟)。 后半章还有:什么能当 dict 的 key:
__hash__和__eq__的契约 ·dict的「插入序」保证到什么程度 · 复杂度速查表(一张,不展开) ·collections:三个专门替掉样板代码的容器 回来的时候不用重读,直接从下一节接着看就行。
🧯 四、什么能当 dict 的 key:__hash__ 和 __eq__ 的契约
规则只有两条,但第二条经常被忘掉:
- 能算
hash()—— 即「可哈希」。 - ⭐ 在它当 key 期间,
hash()的值不许变。字典是先按哈希找槽、再用==确认, 哈希变了就等于换了个槽去找,原来那格永远够不着。
# key.py —— 什么能当 key,以及改了 key 之后会发生什么
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __eq__(self, other):
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y)) # ⭐ 和 __eq__ 用同一批字段
# ① 列表不能当 key
try:
{[1, 2]: "x"}
except TypeError as e:
print("① 列表当 key:TypeError:", e)
# ② 元组可以 —— 但只在它内部全是不可变的时候
print("② 元组当 key:", {(1, 2): "ok"})
try:
{(1, [2]): "x"}
except TypeError as e:
print(" 里面塞个列表:TypeError:", e)
# ③ 只定义 __eq__ 不定义 __hash__ → 类当场变成不可哈希
class OnlyEq:
def __eq__(self, other):
return True
try:
{OnlyEq(): 1}
except TypeError as e:
print("③ 只写 __eq__:TypeError:", e)
# ④ 改了 key 之后:东西还在,但找不回来了
p = Point(1, 2)
d = {p: "原点附近"}
print("\n④ 改 key 之前 d[p] =", d[p], "| hash =", hash(p) % 10007)
p.x = 99 # ⭐ 就改这一行
print(" 改完 p =", p, "| hash =", hash(p) % 10007)
print(" p in d →", p in d)
print(" Point(99,2) in d→", Point(99, 2) in d)
print(" Point(1,2) in d→", Point(1, 2) in d)
print(" 但它还在字典里 :", list(d.items()))
print(" len(d) =", len(d))
实跑输出:
流程图
💀 第 ④ 段是本章最坏的那个状态:len(d) == 1,list(d.items()) 明明白白印着那条数据,
但三种找法全部 False —— 连拿着原对象本身去找都找不到。
⭐ 机制:插入时哈希是 hash((1, 2)),字典把它放进了 A 槽;改完 x 之后哈希变成 hash((99, 2)),
查找会去 B 槽敲门,而 A 槽里那条记录再没人去看。Point(1, 2) 也找不到,因为它算出的是 A 槽的哈希、
到了 A 槽却发现里面那个对象现在 == Point(99, 2),__eq__ 不通过。
⚠️ 更糟的是:它会一直占着内存和 len,序列化出去还在,只是逻辑上不可达。
⭐ 三条能直接照抄的规则:
| 规则 | 说明 |
|---|---|
| ⭐ 只拿不可变的东西当 key | str / int / tuple(内部也全不可变)/ frozenset / @dataclass(frozen=True) |
⭐ __eq__ 和 __hash__ 必须用同一批字段 |
只写 __eq__ 会让 __hash__ 自动变 None(第 ③ 段就是这个),Python 这么设计正是在逼你别把契约写坏 |
| 相等的对象哈希必须相等(反过来不必) | 哈希相同只是「进同一个槽」,最后还是靠 == 定胜负 |
🚦 同一个契约的另一面:1、1.0、True 是同一个 key
# numkey.py —— hash 相等 + == 相等 ⇒ 字典认为是同一个 key
print("hash(1) == hash(1.0) == hash(True) →", hash(1) == hash(1.0) == hash(True))
print("1 == 1.0 == True →", 1 == 1.0 == True)
d = {1: "int", 1.0: "float", True: "bool"}
print("\n写下 {1:'int', 1.0:'float', True:'bool'} 实际得到 →", d)
print("len =", len(d), "| 留下来那个 key 的类型 =", type(list(d)[0]).__name__)
counts = {}
for x in [1, True, 1.0, 2, 2.0]:
counts[x] = counts.get(x, 0) + 1
print("\n拿这 5 个值计数 →", counts)
实跑输出:
信息关系
⚠️ 三个字面上不同的 key 塌成了一个,而且行为不对称:key 保留最先插入的那个(1,int),
value 用最后写入的那个('bool')。计数那行更能说明问题 —— 5 个值只剩 2 个桶。
💀 真实场景:标签列里混了 True/False 和 1/0,Counter 出来只有两类而不是四类,
没有任何报错,你会以为数据本来就这样。
🧩 五、dict 的「插入序」保证到什么程度
# dictorder.py —— dict 的「插入序」保证到什么程度
from collections import OrderedDict
d = {"a": 1, "b": 2, "c": 3}
d["b"] = 99 # 改已有 key 的值
print("① 改值 →", list(d))
del d["a"]
d["a"] = 1 # 删掉再加回来
print("② 删了再加回 →", list(d))
print("③ dict 的 == 不看顺序 →",
{"x": 1, "y": 2} == {"y": 2, "x": 1})
print("④ OrderedDict 的 == 看 顺序 →",
OrderedDict(x=1, y=2) == OrderedDict(y=2, x=1))
print("\n⑤ frozenset 能当 key:", {frozenset({1, 2}): "ok"})
try:
{{1, 2}: "x"}
except TypeError as e:
print(" set 不能当 key:TypeError:", e)
实跑输出:
流程图
⭐ 四条边界,记住 ① 和 ② 的差别就够了:
- ① 改值不动位置 ——
d["b"] = 99之后b还在中间。 - ⚠️ ② 删掉再加回来会挪到末尾 —— 「插入序」保证的是当前这次插入的顺序,不是历史顺序。
常见触发:先
pop()再放回去、按条件重建 dict、更新配置时先删后加。 - ③④
dict的==不看顺序,OrderedDict的看 —— ⭐ 这是 3.7 之后OrderedDict仅存的两个理由之一 (另一个是move_to_end()/popitem(last=False),比如写 LRU 缓存)。其余场合直接用dict。 - ⑤
set可变所以不能当 key,frozenset可以 —— 想拿「一组东西」当 key 就用frozenset。
🛑 第二个休息点 —— 中段讲完了(约 21 分钟)。 最后一段还有:复杂度速查表(一张,不展开) ·
collections:三个专门替掉样板代码的容器 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
🛑 读到这里可以停 —— 已经读了约 60 分钟。 最后一段还有(约 28 分钟):复杂度速查表(一张,不展开) ·
collections:三个专门替掉样板代码的容器 回来的时候不用重读,直接从下一节接着看就行。
📋 六、复杂度速查表(一张,不展开)
| 操作 | list |
tuple |
dict |
set |
deque |
|---|---|---|---|---|---|
x in c |
O(n) | O(n) | O(1)* | O(1)* | O(n) |
按下标 c[i] |
O(1) | O(1) | — | — | O(n)(两端 O(1)) |
按键 c[k] |
— | — | O(1)* | — | — |
| 尾部追加 | O(1) 均摊 | 不可变 | — | O(1)* | O(1) |
| 头部插入/弹出 | O(n) | 不可变 | — | — | ⭐ O(1) |
| 中间/按值删除 | O(n) | 不可变 | O(1)* | O(1)* | O(n) |
len(c) |
O(1) | O(1) | O(1) | O(1) | O(1) |
| 能当 dict 的 key | ❌ | ✅(内部全不可变时) | ❌ | ❌(用 frozenset) |
❌ |
| 迭代顺序 | 位置序 | 位置序 | ⭐ 插入序(3.7+) | ⚠️ 无保证 | 位置序 |
* 平均情况。最坏是 O(n) —— 大量哈希碰撞时哈希表退化,这正是第二节那条安全默认值要防的事。
# perf.py —— 复杂度不是理论:把「x in 容器」和「往头部插」量出来
import random
import timeit
from collections import deque
for n in (1_000, 10_000, 100_000):
data = list(range(n))
random.shuffle(data)
lst, st = data, set(data)
target = -1 # ⭐ 不存在 → 逼 list 走完全程
t_list = timeit.timeit(lambda: target in lst, number=200) / 200
t_set = timeit.timeit(lambda: target in st, number=200) / 200
print(f"n={n:>7,} x in list = {t_list * 1e6:9.2f} us "
f"x in set = {t_set * 1e6:6.3f} us "
f"倍数 = {t_list / t_set:8.0f}x")
print()
for n in (10_000, 50_000):
t_list = timeit.timeit("l.insert(0, 1)",
setup=f"l = list(range({n}))", number=20_000) / 20_000
t_deq = timeit.timeit("d.appendleft(1)",
setup="from collections import deque; "
f"d = deque(range({n}))", number=20_000) / 20_000
print(f"n={n:>7,} list.insert(0,x) = {t_list * 1e6:7.2f} us "
f"deque.appendleft(x) = {t_deq * 1e6:5.3f} us "
f"倍数 = {t_list / t_deq:6.0f}x")
print("\n(deque 只是为了对比才 import 的:", deque([1, 2]), ")")
本机实跑(绝对耗时随机器不同,看倍数那一列):
算一算
n= 1,000 x in list = 7.56 us x in set = 0.049 us 倍数 = 156x
n= 10,000 x in list = 88.38 us x in set = 0.062 us 倍数 = 1426x
n=100,000 x in list = 1877.69 us x in set = 0.076 us 倍数 = 24706x
n= 10,000 list.insert(0,x) = 6.48 us deque.appendleft(x) = 0.022 us 倍数 = 299x
n= 50,000 list.insert(0,x) = 23.45 us deque.appendleft(x) = 0.023 us 倍数 = 1005x
⭐ 这张表的读法不是背数字,是看两列的形状:
x in set 从 n=1000 到 n=100000 基本没动(0.049 → 0.076 µs),
x in list 涨了约 248 倍(7.56 → 1877.69 µs)—— 一条平的,一条线性的。
倍数从 156x 变成 24706x,说明数据一大,这个选择就从「无所谓」变成「决定成败」。
💀 最高频的一个实际形态:
# lookup.py —— 把「被查的那一侧」先转成 set
import time
big = list(range(20_000))
other = list(range(10_000, 30_000))
t0 = time.perf_counter()
bad = [x for x in big if x in other] # ❌ 每个 x 都扫一遍 other
t1 = time.perf_counter()
lookup = set(other) # ⭐ 提到循环外面,只转一次
good = [x for x in big if x in lookup] # ✅
t2 = time.perf_counter()
print("结果一样吗:", bad == good, "| 命中", len(good), "个")
print(f"❌ x in list : {t1 - t0:7.4f} s")
print(f"✅ x in set : {t2 - t1:7.4f} s 快 {(t1 - t0) / (t2 - t1):.0f}x")
实跑输出:
对照
结果一样吗: True | 命中 10000 个
❌ x in list : 1.6106 s
✅ x in set : 0.0021 s 快 774x
⭐ 两万乘两万的规模,改一行从 1.6 秒变成 2 毫秒,结果一模一样。
⚠️ 但注意成本在哪边:set(other) 本身是 O(n),所以只查一次时白转;
在循环里反复查才赚,而且必须把 set() 提到循环外面 —— 写成
if x in set(other) 就等于每轮重建一次集合,比原来还慢。
🛑 读到这里可以停 —— 已经读了约 73 分钟。 最后一段还有(约 35 分钟):
collections:三个专门替掉样板代码的容器 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🧩 七、collections:三个专门替掉样板代码的容器
# coll.py —— defaultdict / Counter / deque 各替掉了哪几行样板代码
from collections import Counter, defaultdict, deque
rows = [("cat", 3), ("dog", 1), ("cat", 5), ("bird", 2), ("dog", 4), ("cat", 1)]
# ① 分组:手写 vs defaultdict
plain = {}
for k, v in rows:
if k not in plain: # ⭐ 这两行就是 defaultdict 干掉的
plain[k] = []
plain[k].append(v)
dd = defaultdict(list)
for k, v in rows:
dd[k].append(v) # ⭐ 一行
print("① 手写 :", plain)
print(" defaultdict:", dict(dd), "| 一样吗:", plain == dict(dd))
# ⚠️ defaultdict 的坑:只是"读"一下也会把 key 建出来
print(" len 之前 =", len(dd), "| 读一个不存在的 key:", dd["fish"],
"→ len 之后 =", len(dd))
print(" 稳的读法 dd.get('snake') =", dd.get("snake"), "| len =", len(dd))
# ② 计数
c = Counter(k for k, _ in rows)
print("\n② Counter :", c)
print(" most_common(2) =", c.most_common(2))
print(" 不存在的 key =", c["fish"], "| 查完 len =", len(c), "(不新建)")
print(" 相加 =", c + Counter({"cat": 10}))
print(" 相减 =", c - Counter({"cat": 10})) # ⚠️ 负数和零会被丢掉
# ③ deque:定长滑动窗口
print("\n③ deque(maxlen=3) 当滑动窗口:")
window = deque(maxlen=3)
for loss in [0.9, 0.7, 0.55, 0.51, 0.50]:
window.append(loss) # ⭐ 满了自动挤掉最老的
print(f" 加入 {loss} → 窗口 {list(window)} 近3步均值 "
f"{sum(window) / len(window):.4f}")
d = deque([1, 2, 3])
d.appendleft(0)
d.rotate(1)
print(" appendleft + rotate(1) →", list(d))
实跑输出:
流程图
| 容器 | 它解决的那件事 | ⚠️ 它自带的坑 |
|---|---|---|
defaultdict(list) |
「先判断 key 在不在,不在就建个空的」这两行样板 | 💀 读一下就会把 key 建出来:len 从 3 变成 4。要只读不建,用 dd.get(k) |
Counter |
计数 + most_common(n) 排行 |
查不存在的 key 返回 0 但不新建(和 defaultdict 相反);⚠️ c1 - c2 会丢掉 ≤0 的项(cat 3−10 直接消失),要保留负数用 c1.subtract(c2) |
deque(maxlen=n) |
⭐ 定长滑动窗口:满了自动挤掉最老的,不用自己切片 | 按下标随机访问是 O(n),只适合两端操作 |
💡 Counter 的顺序也受本章第二节管:most_common() 里计数相同的那几项,
排序是稳定的(按首次出现顺序),⭐ 而如果它是从一个 set 喂进去的,那个「首次出现顺序」就跨进程漂了。
要出稳定排行,写 sorted(c.items(), key=lambda kv: (-kv[1], kv[0])) —— 数量降序、同数量按名字升序。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 08 · 环境、依赖和 import | 上一章解决「你那台和我这台不一样」。⭐ 环境统一之后还是不一样,本章第二、三节讲的就是剩下的那个来源 |
| 01 · 名字、对象和绑定 | 第四节「改了 key 就找不回来」的根子是那一章的模型:名字绑到对象、对象能原地改。字典存的是引用,不是快照 |
| 05 · 怎么量:计时、剖析、内存 | 第六节那张复杂度表怎么在你自己的数据上验证。⚠️ 别照搬本章的绝对耗时,去量你的 |
| 机器学习与深度学习基础 · 11 · 训练调试手册 | ⭐ 本章存在的直接理由。它的可复现检查单写着「固定所有随机种子(python / numpy / torch / cuda)」,配的 set_seed() 那四行全设了也挡不住 set 的顺序漂移 —— 本章第三节补的就是缺的那一格 |
| NumPy 与向量化思维 · 10 · 随机数、种子与可复现 | 可复现的另一层:随机数 API 本身(全局种子 vs Generator)。那一章管「随机数怎么来」,本章管「明明没有随机数,怎么还是不稳」 |
| 模型上线之后 · 17 · 版本回溯与可复现 | 它列了「逐位复现难」的四个随机性来源:随机种子、多线程/分布式的浮点累加顺序、GPU 非确定性算子、数据源本身在变。⭐ 本章这条是那张单子上没有的第五个 —— 而且它是唯一一个连「随机」二字都不带的,所以最难被想到 |
| 10 · 编码、路径和文件 | 下一章。⭐ 同一个母题的第三副面孔:os.listdir() 的返回顺序也是不保证的 —— 数据集文件的读入顺序又是一处静默的不确定性 |
| 代码题拆解 08 · 字符串与输入 | 本章七节那三个 collections 容器的实战:字符流第一个不重复字符靠 Counter + deque,要的正是「计数」和「第一次出现的顺序」两件事分开存 |
✅ 检查点
dict的插入序是从哪个版本起被语言规范保证的?set有类似保证吗?- 想「去重同时保序」,为什么不该用
set?该写什么? - 连跑五个进程
python -c "print(list({'a','b','c','d'}))",结果一样吗?换成{1,2,3,4}呢?为什么不同? - 同一个进程里把
{'a','b','c','d'}建 5 次,顺序会变吗?⚠️ 这一点为什么反而让这个 bug 更难被发现? - 在
set_seed()里写os.environ["PYTHONHASHSEED"] = "0"有用吗?为什么?正确的三种设法是什么? - 一个脚本里
random.seed(42)已经设了、random.random()每次都输出0.639427,为什么另一列结果还是每次不同?一行怎么修? - 什么样的对象能当 dict 的 key?只写
__eq__不写__hash__会怎样? - 一个可变对象当了 key、之后被改了字段,字典会变成什么状态?
len(d)是多少?拿原对象去in能找到吗? {1: "int", 1.0: "float", True: "bool"}得到什么?key 和 value 分别留下的是哪一个?dict里把一个 key 删掉再加回来,它在迭代顺序里的位置变不变?dict和OrderedDict的==有什么区别?x in list和x in set各是什么复杂度?n 从 1000 涨到 100000,两者的差距变化了多少?defaultdict和Counter在「查一个不存在的 key」时的行为差别是什么?c1 - c2有什么坑?
👀 答案
- 3.7 起写进语言规范(3.6 只是 CPython 的实现细节)。
dict、kwargs、json.load()的结果、__dict__都可以放心依赖。⚠️set从来没有任何顺序保证**,实跑里同一批 5 个字符串,dict迭代出来和插入序完全一致,set出来是另一个样子。 - 因为
set的顺序不但不等于插入序,还跨进程漂。正确写法是list(dict.fromkeys(items))—— 借dict的保序性,同样是 O(n)。 - 字符串那组每次都不一样(实跑 5 次 5 种排列);
{1,2,3,4}五次全是[1, 2, 3, 4]。差别在于只有str/bytes/datetime的哈希被随机化,整数的hash(n)就是n本身。实跑里hash('a')在 5 个进程里给出 5 个完全不同的 64 位数。 - 不会变 —— 实跑第 ④ 段,同进程建 5 次全是
['c', 'b', 'd', 'a']。⚠️ 这正是它难查的原因:在一个 notebook 里反复跑同一个 cell,结果永远一致,你会以为它是确定的;等到重启内核或者上 CI,数字就变了。 - 没用。 哈希种子是解释器启动时读一次就定死的,代码跑起来时早过了 —— 实跑里加了那一行,
hash('a')三次仍是三个值,还会让你以为已经修好了。正确设法都在解释器之外:① 命令行PYTHONHASHSEED=0 python train.py② Dockerfile / CI 里ENV PYTHONHASHSEED=0③ 脚本检测到没设就带着 envos.execve重启自己。实跑里启动前设好,三次全是4644417185603328019。 - 因为那一列是
[f for f in feats][:3],直接遍历了一个set。0.639427四次相同恰恰证明种子没问题 —— 漂的不是随机数,是集合的迭代顺序。修法一行:sorted(feats)[:3],实跑第三列四次全是['age', 'city', 'clicks']。通则:任何set变成「一串东西」的那一刻套一个sorted()。 - 两条:能算
hash(),且当 key 期间哈希值不许变。所以用str/int/tuple(内部也全不可变)/frozenset/@dataclass(frozen=True)。只写__eq__会让__hash__自动变成None,实跑报TypeError: unhashable type: 'OnlyEq'—— Python 这么设计是在逼你别把契约写坏。__eq__和__hash__必须用同一批字段。 - 变成「数据还在,但谁都找不到」:实跑里
len(d) == 1、list(d.items())明明白白印着[(Point(99, 2), '原点附近')],但p in d、Point(99,2) in d、Point(1,2) in d三种找法全是False—— 连拿原对象去找都不行。机制:插入时按hash((1,2))放进 A 槽,改完按hash((99,2))去 B 槽敲门;Point(1,2)到了 A 槽又过不了__eq__。它还会一直占着内存和len。 - 得到
{1: 'bool'},len == 1。因为hash(1) == hash(1.0) == hash(True)且1 == 1.0 == True,字典认为是同一个 key。⚠️ 行为不对称:key 留最先插入的(1,类型是int),value 用最后写入的('bool')。💀 实际后果:标签列混了True/False和1/0,实跑 5 个值计数只剩{1: 3, 2: 2}两个桶,没有任何报错。 - 会挪到末尾 —— 实跑
{"a","b","c"}删掉a再加回来变成['b', 'c', 'a']。「插入序」保证的是当前这次插入的顺序,不是历史顺序(先pop再放回、更新配置先删后加都会撞上)。而改已有 key 的值不动位置。dict的==不看顺序(True),OrderedDict的==看顺序(False)—— 这是 3.7 后OrderedDict仅存的两个理由之一,另一个是move_to_end()/popitem(last=False)。 x in list是 O(n),x in set是平均 O(1)(最坏 O(n),大量碰撞时退化)。实跑 n=1000 差 156x,n=100000 差 24706x ——set那列基本没动(0.049 → 0.076 µs),list那列涨了约 248 倍(7.56 → 1877.69 µs)。⚠️ 但set(another_list)本身是 O(n),只查一次时白转,且必须把它提到循环外面。defaultdict💀 读一下就会把 key 建出来(实跑dd["fish"]之后len从 3 变成 4),要只读不建得用dd.get(k);Counter查不存在的 key 返回0但len不变(实跑查完还是 3)。⚠️c1 - c2会丢掉 ≤0 的项 —— 实跑cat3−10 直接从结果里消失了,要保留负数用c1.subtract(c2)。
🛑 可以停在这里
⚡ 走神救援
⭐
dict从 3.7 起由语言规范保证插入序,set从来不保证——而set的实际顺序取决于字符串哈希,字符串哈希每次进程启动都不一样。上一章管「两台机器不一样」,这一章管「同一台机器、同一个环境、同一个种子,跑两次还是不一样」。实测连跑 5 个进程打印同一个集合,得到 5 种排列。
三个关键限定:① 只有
str/bytes/datetime被随机化,整数的哈希就是它自己;② 钉死PYTHONHASHSEED=0就稳;③ ⚠️⚠️ 同一个进程里怎么建都一样——这才是它致命的原因:在 notebook 里反复跑同一个 cell 结果永远一致,你会以为它是确定的,重启内核或上 CI 才变。💀 这就是「种子固定了结果还是不一样」的洞:
random.seed(42)一切正常,可从set里出来的顺序四次四个样。⚠️ 站内那份可复现清单写的是「python / numpy / torch / cuda」四个种子,四个全设了也挡不住这条。💀 而在
set_seed()里写os.environ["PYTHONHASHSEED"]="0"完全没用——哈希种子是解释器启动时读一次就定死的,⚠️ 它还会让你以为修好了。必须在解释器之外设(命令行 / Dockerfile / 重启自己)。⭐ 修法通则:任何
set变成「一串东西」的那一刻套一个sorted()。key 的契约两条:能算哈希,且当 key 期间哈希不许变。💀 可变对象当 key 再改字段 → 数据明明还在、
len也对,但三种找法全部找不到:数据还在、内存还占、逻辑上不可达。同一契约的另一面:{1: ..., 1.0: ..., True: ...}会塌成一个 key——key 留最先插入的,value 用最后写入的。
下一节 👉 10-编码、路径和文件.md