Skip to content
This page has been auto-translated and may contain errors.View in English

字典 ​

列表让你按位置查找东西。但很多时候你想按名字查找,不是"给我第 3 项",而是"给我 Alice 的分数"。字典把数据存成键值对:你按键查值,而不是按位置查值。

当列表的位置索引没有实际意义时,字典才是合适的结构。字典把任意的键映射到值,让你能以 O(1) 的时间做按名查找。排行榜、JSON 响应、配置文件——这些天然就适合用键值映射来表达。

dict 是一种键值存储结构,平均情况下查找、插入、删除都是 O(1)(O(1) 意味着不管字典有多大,开销都保持不变,因为键是通过哈希找到的,哈希会把键变成一个数字,直接指向它所在的槽位)。键必须是可哈希的,也就是内容永远不变,这样那个数字才能保持稳定:str、int、tuple 都符合要求,list 不符合。值可以是任意对象。从 Python 3.7 开始,字典会按你插入的顺序保存键,这在你序列化或展示字典时很重要。这个结构在 Python 底层被大量使用,包括你自己对象上的属性,所以字典是非常值得吃透的东西。列表一章讲的是按位置索引的"表亲"结构。

创建字典 ​

用花括号包起来,每个键和值之间用冒号分隔,各个键值对之间用逗号分隔。键几乎总是字符串。值可以是任何东西:数字、字符串、其他列表,甚至其他字典。

字典字面量用花括号加 key: value 的语法。键可以是任何不可变(可哈希)的类型:字符串、整数、元组。值可以是任何 Python 对象。字典会保留插入顺序,所以遍历时你得到的顺序就是添加时的顺序。

字典字面量是从左到右求值的。键必须是可哈希的(内容不能改变,这样 Python 归档它的那个数字才不会变):str、int、tuple 符合要求,list 和 dict 不符合。值没有这个限制。从 Python 3.7 起,插入顺序是语言层面的保证,所以遍历和序列化的结果是可复现的。有一个不起眼的坑:同一个字面量里出现重复的键并不会报错,Python 会保留最后一个值,丢弃前面的。

python
player = {
    "name": "小明",
    "score": 87,
    "level": 5,
    "alive": True,
}
Juno创建字典 花括号,每个键和值之间用冒号分隔,各对之间用逗号分隔。键通常是字符串,值可以是任何东西,甚至可以是另一个字典。你添加的顺序就是你取回来的顺序,这一点我用得比我预想的还多。
Juno创建字典 花括号加 key: value 对。键可以是任意不可变类型(str、int、tuple),值可以是任何对象。遍历顺序遵循插入顺序,所以顺序很重要的场合用字典也没问题。
Juno创建字典 键必须是可哈希的,所以 str、int、tuple 可以用,list 和 dict 不行。从 3.7 起插入顺序是有保证的,你可以放心依赖它来输出。要小心字面量里的重复键:它会悄无声息地只保留最后一个值,不给任何警告。

访问值 ​

用方括号加键就能取到值。如果键不存在,Python 会抛出 KeyError。当你不确定某个键是否存在时,用 .get():它会返回 None 而不是报错崩溃,或者返回你指定的默认值。

用方括号访问,键缺失时会抛出 KeyError。.get(key) 在没命中时返回 None。.get(key, default) 则返回你指定的默认值。当键是否存在不确定时,用 .get();这比用 try/except 包住访问代码更安全、更易读。

方括号访问会对键做哈希运算,然后读取对应的槽位,平均是 O(1)(不管字典多大,开销都不变)。没命中时会抛出 KeyError。.get(key, default=None) 做的是同样的查找,但没命中时返回默认值而不是报错,所以当你不确定某个键是否存在时,应该用它。如果只是想在方括号访问前做一次判断,key in d 同样是 O(1);只想要一个"是或否"的答案就用 in,想一步拿到值或兜底值就用 .get()。

python
player = {"name": "小明", "score": 87}

player["name"]    # "小明"
player["score"]   # 87
player["lives"]   # KeyError(键不存在)
python
player.get("score")          # 87
player.get("lives")          # None(不报错,默认返回 None)
player.get("lives", 3)       # 3   (键不存在时使用这个默认值)

只要键可能缺失,用 .get() 就更安全:

python
count = inventory.get("arrows", 0)   # 如果字典里没有 "arrows" 就返回 0
Juno访问值d["key"] 会把值给你,但如果那个键不存在就会抛出 KeyError。不确定的时候,用 .get():它会返回 None 而不是报错崩溃,或者返回你传入的默认值。我现在什么都默认加一下,程序就不再因为缺键而崩溃了。
Juno访问值 方括号访问在没命中时抛出 KeyError,.get(key) 返回 None,.get(key, default) 返回你传入的值。当键可能缺失时用 .get():比用 try/except 包住访问干净得多。
Juno访问值d[key] 和 .get() 都是一次 O(1) 查找,区别只在没命中时的行为:一个报错,一个返回默认值。想要"是或否"就用 key in d,想一步拿到值或兜底值就用 .get()。

添加与更新 ​

用方括号给某个键赋值。如果这个键已经存在,值会被替换。如果还不存在,就会创建一个新条目。用 .update() 可以一次性把另一个完整的字典合并进来。

对一个键赋值平均是 O(1),而且同时完成两件事:如果键是新的就创建条目,如果已经存在就替换值。.update() 接受另一个字典或键值对的可迭代对象,对每一条都执行同样的赋值操作,覆盖已有的键。

d[key] = value 会对键做哈希,然后在一次平均 O(1) 的操作里完成插入或覆盖,所以并不存在单独的"添加"和"更新":赋值一步到位。.update(other) 就是对 other 里的每一条都执行这个操作,遇到共同的键就覆盖。如果想合并但不改动原字典,用 | 运算符(Python 3.9+),它会返回一个新字典,两边的输入都不受影响;而 |= 则会像 .update() 一样原地修改。当一个函数不该修改调用方手里还拿着的字典时,就用 |。

python
player = {"name": "小明", "score": 87}

player["score"] = 92        # 更新已有的键
player["level"] = 5         # 添加新键
python
extras = {"level": 5, "alive": True}
player.update(extras)   # 用 extras 里的键添加或覆盖
Juno添加与更新 用方括号给某个键赋值:如果它已经存在,值就被替换;如果不存在,就冒出一个新条目。同一种写法两种情况都能处理,不用先判断。用 .update() 可以把另一个完整的字典一次性并进来。
Juno添加与更新d[key] = value 一步完成创建或替换,所以没有单独的添加和更新之分。.update(other) 把整个字典合并进来,遇到共同的键就覆盖。两者每个键都是 O(1)。
Juno添加与更新 赋值在一次 O(1) 操作里完成插入或覆盖,.update() 就是对每个条目重复这个动作。不想动原字典时,| 会返回一个合并后的新字典,|= 则原地修改。在函数内部用 |,这样就不会悄悄改动调用方手里还持有的字典。

删除条目 ​

有四种方式删除条目。.pop() 删除一个键并把值返回给你。带默认值的 .pop() 在键可能不存在时是安全的。del 删除一个键但没有返回值。.clear() 清空整个字典。

.pop(key) 在没命中时抛出 KeyError。.pop(key, default) 会返回默认值,这让它成为安全的删除方式。del d[key] 删除键但没有返回值,没命中时抛出 KeyError。.clear() 会删除所有条目,但保留字典对象本身。

.pop(key, default) 在一次平均 O(1) 的查找里完成删除并返回,带上默认值就意味着不怕没命中;不带默认值的话,缺失的键会抛出 KeyError。del d[key] 删除时没有返回值,没命中时会报错。.clear() 会清空字典但保留同一个对象,所以任何指向它的其他名字看到的也会是空字典。要记住的那个坑是:在遍历字典的同时修改它,会在循环中途抛出 RuntimeError。解决办法是先把要删除的键快照下来,用 for key in list(d):,再从原字典里删除。

python
player = {"name": "小明", "score": 87, "level": 5}

player.pop("level")            # 删除 "level" 并返回 5
player.pop("lives", None)      # 安全删除,键不存在时返回 None
del player["score"]            # 删除 "score",没有返回值
player.clear()                 # 删除所有内容

带默认值的 .pop() 是删除一个可能不存在的键最安全的方式。

Juno删除条目.pop(key) 删除一个键并把值交还给你,.pop(key, None) 在键可能不存在时也不会慌张。del d[key] 删除但不返回任何东西,.clear() 把一切清空。.pop() 的那个默认值参数帮我避开了很多 KeyError 的惊吓。
Juno删除条目.pop(key) 没命中就报错,.pop(key, default) 不会,这让它成为更安全的选择。del d[key] 删除且不返回值,没命中时也会报错;.clear() 清空字典但保留对象本身。
Juno删除条目.pop(key, default) 是不怕缺键的"删除并返回";del 和不带默认值的 pop 遇到缺键都会报错。真正会咬人的是:一边遍历一边删除会抛出 RuntimeError。先用 for key in list(d): 拍个快照,再从原字典删除。

遍历 ​

有三种"视图"让你遍历字典的不同部分。直接遍历字典得到的是键。.values() 得到值。.items() 一次性得到两者,也是你用得最多的方式:把每一对拆到两个名字里,循环就清晰易读。

.keys()、.values()、.items() 返回的是视图对象,不是列表。视图会动态反映字典的当前状态:如果你修改了字典,视图会立刻更新。.items() 对大多数循环来说最有用,因为元组拆包 for k, v in d.items() 读起来很清楚。

.keys()、.values()、.items() 返回的是视图对象(对字典的实时窗口,不是独立的列表)。视图不会复制任何东西,它反映的是字典的当前状态,所以字典变了,视图也随之变。因为键是唯一且可哈希的,键视图支持集合运算:d.keys() & other.keys() 能找出共同的键,- 能找出差异,这在对比两份配置时很好用。同样的"遍历时修改"陷阱在这里也适用;如果必须在循环中修改字典,就用 list(d.items()) 拍个快照再遍历。

python
player = {"name": "小明", "score": 87, "level": 5}

for key in player:               # 遍历键(最常见)
    print(key)

for key in player.keys():        # 一样,明确用键视图
    print(key)

for value in player.values():    # 遍历值
    print(value)

for key, value in player.items():   # 两者都要,最常用
    print(f"{key}: {value}")

.items() 是你用得最多的方式。把每一对拆到两个名字里,能让循环更易读。

Juno遍历 直接遍历字典得到的是键。.values() 给你值,.items() 一次给两者,这也是你最常用到的一种。像 for key, value in player.items() 这样把每一对拆到两个名字里,让我的循环好读了不少。
Juno遍历.keys()、.values()、.items() 返回的是实时视图,不是列表,所以它们会跟着字典一起变化。用 for k, v in d.items() 的 .items() 写法读起来最清楚,也是你写得最多的循环。
Juno遍历 这三种视图是实时窗口,不是拷贝,所以字典变化时它们也随之变化。键视图支持集合运算,所以 d.keys() & other.keys() 一行就能对比出两个字典的差异。跟删除时一样的规则:别在遍历时修改,必须修改就先用 list(d.items()) 拍个快照。

检查成员 ​

in 检查某个键是否存在于字典中。它只检查键,不检查值。要检查某个东西不存在,用 not in。

对字典来说,in 和 not in 都是 O(1),而且只检查键。要检查值的话,可以用 in d.values(),但那是 O(n),因为值没有被索引。

key in d 通过对键做哈希再读取一个槽位来完成查找:平均 O(1),不管字典多大都一样快。value in d.values() 得一个一个地扫描值:O(n),开销随大小增长。这种不对称正是"应该把要查找的东西存成键,而不是靠扫描值"的全部理由:如果你发现自己经常在扫描值,那说明字典的键值大概设反了,或者需要再建一个从值反查键的字典。

python
player = {"name": "小明", "score": 87}

"name"  in player      # True
"lives" in player      # False
"lives" not in player  # True

in 只检查键。要检查值,用 in player.values(),不过这种情况很少用到。

Juno检查成员in 告诉你某个键是否在字典里,只看键,从不看值。不管字典有多大,速度都不会变慢。加个 not in 就能反过来检查某个东西是不是不存在。
Juno检查成员in 和 not in 只检查键,是 O(1)。检查值需要用 value in d.values(),这是 O(n),因为值没有被索引。所以要按你查找时用的东西来设计键。
Juno检查成员key in d 是 O(1),value in d.values() 是 O(n)。这个差距就是为什么要把字典按你的查找方式来建键。如果你一直在扫描值,说明这个字典的键值方向反了,加个反向映射就好。

嵌套字典 ​

值本身也可以是字典。这就是表示多层结构化数据的方式:一个玩家有一段统计信息,一个配置文件有若干子部分。用两层方括号来访问嵌套值:第一层选外层的键,第二层选内层的键。

嵌套字典就是值本身也是字典的字典。用连续的方括号来访问。修改内层字典会影响到外层字典,因为外层字典持有的是同一个对象的引用。尽量让嵌套保持浅一些:嵌套太深会很快变得难读、难导航。

嵌套字典持有的是内层字典的引用,而不是它们的副本(引用就是指向同一个共享对象的指针,所以两个名字可以指向同一个内层字典)。这也是复制容易出问题的地方:d.copy() 是浅拷贝,它只复制外层字典,内层字典依然是共享的,所以通过副本改动内层字典也会改到原字典。当你需要一份完全独立的副本时,copy.deepcopy() 会遍历整棵树,把每一层都复制一遍。在把配置交给可能会修改它的代码之前,用它更保险。每一次方括号访问都是独立的 O(1) 查找,所以嵌套深度不影响速度,只影响可读性。

python
users = {
    "小明": {"score": 87, "level": 5},
    "小红": {"score": 74, "level": 3},
}

users["小明"]["score"]   # 87
users["小红"]["level"]     # 3

用连续的方括号来访问。对于嵌套很深的结构,这会变得笨重,所以尽量让嵌套保持浅一些。

Juno嵌套字典 一个值本身可以是一整个字典,这就是像"带统计信息的玩家"这样的结构化数据的保存方式。用两层方括号伸进去:第一层选外层的键,第二层再从里面选。挺好用的,但我尽量不嵌套太深,否则读起来很折腾。
Juno嵌套字典 嵌套字典就是值本身是字典的字典,用连续的方括号伸进去。外层字典持有的是内层字典的引用,所以改动内层字典会体现在所有引用它的地方。嵌套保持浅一些,不然会很难导航。
Juno嵌套字典 外层字典存的是内层字典的引用,所以 .copy() 是浅拷贝:副本共享那些内层字典,一处改动会同时影响两边。需要真正独立时用 copy.deepcopy()。嵌套深度不影响速度,但影响可读性,所以尽量保持浅层。

setdefault ​

.setdefault() 如果键存在就读取它,如果不存在就设为默认值,然后把值返回给你。当你需要确保某个键存在、但又不想覆盖它已有的值时,这个方法很好用。

.setdefault(key, default) 是"读取或创建"一步到位:如果键存在,返回它当前的值,不改动任何东西;如果不存在,就插入默认值并返回它。常见的用法是构建分组结构,而不用单独做一次存在性检查。

.setdefault(key, default) 是一次 O(1) 查找,完成"读取或创建":已存在的键原样返回现有的值,不存在的键会插入 default 并返回它。有一点值得留意:default 是一个普通的参数,所以即便键已经存在,它也总会被构造出来。如果这个默认值构造成本很高(比如新建一个对象、调用一个函数),那你每次调用都要付出这个代价。接下来会讲到的 defaultdict 就避开了这个问题,它只在没命中时才运行工厂函数。对于日常"把条目分组进列表"这种模式,.setdefault(key, []).append(...) 是取代 key in d 判断的标准写法。

python
inventory = {}

inventory.setdefault("arrows", 0)    # 设置 "arrows": 0,返回 0
inventory.setdefault("arrows", 10)   # "arrows" 已经存在,不改动,返回 0

它对构建分组结构很有用,不需要先检查键是否存在:

python
groups = {}

for name, team in players:
    groups.setdefault(team, []).append(name)
Junosetdefault.setdefault(key, default) 如果键存在就读它,如果不存在就设成默认值并返回。这是确保一个键存在、又不破坏已有值的干净写法。我最常拿它来做分组,不用先判断"这个键在不在"。
Junosetdefault.setdefault(key, default) 在命中时原样返回已有的值,没命中时插入默认值并返回。它的价值在于分组:d.setdefault(k, []).append(x) 能按键构建列表,不用单独判断键是否存在。
Junosetdefault 一次 O(1) 的读取或创建,但默认值即便命中也总会被构造出来,所以一个昂贵的默认值每次调用都要付代价。这正是 defaultdict 用"只在没命中时才运行的工厂函数"解决的问题。分组场景下,.setdefault(k, []).append(x) 一行就替代了 key in d 判断。

collections.defaultdict 和 Counter ​

标准库里有两个字典子类,能自动处理常见的模式。defaultdict 会为缺失的键创建一个默认值,所以你永远不会遇到 KeyError。Counter 统计序列中每个元素出现的次数,并把结果以字典的形式给你。

defaultdict 接受一个可调用对象,用来为新键生成默认值,这样就不再需要 .setdefault()。Counter 是专门用于频次统计的字典,带有 .most_common() 方法。两者都是字典子类,所以所有标准的字典操作在它们上面都能用。

defaultdict(factory) 会在你第一次读取一个缺失的键时运行工厂函数(一个零参数的可调用对象,比如 list 或 int),存下结果并返回它,所以 d[k] 永远不会抛出 KeyError。这就是它和 .setdefault() 的关键区别:工厂函数只在没命中时才触发,所以在常规路径上,一个昂贵的默认值不会带来任何开销。有一个坑要提一下:因为单纯地读取一个缺失的键就会创建它,所以仅仅检查 d[k] 就可能让字典变大,所以只想测试而不想插入时,用 k in d。Counter 是一个为计数而生的 dict:给它一个序列,它就统计出现次数,.most_common(n) 返回按次数排序的前 n 个。两者都在 collections 模块里。

defaultdict 和 Counter 存在于标准库中,所以要先导入才能用。导入的完整讲解在模块一章。

python
from collections import defaultdict

groups = defaultdict(list)
for name, team in players:
    groups[team].append(name)   # 即便 team 是新的也不会抛 KeyError
python
from collections import Counter

words = ["猫", "狗", "猫", "鸟", "猫", "狗"]
counts = Counter(words)
# Counter({'猫': 3, '狗': 2, '鸟': 1})

counts.most_common(2)   # [('猫', 3), ('狗', 2)]

Counter 省掉了大量"用循环数东西"的重复代码。

Junocollections.defaultdict 和 Counterdefaultdict 会为任何缺失的键补上一个默认值,这样分组和计数就不会再朝你抛 KeyError。Counter 统计序列里每个元素出现的次数,并以字典的形式还给你。第一次把手写的计数循环换成 Counter 时,我的代码一下少了一半。
Junocollections.defaultdict 和 Counterdefaultdict(list) 或 defaultdict(int) 会在遇到新键时自动创建默认值,让 .setdefault() 在分组和计数场景中退休。Counter 是专为频次统计打造的字典,自带 .most_common(n)。两者都是字典子类,所以你已经知道的一切照样能用。
Junocollections.defaultdict 和 Counterdefaultdict 的工厂函数只在没命中时运行,所以和 .setdefault() 不同,一个昂贵的默认值在常规路径上不花任何代价。坑在于:读取一个缺失的键就会创建它,所以要用 k in d 而不是 d[k] 来测试。Counter 统计一个序列,.most_common(n) 取出排名前 n 的。

实战演练 ​

构建一个分数追踪器,并打印包含所有条目的摘要:

python
scores = {"小明": 87, "小红": 74, "小刚": 92, "小丽": 55}

total = sum(scores.values())
average = total / len(scores)

print(f"玩家数:  {len(scores)}")
print(f"平均分:  {average:.1f}")
print(f"最高分:  {max(scores.values())}")
print(f"最低分:   {min(scores.values())}")
print()

for name, score in scores.items():
    print(f"  {name}: {score}")

在循环中构建一个记录各文件结果的字典,然后对所有条目做汇总:

python
job_results = {}
files = ["report_jan.csv", "report_feb.csv", "report_mar.csv"]

for filename in files:
    size = len(filename) * 100   # 用作真实文件大小的占位值
    if size < 2000:
        status = "ok"
    else:
        status = "large"
    job_results[filename] = {"size": size, "status": status}

ok_count = 0
large_count = 0

for result in job_results.values():
    if result["status"] == "ok":
        ok_count += 1
    else:
        large_count += 1

print(f"Processed {len(job_results)} file(s): {ok_count} ok, {large_count} large")

通过遍历必填字段来校验一个嵌套的请求字典,然后原地归一化一个特征重要度字典:

python
request = {
    "method": "POST",
    "path": "/users",
    "headers": {"Content-Type": "application/json"},
    "body": {"username": "xiaoming", "email": "[email protected]"},
}

body = request["body"]
errors = []

for field in ["username", "email"]:
    if not body.get(field):
        errors.append(f"Missing required field: {field}")

if "email" in body and "@" not in body["email"]:
    errors.append("Invalid email format")

print(f"Method: {request['method']} {request['path']}")
if errors:
    print(f"Errors: {errors}")
else:
    print("Validation passed")

# 把特征重要度归一化,使其总和为 1
feature_importance = {"age": 0.34, "income": 0.28, "region": 0.15, "purchases": 0.23}
total = sum(feature_importance.values())

for key in feature_importance:
    feature_importance[key] = round(feature_importance[key] / total, 3)

print(f"Normalised: {feature_importance}")