1. 字典到底快在哪:先理解哈希表这个底层逻辑
1.1 哈希表的通俗比喻
Python 字典大概是所有内置数据结构里最“被低估”的一个。很多初学者把它当成“能存键值对的一个容器”,但为什么字典查询能快到惊人,为什么键必须“不可变”,这些底层问题,反而是理解字典精髓的关键。
把字典想象成一个去办事大厅取号的窗口。你不需要挨个窗口喊一遍“我找某某”,你只要把身份证(键)递过去,系统直接算出你在哪个窗口,一步到位。这个“直接算出位置”的动作,在技术里叫哈希(Hash)。字典底层是一张哈希表,Python 拿到你要查的键之后,先对键做一次哈希运算,得到一个数字,再映射到表里的一个位置,直接过去取。正常情况下一次就能拿到,时间复杂度是 O(1)。而列表是挨个比对,最坏情况下要遍历整个列表,复杂度是 O(n)。数据量一大,字典和列表差距就是天上地下。
我用一个实际测试来说明。手头有 10 万条用户数据,存成列表需要查询某一个 ID,列表查找平均要 5 万次比对,字典查找一次哈希就定位。实测下来,列表可能需要几十毫秒,字典不到一微秒,差距是几万倍。所以日常写代码,如果频繁做“按某个唯一标识来找数据”这种操作,别想,直接用字典,这是 Python 里最高效的数据结构之一。
1.2 为什么键必须是不可变类型
既然哈希这么关键,有一个约束就顺理成章了:键必须是可哈希的(Hashable)。什么对象可以哈希?简单说,对象一生中哈希值不能变。像字符串、整数、浮点数、元组(里面也得全是可哈希元素),这些是不可变类型,哈希值稳定,可以用作键。而列表、字典、集合这种可变类型,内容一变,哈希值就会变,字典内部就彻底乱套了,所以 Python 直接禁止它们做键,你一写{[1, 2]: "value"},解释器马上抛TypeError: unhashable type。
这里有个容易忽略的细节:自定义对象默认是可以作为键的,因为object基类默认用id()做哈希,也就是说一个对象不管内容怎么变,哈希值始终不变。如果你自己定义了__eq__方法但没有同步定义__hash__,那么这个对象就变得不可哈希了,当键会报错。这是很多新手容易踩到的暗坑,后面我会专门讲。
2. 创建与访问:掌握多种姿势,别只会用大括号
2.1 创建字典的几种方式
最基础的当然是大括号字面量:
d = {"name": "张三", "age": 30}但除了这个,还有几种你可能用得上的创建方式。
第一种是用dict()构造器。传关键字参数最直观:
d = dict(name="张三", age=30)注意这种写法键必须是合法的标识符,不能是"user-name"这种带连字符的。
第二种是传一个键值对元组的列表:
d = dict([("name", "张三"), ("age", 30)])这种场景常出现在读取外部数据,比如你从数据库里查到一批键值对,想快速转成字典。
第三种是配合zip()使用,非常优雅:
keys = ["name", "age", "city"] values = ["张三", 30, "北京"] d = dict(zip(keys, values))第四种是用fromkeys(),这个函数我不得不专门拎出来说,因为它的坑特别多。fromkeys的功能是给一组键统一赋同一个初始值:
d = dict.fromkeys(["a", "b", "c"], 0) # 结果:{'a': 0, 'b': 0, 'c': 0}看起来没毛病,可是如果你用可变对象当默认值,比如:
d = dict.fromkeys(["a", "b"], []) d["a"].append(1) print(d) # 结果:{'a': [1], 'b': [1]}a和b两个键居然共享了同一个列表!因为fromkeys是把那个默认值对象的同一个引用赋给每个键。你需要独立的列表的话,别用fromkeys,用字典推导式逐个创建:
d = {key: [] for key in ["a", "b"]}第五种是字典推导式,这个后面专门讲,先放一个简单例子:
squares = {x: x*x for x in range(5)} # 结果:{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}2.2 访问元素:[] 与 get 的取舍
访问字典里的值,最常见的做法是d[key]。但有一个问题:如果键不存在,直接抛KeyError,程序当场中断。在很多场景里,你并不确定键是否存在,比如解析用户传入的 JSON 参数,用户可能没传某个可选字段。这时候用.get()更合适:
user = {"name": "张三"} age = user.get("age") # 返回 None age = user.get("age", 18) # 返回默认值 18那是不是所有地方都应该用.get()?也不是。我个人的经验是:如果键不存在属于一种应该尽早暴露的逻辑错误,你就用[],让程序立刻报错,别藏着掖着;如果键不存在是正常的可能情况,你就用.get()给个默认值,保证流程继续。这两者的取舍,本质上是在“快速失败”和“容错处理”之间做选择。
还有一种多级嵌套的场景,比如字典套字典:
data = {"user": {"address": {"city": "北京"}}}你想安全地取 city,用.get()链式操作有个坑,你得每层都给默认值:
city = data.get("user", {}).get("address", {}).get("city", "未知")如果中间某一层没给默认空字典,比如只写data.get("user").get("address"),而user不存在,那么data.get("user")返回 None,再调.get就直接报AttributeError了。这种情况在解析多层 JSON 时极其常见,我踩过的坑不下五次。
2.3 嵌套字典的实战问题
嵌套字典在配置文件、爬虫数据、接口返回里到处都是。除了刚才说的安全访问,还有一个痛点就是修改嵌套值。比如:
data = {"user": {"address": {"city": "北京"}}} data["user"]["address"]["city"] = "上海"如果中间某层不存在,比如你想直接data["user"]["email"] = "a@b.com",但data["user"]本身已经有了那没问题,问题是如果user这一层不存在,这样写就直接报错。要安全地创建一个多级结构,要么逐层检查加赋值,要么用setdefault:
data.setdefault("user", {})["email"] = "a@b.com"setdefault的核心逻辑是:如果键不存在,就先把键设为默认值,然后返回这个默认值。最典型的分组场景里非常好用,比如把一批学生按班级分组:
students = [("一班", "张三"), ("一班", "李四"), ("二班", "王五")] groups = {} for class_name, name in students: groups.setdefault(class_name, []).append(name) print(groups) # 结果:{'一班': ['张三', '李四'], '二班': ['王五']}这个写法比“先判断键在不在,不在就初始化一个空列表”要简洁得多。当然你也可以用collections.defaultdict,那是后话,但setdefault是内置的,不依赖额外导入,很多场景已经够用了。
3. 增删改查:操作字典的标准动作与冷门彩蛋
3.1 新增和修改:update 与 | 运算符的进化史
新增或修改单个键值对,直接赋值就行,这个不多说。批量更新就用到update()方法了:
d = {"name": "张三", "age": 30} d.update({"city": "北京", "age": 31}) print(d) # 结果:{'name': '张三', 'age': 31, 'city': '北京'}update()就地修改原字典,键已存在的覆盖,键不存在的新增。它还能接收关键字参数:
d.update(city="上海", age=32)Python 3.9 之后,合并两个字典有了更优雅的方式,用|运算符:
a = {"x": 1, "y": 2} b = {"y": 3, "z": 4} c = a | b print(c) # 结果:{'x': 1, 'y': 3, 'z': 4}注意a | b会生成一个新字典,原字典不变。如果是a |= b,那就是就地更新,等价于a.update(b)。这个运算符的好处是写起来干净,一眼就能看出“合并”的意图,可读性比dict(a, **b)这种写法高不少。
还有一个合并技巧是**解包:
c = {**a, **b}它的效果和a | b一样,而且 Python 3.5 以上就支持,不需要等到 3.9。多个字典合并时,右边字典的键会覆盖左边,这个重叠规则在|和解包里是一致的。
3.2 删除元素:del、pop、popitem、clear 的差别
删除字典元素有四种手段,很多人只用过前两种,后两种其实也很实用。
第一种del:
d = {"a": 1, "b": 2} del d["a"]键不存在就抛KeyError。
第二种pop(key, default=None):
value = d.pop("a", None)它会返回被删除键对应的值,同时从字典里移掉这一项。如果指定了默认值,键不存在时返回默认值,不抛异常;如果没指定默认值,键不存在就会抛KeyError。这个“删掉并拿到值”的组合能力在很多场景里很省钱,比如你从字典里提取某个字段,还想顺便把这个字段从原字典里去掉,一行搞定。
第三种popitem(),注意它不需要传参数:
key, value = d.popitem()在 Python 3.7 之前,popitem()是弹出一个随机的键值对,随机行为让它成为某个经典面试题——“不用额外空间,原地打乱一个数组”的取巧解法。3.7 之后,字典保持了插入顺序,popitem()的行为变成了弹出最后一个插入的键值对,也就是 LIFO(后进先出)。这个特性做类似“撤销”功能时很好用,你按顺序插入变更记录,撤销时从栈顶弹。
第四种clear()没什么好说的,清空全部键值对,字典变成空壳。
3.3 存在性判断:key in d 的细微差别
判断字典里有没有某个键,最简单的写法是:
if "key" in d: # 做点什么这里有个小知识点:有人习惯写"key" in d.keys(),其实完全没必要。因为in操作符本身就是作用于字典的键集合的,直接"key" in d是惯用写法,而且性能上也更直接,省去一次属性访问。
还有一个隐藏在字典背后的行为:in走的也是哈希查找。所以它和列表的in本质不同,列表的in是逐个遍历,字典的in是直接哈希定位。在大量数据下判断一个元素在不在集合里,用字典或集合比用列表快出几个数量级。这个特性经常被用来做“去重”和“快速查重”,面试八股里常念叨的“用空间换时间”,说的就是它。
4. 遍历与推导式:处理字典数据的高阶技巧
4.1 三种遍历方式的选择
遍历字典有三种姿势,别搞混:
for key in d: # 只遍历键 pass for value in d.values(): # 只遍历值 pass for key, value in d.items(): # 同时遍历键和值 pass最常见的场景是同时需要键和值,那就用items()。很多人会问.items()返回的是一个列表还是一个迭代器?准确说是“视图对象”。视图的特点是动态地反映字典当前的状态:如果你在遍历过程中修改了字典内容(但不是改变键集合的操作),视图能看到最新状态。而且items()返回的每一项是元组,可以直接解包成两个变量,代码非常干净。
d.keys()也返回视图,不过有个冷门细节:字典的keys()视图支持集合运算,比如求两个字典键的交集、并集、差集:
a = {"x": 1, "y": 2} b = {"y": 3, "z": 4} common = a.keys() & b.keys() # {'y'}这个技巧在处理配置合并、权限对比时非常实用,不用把键转成列表再用循环去比。
4.2 迭代时修改字典:经典 RuntimeError
在遍历字典的同时删除或新增键,Python 会毫不犹豫地抛异常:
d = {"a": 1, "b": 2, "c": 3} for k in d: if k == "b": del d[k] # RuntimeError: dictionary changed size during iteration原因很简单:遍历过程中字典的键集合变了,迭代器的内部状态对不上了,解释器怕你拿到错误的遍历结果,直接“罢工”。解决套路有三个。第一个是收集后统一删除:
to_delete = [k for k in d if k == "b"] for k in to_delete: del d[k]第二个是遍历副本:
for k in list(d): if k == "b": del d[k]list(d)先把所有键打成一个列表快照,遍历这个快照就安全了。第三个是用字典推导式重建,过滤掉不要的键:
d = {k: v for k, v in d.items() if k != "b"}注意:在遍历时修改已有键对应的值是允许的,比如for k in d: d[k] += 1,这不改变字典的尺寸,不会报错。很多人误以为任何修改都会炸,其实只要别增删键,值随便改。
4.3 字典推导式的三个实用场景
推导式是 Python 里我最爱用的语法之一,字典推导式的格式是{表达式键: 表达式值 for 元素 in 可迭代对象}。
第一个实用场景是反转键值:
d = {"a": 1, "b": 2, "c": 3} reversed_d = {v: k for k, v in d.items()} # 结果:{1: 'a', 2: 'b', 3: 'c'}这个操作在通过值查键时特别有用。但注意:如果值有重复,反转后的字典会发生覆盖,后面的键会顶掉前面的键,丢失数据。我后面踩坑部分会专门讲。
第二个场景是带条件过滤:
d = {"a": 10, "b": 5, "c": 20} big = {k: v for k, v in d.items() if v >= 10} # 结果:{'a': 10, 'c': 20}第三个场景是列表转字典映射:
names = ["张三", "李四", "王五"] name_dict = {name: len(name) for name in names} # 结果:{'张三': 2, '李四': 2, '王五': 2}推导式比循环赋值好看太多,而且性能上通常也有一点优势,因为它在字节码层面做了优化。不过推导式也别写得过于复杂,嵌套推导式、里面再套一堆条件判断,那代码读起来就像天书。我的原则是:单行推导式能表达清楚的场景就用,超过两三个条件的,干脆写普通循环,可读性优先。
5. 标准库里的三个“字典兄弟”:defaultdict、Counter、OrderedDict
5.1 defaultdict:为缺失键自动补默认值
collections.defaultdict是dict的子类,唯一区别在于:当访问一个不存在的键时,它不会抛KeyError,而是调用你传入的工厂函数自动生成一个默认值并插入字典。
想想我刚才用setdefault分组学生的例子,用defaultdict之后会简洁不少:
from collections import defaultdict students = [("一班", "张三"), ("一班", "李四"), ("二班", "王五")] groups = defaultdict(list) for class_name, name in students: groups[class_name].append(name) print(groups) # 结果:defaultdict(<class 'list'>, {'一班': ['张三', '李四'], '二班': ['王五']})groups[class_name]访问一个不在字典里的键时,工厂函数list()自动生成一个新列表返回,并且把这个空列表塞进字典里,后面直接.append()毫无压力。如果要计数,工厂函数用int,默认值就是 0:
counts = defaultdict(int) for char in "hello": counts[char] += 1 print(counts) # 结果:defaultdict(<class 'int'>, {'h': 1, 'e': 1, 'l': 2, 'o': 1})注意一个细节:defaultdict只对d[key]这种访问方式自动填充默认值。如果你用.get()访问一个不存在的键,它依然返回默认的返回值(一般是 None),不会触发工厂函数。这两个行为不一致,是很多人在实际项目里遇到怪问题的根源。
5.2 Counter:一行代码统计频次
collections.Counter也是dict的子类,专门用来计数。最经典用法:
from collections import Counter words = ["苹果", "香蕉", "苹果", "橙子", "香蕉", "苹果"] c = Counter(words) print(c) # 结果:Counter({'苹果': 3, '香蕉': 2, '橙子': 1})Counter的杀手锏是most_common(n),直接返回频次最高的前 n 项:
top2 = c.most_common(2) print(top2) # 结果:[('苹果', 3), ('香蕉', 2)]这个接口在做热门排行、Top K 统计时太好用了,一行顶别人十行。它还可以做加减运算,比如两批数据合并统计:
c1 = Counter(a=3, b=1) c2 = Counter(a=1, b=2) print(c1 + c2) # Counter({'a': 4, 'b': 3})有人问 Counter 和普通字典的区别,我的理解是:普通字典是通用的“键值对容器”,Counter 专门为“元素频次”这个场景定制,它把计数、比较、合并这些高频操作都封装好了。如果你只是统计频次,用 Counter,别自己手动写counts.get(key, 0) + 1了。
5.3 OrderedDict:在 Python 3.7+ 还有存在意义吗
Python 3.7 起,官方正式规定普通字典保持插入顺序。你可能觉得collections.OrderedDict已经没有存在意义了。其实不然,OrderedDict 有两个普通字典没有的功能。
第一个是move_to_end(key, last=True),可以把某个键移动到最后,或者last=False移到最前面。这在实现 LRU 缓存(最近最少使用淘汰)时很有用,不需要自己维护一个额外的顺序列表。
第二个是popitem(last=False),普通字典的popitem()只能弹最后一个键,但 OrderedDict 可以弹第一个。就好比一个名单,普通字典只能从队尾抽人,OrderedDict 可以从队头或者队尾抽。
还有一个细微差别:普通字典比较相等性时不考虑顺序,两个插入顺序不同的普通字典也可能相等;但 OrderedDict 比较时会考虑顺序:
from collections import OrderedDict a = OrderedDict(a=1, b=2) b = OrderedDict(b=2, a=1) print(a == b) # False,顺序不同就不相等所以在某些需要严格保持顺序语义的场景,OrderedDict 还是有用武之地的。
6. 排序与序列化:字典和外部世界的沟通
6.1 按键排序和按值排序的完整姿势
排序是字典操作里最绕的一块,因为字典本身没有顺序概念(在 3.7 之前更明显),你得通过sorted()对视图排序,再重新组装成新字典。
按键排序:
d = {"b": 2, "a": 1, "c": 3} sorted_by_key = dict(sorted(d.items())) print(sorted_by_key) # 结果:{'a': 1, 'b': 2, 'c': 3}sorted(d.items())默认按元组第一个元素(键)排序,所以不需要额外指定 key 函数。
按值排序:
sorted_by_value = dict(sorted(d.items(), key=lambda item: item[1])) print(sorted_by_value) # 结果:{'a': 1, 'b': 2, 'c': 3}这里key=lambda item: item[1]的意思是:排序时拿元组的第二个元素(值)作为比较依据。
按值排序有个坑:如果多个键的值相同,它们的相对顺序是不确定的。想要稳定,可以把键也作为排序依据:
sorted_by_value_then_key = dict(sorted(d.items(), key=lambda item: (item[1], item[0])))按“值升序、值相同时按键升序”排序,结果完全可以预测。
另外,降序就加reverse=True:
sorted_by_value_desc = dict(sorted(d.items(), key=lambda item: item[1], reverse=True))还有一个常见的场景:只想要排序后的键列表,不关心值,可以这样:
keys = sorted(d, key=d.get)d.get作为 key 函数,相当于用每个键对应的值来做排序依据,返回的是一串排好序的键,再往里填值。
从性能角度说,sorted()一次性排完,时间复杂度 O(n log n)。如果你的数据量特别大,而且你反复只需要 Top N 的键,可以考虑用heapq.nlargest只取出前几个,比全排序略省一些。不过大多数业务场景,数据到不了那个量级,sorted()一把梭就完了。
6.2 字典与 JSON 互转的三个坑
字典最常见的“外部沟通”场景就是转 JSON。json.dumps()把一个字典变成 JSON 字符串,json.loads()把 JSON 字符串解析成字典。逻辑看起来很简单,但有几个坑我每次都得提醒自己。
第一个坑:非字符串键会被转成字符串。
import json d = {1: "a", 2: "b"} s = json.dumps(d) print(s) # 结果:{"1": "a", "2": "b"} parsed = json.loads(s) # 结果:{'1': 'a', '2': 'b'},键全变成了字符串也就是说,如果你原本用整数键,经过 JSON 序列化和反序列化之后,键类型变了。如果你的代码之后还依赖“键是整数”这个前提,比如做parsed[1],就报KeyError了。解决方案是序列化前后手动转换,或者使用json.loads(s, parse_int=int)加类型钩子,但更稳妥的做法是,如果键类型很重要,别直接 dumps,而是先把键转成字符串再存。
第二个坑:元组键会被转成数组。
d = {("a", "b"): 1} s = json.dumps(d) print(s) # 结果:{"a", "b": 1} 之类,实际是 {"["a", "b"]": 1}元组序列化之后变成一个数组,反序列化回来键变成了列表的字符串形式,完全不是原来的元组。所以带元组键的字典,要么别走 JSON,要么自己定义转换逻辑。
第三个坑:JSON 只能处理基本类型。如果字典里有自定义对象、日期时间对象,直接 dumps 会报TypeError: Object of type ... is not JSON serializable。解决办法是给dumps传一个default参数,写一个自定义转换函数,把特殊对象变成字符串。比如:
import json from datetime import datetime def json_default(obj): if isinstance(obj, datetime): return obj.isoformat() return str(obj) data = {"time": datetime.now()} s = json.dumps(data, default=json_default)这样日期时间就会变成 ISO 格式的字符串,解析的时候再自己转换回来。
关于 JSON 还有一个常被忽略的点:JSON 是文本协议,字典是无序容器。即便 Python 3.7 后普通字典保持插入顺序,但序列化成 JSON 再被其他语言(比如 JavaScript)解析,顺序完全由对方决定。所以不要依赖 JSON 文本里键的排列顺序,特别是在和外部系统对接时。
7. 我踩过的几个坑和最终的实战心得
7.1 三个典型事故复盘
第一个是fromkeys的可变默认值问题。我前文讲过,这里再强调一遍,因为它太容易让人栽跟头。有一次我写一个统计脚本,想初始化一批键的计数列表,随手用dict.fromkeys(keys, []),结果每个键 append 的数值全混在一起,程序输出完全错误。排查了十几分钟才反应过来是共享引用问题。现在我一看到fromkeys就会条件反射地思考“默认值是不是可变对象”,如果是可变对象,就用字典推导式。
第二个是反转键值时丢数据。在做数据去重时,我需要根据用户邮箱反查用户 ID,也就是{email: user_id}反转成{user_id: email}。结果代码跑完,发现有些用户莫名其妙消失了。原因是有两个用户邮箱对应同一个 ID(数据源有脏数据),反转时后一个覆盖了前一个。这个问题的根治方案是反转前先检查有没有重复值,或者反转时把值变成列表来承接多对一关系:
d = {"张三": "A", "李四": "B", "王五": "A"} groups = {} for name, user_id in d.items(): groups.setdefault(user_id, []).append(name) print(groups) # 结果:{'A': ['张三', '王五'], 'B': ['李四']}第三个是遍历时删除键,这个我在前面章节已经讲透了,但每次面试题里都能见到它,可见踩的人不少。最简单的防御姿势就是:只要循环体里出现del或者新增键的代码,先停下来想三秒,是不是该用“收集后删除”或“副本遍历”。
7.2 字典的高级玩法:把函数放进字典
字典不仅能存数据,还能存行为。在 Python 里,函数是一等公民,可以像普通值一样放进字典。这个特性可以用来实现“伪 switch-case”:
def add(a, b): return a + b def multiply(a, b): return a * b def subtract(a, b): return a - b operations = { "add": add, "multiply": multiply, "subtract": subtract, } result = operations["add"](3, 5) # 8 result = operations.get("mod", lambda a, b: "不支持的操作")(3, 5)如果某个操作不存在,配合.get()给个默认函数,代码既安全又优雅。在业务逻辑里处理多种操作类型、路由分发、命令模式时,这个技巧会让代码结构干净很多。
另一个实用玩法是用字典做缓存(Memoization)。比如递归计算斐波那契数列,不加缓存的时间复杂度是 O(2^n),指数级爆炸;用一个普通字典当缓存,把已经算过的结果存起来,复杂度直接降到 O(n):
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当然标准库的functools.lru_cache更专业,但理解“字典当作缓存表”这个思路,比背某个装饰器的用法有价值得多——因为它揭示了字典这个数据结构的本质:它天然就是一个“键到值的查询索引”。
7.3 性能边界:字典不是万能的
还有一个很多人容易忽略的问题:字典的内存占用。本质上一个键值对除了存储数据本身,还要维护哈希表结构、哈希值、冲突链等信息,内存开销比一个列表元素要大不少。如果数据量大到几百万甚至上亿级别,字典占用的内存可能让你措手不及。此时要评估是否能用更省内存的方案,比如数组 + 二分查找、array模块,或者直接把数据落到 SQLite 里。
字典查找是 O(1),但不代表字典在所有场景下都最优。如果数据量很小(比如十几个分支),一个 if-elif 链可能比字典映射可读性更好。我见过有人为了“显得高级”,把三个条件的逻辑硬写成字典映射,结果调用方根本看不懂该传什么键,维护成本反而上升。好的代码永远是“复杂度够用 + 可读性优先”,技术上最快的方案未必是工程上最好的方案。
最后说一个经验法则:当你需要在循环里反复查找某个东西时,先问自己“这个数据能不能预先构建成字典索引?”如果能,性能会有质的飞跃。我在处理跨表关联、批量匹配、去重统计时,几乎都是先把数据组织成字典,再跑业务逻辑,这比在循环里不停地遍历列表要快出好几个量级。
Python 字典这东西,入门时觉得就是一个存键值对的盒子,用久了才发现它背后涉及哈希原理、不可变类型、视图机制、类字典工具、序列化边界等一大片只是图。把这一整套融会贯通之后,写出来的代码不仅快,而且结构清爽。如果你在实际项目里遇到过字典相关的怪问题,多半就是上面某个细节没踩稳,回头对照着排查一遍,基本能找到根因。