码农高天
Python集合遍历纯粹依循哈希表物理槽位,小整数因自哈希呈现伪有序,而字符串输出乱序则源于解释器防碰撞机制每次启动生成的随机哈希种子。
AI-assisted summary of 【python】同样的set,不同的输出顺序?深度解析你肯定不知道的set背后的奥秘! — original source. Check the original for full context.
Source published:
Key ideas
- Python set 的输出顺序并非由插入顺序决定,而是完全取决于元素在底层哈希表中的物理槽位排列,遍历时自槽位 0 开始线性向后读取。 (5:00)
- 小正整数的哈希值即为其本身,计算哈希表槽位时直接映射到低位索引,导致无论正序还是逆序插入都会呈现从小到大的固定遍历顺序。 (8:00)
- CPython 为防范哈希碰撞拒绝服务攻击,默认在每次启动解释器时随机生成 hash_secret,导致字符串在不同进程运行间的哈希值完全随机,进而使输出顺序不可预测。 (8:00)
- 通过在终端中设置环境变量 PYTHONHASHSEED=0,可以固定字符串哈希种子,便于在调试时稳定复现由于哈希顺序变化引发的问题。 (8:00)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 2 月 17 日发布。视频源于一位粉丝关于“为什么相同的 set 每次输出顺序都不一样”的提问,作者借此契机深入整理了 Python set 背后的底层数据结构,并系统剖析了关于哈希实现的技术细节。
## TL;DR 视频深入探讨了 Python set 无序且输出不稳定的深层原因。通过对比整数 set 的看似“有序”与字符串 set 的完全随机,高天带领观众查阅 CPython 源码,揭示了 set 基于开放寻址法哈希表的底层实现与线性遍历机制。小整数因哈希值等于自身且无冲突而固定落入对应槽位,而字符串则因 Python 防止哈希碰撞攻击而在每次启动时随机生成哈希种子(Hash Seed),最终导致槽位随机。若需调试与复现,可通过设置环境变量 `PYTHONHASHSEED=0` 来固定随机种子。
## Atmosphere & style 视频风格硬核严谨且通俗易懂,展现出典型资深工程师拨开玄学迷雾的技术探究过程。高天以极具互动感的粉丝提问开篇,通过运行令人反直觉的代码样例制造悬念,随后直击 CPython 源码逐行推导,将晦涩的底层位运算与哈希槽位原理解释得条理清晰、抽丝剥茧。整个观看体验充满求真务实的代码极客氛围,既有顿悟原理的智力快感,又具备很强的工程实用价值。
## Bulletin - [05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) Python set 的输出顺序并非由插入顺序决定,而是完全取决于元素在底层哈希表中的物理槽位排列,遍历时自槽位 0 开始线性向后读取。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 小正整数的哈希值即为其本身,计算哈希表槽位时直接映射到低位索引,导致无论正序还是逆序插入都会呈现从小到大的固定遍历顺序。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) CPython 为防范哈希碰撞拒绝服务攻击,默认在每次启动解释器时随机生成 hash_secret,导致字符串在不同进程运行间的哈希值完全随机,进而使输出顺序不可预测。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 通过在终端中设置环境变量 PYTHONHASHSEED=0,可以固定字符串哈希种子,便于在调试时稳定复现由于哈希顺序变化引发的问题。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=0s) 与自 Python 3.6 起已实现保序的 dict 不同,Python 的 set 结构始终没有引入记录插入顺序的机制。 - [00:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=0s) 包含连续小整数的 set 输出往往看似经过正向排序,即使倒序插入也同样如此,但这只是低位哈希与槽位对应的巧合而非真实排序。 - [00:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=0s) 当整数范围变化(如 1022 到 1027)时,低位二进制的不同使得元素在哈希表中的映射被打乱,从小到大的假象随即破灭。 - [05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) CPython 源码中,哈希表的槽位数量必须是 2 的 n 次方,因此掩码 mask 恒等于 slots - 1(二进制全为 1)。 - [05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) 元素落入哈希表的初始索引通过 hash & mask 快速计算,本质上是取哈希值二进制表示的最后几位作为数组下标。 - [05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) Set 底层采用开放寻址法(Open Addressing)解决哈希冲突,在冲突时通过步长探测相邻槽位,而非采用链表或红黑树。 - [05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) Set 输出时调用的 __repr__ 函数依赖迭代器 setiterobject,该迭代器从 table[0] 线性顺序扫描至结尾并输出非空槽位元素。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 对于较小的正整数,其哈希值就等于该数字本身,因此 0 到 4 在未发生碰撞前分别精准占据哈希表的第 0 至第 4 号槽位。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 同一次 Python 运行过程中,相同字符串的哈希值保持恒定;但在多次运行之间,每次生成的 hash_secret 都会改变该值。 - [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 开发者可以通过指定环境变量 PYTHONHASHSEED=0 强制固定随机种子,使得字符串的哈希计算及 set 的遍历顺序跨进程完全一致。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=0s) 粉丝的提问与 set 输出顺序的玄学现象
视频的缘由来自一位 B 站粉丝的提问:大家都知道 Python 的 set(集合)本身是不保序的,但奇怪的是,“对于同样的数据它每一次输出的顺序竟然还不一样呢”。为了说明这个问题,码农高天先梳理了背景:自 Python 3.6 起,字典(dict)就已经实现了保序,即能够记住每个键的插入顺序并在输出时按插入顺序展示,但集合(set)始终没有引入保序机制。
码农高天运行了几组代码来展示这种令人困惑的“玄学”现象: 1. **整数 set(正序插入)**:创建一个包含整数 `[0, 1, 2, 3, 4]` 的 set,连续运行多次,其输出顺序完全固定,不仅没变,而且恰好与传入的列表顺序完全一致。 2. **字符串 set**:将上述数字换成字符串形式放进 set,结果不仅输出乱序,更诡异的是“每一次运行顺序还都不同”。 3. **整数 set(逆序插入)**:创建一个从 `[4, 3, 2, 1, 0]` 逆序插入的 set,输出结果依然是 `0, 1, 2, 3, 4`。这表明它保的根本不是插入顺序,而是呈现出一种“从小到大排列的一个顺序”。 4. **特殊整数范围 set**:传入一组从 1022 到 1027 的整数(`[1022, 1023, 1024, 1025, 1026, 1027]`),其输出顺序并非从小到大排列,规律再次被打破。
这种反直觉的表象背后,唯有通过查阅 CPython 源码才能彻底搞清楚其底层的运作机制。
```mermaid flowchart TD accTitle: Set Insertion Flowchart accDescr: 元素插入 Set 并计算哈希表存储位置的完整源码逻辑
subgraph Input ["元素输入"] A["传入 Python 对象 (key)"] --> B["获取已计算好的哈希值 (hash)"] end
subgraph HashIndex ["计算初始槽位"] B --> C["读取 setobject->mask<br/>(slots - 1, 如 8-1=7, 即 0b111)"] C --> D["按位与运算: i = hash & mask"] end
subgraph TableProbe ["哈希表寻址与冲突处理"] D --> E["定位到 table[i] 槽位"] E --> F{"槽位是否为空?"} F -- "是" --> G["放入当前 entry"] F -- "否" --> H{"哈希相同且对象相等?"} H -- "是" --> I["元素已存在, 忽略"] H -- "否" --> J["线性寻址/探测下一个 entry"] J --> E end ```
---
[05:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=300s) 剖析 CPython 源码:Set 的底层结构与遍历机制
使用列表初始化 set 时,底层实际上是逐一将元素插入 set 中。码农高天深入剖析了 CPython 源码中向 set 添加 entry 的核心函数。该函数接受两个关键参数:`key`(传入的 Python 对象)和 `hash`(在插入前就已经计算出的该对象的哈希值)。
在插入逻辑中,有几个关键步骤: - **`mask` 的含义**:源码注释显示,`mask` 实际上等于 `slots - 1`。由于哈希表的槽位数(slot)必须是 2 的 n 次方,因此 `mask` 是低位由若干个连续的 1 组成的二进制数(如 8 个 slot 时 mask 为 7,即二进制 `111`;256 个 slot 时 mask 为 255,即十六进制 `0xFF`)。 - **槽位索引计算**:`i = hash & mask`。通过按位与操作对哈希值截断,只保留最后几个 bit,将庞大的哈希值转换成一个比 slot 总数小的合法数组下标,这也是哈希表查找的初始位置。 - **冲突处理(Open Addressing)**:如果该槽位为空,元素便直接落入;如果槽位已有数据,则先判断哈希值是否相同,相同再比较对象本身是否一致,一致则说明元素已存在;若不一致则通过探测步长(probes)顺延查找隔壁邻居。码农高天强调,set 的底层并没有采用拉链法(链表)或二叉树,而是标准的开放寻址法,“选择把这个元素放在什么位置完全取决于它的hash甚至于它位置的计算都非常简单就是hash值取后面几个bit 这个数就是它开始式的index值”。
至于 set 打印时的输出逻辑,则是通过其 `__repr__` 函数触发。源码中它把自己转换成一个 list,而这个转换过程依赖迭代器 `setiterobject`。迭代器初始化时会将游标 `si_pos` 设为 0。在调用 `next()` 时,它的逻辑极其简单:从哈希表的 `table[0]` 开始线性向后扫描,检查槽位是否有数据,有数据就弹出一个,接着找下一个槽位。因此,**set 的输出顺序纯粹取决于元素在底层哈希表中的排列物理顺序**。
---
[08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 整数与字符串表现差异的底层根源:Hash 与 Hash Seed
结合源码对哈希表的理解,前面出现的各种整数 set 谜团迎刃而解: - **小整数的哈希特性**:“对于一个比较小的正整数来说 它的hash就是它本身这个事情可能很多人不知道”。对于 0 到 4 来说,它们的哈希值就是 0 到 4,在与 mask(比如 7)进行与运算后,对应的索引分别是 `table[0]`、`table[1]`、`table[2]`、`table[3]`、`table[4]`。由于不存在哈希冲突,无论是正序插入还是倒序插入,它们最终落入哈希表的物理槽位都完全一致,因此打印遍历时自然呈现出从小到大的固定顺序。 - **1022 到 1027 的特殊序列**:初始 set 较小,截取的只是二进制的后几位。1024 恰好是 $2^{10}$,低位全是 0,`1024 & mask` 计算出的索引是 0;1025 的索引是 1;1026 的索引是 2。而 1022 和 1023 的低位为全 1,算出来的是较大的索引,被排在了哈希表的靠后槽位。因此打印顺序依次变成了 `1024, 1025, 1026, 1022, 1023`。
针对字符串 set 每次运行顺序都不同的现象,码农高天指出根源在于字符串哈希的随机化: - 在单次运行中,相同的 Unicode 字符串算出的哈希值必然相同;但如果在多次运行之间哈希结果发生改变,“因为结果不同 一定意味着它的hash不同”。 - CPython 为了防止哈希碰撞拒绝服务攻击,在每次启动 Python 解释器时,都会随机生成一个 `hash_secret`,并使用该密钥参与字符串哈希运算。这导致**同一次运行中相同字符串哈希值不变,但在不同的运行实例之间,相同字符串的哈希值完全随机**。落入的槽位不同,最终遍历输出的顺序自然也就每次都不一样。
如果开发者遇到了因哈希不一致导致的 bug 需要复现,可以通过设置环境变量来固定哈希种子。执行 `PYTHONHASHSEED=0 python example.py` 后,CPython 每次运行都会使用固定的随机种子,此时包含相同字符串的 set 在各次运行中的输出顺序也会完全固定。
- 关键环境变量用法:在终端中指定 `PYTHONHASHSEED=0`,可让每次启动解释器时的字符串哈希计算保持一致。
## Notable quotes > [08:00](https://www.youtube.com/watch?v=MIzt-xTTm8g&t=480s) 对于一个比较小的正整数来说 它的hash就是它本身这个事情可能很多人不知道 — 码农高天
## Mentioned 码农高天, Python, CPython, B站, set, dict, PYTHONHASHSEED
## Video description 这期视频来源于一个粉丝的提问,这个问题很有趣,聊到了set的输出顺序,而答案隐藏的还是比较深的。借这个回答问题的机会,我们可以把set背后的数据结构整理一下,顺便聊一下B站不会有人聊的hash的一些事。
[Source](https://www.youtube.com/watch?v=MIzt-xTTm8g)