ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python字典与集合底层原理:哈希表、O(1)查找与实战选型指南

Python字典与集合底层原理:哈希表、O(1)查找与实战选型指南

写Python写了几年之后,我发现自己最常用的反而不是那些花哨的框架,而是最基本的内置数据结构。尤其是字典和集合,这俩在代码里出现的频率仅次于列表。但有意思的是,很多人在面试里一问到“字典为什么查找快”就卡壳,或者一遇到“列表能不能当字典的key”这种问题就翻车。今天想好好聊聊这两个数据结构的底细和实战玩法,用我踩过的一些坑和总结过的经验,把字典和集合这层窗户纸捅破。

这套内容不光适合刚学Python的初学者,也适合写了两三年Python但只在“用”、没在“懂”的人。搞清楚底层机制之后,你在做数据去重、关联映射、缓存设计、性能优化的时候,心里会有一个明确的判断标准,而不是靠猜。

1. 字典背后那套“快”的机制:哈希表是怎么工作的

1.1 字典为什么能做到O(1)查找

字典在Python里的底层是哈希表。哈希表的思路其实特别朴素:你不需要在数据堆里挨个翻,而是直接根据key计算出一个“存放位置”,然后一步走过去。

我们拿查字典这件事来类比。英汉字典如果按字母顺序排好,你想查“python”这个词,不会从第一页开始翻,而是直接翻到P字母的区域。哈希表做的事情类似,但更精确——它直接把你的关键词经过哈希函数换算成一个数字,这个数字对应一个存储槽位。哈希函数的计算时间是固定的,不随数据量变化,所以理论上不管你的字典里装100个键还是1000万个键,单次查找要花的时间都差不多,这就是O(1)的由来。

如果你之前一直是用列表来模拟“查找”这件事,感受会特别明显。列表里找一个元素,最坏情况是遍历全部元素,复杂度是O(n)。数据量从1万涨到10万,查找时间也跟着涨10倍;字典则几乎感知不到变化。

用几个数字直观感受一下:

import time # 造一个大列表和等长字典 size = 1_000_000 keys = list(range(size)) values = [f"value-{i}" for i in range(size)] list_data = list(zip(keys, values)) dict_data = dict(zip(keys, values)) # 在列表里查找 target = size - 1 start = time.perf_counter() for k, v in list_data: if k == target: break print(f"列表查找耗时: {time.perf_counter() - start:.6f}s") # 在字典里查找 start = time.perf_counter() v = dict_data[target] print(f"字典查找耗时: {time.perf_counter() - start:.9f}s")

我自己本地跑过,100万个数据里,列表遍历找最后一个元素大概要几十毫秒,而字典查询基本上在微秒级,差距达到四五个数量级。这就是为什么很多业务场景里能用哈希表就绝不用列表硬搜。

1.2 哈希冲突时Python做了啥:开放寻址

哈希函数虽然能把key映射到一个位置,但不同的key算出同样的哈希值是可能的,这就是哈希冲突。如果冲突了,Python不会抛异常,而是用“开放寻址”的方式解决。

开放寻址的意思就是:这个地方被占了,我按某种规则再找下一个空位。Python具体用的是“探测序列”,先找原位置往后顺延的位置,如果也被占了就继续找,直到遇到空位。查的时候也类似:根据hash值定位,如果发现这个槽位的key不是我要找的key,就顺着同一套规则继续探测。

为了性能,Python对哈希表的装载因子有控制。装载因子就是“已用槽位 / 总槽位”。字典的装载因子大约在2/3时就会触发扩容,扩容时新建更大的表,把原来的所有键值对重新插入一遍,这个过程叫rehash。这意味着字典偶尔一次插入可能会慢一下,但均摊下来依然是常数时间。

这里的经验是:如果你能提前知道要存的数据规模,可以用dict.fromkeys或者直接传一个足够大的初始容量来减少扩容次数。虽然Python没有像Java那种直接指定初始容量的构造参数,但你用d = {}之后频繁插入大量数据,扩容是自动的,一般情况下不用管,但如果追求极致的性能,可以先粗估一个规模,用列表推导或生成器一次性构造字典,让Python在创建时就分配好足够的表。

1.3 3.6版本之后的紧凑字典到底改了啥

很多人可能听说过Python 3.6之后字典有序且内存变小了,但不太清楚具体怎么回事。

早先的字典底层是一个“稀疏数组”,每项存储完整的键、值和哈希值。为了哈希表的探测效率,这个数组需要预留很多空位,所以内存浪费比较明显。

Python 3.6引入了一种“紧凑”布局:键值对数据集中保存在一个连续紧凑的数组里,另外再配一个稀疏的索引数组。索引数组里的每个槽位存的是“数据实际存放的位置”。因为索引数组每个元素占用空间很小,比直接存键值对象要省得多。官方测试中这种布局让字典内存平均减少约20%到25%。

同时,这种设计天然地保留了插入顺序。因为在紧凑数组里新元素就是追加到末尾,迭代时按数组顺序遍历,也就是插入顺序。从3.7开始这个行为成为语言规范的一部分,不只是实现细节了。

这个特性对实战的意义很大:以前你如果需要一个“有序字典”,得专门去用collections.OrderedDict来做,现在普通字典就够了。比如你想按用户注册顺序展示昵称,插入顺序已经是现成的,不需要额外维护一个列表。

user_score = {} user_score["alice"] = 90 user_score["bob"] = 80 user_score["carol"] = 95 for name in user_score: print(name, user_score[name]) # 输出顺序一定是 alice, bob, carol

但要注意一个细节:这种顺序是插入顺序,不是按key排序。如果你需要按成绩排个名次,得单独用sorted(),别指望着字典自己帮你排好。

2. 字典实战:从数据聚合到业务映射

2.1 分组与计数:别再手动判断key了

字典最经典的用法是统计和分组。我见过不少刚开始写Python的同学,做词频统计时写出下面这种代码:

words = ["apple", "banana", "apple", "cherry", "banana", "apple"] counter = {} for word in words: if word in counter: counter[word] += 1 else: counter[word] = 1

这段代码逻辑没错,但不够优雅,而且每次都做两次字典查询(一次in,一次[word] += 1)。利用get(key, default)方法,一行就能搞定:

counter = {} for word in words: counter[word] = counter.get(word, 0) + 1

或者用defaultdict更省事:

from collections import defaultdict counter = defaultdict(int) for word in words: counter[word] += 1

defaultdict的工作方式是:当key不存在时,自动调用工厂函数生成默认值。这里传入int,所以默认值是0,直接加1就行。

分组场景也一样。比如你有一批订单数据,想按地区统计订单数量:

orders = [ {"region": "华东", "amount": 100}, {"region": "华南", "amount": 200}, {"region": "华东", "amount": 150}, ] region_total = defaultdict(int) for order in orders: region_total[order["region"]] += order["amount"] print(dict(region_total)) # {'华东': 250, '华南': 200}

还有一种情况是分组后需要保留列表,比如按部门收集员工名单。setdefault方法这时候很好用:

employees = [("研发", "张三"), ("市场", "李四"), ("研发", "王五")] by_dept = {} for dept, name in employees: by_dept.setdefault(dept, []).append(name) print(by_dept) # {'研发': ['张三', '王五'], '市场': ['李四']}

注意看:setdefault会先判断key在不在,不在就插入默认值(空列表),然后返回这个列表。这样我们直接对返回的列表append就行,一气呵成,不用写三行判断。

2.2 映射与查表:把if-else换成字典

业务代码里频繁出现的if-elif-else,很多时候都能用字典查表来替代。比如后端接口要根据状态码返回提示文案:

status_text = { 200: "成功", 400: "请求参数错误", 401: "未认证", 403: "无权限", 404: "资源不存在", 500: "服务器内部错误", } def describe(code): return status_text.get(code, "未知状态")

这比一长串if判断清晰得多,而且后续新增状态码只需要加一行。再比如你要根据文件后缀选择处理函数:

def handle_image(path): ... def handle_text(path): ... def handle_json(path): ... handlers = { ".png": handle_image, ".jpg": handle_image, ".txt": handle_text, ".json": handle_json, } handler = handlers.get(ext, handle_text) handler(path)

把函数直接存进字典,按需取出调用。这种“查表法”在状态机和策略模式里特别常用,比硬编码一堆分支条件好维护得多。

不过有个原则要记住:查表适合“条件本身就是有限且确定的集合”这种场景。如果分支条件是一个范围判断(比如分数大于90走A逻辑),那就老老实实用if,别硬套字典,否则反而要写很多边界判断的代码。

2.3 缓存与记忆化:字典的另一张脸

字典当缓存用,是哈希表特性发挥到极致的场景。因为它能根据key快速判断结果是否已经算过,避免重复计算。

一个简单的斐波那契数列递归,如果不做缓存,n稍微大一点就会指数爆炸:

def fib(n): if n < 2: return n return fib(n-1) + fib(n-2)

fib(40)就要等好几秒了。加上一个字典做记忆化:

cache = {} def fib(n): if n in cache: return cache[n] if n < 2: return n result = fib(n-1) + fib(n-2) cache[n] = result return result

fib(200)计算起来也是瞬间的事。这里面的关键就是:每个n只需要真正递归计算一次,之后直接从字典里取结果。

Python标准库也提供了现成的functools.lru_cache,它的底层实现就是字典加双向链表,用于限制缓存大小。用到自定义函数上的话,加一个装饰器就完事:

from functools import lru_cache @lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n-1) + fib(n-2)

用字典做缓存的时候,有一个坑要特别注意:缓存是无界增长的。如果key是用户输入之类的无限维度值,缓存越积越多,内存迟早被撑爆。线上环境我建议要么用lru_cache(maxsize=某个上限),要么在写缓存的时候定期清理,或者给key设置过期时间。别以为字典便宜就随便缓存一切数据。

2.4 字典API里的那些隐藏细节

字典的API很简洁,但每个都有讲究。

dict[key]和dict.get(key)的区别是基础中的基础:前者在key不存在时直接抛KeyError,后者返回默认值。但还有一个细节很多人忽略——get不会让代码看起来更好,它只是把错误掩盖了。如果你的逻辑里key必须存在,别用get,直接用[]让它快速报错,反而更容易发现问题。

pop可以在删除key的同时拿到value,还能给默认值:

data = {"a": 1, "b": 2} value = data.pop("a", 0) # value为1, 字典里不再有"a"

popitem在3.7之后删除并返回的是“最后插入”的键值对,这跟有序字典的特性是对应的。早期版本它是随机的,如果你依赖“删除最后一项”这个语义,记住一定要基于3.7以上版本。

update方法可以合并两个字典:

d1 = {"a": 1, "b": 2} d2 = {"b": 3, "c": 4} d1.update(d2) print(d1) # {'a': 1, 'b': 3, 'c': 4}

Python 3.9之后还新增了|运算符直接合并,更加直观:

merged = d1 | d2

这里要注意:update会就地修改原字典,而|操作符返回的是新字典。根据你的业务需求决定用哪个,别搞混了导致原数据被意外改动。

3. 集合:去重、成员判断与集合运算背后的逻辑

3.1 set的本质:没有value的哈希表

很多人第一次接触set的时候,理解停留在“一个里面没有重复元素的列表”。这个比喻勉强对,但很容易误导人。

集合实际上是一张只有key没有value的哈希表。它的查找、插入、删除也都基于哈希值,所以成员判断的复杂度是O(1)。这也是为什么用集合做去重、做“在不在”判断,效率碾压列表。

用一段代码展示集合判重的优势:

import time size = 1_000_000 list_data = list(range(size)) set_data = set(range(size)) target = size - 1 start = time.perf_counter() found = target in list_data print(f"列表包含判断耗时: {time.perf_counter() - start:.6f}s") start = time.perf_counter() found = target in set_data print(f"集合包含判断耗时: {time.perf_counter() - start:.9f}s")

结果跟字典查找一样,量级差距很明显。所以只要你的需求是“判断某个元素是否在数据里出现过”,优先选择集合。

去重操作也是集合的经典应用。文本处理里经常要对一篇文章提取不重复的词:

words = ["the", "a", "the", "an", "a", "the"] unique_words = set(words) # {'a', 'an', 'the'}

需要注意的是:set(words)只能处理元素可哈希的情况。列表是可变的,不可哈希,所以如果列表里有列表元素,直接转set会报TypeError: unhashable type: 'list'。解决办法是把内层列表转成元组再进集合。

3.2 集合运算:交集并集差集才是真正杀招

集合运算在数据处理里非常实用。假设你有两个用户ID集合,一个是VIP用户,一个是活跃用户:

vip_users = {"u001", "u002", "u003", "u004"} active_users = {"u003", "u004", "u005"}

找出既是VIP又活跃的用户,手动写循环当然可以,但用交集一行:

both = vip_users & active_users # {'u003', 'u004'}

VIP但没活跃的,用差集:

inactive_vip = vip_users - active_users # {'u001', 'u002'}

两个群体合并后的名单,用并集:

all_users = vip_users | active_users # {'u001', 'u002', 'u003', 'u004', 'u005'}

还有一个对称差集^,表示“两个集合中只属于其中一个的元素”:

only_one = vip_users ^ active_users # {'u001', 'u002', 'u005'}

运算符和方法名对应的关系如下表,方便查阅:

运算符方法含义
A & BA.intersection(B)交集,两边都有的
A | BA.union(B)并集,合并去重
A - BA.difference(B)差集,A有且B没有
A ^ BA.symmetric_difference(B)对称差,只属于一边的

权限系统也经常用集合运算。比如一个用户有基础权限集合、角色权限集合、被屏蔽权限集合,实际生效权限就是(base | role) - blocked。这种计算方式简洁清晰,逻辑不容易出错。

子集判断用issubset:

{"admin"}.issubset(all_permissions)

比如你需要检查某个基本权限组合是否全部包含在用户权限里,直接一行搞定,比循环遍历清晰多了。

3.3 frozenset:不可变集合的存在意义

集合本身是可变的,所以不能作为字典的key,也不能放在另一个集合里。但如果你的数据集合不需要修改,可以用frozenset,它是不可变版本,可以作为key使用。

什么场景会用到?比如你要对一批订单按“涉及的地区组合”分组,组合本身是集合,但如果直接用set做key会报错:

regions_map = {} regions = {"华东", "华南"} # regions_map[regions] = [...] # 报错: unhashable type: 'set'

用frozenset就正常了:

regions_key = frozenset({"华东", "华南"}) regions_map[regions_key] = [100, 200, 300]

顺带一提,如果你需要去重的对象是字典,那要麻烦一点。因为字典也是可哈希?其实不是,字典是可变对象,不可哈希。但frozenset本身和元组都是可哈希的。你如果有一组键值对想整体作为一个key,可以把它们转成元组列表再转frozenset,或者设计成不可变映射types.MappingProxyType,不过后者平时用得少。

4. 选型思考:到底什么时候该用哪个

4.1 dict、set、list怎么选

很多新手容易踩的坑是:不管什么数据都用列表装。列表确实万能,但“万能”不等于“高效”。我平时选型的思路很简单,就三个判断:

看你要不要精确的索引顺序。如果只需要遍历,或者数据本身就是时间序列、排行榜这类有先后关系的数据,用列表。但如果你要频繁按某个标识去查找对应的值,列表就得线性搜索,这时候字典是更好的选择。

看你要不要关注键值关联。如果只是判断“某个ID在不在名单里”,用集合就够了,没必要用字典额外存value。比如黑名单判断、IP白名单判断,set就是最轻量的方案。

看你要不要保持插入顺序且按顺序迭代。字典在3.7之后保持插入顺序,而且是一个哈希表,两边的好处在特定场景下是有取舍的。列表迭代快、切片方便,字典查找快、语义强。

下面这个表是我自己用的速查,也顺手分享:

需求推荐结构理由
按标识快速取值dictO(1)查找,key-value语义
只关心元素“在不在”setO(1)查找,无冗余value
有序遍历、切片、翻转list索引访问,天然有序
数据需要去重set自动去重,保留唯一项
大量数据分组聚合dict配合defaultdict非常顺手
保持插入顺序且快速查找dict3.7+ 普通字典就满足

4.2 内存和性能的实测感受

单纯从内存角度说,同一个数据量下,list可能是最省的(如果只是存元素);set其次,因为它只存key;dict最“贵”,因为每个键值对还要存value和哈希信息。

实际体验上,如果你存的是10万个整数ID,set和dict的内存差距可能会差到两三倍以上。如果你只关心用户是否存在于某个列表,用set能明显减少内存占用。当然业务对象本身很重的时候,这些结构体的内存占比反而不重要,优先考虑代码可读性就好。

另外有个性能细节:很小的数据集上,list查询反而不一定慢。因为list的遍历操作是C语言层面的高效循环,数据量小于几百时,线性搜索的开销可能比哈希计算的函数调用开销还小。所以别一上来就无脑用set,数据量明确比较大、查询频繁,才值得追求O(1)。这个判断在写算法题和做小工具时尤其有用。

4.3 有序字典已经不需要额外引用了

老代码里为了保持插入顺序,经常会看到collections.OrderedDict。Python 3.7之后普通dict本身就是有序的,这块基本可以退役了。

OrderedDict目前还剩一个普通dict没有的API:move_to_end,可以把某个key移动到末尾。如果你要实现LRU缓存这类需要调整“最近使用顺序”的逻辑,OrderedDict还是很有用的。lru_cache的底层就是它。除此之外,日常业务代码直接用dict就行,少引一个模块少一份心智负担。

如果需要对字典按key或value排序,用sorted(dict.items())就好:

data = {"banana": 3, "apple": 2, "cherry": 5} # 按键名字母排序 for k, v in sorted(data.items()): print(k, v) # 按值排序 for k, v in sorted(data.items(), key=lambda item: item[1]): print(k, v)

注意这里sorted返回的是列表,不是字典。如果你确实想要一个新字典(比如Python 3.9之后有更简洁的写法),可以配合字典推导式:

sorted_data = {k: data[k] for k in sorted(data)}

5. 常见问题与避坑实录

5.1 把可变对象当key:TypeError

这是新手最高频的报错之一。错误写法:

d = {} d[[1, 2]] = "value" # TypeError: unhashable type: 'list'

原因很直接:哈希表要求key必须是可哈希的,也就是不可变。列表是可变对象,它的内容一变哈希值就变了,没法稳定定位。

解决办法也很简单:用元组代替列表。

d = {} d[(1, 2)] = "value"

但要小心一个隐蔽的情况:元组里如果嵌套了列表,它一样不可哈希。

key = (1, [2, 3]) # d[key] = "value" # TypeError

因为外层元组包含了一个可变对象,整体哈希值不稳定。要检查一个对象是否可哈希,可以直接用hash()函数试:

hash((1, 2)) # 正常返回数字 hash((1, [2, 3])) # TypeError

还有一个隐含的坑:自定义类如果重写了__eq__但没有重写__hash__,Python会把它的__hash__设为None,导致实例不可哈希。这在写ORM对象、领域模型时特别容易踩,重写__eq__后记得把__hash__也补上,或者明确将其设置为None表示不可哈希。

5.2 遍历字典时不能增删元素

在遍历字典的同时添加或删除元素,会抛出RuntimeError: dictionary changed size during iteration。这个报错不是参数问题,是Python防止迭代过程中内部结构被破坏的安全机制。

data = {"a": 1, "b": 2, "c": 3} for k in data: if k == "a": del data[k] # RuntimeError: dictionary changed size during iteration

如果你想在遍历中过滤掉部分键,正确做法是先把keys转成列表再遍历,或者用字典推导式生成一个新的字典:

data = {"a": 1, "b": 2, "c": 3} data = {k: v for k, v in data.items() if k != "a"}

集合也一样,迭代中不能增删。所以如果要做“边遍历边删除”,记得先list(some_set)复制一份。

这里插一句:遍历时修改key对应的value是允许的,因为修改value不改变哈希表的结构,只有增删键才会改变大小。这算是一个容易混淆的边角,但搞清楚了能省不少调试时间。

5.3 空集合的写法陷阱

{}在Python里是空字典,不是空集合。想创建空集合必须用set()。

empty_dict = {} # 类型是 dict empty_set = set() # 类型是 set

这是一个让几乎所有Python新手都摔过跟头的坑。写函数默认参数时尤其要小心:

def process_items(items=set()): # 不建议 ...

默认参数只在函数定义时创建一次,可变默认参数(比如空集合、空字典)在多次调用之间是共享的。如果函数里修改了这个集合,下一次调用就会带上上一次的数据。经验之谈:默认参数统一用None,函数内部再创建新的容器。

5.4 布尔值和数字的哈希陷阱

在Python里True和1的哈希值相同,False和0的哈希值也相同。所以下面的代码会覆盖:

d = {True: "yes", 1: "no"} print(d) # {True: 'no'}

因为True和1在哈希层面是等价的,后者赋值会覆盖前者。集合也类似,{True, 1, 0, False}实际上只有两个元素{True, 0}或{0, True}(取决谁先插入、输出顺序等)。

这不是bug,是哈希表的必然结果。但在实际业务中如果key可能混入布尔值和数字,一定要提前约定好类型,避免“看起来是两个key,实际上同一个key”的诡异问题。

5.5 与JSON互转时key类型的变化

Python字典转JSON再转回来,key类型有时候会悄悄变化。比如数字key,JSON要求键必须是字符串,所以:

import json data = {1: "one", 2: "two"} s = json.dumps(data) print(s) # '{"1": "one", "2": "two"}' back = json.loads(s) print(back) # {'1': 'one', '2': 'two'}

数字1变成了字符串"1"。如果你的程序后续用back[1]去取值,会得到KeyError。解决方案是在loads之后自己转换,或者从一开始就用字符串key。

嵌套字典的安全取值也是常见问题。多层结构取某个深层字段,中间任何一层缺失都会抛KeyError,代码就崩了。一个比较稳的写法是用循环调用get:

def deep_get(d, keys, default=None): for k in keys: if not isinstance(d, dict): return default d = d.get(k, {}) return d if d != {} else default

这种“链式get”在解析外部接口返回的复杂JSON时非常有用,省得写一堆try-except。

5.6 从“用”到“懂”的几个习惯

我个人这几年最大的体会是:新人在学Python的时候,应该花点时间把数据结构的行为边界摸清楚,而不是急着上框架。比如你可以做几个小实验:看看字典无序和有序在不同版本下的差异,试试集合的交集运算在权限模型里怎么写,测一测大数据量下list和set的性能差多少。

这种实验花不了多少时间,但对认知的提升很大。以后你写代码的时候,会自然地做出选择,不需要靠背结论。比如看到“去重”两个字,你会想到set(),但更进一步,你会想到如果还要保持原始顺序怎么办——那就用dict.fromkeys()保留key的顺序去重。这是我后来写脚本常用的技巧:

words = ["b", "a", "a", "c", "b"] dedup_ordered = list(dict.fromkeys(words)) # ['b', 'a', 'c']

dict.fromkeys会保留插入顺序并自动去重,再转回列表就能得到一个保持原有相对顺序的唯一元素列表。这种小技巧不翻源码很难总结出来,但知道之后写代码效率高很多。

再分享一个最后的小习惯:如果你写的代码要给别人维护,尽量少用那些“依赖哈希表内部布局”的技巧。比如字典的插入顺序虽然是规范了,但如果你把“根据插入顺序取最后一个元素”当成一种常规操作,建议还是显式加一个注释说明意图。代码是给人读的,数据结构只是工具。

字典和集合这俩数据结构,说简单也简单,但真要发挥它的价值,还是得在实战里多体会。希望这篇内容能帮你少走一些弯路,下一次选型的时候心里更有数。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进