
Python 字典dict是我日常写代码时使用频率最高的数据结构没有之一。写爬虫要解析返回字段写脚本要统计数量写接口要组装参数写算法要构建索引几乎每一个场景都绕不开它。很多人刚接触时觉得字典不过是“花括号里放几对键值”用起来也确实简单但真正到了项目里你会发现同样的需求不同人写出来的代码效率和可读性差别非常大。这背后不是天赋而是对字典核心机制的理解深度不同。这篇文章想把 dict 从创建到遍历、从进阶工具到实战案例完整串一遍不仅讲用法更讲清楚每个选择背后的原因和取舍。无论你是刚学 Python 不久的新手还是已经写了一阵子想提升代码质量的人都能从中拿到一些能直接用到项目里的东西。1. 字典的核心逻辑为什么是它1.1 一个最典型的场景从“找东西”说起假设你手上有一个学生名单需要根据姓名查成绩。用列表存的写法是这样students [(张三, 88), (李四, 92), (王五, 76)] def get_score(name): for student_name, score in students: if student_name name: return score return None这个函数本身没问题但问题是每次查找都要从头到尾遍历一遍。当数据量从几十条涨到几十万条时每次查找都是 O(n) 的时间复杂度性能会肉眼可见地变差。而用字典的话一行就能解决score_map {张三: 88, 李四: 92, 王五: 76} score_zhangsan score_map.get(张三)这个差别还是其次。真正让字典成为“Python 灵魂数据结构”的是它解决的问题足够通用任何通过一个键去定位一个值、并且查找频率很高的场景都适合用字典。配置项映射、用户 ID 对应用户信息、单词统计、缓存数据、节点关系建模全是同一类需求。生活化类比一下列表就像一本没有目录的书你要找内容就只能从头翻字典则像目录你看目录页码直接翻到那一页。目录的“页码索引”就是哈希表而“标题”就是键。1.2 字典的工作原理哈希表、可哈希键与顺序Python 字典底层是一张哈希表hash table。当你执行d[key] value时Python 会先对key调用哈希函数hash()得到一个整数这个整数决定了键值对在内存中存储的位置。之后读取时同样用hash(key)定位到同一个位置直接把值拿出来。这就是为什么字典查找的平均时间复杂度是 O(1)而列表是 O(n)。由于这个机制字典里的键是有硬性要求的键必须是可哈希的hashable。字符串、整数、浮点数、元组元素也得可哈希都可以列表、字典、集合这类可变对象不行。# 这样写会直接报错 d {} d[[a, b]] 1 # TypeError: unhashable type: list原因也很直观如果键的内容可变哈希值就会变之前存进去的位置就找不到了。这就像你把书放在书架第三层但书脊上的编号随时会变下次按编号怎么找得到关于顺序这里多说一句。Python 3.7 之后字典正式保证保持插入顺序遍历时按你写入键值对的顺序输出。很多人在网上看到“字典无序”的说法那是老版本或者拿集合来类比导致的误解。但要注意插入顺序不等于排序如果你需要按键或按值排序还是得显式调用sorted()。2. 创建、增删改查与日常操作细节2.1 五种创建字典的写法选哪种取决于场景最直观的方式是直接字面量d {name: 张三, age: 18}但如果键是变量或者要从两个列表拼出映射关系下面这几种写法更省事# 从键值对列表创建 d1 dict([(name, 张三), (age, 18)]) # 用 zip 把两个长度相同的序列拼成键值对 keys [name, age] values [张三, 18] d2 dict(zip(keys, values)) # 批量初始化同一个值 d3 dict.fromkeys([a, b, c], 0)fromkeys是个容易踩坑的地方。如果默认值传的是可变对象比如dict.fromkeys(keys, [])那么所有键共享同一个列表改其中一个其他全变。这在业务里是一个很经典的 bug 来源。d dict.fromkeys([a, b], []) d[a].append(1) print(d) # {a: [1], b: [1]}正确做法是用字典推导式或者defaultdict后面的高阶部分会讲。2.2 增删改查的关键方法get、setdefault、update、pop日常用得最多的四个操作读取、写入、更新、删除。读取时一定要养成用get的习惯value d.get(key, 默认值)直接d[key]在键不存在时会抛KeyError而get不会。如果你的代码逻辑允许键缺失用get能让代码少很多 try/except。写入和更新d[new_key] 1 # 不存在则新增存在则覆盖 # 批量更新 d.update({a: 1, b: 2}) d.update(a1, b2) # 键是字符串时可以直接这样写update有个容易被忽略的返回值它返回None是原地修改。所以如果写成d2 d.update(...)你拿到的d2是None不是更新后的字典。删除操作d.pop(key) # 删除并返回值键不存在会抛 KeyError d.pop(key, None) # 加了默认值就安全了 d.popitem() # 弹出最后一个插入的键值对返回元组 del d[key] # 只删不取键不存在同样抛 KeyError我在项目里最喜欢配合setdefault来处理“先判断再初始化”的逻辑。比如统计每个用户的商品列表user_products {} for user_id, product in data: user_products.setdefault(user_id, []).append(product)setdefault的意思是如果键不存在就把它设为默认值然后返回该键对应的值如果键存在就只返回原值。所以上面一行等价于这样三段逻辑if user_id not in user_products: user_products[user_id] [] user_products[user_id].append(product)用setdefault不仅仅是少写几行更重要的是把意图表达得更清楚我就想要一个“取不到就建默认值”的行为。2.3 新手最容易踩的坑KeyError、可变键和布尔值当键第一个坑是KeyError。很多人从别的语言转过来习惯了下标访问到了 Python 里还用d[key]一遇到缺失键程序就崩。我的建议是读操作统一走get除非你能百分之百确认键存在。第二个坑是可变键。除了列表不能当键还有一个隐蔽问题元组里如果包含可变对象也不能当键。# 会报错 t (1, [2, 3]) d {t: value} # TypeError: unhashable type: list第三个坑是1和True被视为同一个键。因为hash(1) hash(True)而且1 True所以它们互相覆盖。看个例子d {1: a, True: b} print(d) # {1: b}这个现象在布尔值参与映射时经常让人摸不着头脑。如果你确实需要区分1和True作键那就要换数据结构比如用元组包装成(type(x), x)。但更实际的建议是避免把布尔值和整数混在同一场景里当键。3. 遍历、排序与推导式让代码更像“老手”3.1 遍历的四种姿势和取舍遍历字典有很多种写法但本质只有四种for key in d: # 只遍历键 for key in d.keys(): # 同上显式一点 for value in d.values(): # 只遍历值 for key, value in d.items(): # 同时遍历键和值性能上直接for key in d和d.keys()差不多但如果你同时需要值和键一定要用items()不要写for key in d: value d[key]。后者虽然也能跑但每次循环都多做一次字典查找数据量大时有明显差别。写代码时“顺手”和“刻意”之间的差距就是靠这些小细节拉开的。还有一个进阶场景遍历的同时要修改字典。直接改会报错d {a: 1, b: 2, c: 3} for k, v in d.items(): if v % 2 0: d.pop(k) # RuntimeError: dictionary changed size during iteration正确做法是把要处理的键先收集到列表里循环结束后再统一处理或者干脆用推导式生成一个新字典。3.2 按键排序、按值排序、多条件排序字典本身没有“排序”方法一般用sorted配合items()做。按键排序sorted_keys sorted(d) sorted_dict {k: d[k] for k in sorted_keys}按值排序这是高频需求sorted_items sorted(d.items(), keylambda item: item[1], reverseTrue) top_n dict(sorted_items[:3])多条件排序比如先按值从大到小值相同再按键字母序sorted_items sorted(d.items(), keylambda item: (-item[1], item[0]))注意sorted(d.items(), ...)返回的是元组列表不是字典。如果你需要排序后的字典要再用dict()包装一次。Python 3.7 之后字典保持插入顺序所以用dict(sorted_items)重建一个字典是可行且常见的。这里也提醒一个容易犯的错如果字典里键的类型不统一比如同时存在字符串和整数sorted(d)会抛TypeError因为 Python 不知道如何比较它们。要避免这种混型键的出现。3.3 字典推导式与数据清洗实战字典推导式是我在重构代码时用得最多的工具之一没有它很多循环至少要啰嗦三倍。基础用法squares {x: x * x for x in range(5)}带条件筛选new_dict {k: v for k, v in old_dict.items() if v is not None}反转键值inverted {v: k for k, v in d.items()}数据清场景很实用从接口返回的字典里把空值、空字符串、None 都过滤掉同时把某些字段做类型转换。raw {name: 张三, age: 18, city: , tags: None} cleaned { k: v for k, v in raw.items() if v not in (None, ) }用推导式的另一个好处是它天然产生一个新字典不污染原数据。很多业务要求“不能改动传入参数”这时候推导式比在循环里一个一个删除安全得多。字典推导式还有一个冷门但好用的场景按条件分组。比如把一段文本里的单词按照首字母归组words [apple, banana, avocado, blueberry] grouped {ch: [w for w in words if w.startswith(ch)] for ch in set(w[0] for w in words)}当然这种写法在数据量大时不够高效因为内层推导式反复遍历列表。更专业的做法是用下面的defaultdict(list)。4. 实战案例词频统计、邻接表与 JSON 配置解析4.1 词频统计一个字典搞定计数字典最经典的应用是计数。统计一段文本里每个单词出现的次数新手最容易写出的版本是freq {} for word in text.split(): if word not in freq: freq[word] 1 else: freq[word] 1利用get可以精简成一行freq[word] freq.get(word, 0) 1完整案例读文件、分词、统计、输出出现次数最多的前 10 个词。from collections import Counter import re with open(article.txt, encodingutf-8) as f: text f.read().lower() words re.findall(r\w, text) top10 Counter(words).most_common(10) for word, count in top10: print(f{word}: {count})这个案例里如果不用Counter纯字典写法也完全可以freq {} for word in words: freq[word] freq.get(word, 0) 1 top10 sorted(freq.items(), keylambda item: item[1], reverseTrue)[:10]区别在于Counter自带most_common语义更清楚。但你要明白Counter本身就是字典的子类底层依然是dict的哈希机制。4.2 图论中的邻接表用字典构建图结构图结构在算法题和实际系统里都很常见。字典特别适合表示“邻接表”键是节点值是与该节点相连的邻居列表。graph { A: [B, C], B: [A, D, E], C: [A, F], D: [B], E: [B, F], F: [C, E], }无向图加边时注意两边都要加def add_edge(graph, u, v): graph.setdefault(u, []).append(v) graph.setdefault(v, []).append(u)用字典实现图的深度优先搜索也相当直观def dfs(graph, start): visited set() stack [start] result [] while stack: node stack.pop() if node in visited: continue visited.add(node) result.append(node) stack.extend(graph.get(node, [])) return result这里用到了graph.get(node, [])避免键不存在时报错是处理邻接表时的标准姿势。邻接表比邻接矩阵省内存。邻接矩阵是 N x N 的二维数组节点一多就爆炸邻接表只存实际存在的边。而 Python 里用dict list组合出来的邻接表写起来和读起来都非常顺手。4.3 JSON 配置解析与嵌套字典的安全读取爬虫、接口、配置文件凡是 Python 和外部系统打交道十有八九会遇到嵌套字典。很多人直接连续下标取值一旦中间某个字段缺失就是一道KeyError。比如接口返回这样一段结构response { data: { user: { profile: { name: 张三 } } } }普通访问很容易崩name response[data][user][profile][name]如果某个节点没有profile或者user是None程序直接中断。更稳的写法是写一个递归取值的函数或者用get逐层兜底def deep_get(data, keys, defaultNone): for key in keys: if not isinstance(data, dict): return default data data.get(key) if data is None: return default return data name deep_get(response, [data, user, profile, name], 未知用户)这种函数我在多个项目里都维护过看似简单但能避免大量重复的 try/except。对于更正规的团队也可以直接用jsonpath-ng之类库但小项目不值得引入额外依赖自己写一个十行函数足够。另外提醒一句json.loads()解析出来的对象键类型默认是字符串值是列表或字典。如果 JSON 里数字键比如{1: a}解析后键是字符串1不是数字1这一点在拼接查询条件时容易踩坑。5. 高阶工具collections 模块里的“字典增强版”5.1 defaultdict自动初始化告别烦人的判断defaultdict是dict的子类最大的价值是传入一个工厂函数在访问不存在的键时自动初始化。from collections import defaultdict grouped defaultdict(list) grouped[a].append(1) grouped[b].append(2) print(grouped) # defaultdict(class list, {a: [1], b: [2]})没有defaultdict时分组逻辑里到处都是if key not in d: d[key] []。有了它上面的 4.2 节add_edge可以简化成graph defaultdict(list) def add_edge(graph, u, v): graph[u].append(v) graph[v].append(u)注意defaultdict在读取不存在的键时不会报错但会给字典添加这个键。如果你只是想查询而不想改变字典要小心使用建议用d.get(key, [])而不是直接d[key]。5.2 Counter一行代码完成计数Counter是用于计数的专用字典前面词频统计已经展示了。它的常见用法还有合并、相减、取前 Nfrom collections import Counter c1 Counter({a: 3, b: 2}) c2 Counter({a: 1, b: 4, c: 2}) print(c1 c2) # Counter({a: 4, b: 6, c: 2}) print(c1 - c2) # 只保留正数差值 Counter({b: 0?}) 实际输出 Counter({b: 2})负数和0会被丢弃 print(c1.most_common(2)) # [(a, 3), (b, 2)]Counter和defaultdict(int)的区别是Counter提供更多统计语义方法比如elements()可以用来展开每个元素。5.3 OrderedDict、ChainMap 与其他OrderedDict是 Python 3.7 之前维持插入顺序的首选现在dict本身有序了大部分场景可以直接用普通字典。但OrderedDict仍然保留两个普通字典没有的实用方法move_to_end和popitem(lastFalse)。实现一个简易的 LRU 缓存时OrderedDict非常顺手。ChainMap则用于把多个字典合并成一个逻辑上的“视图”。比如配置文件查找默认配置和用户配置叠加查找时优先用户配置没有就落到默认配置。from collections import ChainMap defaults {host: localhost, port: 5432, debug: False} user_config {host: 192.168.1.10, debug: True} merged ChainMap(user_config, defaults) print(merged[host]) # 192.168.1.10 print(merged[port]) # 5432落到 defaults注意ChainMap不会通过update生成新字典它只是把多个字典串起来适合“不想污染原始配置”的场景。6. 常见问题排查与性能优化心得6.1 迭代时修改字典RuntimeError 怎么处理前面提到过遍历时不能直接增删键。你的代码里如果出现RuntimeError: dictionary changed size during iteration通常是因为在for循环里执行了pop或del。解决方案有两种方案一先收集要删除的键循环外统一删to_remove [k for k, v in d.items() if v 0] for k in to_remove: d.pop(k, None)方案二用推导式直接生成新字典d {k: v for k, v in d.items() if v 0}两种方案没有绝对优劣。前者适合“需要保留原对象引用”的情况后者更简洁而且从根本上避免迭代时修改的问题。我个人在内部工具脚本里偏好第二种在需要保持传入对象地址不变的业务代码里用第一种。6.2 深浅拷贝改了副本原字典也跟着变直接dict2 dict1不是拷贝是让两个变量指向同一个字典。你要修改副本时如果不希望影响原字典需要copy()或deepcopy()。d1 {data: [1, 2, 3]} d2 d1.copy() # 浅拷贝 d2[data].append(4) print(d1) # {data: [1, 2, 3, 4]}被改了浅拷贝只复制外层容器内部的可变对象依然是共享的。如果字典里有嵌套结构要用copy.deepcopyimport copy d3 copy.deepcopy(d1) d3[data].append(5) print(d1) # 不变判断自己到底需要浅拷贝还是深拷贝记住一个原则你想修改的是字典本身还是字典里某个可变值只给最外层新增键值对浅拷贝就够要独立修改嵌套列表、嵌套字典必须深拷贝。6.3 哈希性能与内存为什么字典不是万能解字典好用但也是有代价的。哈希表的底层要预留大量空槽位来减少冲突所以字典的内存占用比列表大得多。如果你要存储的是几十万个“编号到名称”的静态映射并且后续只读可以考虑用两个元组配合二分查找内存会更省。但多数业务场景字典带来的开发效率提升远超这点内存成本我一般不推荐过早优化。另一个性能点是哈希计算。用整数作为键比用长字符串稍微快一点因为哈希一个长字符串要遍历全部字符。但这是微秒级差别除非你在写超大规模算法否则不用在意。真正值得在意的是hash冲突导致的退化。极端情况下如果碰撞很多字典查找会从 O(1) 退化。Python 内部的哈希随机化已经做了很多防护普通场景不用担心。6.4 键的相等性1 和 True 的坑前面提到过1和True作为键时互相覆盖。这里再补一个浮点数1.0也会和1撞键。d {1: 整数, 1.0: 浮点数} print(d) # {1: 浮点数}因为它们哈希值相同且相等。如果你在写配置解析或缓存系统遇到这种问题不要怀疑 Python先检查是不是键的类型混用了。处理方法是把键包装成元组d {(1, int): 整数, (1.0, float): 浮点数} d[(1, int)] # 整数 d[(1.0, float)] # 浮点数7. 项目实战中的一点个人体会最后分享一个我在实际项目中受益最多的习惯凡是遇到“映射”需求第一反应先用字典而不是先用列表配循环。这句话听起来很简单但真的能把代码质量拉开一个层次。我见过很多同事写这种代码用一个列表存配置每次查都要循环取用一个嵌套的 if/else 判断状态码对应的文案。这两种场景换成都字典代码既短又不会错。比如状态码映射status_text { 200: 成功, 404: 不存在, 500: 服务器错误, } message status_text.get(code, 未知状态)再比如把字典推导式和get组合起来能在不清洗数据的情况下优雅处理缺失字段。我在解析第三方接口时最常用的就是deep_get函数加defaultdict前者防嵌套字段缺失后者防分组时键不存在。这套组合拳帮我少写了至少几千行防御性代码。如果你现在写 Python 还只是把字典当成“高级数组”强烈建议从今天开始刻意练习这几个点能用get就不用下标能用推导式就不写循环能用setdefault就不写 if 判断。等这些写法变成肌肉记忆你再回看之前代码会发现很多地方其实两三行就能写完。