我接触过不少写后端、做数据分析、甚至折腾脚本的朋友。聊到数据结构的时候,大家通常都会背两句口诀:字典查得快,集合能去重。可真到现场,被追问“为什么字典按键取值不是一个个遍历而像点了魔法一样直接命中”“为什么集合里的元素不能是 list”“为什么都说 Python 的字典和 Java 的 HashMap 不一样,差在哪儿”,很多人就一下子卡住了。这篇文章就围绕字典和集合的原理及应用来做一次系统拆解。第一篇的重点会放在底层共性上:哈希表。搞懂哈希表,就等于同时拿到了字典和集合的底层设计图纸。学完之后你能明白 dict 的 O(1) 查找到底建立在什么机制上,也能看懂 set 去重、取交集为什么那么快。本篇以 Python 为主,途中会对照 Java 的 HashMap / HashSet 和 C++ 的无序容器,适合正在学 Python、准备校招面试、或者在工作中已经把字典用得很熟练但还想更深一层的人。读完后你可能不会立刻改很多代码,但面对性能问题、哈希相关报错,你会比大多数人更早定位到原因。
1. 一个查询问题引发的技术路线:为什么“找东西”能快到这个程度
1.1 数组:用“位置”换时间的第一个杀手锏
先回到最朴素的场景:内存里有一排数据,怎么最快找到某一个?如果数据是按顺序排放的,并且你提前知道它的下标,那么一次访问就能结束战斗。数组就是这种模型的典型代表。CPU 访问数组元素时,本质上做了一次“基地址 + 下标 × 单个元素大小”的地址计算,直接跳到目标内存。整个过程没有比较,没有循环,时间近乎是恒定的。这也是计算机基础课里反复说的“随机访问”能力。你可以把数组想象成一栋每层只有一间房的公寓,门牌号都是固定编号。只要知道房号是 502,你不需要先从 101 挨个看牌子。下标本身就是“位置”,这是数组查找快的根本原因。
但是实际业务里,我们很少按“下标”查东西。用户要的是“根据用户名查积分”“根据订单号查状态”,而不是“根据数组位置查元素”。用户名和订单号是内容,不是位置。这时候数组的瞬间定位优势就发挥不出来了。
1.2 线性查找的老大难:数据规模一上来就现形
如果数组里只存了一堆英文单词,而我希望知道某个单词是否存在,最简单粗暴的办法是写一个循环从头到尾逐个比较。这个策略叫线性查找。线性查找的优点是省事,但代价也很直观:数据量越大,平均比较次数越多。比如一万个元素,运气最差要比较一万次;一百万个元素,最差要一百万年? 其实是一百万次,虽然不至于用“年”计时,但对计算机来说已经不是小数目了。尤其当这种查找发生在循环内部、一个请求要被执行几万次时,性能差距就会被急剧放大。
Python 里最典型的就是 list 类型的 in 操作。看到一个常见问题:“为什么我在列表里判断一个元素是否存在,数据一多就慢?”原因就在这里。列表不会自动保证元素有序,也不可能让元素根据内容直接跳转到某个位置,所以它只能老老实实做线性扫描。即使列表里刚好有你要找的元素,你也不得不从第一个开始挨个看。
这让我想到一个更直观的比喻。去一个没有索引的图书馆找一本特定的书,唯一的办法是沿着书架一本一本翻;可如果图书馆提供“按书名首字母定位到某个分区”,你就能直接略过大量无关区域。哈希思想做的事情,本质上就是给内容配一个合适的“分区号”甚至“精确位置”,让查找从“挨个问”变成“直接去”。
1.3 把内容变成位置:哈希思想顺理成章地出现
既然下标可用但不符合业务,内容符合业务但没法用来定位,能不能找到一个函数,把“内容”转换为“下标”?这就是哈希函数。哈希函数接收任意对象,输出一个整数,这个整数被称作哈希值。只要计算足够快,且能保持稳定,就能用这个整数去访问一个类似数组的存储区域。
“字典”和“集合”这两个数据结构,就是在这个想法上长出来的。它们在不同语言里的名字各不相同,比如 Python 里的 dict 和 set、Java 里的 HashMap 和 HashSet、C++ 里的 unordered_map 和 unordered_set。名字五花八门,但底层基本都是哈希表。哈希表为键值查找提供了一条高速通路,所以字典和集合才敢说自己查找、插入、删除的平均时间复杂度是 O(1)。这里的 O(1) 并不是“一次都不需要比较”,而是说比较次数不会随着数据规模线性增长。哈希表把大头开销花在了哈希值的计算和桶位置的寻找上,这部分和表里已有的元素数量没有直接关系。
想明白这件事,再看有些老同事的疑问就有意思了:为什么有人用 list 做成员判断时越跑越慢,换成 set 后速度突飞猛进?差距并不是 Python 对 set 做了什么神级优化,而是数据结构本身的工作原理决定了它不需要遍历全部数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表是怎么运转的:从哈希函数到冲突处理
2.1 哈希函数的三个底线
哈希表的地基就是哈希函数。理解一个哈希函数设计得好不好,我一般会看三件事:确定性、计算速度、分布均匀程度。
确定性是说同一个对象在同一个进程里多次调用哈希函数,必须得到同样的整数结果。否则今天插进去的时候放在 5 号桶,明天查的时候哈希变成了 6 号桶,这个表就没法用了。计算速度好理解,哈希函数本身要是很慢,那 O(1) 的优势就被抵消了。很多资深工程师喜欢用哈希值来快速比较两个大对象,本质上也是在赌“哈希计算比逐字段比较更便宜”。分布均匀这条最容易被忽略:如果哈希值分布很差,所有键都落到同一个桶,哈希表就会退化成链表,查询复杂度重新变回 O(n),那还不如一开始就用列表。
要注意,哈希函数和加密算法不是一回事。哈希函数不需要做到“不可逆”,它只负责把对象映射成整数。Python 内建的 hash() 函数每次启动时可能得到不同结果?不完全对。整数对象的哈希值通常就是它自身,但字符串为了保证安全性会加入随机种子。这里不要试图依赖 hash() 的具体返回值,它属于解释器内部细节。
python复制# 在不同进程里运行,字符串的 hash 值可能不同
print(hash("apple"))
# 整数的 hash 在同一版本解释器里基本稳定
print(hash(9527))
写代码的时候,你可以用 hash() 去猜测一个对象是否可哈希,但永远不要把某个哈希值硬编码进配置或数据库。哈希值不是稳定 ID。
2.2 散列桶:整数哈希值如何落到具体位置
哈希函数输出的是一个大整数,但我们不可能真开一个那么大数组。通常做法是让这个大整数和哈希表当前容量做取模或者位运算,得到一个桶下标。很多实现里数组长度故意取 2 的幂,这样可以用位与运算替代取模,因为位运算比除法更快,一个代表性的模糊代码如下。
c复制index = hash_value & mask; // mask = table_size - 1
这里的 index 决定了这个条目应该落到哈希表的哪个位置。数组里面每个位置,概念上叫“桶”。理想情况是每个键都能分到独立的桶,这样查找键的时候只需要算一次哈希,去对应桶里瞄一眼就能确认存在或不存在。
但现实中哈希函数不是完美的,或者说,即便函数本身分布均匀,桶的数量也远远小于可能输入的数量。“两个不同键算出了同一个桶下标”这件事,在哈希表里叫哈希冲突。冲突不是异常,而是常态。所有哈希表实现都必须回答一个问题:冲突发生了,你把后到的元素放哪儿?查找的时候又该怎么区分这些挤在一起的键?
2.3 冲突处理的两大流派:拉链法与开放寻址
业界处理哈希冲突主要有两类思路。
第一类叫拉链法(也叫链地址法)。数组里每个桶不是一个元素,而是一条链表的头指针。冲突的多个键都放到同一条链表上,查找的时候算完哈希找到桶,再沿着链表比较。Java 的 HashMap 早期就是这种设计,C++ 的 unordered_map 也类似。这种方案写起来直接,只要哈希函数均匀,链表一般都很短,平均性能依然可以接近 O(1)。但极端情况下,如果哈希函数被攻击者故意制造大量碰撞,链表就会变得很长,查询退化成线性扫描。所以新版 Java 在链表长度超过阈值(8)时,会把链表转成红黑树,利用树的高度优势把复杂度压到 O(log n),这是一种工程上的保险丝。
第二类叫开放寻址法。整个哈希表就是一块连续的大数组,没有“桶里挂链表”的概念。冲突发生以后,我会按一定规则继续在数组里找下一个空闲位置,直到放进一个空位。查找的时候同样沿着规则往后探测,直到找到键或者遇到空位。Python 的 dict 和 set 就是走开放寻址这条路的。开放寻址的好处是内存局部性好、没有额外的指针存储,缓存利用率高;坏处是装载因子不能太高,否则冲突探测会快速变长,表里必须有足够的空位垫底。
有人会问:Python 为什么不用拉链法?这里其实没有一个唯一正确答案。语言设计团队更看重内存紧凑、缓存友好,于是选了开放寻址。Java 的 HashMap 需要考虑更复杂的删除场景,还有并发容器等历史包袱,所以用了链地址法。条条大路通罗马,只要底层机制成立,二者都能实现高效哈希表。
2.4 Python 字典与 Java HashMap,实现思路的对照
Python 字典在 3.6 之后发生变化,这里不展开所有实现细节,但有一个结构值得知道:索引数组和真正键值对数组被拆开了。键值对仍按插入顺序紧凑地存放在一个连续区域里,索引数组则用来做哈希定位。这让我想起老版本 Python 的一个重要问题:字典常常额外多占内存。有些 Python 老用户可能体会过,创建大量字典对象时内存涨得飞快,这是旧版 dict 每条记录都单独保存哈希、key、value,而且数组要留空位,导致内存占用偏高。新实现让真正数据紧凑排列、索引单独存放,整体内存占用比旧版少很多,同时还能保留插入顺序。
Java HashMap 的存储则是一张 Node 数组,每个 Node 要么是普通链表节点,要么是红黑树节点。它允许装载因子到 0.75 才扩容,而 Python 通常保留较多空闲槽位,这背后是两种语言对时间和空间的取舍。用一张表粗略对照:
| 对比维度 | Python dict | Java HashMap |
|---|---|---|
| 冲突处理 | 开放寻址式探测 | 拉链法,链表过长转红黑树 |
| 常见扩容阈值 | 大约 2/3 负载左右触发 | 默认 0.75 |
| 是否保留插入顺序 | 是 | 不保证 |
| 存储结构 | 索引数组 + 紧凑键值数组 | Node 数组 + 链表/红黑树 |
| 哈希随机化 | 字符串哈希加随机种子 | 有类似扰动设计 |
看到这个对照表,不要死记数字,重点是理解:每个设计都是平衡。扩容阈值低一些,空间浪费多,但冲突少、查找快;阈值高一些,空间利用充分,但冲突和探测成本上升。没有绝对的“最优”,只有适不适合当前语言生态。
3. 集合:裁掉 value 的字典,却撑起了半壁江山
3.1 一个 entry 里少了字段,其他机制原样保留
理解了字典,集合就很好理解了。可以把集合看作“只存键、不存值”的哈希表。字典里每个条目要维护 key 和 value 两个对象,集合的条目只需要维护一个元素本身。但是哈希值计算、桶定位、冲突探测、扩容重排这些机制,一个也没有少。Python 的 set 在底层可以理解为复用了 dict 的哈希查找框架,元素必须可哈希,底层同样会做扩容和重哈希。
正因为这样,集合和字典共享了不少特性。比如查找一个元素是否出现,平均 O(1);元素没有顺序(Python 里 set 不保证遍历顺序稳定);元素不可重复。正因为元素不能重复,集合最朴素的用法才成了去重。可以说,去重不是集合设计者的主要目的,而是“集合里不允许重复元素”这个天然性质带来的副作用。只要底层是哈希表,这个副作用就非常高效。
python复制only_once = {1, 2, 3, 3, 2}
print(only_once) # {1, 2, 3}
注意这里有一个非常容易踩到的点:集合里不允许重复,但实际上判断重不重复靠的是“哈希相等”和“对象相等”两个规则。不是只有字面完全一致才算重复。后面第 4 节会专门说 Python 的相等规则有多么容易让人意外。
3.2 去重和成员判断:集合最顺手的两件事
要是现在要处理一批用户 ID,剔除重复项,同时还不关心原始顺序,你多半会写出 list(set(user_ids))。这一行代码跑了两次哈希结构转换,但整体还是 O(n),比两两比较的清重方式高效得多。
另一个高频用法是成员判断。一个关键词到底在不在全部名单里,用 if x in big_list 和 if x in big_set,在元素数量多到一定程度后能感受到明显的速度差距。这里的差异同样来自线性扫描和哈希定位的区别。列表成员判断是逐个比较,集合成员判断只需一次哈希定位。当这段代码被包在循环里执行成千上万次时,哪怕每次省下几十微秒,累积效果也很大。
有一点提醒:如果你只需要做一次“判断一个元素是否存在”的操作,并且数据量不大,list 和 set 的绝对耗时差距没那么惊人,没必要为了炫技把所有 list 都转成 set。等集合创建本身也要花费时间和内存。先判断需求形态:是“一次性查询”还是“反复某个集合上查询”?后者才更有必要用 set。
3.3 用哈希成员关系快速做集合运算
集合在数学上天然支持交集、并集、差集,Python 也把这些运算符号直接搬了过来。底层的计算方式很有意思:求两个集合的交集时,通常会遍历较小的集合,对每个元素去较大的集合做哈希查找,而不是真的两个集合嵌套循环。这个策略保证了集合运算不是 O(n²),而是大致 O(min(m,n)) 的查找成本。
python复制backend = {"python", "go", "rust"}
frontend = {"javascript", "typescript", "go"}
# 同时出现在两个技术栈里的语言
print(backend & frontend)
# 后端技术栈里的所有语言
print(backend | frontend)
# 只用后端不用前端的语言
print(backend - frontend)
这个设计对业务代码很有启发。比如用户在多个渠道有过行为,你要分析“同时出现在渠道 A 和渠道 B 的用户”,本质就是两个用户 ID 列表转成集合做交集。只要 ID 是可哈希的整数或字符串,整个运算可以写得非常简短。同样,如果业务里频繁判断两个名单是否有重叠,a.isdisjoint(b) 也比自己写双层循环或先求交集再判断是否为空要清晰。
3.4 Java HashSet 与 C++ 的 unordered_set:跨语言看同一件事
聊到这里,我喜欢拿其他语言互相印证一下,因为数据结构这层知识不应绑定在任何单独语言上。Java 里的 HashSet 底层是 HashMap,只是所有 value 都指向同一个固定占位对象。换句话说,Java 根本没有独立的哈希集合核心实现,直接复用了 HashMap 的 key 部分。这再次说明集合和字典关系密切。
C++ 的 unordered_set 则源自哈希表容器家族。即使 API 风格不同,核心约束都一样:元素必须能计算哈希,元素被插入后不能破坏哈希值。C++ 标准里,如果你往 unordered_set 里放了自定义对象,却没有提供哈希函数和相等函数,是编译不过的。这些跨语言的相似性,回头验证了哈希表这套设计有多通用。所以别再问“字典和集合是不是两种完全无关的东西”,它们在我眼里是同一台发动机装在不同车壳子里:字典带着一个值的座位,集合把座位拆了用来多拉几个“键乘客”。
4. 看懂了原理,才能在代码里避开这些坑
4.1 list 为什么不能进 set 和当字典 key
初学 Python 时常有人写出 set([[1, 2], [3, 4]]),然后收到 TypeError: unhashable type: 'list'。从 API 角度看,这是 Python 的任性限制;从原理角度想,这个限制其实是保护。列表是可变对象,放进集合后再添加元素,对象内容变了,那它原本的哈希值岂不是还得变?如果允许这种情况,一个元素刚插入时在第 5 个桶里,你改完内容后它可能应该去第 8 个桶,但哈希表并不知道要搬它,后续查找自然找不到。
所以 Python 只允许“不可变且可哈希”的对象作为 dict 的 key 或 set 的元素。整数、字符串、元组通常可哈希。但元组里如果嵌套了列表,比如 (1, [2]),照样不可哈希,因为元组的哈希值要依赖里面元素,而 list 没有稳定的哈希值。
日常项目里如果确实需要把一个“包含多个元素的逻辑集合”当作 key,可以考虑两个常见替代方案:用 frozenset 表示无序集合,或者用一个规范化后的字符串/元组表示实际业务键。下面这两种写法都是合法的:
python复制user_roles = {"admin", "editor"}
# frozenset 本身不可变,可以作为 key
role_score = {
frozenset({"admin", "editor"}): 80,
frozenset({"viewer"}): 10,
}
# 业务键通常用元组表达
order_key = ("2026-05-20", "CN", 10086)
order_info = {order_key: "example"}
以后看到 unhashable 报错,第一时间不要只想着“加个 tuple 转换”。先问自己:我是不是把一个会变的对象塞进了哈希容器?如果业务上需要“能变也能被查找”,那你需要的不是修改容器,而是设计一个新的不可变标识字段。
4.2 别被 Python 的相等规则骗了:1、1.0 和 True 是同一个键?
这里有个让很多人咋舌的细节。Python 里 1 == 1.0 成立,1 == True 也成立,而且它们的哈希值完全相同。于是字典会把这三个值视为同一个键。看这个例子:
python复制d = {}
d[1] = "整数"
d[1.0] = "浮点数"
d[True] = "布尔"
print(len(d)) # 想清楚,不是 3,而是 1
print(d) # 后面写入的值会覆盖前面
这不是 bug,这是“相等对象必须有相同哈希值”这条铁律的自然结果。哈希表查找时先比哈希,再比相等;1 和 True 既然相等,就必须落到同一个位置,不能在一个字典里同时占据两个键。这个坑体现出的原理是:哈希容器到底以什么标准判断两个键相同?判断标准不是“类型相同”,而是对象的 == 语义和 hash() 语义是否一致。
实际业务里,如果用户标识可能同时出现字符串形式的数字和真正的数字,比如一会儿从前端拿到 "10086",一会儿从数据库拿到整数 10086,不小心把这些混进同一个 key,字典就可能在你看不到的地方把数据合并。做数据处理之前,最好统一 key 的类型。不要赌 Python 的哈希和相等规则会照顾你的直觉,它只遵守语言定义。
4.3 装载因子、扩容和你平时不在意的卡顿
哈希表如果想要保持 O(1) 查询,就不能让桶装得太满。当已使用容量超过某个阈值时,哈希表会进行扩容。扩容不是简简单单把数组变大,而是申请一块更大的连续内存,把旧元素全部重新计算哈希、重新放到新数组里,也就是 rehash。这个过程的时间开销通常和当前元素数量成正比。
Python 里的字典和集合,也遵循类似的扩容机制。创建一个包含几十万条数据的字典,如果是一边算一边往里面塞,中间可能触发多次扩容。每次扩容都伴随着全量搬运,最后一次扩容尤其明显。更微妙的是,扩容是临时的 double 或更大内存申请,在旧表还没释放、新表已经申请完的那一刻,内存会出现一个明显的尖峰。如果机器内存本来就紧,一不小心就把进程 OOM 掉了。
知道了这个原理,写代码时会有一个习惯:能用一次性构造的数据集合,就不要用“循环单条插入”的方式慢慢撑大字典。比如从一个列表批量创建映射表,直接用 dict(zip(keys, values)) 或字典推导式,比在循环里不停 d[k] = v 更省心。虽然底层依然会发生扩容,但一次性构造通常能让解释器更合理分配容量。同理,如果你发现某个大字典需要过滤掉大量旧键,别图省事在原字典上反复 pop。与其让哈希表留在“布满删除痕迹”的状态,不如创建一个新字典,把需要的键拣过去。这能让负载和内存布局更健康。
4.4 遍历中不能改结构:一句报错背后的原因
在遍历字典或集合的同时直接新增或删除元素,Python 会抛出 RuntimeError: dictionary changed size during iteration。底层原因也不难理解:哈希表扩容会让所有元素重新排布,迭代器内部的“当前走到第几个”状态会全部失效。如果不挡住这种操作,可能出现元素跳漏、重复出现,甚至是死循环。
安全的做法是遍历一份副本,或者先收集要修改的键,遍历结束后再统一处理。
python复制scores = {"python": 90, "go": 88, "java": 85}
# 错误范例:遍历中直接删除
for lang in scores:
if scores[lang] < 86:
del scores[lang]
# 正确做法:先取出 keys 快照
for lang in list(scores):
if scores[lang] < 86:
del scores[lang]
这不仅是语法约束。如果你能意识到扩容会重排哈希表,就能明白所有语言里的哈希容器,在遍历时内部结构都不应该被随意修改。Java 的 ConcurrentModificationException 也是同样道理。与其去背每种语言的报错名,不如记住一条通用经验:遍历哈希结构时,别裸奔,先做快照。
5. 应用一:把字典和集合放进真实需求的五类姿势
5.1 用字典替代长长的 if-elif 分支
很多初学者处理“根据某种状态执行不同函数”的需求时,会写很长一串 if-elif-elif。这种代码不是不能跑,而是当分支数量从三个涨到十几个时,肉眼维护的成本就开始飙升。字典在这里可以充当一张“可执行函数映射表”。把 key 映射到函数对象,查找时用 dict.get 一步定位,代码结构比一长串分支清爽得多。
python复制def handle_new():
return "new logic"
def handle_edit():
return "edit logic"
handlers = {
"new": handle_new,
"edit": handle_edit,
}
def dispatch(action):
func = handlers.get(action)
if func is None:
return "unknown action"
return func()
这种写法还有一个隐形收益:动作与处理逻辑的对应关系被集中到一张表里,以后加动作,直接加一项映射,不用在控制流里翻来翻去找。我在真实项目里看到很多状态机、事件分发器、插件化的处理流程,本质都是这套设计。字典值的位置不只可以放普通对象,也可以放函数,这是 Python 一切皆对象带来的便利。如果只是常量映射,也可以用类似方式替代 switch-case 式的代码,语义非常清晰。
5.2 用集合完成去重、交集和高频成员过滤
假设一个订单列表里可能有重复的用户 ID,现在想统计“真正下过单的用户数”,最简单直接的方法是:
python复制user_ids = ["u01", "u02", "u01", "u03", "u02"]
unique_user_ids = set(user_ids)
print(len(unique_user_ids))
再进一步,如果要把一个日志文件里出现的“活跃用户”按时间去重,但日志量大、不想一次把所有 ID 都读到内存里,可以一行一行读,用集合维护已见过的 ID。用集合查重的时间复杂度是 O(1),整体处理速度跟着数据量走,不会变成 O(n²)。
python复制seen = set()
with open("events.log", encoding="utf-8") as f:
for line in f:
user_id = line.strip()
if user_id not in seen:
seen.add(user_id)
# 对首次出现的 user_id 做后续统计
process_first_seen(user_id)
这里有一个很容易被忽略的问题:如果 user_id 可能为 long 的大字符串,集合的内存会比较大。如果数据量真的上亿,单机内存撑不住时,就该考虑外部数据库或者去重系统,而不是硬撑 set。数据结构带来便利,也带来成本,选型时永远要算内存账。
5.3 用 collections 容器组合:Counter、defaultdict 与双向索引
字典的 key 可以是任意可哈希对象,value 也可以是一个集合或另一个字典,这种“嵌套组合”能力非常强大。词频统计我会用 Counter;需要给一组新闻标题建立“单词到标题集合”的反向索引时,我就用 defaultdict(set)。前者本质是基于字典实现的计数器,后者则是字典的值变成了集合,二者都很好用。
python复制from collections import Counter, defaultdict
titles = [
"Python 字典原理",
"Redis 集合应用",
"Python 哈希表",
]
# 统计目录标题里每个词出现的次数
words = []
for title in titles:
words.extend(title.split())
word_count = Counter(words)
print(word_count.most_common(2))
# 建立单词 -> 出现标题的倒排索引
index = defaultdict(set)
for i, title in enumerate(titles):
for word in title.split():
index[word].add(i)
print(index["Python"])
defaultdict(set) 和 dict[str, set] 的差别在于,操作缺少 key 时不会被 KeyError 打断,减少了一段“先判断再初始化”的样板代码。很多人希望把两个列表转成字典,也可以用 dict(zip(list_a, list_b)) 解决。这些 API 看起来都是语法糖,但基于的哈希原理是一致的:先在 key 上做哈希定位,再把 value 对象放进去。之所以推荐 Counter 而不是手动维护 dict,不只是因为它省代码,更因为它自动把“计数对象的默认值”这件事处理好了,降低漏更新的概率。
5.4 图遍历和状态去重里最稳定的 visited 搭档
经常写算法题或做工程里依赖遍历的人,肯定对 visited 这个变量不陌生。遍历一张图时,核心难点是怕节点被重复访问。如果用一个列表记录已访问节点,每一次“是否访问过”的判断都是 O(n);但换成一个 set,同样的判断变成 O(1)。在节点多、边深的场景里,这个大 O 的区别往往把算法复杂度从可控变成不可控。
python复制def count_reachable_nodes(graph, start):
visited = set()
stack = [start]
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
stack.append(neighbor)
return len(visited)
这里的 visited 就是一个典型应用:既要保证“查重”高效,又不需要维护顺序。工程里很多真正的图结构不一定写成邻接表,可能藏在一个大型字典的嵌套关系中,但只要访问过一批节点 ID,用 set 记录就比用 list 稳妥。实际开发中,即使图的规模不大,这样写也能省掉很多“重复处理”带来的潜在 bug,因为哈希集合天然不重复,访问过的节点不会再次入栈。
这一篇先把字典和集合背后的哈希表原理讲清楚,再结合日常代码里最容易踩的坑和常规用法做了一轮梳理。代码能不能跑通只是第一步,容器选型是否合理,往往决定了一段程序在数据量上来之后是丝滑还是挣扎。对照我自己写项目的经验,遇到性能问题先别急着优化算法,先检查是不是在用 list 做哈希表该干的查重工作,或者是不是把一个可变对象强行塞进了 dict 的 key。下一篇我会在这个基础上继续深入:比如自定义对象的哈希与相等如何设计、集合在大数据量下的内存实测对比、以及不同语言哈希容器之间迁移时容易忽略的细节。
