🏠 总目录📚 本教程 09 · 容器、哈希与顺序 ← →
📑 本页目录(点开跳转)

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())

实跑输出:

操作步骤

  1. 默认(哈希随机化开着):
  2. ['c', 'b', 'd', 'a']
  3. ['c', 'd', 'b', 'a']
  4. ['d', 'b', 'a', 'c']
  5. ['c', 'b', 'a', 'd']
  6. ['c', 'd', 'b', 'a']
  7. 钉死 PYTHONHASHSEED=0:
  8. ['d', 'c', 'a', 'b']
  9. ['d', 'c', 'a', 'b']
  10. ['d', 'c', 'a', 'b']
  11. ['d', 'c', 'a', 'b']
  12. ['d', 'c', 'a', 'b']
  13. 换成整数元素(默认环境):
  14. [1, 2, 3, 4]
  15. [1, 2, 3, 4]
  16. [1, 2, 3, 4]
  17. [1, 2, 3, 4]
  18. [1, 2, 3, 4]
  19. 同一个进程里建 5 次(默认环境):
  20. [['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a'], ['c', 'b', 'd', 'a']]
  21. hash('a') 在 5 个进程里:
  22. 9020997369174567764
  23. -6646560285226559150
  24. 2012689219294902623
  25. -8178510508689186273
  26. -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())

实跑输出:

操作步骤

  1. 脚本里 os.environ['PYTHONHASHSEED']='0' 之后 hash('a'):
  2. 922772475798321245
  3. -2360262693598893259
  4. 2801284360798806853
  5. 解释器启动前就设好:
  6. 4644417185603328019
  7. 4644417185603328019
  8. 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']

⭐ 这张输出的三列缺一不可:

💀 为什么这个 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)

连跑两次的实跑输出:

流程图

--- 第一次
❌ list(set)→['zeta', 'xray', 'wolf']
✅ sorted→['wolf', 'xray', 'zeta']
✅ 保留原顺序→['zeta', 'xray', 'wolf']
--- 第二次
❌ list(set)→['xray', 'wolf', 'zeta']
✅ sorted→['wolf', 'xray', 'zeta']
✅ 保留原顺序→['zeta', 'xray', 'wolf']

⭐ 只有第一行在两次之间变了,后两行纹丝不动。⚠️ 注意两个 ✅ 给出的是不同的顺序 —— sorted 是字典序,keep 是 a 的原始顺序。两个都是确定的,选哪个看你要不要保留原始顺序。

💡 PYTHONHASHSEED=0 和 sorted() 该选哪个: sorted() 治本(代码在哪跑都对),PYTHONHASHSEED=0 治标但覆盖面广(连你没读过的第三方库都管)。 ⭐ 生产上两个都上:sorted() 写进代码,PYTHONHASHSEED=0 写进 Dockerfile 兜底。


🛑 读到这里可以停 —— 前半章讲完了(约 38 分钟)。 后半章还有:什么能当 dict 的 key:__hash__ 和 __eq__ 的契约 · dict 的「插入序」保证到什么程度 · 复杂度速查表(一张,不展开) · collections:三个专门替掉样板代码的容器 回来的时候不用重读,直接从下一节接着看就行。


🧯 四、什么能当 dict 的 key:__hash__ 和 __eq__ 的契约

规则只有两条,但第二条经常被忘掉:

  1. 能算 hash() —— 即「可哈希」。
  2. ⭐ 在它当 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))

实跑输出:

流程图

① 列表当 key:TypeError: unhashable type: 'list'
② 元组当 key: {(1, 2): 'ok'}
里面塞个列表:TypeError: unhashable type: 'list'
③ 只写 __eq__:TypeError: unhashable type: 'OnlyEq'
④ 改 key 之前 d[p] = 原点附近 | hash = 3683
改完 p = Point(99, 2) | hash = 3455
p in d→False
Point(99,2) in d→False
Point(1,2) in d→False
但它还在字典里 : [(Point(99, 2), '原点附近')]
len(d) = 1

💀 第 ④ 段是本章最坏的那个状态: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)

实跑输出:

信息关系

hash(1) == hash(1.0) == hash(True)→True
1 == 1.0 == True→True
写下 {1:'int', 1.0:'float', True:'bool'} 实际得到→{1: 'bool'}
len = 1 | 留下来那个 key 的类型 = int
拿这 5 个值计数→{1: 3, 2: 2}

⚠️ 三个字面上不同的 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)

实跑输出:

流程图

① 改值→['a', 'b', 'c']
② 删了再加回→['b', 'c', 'a']
③ dict 的 == 不看顺序→True
④ OrderedDict 的 == 看 顺序→False
⑤ frozenset 能当 key: {frozenset({1, 2}): 'ok'}
set 不能当 key:TypeError: unhashable type: 'set'

⭐ 四条边界,记住 ① 和 ② 的差别就够了:


🛑 第二个休息点 —— 中段讲完了(约 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))

实跑输出:

流程图

① 手写 : {'cat': [3, 5, 1], 'dog': [1, 4], 'bird': [2]}
defaultdict: {'cat': [3, 5, 1], 'dog': [1, 4], 'bird': [2]} | 一样吗: True
len 之前 = 3 | 读一个不存在的 key: []→len 之后 = 4
稳的读法 dd.get('snake') = None | len = 4
② Counter : Counter({'cat': 3, 'dog': 2, 'bird': 1})
most_common(2) = [('cat', 3), ('dog', 2)]
不存在的 key = 0 | 查完 len = 3 (不新建)
相加 = Counter({'cat': 13, 'dog': 2, 'bird': 1})
相减 = Counter({'dog': 2, 'bird': 1})
③ deque(maxlen=3) 当滑动窗口:
加入 0.9→窗口 [0.9] 近3步均值 0.9000
加入 0.7→窗口 [0.9, 0.7] 近3步均值 0.8000
加入 0.55→窗口 [0.9, 0.7, 0.55] 近3步均值 0.7167
加入 0.51→窗口 [0.7, 0.55, 0.51] 近3步均值 0.5867
加入 0.5→窗口 [0.55, 0.51, 0.5] 近3步均值 0.5200
appendleft + rotate(1)→[3, 0, 1, 2]
容器 它解决的那件事 ⚠️ 它自带的坑
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,要的正是「计数」和「第一次出现的顺序」两件事分开存

✅ 检查点

  1. dict 的插入序是从哪个版本起被语言规范保证的?set 有类似保证吗?
  2. 想「去重同时保序」,为什么不该用 set?该写什么?
  3. 连跑五个进程 python -c "print(list({'a','b','c','d'}))",结果一样吗?换成 {1,2,3,4} 呢?为什么不同?
  4. 同一个进程里把 {'a','b','c','d'} 建 5 次,顺序会变吗?⚠️ 这一点为什么反而让这个 bug 更难被发现?
  5. 在 set_seed() 里写 os.environ["PYTHONHASHSEED"] = "0" 有用吗?为什么?正确的三种设法是什么?
  6. 一个脚本里 random.seed(42) 已经设了、random.random() 每次都输出 0.639427,为什么另一列结果还是每次不同?一行怎么修?
  7. 什么样的对象能当 dict 的 key?只写 __eq__ 不写 __hash__ 会怎样?
  8. 一个可变对象当了 key、之后被改了字段,字典会变成什么状态?len(d) 是多少?拿原对象去 in 能找到吗?
  9. {1: "int", 1.0: "float", True: "bool"} 得到什么?key 和 value 分别留下的是哪一个?
  10. dict 里把一个 key 删掉再加回来,它在迭代顺序里的位置变不变?dict 和 OrderedDict 的 == 有什么区别?
  11. x in list 和 x in set 各是什么复杂度?n 从 1000 涨到 100000,两者的差距变化了多少?
  12. defaultdict 和 Counter 在「查一个不存在的 key」时的行为差别是什么?c1 - c2 有什么坑?
👀 答案
  1. 3.7 起写进语言规范(3.6 只是 CPython 的实现细节)。dict、kwargs、json.load() 的结果、__dict__ 都可以放心依赖。⚠️ set 从来没有任何顺序保证**,实跑里同一批 5 个字符串,dict 迭代出来和插入序完全一致,set 出来是另一个样子。
  2. 因为 set 的顺序不但不等于插入序,还跨进程漂。正确写法是 list(dict.fromkeys(items)) —— 借 dict 的保序性,同样是 O(n)。
  3. 字符串那组每次都不一样(实跑 5 次 5 种排列);{1,2,3,4} 五次全是 [1, 2, 3, 4]。差别在于只有 str / bytes / datetime 的哈希被随机化,整数的 hash(n) 就是 n 本身。实跑里 hash('a') 在 5 个进程里给出 5 个完全不同的 64 位数。
  4. 不会变 —— 实跑第 ④ 段,同进程建 5 次全是 ['c', 'b', 'd', 'a']。⚠️ 这正是它难查的原因:在一个 notebook 里反复跑同一个 cell,结果永远一致,你会以为它是确定的;等到重启内核或者上 CI,数字就变了。
  5. 没用。 哈希种子是解释器启动时读一次就定死的,代码跑起来时早过了 —— 实跑里加了那一行,hash('a') 三次仍是三个值,还会让你以为已经修好了。正确设法都在解释器之外:① 命令行 PYTHONHASHSEED=0 python train.py ② Dockerfile / CI 里 ENV PYTHONHASHSEED=0 ③ 脚本检测到没设就带着 env os.execve 重启自己。实跑里启动前设好,三次全是 4644417185603328019。
  6. 因为那一列是 [f for f in feats][:3],直接遍历了一个 set。0.639427 四次相同恰恰证明种子没问题 —— 漂的不是随机数,是集合的迭代顺序。修法一行:sorted(feats)[:3],实跑第三列四次全是 ['age', 'city', 'clicks']。通则:任何 set 变成「一串东西」的那一刻套一个 sorted()。
  7. 两条:能算 hash(),且当 key 期间哈希值不许变。所以用 str / int / tuple(内部也全不可变)/ frozenset / @dataclass(frozen=True)。只写 __eq__ 会让 __hash__ 自动变成 None,实跑报 TypeError: unhashable type: 'OnlyEq' —— Python 这么设计是在逼你别把契约写坏。__eq__ 和 __hash__ 必须用同一批字段。
  8. 变成「数据还在,但谁都找不到」:实跑里 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。
  9. 得到 {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} 两个桶,没有任何报错。
  10. 会挪到末尾 —— 实跑 {"a","b","c"} 删掉 a 再加回来变成 ['b', 'c', 'a']。「插入序」保证的是当前这次插入的顺序,不是历史顺序(先 pop 再放回、更新配置先删后加都会撞上)。而改已有 key 的值不动位置。dict 的 == 不看顺序(True),OrderedDict 的 == 看顺序(False)—— 这是 3.7 后 OrderedDict 仅存的两个理由之一,另一个是 move_to_end() / popitem(last=False)。
  11. 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),只查一次时白转,且必须把它提到循环外面。
  12. defaultdict 💀 读一下就会把 key 建出来(实跑 dd["fish"] 之后 len 从 3 变成 4),要只读不建得用 dd.get(k);Counter 查不存在的 key 返回 0 但 len 不变(实跑查完还是 3)。⚠️ c1 - c2 会丢掉 ≤0 的项 —— 实跑 cat 3−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

打卡记录保存在你的浏览器里,首页能看到总进度