05-Python字典底层原理-Hash表与有序性的真相

文章目录

  • [Python 字典的无序与有序------Hash 表到底怎么存你的 Key](#Python 字典的无序与有序——Hash 表到底怎么存你的 Key)
    • 导入语
    • [1 ~> 字典的本质------不是数组,是 Hash 表](#1 ~> 字典的本质——不是数组,是 Hash 表)
      • [1.1 为什么字典查找是 O(1)](#1.1 为什么字典查找是 O(1))
      • [1.2 但是:哈希碰撞](#1.2 但是:哈希碰撞)
    • [2 ~> Python 如何解决哈希碰撞------开放寻址法](#2 ~> Python 如何解决哈希碰撞——开放寻址法)
    • [3 ~> Python 3.6 之前的字典------无序的根源](#3 ~> Python 3.6 之前的字典——无序的根源)
    • [4 ~> Python 3.6+ 的紧凑存储------为什么又有序了](#4 ~> Python 3.6+ 的紧凑存储——为什么又有序了)
    • [5 ~> 扩容------什么时候字典会变慢](#5 ~> 扩容——什么时候字典会变慢)
    • [6 ~> Python 字典的使用限制](#6 ~> Python 字典的使用限制)
      • [6.1 Key 必须可哈希](#6.1 Key 必须可哈希)
      • [6.2 字典遍历中不能修改大小](#6.2 字典遍历中不能修改大小)
    • [思考 && 总结](#思考 && 总结)
    • 结尾

Python 字典的无序与有序------Hash 表到底怎么存你的 Key

📖 文章简介: Python 字典是使用频率最高的数据结构之一,但大多数人对它的认知停留在"key-value 存取"。本文从 hash 表原理讲起,逐步推导到 Python 字典的底层实现:哈希函数 → 哈希碰撞 → 开放寻址法 → 负载因子与扩容。重点讨论 Python 3.6+ 字典"有序"的实现原理------实际上是"紧凑存储 + 索引表"的设计带来的副作用,而非真正的有序数据结构。穿插真实场景:为什么用 dict 代替 OrderedDict 做 LRU 缓存时要小心、字典在多线程环境下的读写安全边界。读完你对字典的理解将从"会用"升级到"知道它怎么存的"。


🎬 个人主页: 源码骑士

专栏传送门: 《Android开发基础》《python基础课程》

⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂


🎬 源码骑士的简介:

5年Android Framework系统开发经验,曾主导多项系统级性能优化专项

技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)

累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"


导入语

字典------你在 Python 里用的最多的数据结构,没有之一。但你真的理解它怎么存数据吗?

我面试过一个人,他简历上写"熟练使用 Python 字典",我问"Python 3.6 之后字典是有序的还是无序的",他犹豫了半天,说"无序的,因为字典基于 hash"。我接着问"但为什么 Python 3.7 官方把'字典保持插入顺序'写进了语言规范?",他答不上来。

这个问题我早年也没有关注细节。早年在 Python 3.5 里字典确实是"不保证顺序"的,后来某个项目需要按插入顺序遍历字典,同事给了个 OrderedDict------我也不知道为什么需要这个。后来看到一篇 CPython 源码分析的帖子,"字典重构"时才恍然大悟:Python 3.6 用了一种叫"紧凑存储"的设计,保持插入顺序只是它的副效应。


1 ~> 字典的本质------不是数组,是 Hash 表

1.1 为什么字典查找是 O(1)

数组按下标访问是 O(1)------因为知道下标就能直接计算内存地址:基地址 + 下标 × 元素大小

字典的"key"怎么变成下标?靠哈希函数

python 复制代码
key = "张三"
hash_value = hash(key)              # 把字符串映射成一个整数
index = hash_value % table_size     # 取模 → 得到槽位下标

整个魔术字就是"取模"------不管 key 是什么类型,hash() 都能输出一个唯一整数,取模后落到固定范围内的某个槽位上。所以字典查找接近 O(1)。

1.2 但是:哈希碰撞

两个不同的 key,hash() 之后取模落到同一个槽位怎么办?这叫做"哈希碰撞"。


2 ~> Python 如何解决哈希碰撞------开放寻址法

常见的做法有"链地址法"(每个槽位挂一个链表,碰撞了往里挂),但 Python 用的是开放寻址法------

碰撞了就在哈希表里接着往后找,直到找到空槽位。具体做法是:

bash 复制代码
index = hash(key) % size

如果 slots[index] 已经被占用:
    向后循环探测 index = (index + perturb) % size
    直到找到空槽位

这个设计意味着:字典的每个槽位不是只存一个 key,而是存(key, value, hash)三元组,Python 内部用一个叫 PyDictKeyEntry 的结构维护。


3 ~> Python 3.6 之前的字典------无序的根源

Python 3.5 及之前,字典的存储结构是一个稀疏表:

bash 复制代码
[槽位0] [槽位1] [槽位2] [槽位3] [槽位4] [槽位5] ... [槽位7]
  空      (k1,v1)   空      (k2,v2)   空      (k3,v3)     空

你遍历的时候,挨个扫描槽位,跳过空的,输出非空的。遍历顺序 = 槽位顺序 = 哈希槽的分布,跟你的插入顺序完全无关。 这就是字典无序的根本原因。


4 ~> Python 3.6+ 的紧凑存储------为什么又有序了

Python 3.6 引入了一个重要的字典内部表示重构(PEP 468 和 bpo-27350)。新设计将字典拆为两部分:

① 索引表(indices): 一个紧凑的稀疏数组,存的是"去数据表的位置"

② 数据表(entries): 一个紧凑的密集数组,存的是真正的(key, value)对,按插入顺序排列

bash 复制代码
插入 "张三" → 数据表: [("张三", 28)]
插入 "李四" → 数据表: [("张三", 28), ("李四", 22)]
插入 "王五" → 数据表: [("张三", 28), ("李四", 22), ("王五", 30)]

遍历字典时,直接按数据表的顺序输出------而数据表是按插入顺序追加的------所以遍历自然就是插入顺序。

这就是那个"副作用":紧凑存储的本意是为了省内存(减少稀疏浪费),但设计顺便让字典记住了插入顺序。 Python 3.7 官方干脆把这个行为写进了语言规范------"字典保持插入顺序"成了强制要求。


5 ~> 扩容------什么时候字典会变慢

当字典的负载因子超过约 2/3 时,Python 会扩容

  1. 分配一块更大的内存(通常是当前容量的 2 倍)
  2. 把所有 key 重新 hash 到新的哈希表中(rehash)
  3. 旧哈希表被释放
python 复制代码
# 你可以用 sys.getsizeof 看到字典大小的跳变
import sys
d = {}
print(sys.getsizeof(d))  # 初始 72 字节(Python 3.8)

for i in range(100):
    d[i] = i
print(sys.getsizeof(d))  # 现在大概 4700+ 字节

如果你提前知道要插入几百个 key-value,用 dict.fromkeys() 直接分配好初始容量能避免反复 rehash:

python 复制代码
# 预先分配
keys = range(1000)
d = dict.fromkeys(keys)

不过日常代码不用太纠结这个,只有处理海量插入时才有意义。


6 ~> Python 字典的使用限制

6.1 Key 必须可哈希

只有不可变类型才能做 key。因为 key 变了,hash 值也会变,Python 就找不回原来的槽位。

python 复制代码
# ✅ 可哈希
d = {1: "一", "key": "value", (1, 2): "元组"}

# ❌ 不可哈希
d = {[1, 2]: "列表"}   # TypeError: unhashable type: 'list'

6.2 字典遍历中不能修改大小

python 复制代码
d = {"a": 1, "b": 2}
for k in d:
    del d[k]     # RuntimeError: dictionary changed size during iteration

上一篇文章详细拆了这个问题。安全的做法是用列表推导式构造新字典,或者先收集要删的 key 再统一删除。


思考 && 总结

  1. 字典底层是哈希表 + 开放寻址法。 key 通过 hash() 计算槽位,碰撞后向后探测。查询、插入、删除都接近 O(1)。
  2. "有序"是紧凑存储的副产品。 Python 3.6+ 将字典拆为"索引表 + 数据表",数据表按插入顺序追加,遍历自然就是插入顺序。这不是主动设计的有序性,但 Python 3.7 把它写入了语言规范。
  3. 负载因子超 2/3 时扩容。 扩容是新建更大的表 + rehash 所有 key。预知数据量大时,一次性插入比逐步追加更高效。
  4. Key 必须可哈希(不可变)。 因为 key 变了 hash 就变,字典找不到原来的槽位。

结尾

各位小伙伴,本文到此全部结束,感谢阅读!

源码骑士 --- Python 全栈 & 系统架构

👀 关注:跟博主一起从源码视角深耕底层原理,见证每一次成长

❤️ 点赞:让优质内容被更多人看见,让知识传递更有力量

收藏:把核心知识点存好在菜鸟仓,随用随查

💬 评论:分享你的经验或疑问,评论区一起交流避坑

🔄 一键四连:不要忘记给博主"一键四连"哦!今日源码拆解达成!

🗡️ 寄语:技术之路,同行的人会让前路更有方向

结语:字典讲完了,接下来是一个让很多同学"会写但不知道为什么这样写"的话题------装饰器。我们下篇见。一键四连别忘了!

相关推荐
J2虾虾1 小时前
Android支持Java语言的标准
android·java·开发语言
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第六章 Item 44 - 47)
开发语言·人工智能·经验分享·笔记·python
mxlwd1681 小时前
movielen 100k lr模型训练过程
开发语言·python·机器学习
小森林之主1 小时前
深入正则表达式:核心语法与实战剖析
javascript·python·正则表达式·编程技巧·字符串处理
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第113题】【并发篇】第13题:说一下乐观锁的优点和缺点?
java·开发语言·面试
果丁智能1 小时前
智慧校园一卡通深度融合方案:基于超级SIM卡的手机碰一碰智能开锁技术落地实践
数据结构·人工智能·python·科技·算法·智能家居·信息与通信
码来的小朋友2 小时前
[Python] 制作小游戏创意之3D魔方
python·3d·pygame