AI 技术博客
返回首页
Python 基础 19 分钟阅读

字典:键值对的增删查改与遍历

Python字典映射遍历

引言

列表用下标定位,下标是 0、1、2 这样的整数;但真实世界的问题很少是「第 3 个元素是谁」,更多是「这个 IP 访问了多少次」「这个配置项的取值是什么」「这个用户名对应的邮箱在哪」。这种「按照键找值」的需求,就是本篇主角——字典(dictionary,简称 dict)的领地。字典是 Python 最常用的容器,没有之一:JSON 长在字典上,函数的关键字参数是字典,类实例的属性存在字典里,第五章第 8 篇的 nginx 日志解析里那句 ip_counts[ip] = ip_counts.get(ip, 0) + 1 就是字典在发光——当时只当计数器用,本篇把它的全貌讲透。

先决知识:前两篇的列表与元组(本篇的「键必须可哈希」直接依赖上篇的不可变概念),以及第三章的 for 循环、第四章的 lambda。本篇是本章承上启下的一篇:集合(第 4 篇)建立在可哈希语法之上,第 8 篇的词频统计实战会直接复用本篇的 d.get(k, 0) 计数模式,第 9 篇的聚合报表遍历与排序也以此为地基。

概念与原理

键值对建模:从「第几个」到「叫什么」

列表/元组用连续整数下标存取,适合「位置有意义」的数据(序列、排序结果);字典用任意不可变对象做键(key)存取任意对象做值(value),适合「名字有意义」的数据。绑定关系在别的语言里叫关联数组(associative array)或映射(map),Python 称作 dictionary(字典)。建模直觉:凡是「根据 A 找 B」的成对数据都先想字典——配置项与取值、用户名与邮箱、路径与访问次数。值可以是任意对象(包括列表、字典,嵌套容器第 6 篇讲),键则受「可哈希」约束。

哈希表:为什么查得那么快

字典的底层是哈希表(hash table)。存入 d[k] = v 时,Python 先对键调用 hash(k) 得到一个整数,再用它对内部数组长度取模得到槽位,把键值对存进那个槽;查找 d[k] 时重算哈希、直达槽位,比较找到的键与目标键是否相等,相等即命中。整个过程不依赖表里有多少元素——平均 O(1),数据量翻一百倍,单次查找的步骤数基本不变。对照列表的成员测试 x in lst 是 O(n) 线性扫描:字典把「有没有」这个问题从逐个比较变成了「一跳到点」。代价分三笔:内存(槽位数组 + 扩容冗余,字典约三分之二满时扩容);哈希计算的开销(对简单键很小);以及哈希冲突(两个不同键算出同一槽位,Python 用探测序列在邻近槽位找空位,冲突多时退化,最坏 O(n))。工程结论:频繁做「xx 在不在 / 出现过几次」就上字典,别用列表扫描。

可哈希:键的准入门槛

哈希查找要求键的哈希值恒久稳定:存进去时算出的哈希,查找时必须算出同一个数。可变对象做不到——lst 内容一改,哈希就面目全非,之前存放的位置就找不回来了(甚至出现两个相等的键占据两个槽)。因此 Python 规定:只有不可变对象可以哈希(hashable)。整数、浮点数、字符串、元组(其元素也都可哈希时)都能当键;列表、字典、集合不能,一试就抛 TypeError: unhashable type: ...。上篇结尾的 {(1, 2): "基地"} 正是这条规则的正面用法:元组是键的主力军(坐标、复合标识符),字符串是键的常客。注意「不可变 ⇒ 可哈希」在 Python 里是约定而非硬性语法:自定义类型默认按对象身份(id)哈希,可变的自定义类也能当键,但那是身份语义(第 8 章讲类时展开),内置类型严格遵守「不可变才可哈希」。

插入顺序:3.7 起的语言保证

早年间字典是无序的(这正是「哈希表」名词的刻板印象来源)。CPython 3.6 改用了紧凑实现(compact dict),插入顺序随之保留;3.7 起插入顺序保持成为语言规范,所有 Python 实现必须遵守:遍历字典按「键首次插入的顺序」进行,重新赋值已有键不改变它的位置,删掉再插入才挪到末尾。本篇与第 4 篇的输出都依赖这个保证。但注意:顺序是「插入序」不是「排序序」,想按键排序遍历要显式 sorted(d);而字典判等 == 只看键值对应关系,忽视顺序。

操作与实现

创建与读取:下标直取、get 兜底、in 探测

server = {"host": "192.168.1.10", "port": 3306, "role": "db"}
print(server["host"])
print(server.get("port"))
print(server.get("user", "root"))
print(server.get("timeout"))
print("role" in server)
print(len(server))
192.168.1.10
3306
root
None
True
3

server["host"] 按下标直取,键存在即返回;键不存在会抛 KeyError(见陷阱一)。get(key) 与下标等价但键缺失时返回 None 而不报错,是「探测性读取」的标准工具;get(key, 默认值) 更进一步,缺失时返回自定义默认。"role" in server 只探测键是否存在、不取值,O(1)。len(d) 统计键值对个数。读取的三种姿势按意图选:确定存在 → 下标;可能缺失、兜底处理 → get;只问存不存在 → in。

增删改一套拳:赋值、update、pop、del、clear

server = {"host": "192.168.1.10", "port": 3306}
server["port"] = 3307
server["user"] = "admin"
print(server)
server.update({"readonly": False, "port": 3306})
print(server)
port = server.pop("port")
print(port, server)
del server["user"]
print(server)
gone = server.pop("zzz", "未找到")
print(gone)
server.clear()
print(server)
{'host': '192.168.1.10', 'port': 3307, 'user': 'admin'}
{'host': '192.168.1.10', 'port': 3306, 'user': 'admin', 'readonly': False}
3306 {'host': '192.168.1.10', 'user': 'admin', 'readonly': False}
{'host': '192.168.1.10', 'readonly': False}
未找到
{}

加改是同一个操作:server["port"] = 3307 键已存在就是改(注意 'port' 在输出中的位置没变——重新赋值不动顺序),键不存在就是增('user' 出现在末尾)。update 把另一个字典(或键值对序列、关键字参数)成批并入,已有的键被覆盖、缺失的键追加。删除家族三件套:pop(key) 弹出并返回被删的值(键缺失抛 KeyError,可给第二参数兜底:pop("zzz", "未找到") 返回默认值),del d[key] 只删不返回(键缺失同样抛 KeyError,这是它与 pop 带默认值的重要差别),clear() 清空整个字典。真实场景如配置合并:默认配置在前、用户配置 update 上去,自由覆盖。

键的事故现场:不可哈希与「相等即同键」

config = {"timeout": 30}
try:
    config[[1, 2]] = "x"
except TypeError as err:
    print(err)
try:
    hash((1, [2]))
except TypeError as err:
    print(err)
d = {}
d[1] = "一"
d[True] = "真"
print(d)
d[1.0] = "壹"
print(d)
unhashable type: 'list'
unhashable type: 'list'
{1: '真'}
{1: '壹'}

前半段:可变键、含可变元素的元组键,都拒绝进入字典——报错信息直白:unhashable type: 'list'。后半段是个隐蔽事故:d[True] = "真" 没有新增键,因为 True == 1 成立,而 Python 强制「相等的对象哈希必相等」(hash(True) == hash(1)),字典把 True 视为与 1 同一个键,覆盖了原值——输出 {1: '真'}。d[1.0] = "壹" 同理,1.0 == 1 也命中同一个键。教训:键的判等是哈希 + 等值双重语义,1、True、1.0 在字典里是同一个键;计数、缓存这类场景注意键的来源,别让布尔值悄悄吃掉相邻整数键。

计数模式:d.get(k, 0) + 1

第五章第 8 篇的日志解析已经用过这个三件套,本篇正式命名:计数模式(counting pattern)。

text = "hello world hello python hello"
words = text.split()
counts = {}
for w in words:
    counts[w] = counts.get(w, 0) + 1
print(counts)
{'hello': 3, 'world': 1, 'python': 1}

逻辑拆解:counts.get(w, 0) 是「w 已出现过就取旧计数,没出现过按 0 计」,+ 1 得到新计数,再赋回 counts[w]。第一次见 hello:get 返回 0,写入 1;第二次见 hello:get 返回 1,写入 2;以此类推。它等价于「先查再分支」的两步写法(if w in counts: counts[w] += 1 else: counts[w] = 1),但一行完成、无分支。它比 if w in counts 更快(一次查找完成读与写两步的读取部分),也更不易写错。第 8 篇词频统计将把它升级为「剔除停用词、top-N 排序」的完整实战,现在把「读默认、写回」的语义吃透即可。反向操作——按键分组收集而非计数——用 setdefault:

cart = {}
items = cart.setdefault("sku_list", [])
items.append("sku-001")
print(cart)
print(cart.setdefault("sku_list", ["x"]))
stats = {}
stats.setdefault("views", 0)
stats["views"] += 1
print(stats)
{'sku_list': ['sku-001']}
['sku-001']
{'views': 1}

setdefault(key, 默认值):键存在返回原值,键不存在先写入默认值再返回——「取或建」一步完成,特别适合「首次见到某键时初始化一个空列表/计数器,随后反复追加/累加」的分组场景。第二次调用时键已存在,["x"] 默认值被忽略,返回原列表。

遍历三件套:keys、values、items 与视图动态性

server = {"host": "192.168.1.10", "port": 3306, "role": "db"}
for key in server:
    print(key)
print(server.keys())
print(server.values())
for k, v in server.items():
    print(f"{k}={v}")
host
port
role
dict_keys(['host', 'port', 'role'])
dict_values(['192.168.1.10', 3306, 'db'])
host=192.168.1.10
port=3306
role=db

四种遍历按需选:for key in server 直接遍历字典本体,等价于 for key in server.keys()——语义是「遍历键」;keys() 给键视图,values() 给值视图,items() 给键值对视图(配合本章第 2 篇的多变量解包,for k, v in server.items() 每轮解包一对)。keys() 打印为 dict_keys([...])——这是视图(view)对象,不是列表,也没有被复制:

scores = {"zhang": 88, "li": 92}
view = scores.keys()
print(view)
scores["wang"] = 75
print(view)
for k in list(scores):
    if scores[k] < 90:
        del scores[k]
print(scores)
try:
    for k in scores:
        del scores[k]
except RuntimeError as err:
    print(err)
dict_keys(['zhang', 'li'])
dict_keys(['zhang', 'li', 'wang'])
{'li': 92}
dictionary changed size during iteration

view 在 scores["wang"] = 75 之后自动多出 'wang'——视图是字典的实时窗口,字典一变,已持有的视图立刻反映(这是视图与「复制出来的列表」的本质区别,遍历中需要增删时用 list(scores) 快照)。for k in list(scores): ... del scores[k] 在副本上遍历、原字典上删除,安然无恙;而直接 for k in scores: del scores[k] 中途改了字典大小,RuntimeError: dictionary changed size during iteration——遍历中修改键集合是重罪,Python 宁可报错也不给不确定的结果。

排序遍历与等值判等

marks = {"zhang": 88, "li": 92, "wang": 75}
for name in sorted(marks):
    print(name, marks[name])
a = {"x": 1, "y": 2}
b = {"y": 2, "x": 1}
print(a == b)
li 92
wang 75
zhang 88
True

marks 插入序是 zhang、li、wang,sorted(marks) 按键的字典序得到 li、wang、zhang——「按序输出报表」的固定姿势(第 9 篇还会配 key=lambda 按值排序)。a == b 为 True:字典判等只比较键值对应,1、True、1.0 那种「相等键」的语义不参与——这是字典层级上的「内容即一切,顺序不重要」。

易错点与陷阱

陷阱一:下标直取键缺失抛 KeyError,读前先想「会不会没有」

votes["wang"] 在键缺失时抛 KeyError: 'wang',这是字典最常见的运行时事故。读者与写者的心理契约不同:下标写法承诺「键一定存在」,get 写法承认「键可能缺席」。什么时候该用哪种?循环累加的键是自己刚写入的,用下标;解析外部数据(用户输入、配置文件、网络响应)时键不可控,一律 get + 默认值,或先 in 判断。更微妙的是 del d[k] 键缺失同样抛 KeyError——「删除不存在的键」要先确认存在性或用 pop(k, None)。

陷阱二:get 的默认值是「调用时求值」,可变默认会共享

shared = []
d = {}
a = d.get("a", shared)
a.append(1)
b = d.get("b", shared)
print(b)
print(d)
[1]
{}

d.get("a", shared) 的默认值参数在调用当刻求值:这里传入的是同一个列表对象 shared,第一次 get 把 1 追加进 shared,第二次 get 又返回同一个 shared——b 拿到 [1]。键没写进字典(print(d) 是空),但「默认值」这个共享列表被悄悄污染了。正确姿势:缺省值按每次调用新建(d.get(k, []) 每次都是新列表,安全),或不用 get 而用 setdefault/defaultdict(第 7 篇的 defaultdict 专治此症)。规律一句话:get 的默认值若复用同一个可变对象,等于传了个共享状态进去。

陷阱三:遍历字典时增删键

for k in d: del d[k] 直接抛 RuntimeError: dictionary changed size during iteration——遍历过程中字典大小不能变(修改已有键的值不在此列,只有增删键才算)。两条出路:遍历快照 for k in list(d)(副本上遍历、原字典上删改,实测可行),或先收集再统一处理:to_delete = [k for k in d if 条件] 后 for k in to_delete: del d[k]。

陷阱四:值会被「原地改」,解包不写回

d[k] = v 只是让键 k 指向 v,但 v 若是可变对象,对它的原地修改直接反映在字典里:lst = d["items"]; lst.append(x) 等价于 d["items"].append(x)——别以为「取出来就是副本」。反过来,for k, v in d.items() 里改 v 只换本地变量绑定,写不回字典;要批量改值,得在循环体里写 d[k] = 新值。这两条镜像规则(取出的可变对象是引用、解包的变量是拷贝)是字典工程里最常见的「我以为改了/没改」误会。

小结

字典是键值对映射,底层哈希表给出平均 O(1) 的存取:存在性判断、取值、计数、分组都该用它。键必须可哈希(不可变类型才行,列表直接 TypeError;True、1、1.0 互为同一键),值可以是任意对象。增删改一套拳记牢:d[k] = v 加改合一、update 批量合并、pop 弹出可带默认、del 只删不返回且缺失即 KeyError、clear 清空。计数模式 d.get(k, 0) + 1 是本章最重要的惯用法,第 8 篇词频统计直接升级它;setdefault 负责「取或建」。遍历用 for k in d / keys() / values() / items(),视图实时反映字典变化,排序遍历用 sorted(d)。四个陷阱:键缺失 KeyError 要先想、get 的可变默认值会共享、遍历中增删键抛 RuntimeError、解包与取出都是引用语义。下篇集合(set)把「键」抽出来单独成军——去重、成员测试与集合运算,语法与字典同源。

练习与思考题

  1. 手工推演并验证:对 stats = {} 循环执行三次 stats.setdefault("pv", 0); stats["pv"] += 1 后 stats 的值;再用 d.get(k, 0) + 1 写出等价代码。
  2. 用字典统计一篇文章中每个字符出现次数(text = "python dict mapping"),输出出现次数最多的三个字符。提示:计数模式 + sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:3]。
  3. 合并两个配置字典:defaults = {"host": "localhost", "port": 80, "debug": False} 与 custom = {"port": 8080},要求 custom 覆盖 defaults 且不修改原字典,写出实现并解释为什么 defaults.update(custom) 不满足「不修改原字典」。
  4. 思考题:阅读并推演 d = {}; d[(1, 2)] = "a"; d[(1, 2, 3)] = "b" 后字典内容;再解释为什么把 server.items() 的返回直接 list() 出来能获得「可遍历可修改」的副本,而 view 本身不行(对比两种对象的生命周期)。