跳转至

第 7 章 字典

配套例题:BISHI23 小红书推荐系统 来源:菜鸟教程 Python3 字典、Python3 数据结构

dict 是 Python 的哈希表,对应 C++ 的 std::unordered_map。 它是语言内置、C 实现、高度优化的——在 Python 里手写哈希表永远打不过它。

竞赛中 dict 承担三件事:

  1. 计数(词频、桶统计、出现次数)
  2. 记忆化(把递归搜索变成 DP)
  3. 映射 / 离散化(把稀疏的大值域压到小下标)

本章还有一个 C++ 选手必须调整的认知:Python 的 dict 保持插入顺序, 但它不是有序表——它没有 std::map 的按键有序遍历和 lower_bound


7.1 创建字典

d = {}                                  # 空字典(注意 {} 是字典不是集合!)
d = {"a": 1, "b": 2}                    # 字面量
d = dict(a=1, b=2)                      # 关键字参数(键只能是合法标识符)
d = dict([("a", 1), ("b", 2)])          # 从键值对序列
d = dict(zip(keys, values))             # 两个列表压成字典 —— 高频
d = dict.fromkeys("abc", 0)             # {'a': 0, 'b': 0, 'c': 0}
d = {x: x * x for x in range(5)}        # 字典推导式

dict.fromkeys(seq, []) 是个陷阱:所有键会共享同一个列表对象, 和 05-列表 里的 [[0]*m]*n 是同一个病。 值是可变对象时请用推导式 {k: [] for k in seq},或者直接用 defaultdict(list)

键的要求

键必须可哈希(不可变),值随便:

d[(1, 2)] = "ok"        # ✅ 元组可以
d[frozenset({1, 2})] = 1    # ✅
d[[1, 2]] = "no"        # ❌ TypeError: unhashable type: 'list'

同一个字面量里键重复时,后写的覆盖先写的

{"a": 1, "b": 2, "a": 3}        # {'a': 3, 'b': 2}

还有一个容易忽略的点:11.0True 是同一个键,因为它们 ==hash 相同:

d = {}
d[1] = "int"
d[1.0] = "float"
d[True] = "bool"
print(d)                # {1: 'bool'}    ← 只有一个键!

混用 intstr 键时要小心:d[1]d["1"]两个不同的键。 从 sys.stdin.buffer 读进来的 token 是 bytesd[b"1"] 又是第三个键。 类型不统一是这类 bug 的唯一来源,读进来立刻决定用什么类型做键。


7.2 增删改查

d["a"]                  # 取值,键不存在抛 KeyError
d.get("a")              # 取值,不存在返回 None
d.get("a", 0)           # 取值,不存在返回 0     ← 计数的标配
"a" in d                # 判断键是否存在,O(1)
"a" not in d

in 判断的是「键」,不是「值」。想判值要写 x in d.values(),那是 \(O(n)\)

d.get(k, 0)d[k] 的选择:

# ❌ 慢:查两次哈希
if k in d:
    v = d[k]
else:
    v = 0

# ✅ 一次搞定
v = d.get(k, 0)

改与增

d["a"] = 1              # 存在则改,不存在则增,O(1)
d.update({"b": 2})      # 批量更新
d.update(b=2, c=3)
d.update(zip(keys, vals))
d2 = {**d, "b": 9}      # 合并成新字典(3.5+)
d3 = d | {"b": 9}       # 合并运算符(3.9+)
d |= {"b": 9}           # 原地合并(3.9+)

del d["a"]              # 键不存在抛 KeyError
v = d.pop("a")          # 删并返回值,不存在抛 KeyError
v = d.pop("a", None)    # 不存在时返回默认值 —— 推荐
k, v = d.popitem()      # 删除并返回「最后插入」的一对(3.7+ 保证是 LIFO)
d.clear()               # 清空

setdefault:查不到就先塞一个

d.setdefault(k, default)        # 若 k 不存在则 d[k] = default,然后返回 d[k]

它的经典用法是建邻接表 / 分组,一行完成「没有就建空列表,然后追加」:

g = {}
for u, v in edges:
    g.setdefault(u, []).append(v)       # ✅ 一行搞定

等价的笨写法要三行:

if u not in g:
    g[u] = []
g[u].append(v)

setdefault 的默认值每次调用都会求值,哪怕键已经存在。 d.setdefault(k, expensive()) 会白算一次 expensive()。 这种情况用 defaultdict 更好——它只在缺失时才调用工厂函数。

方法全表

方法 作用 复杂度
d[k] 读 / 写 / del 基本操作 平均 \(O(1)\)
k in d 键是否存在 平均 \(O(1)\)
len(d) 键的个数 \(O(1)\)
d.get(k[, default]) 安全取值 \(O(1)\)
d.setdefault(k[, default]) 缺失则插入并返回 \(O(1)\)
d.pop(k[, default]) 删并返回 \(O(1)\)
d.popitem() 删并返回最后一对 \(O(1)\)
d.update(other) 批量更新 \(O(\|other\|)\)
d.keys() / d.values() / d.items() 视图对象 \(O(1)\) 创建
d.copy() 拷贝 \(O(n)\)
d.clear() 清空 \(O(n)\)
dict.fromkeys(seq[, v]) 由键序列建字典 \(O(n)\)

7.3 视图对象

keys() / values() / items() 返回的不是列表,而是「视图」

d = {"a": 1, "b": 2}
ks = d.keys()
print(ks)               # dict_keys(['a', 'b'])
d["c"] = 3
print(ks)               # dict_keys(['a', 'b', 'c'])   ← 自动跟着变了!

视图的三个特点:

  1. 动态:字典变了,视图跟着变。
  2. 不占额外内存\(O(1)\) 创建,不复制。
  3. 不能索引d.keys()[0]TypeError,要先 list(d)
list(d)                 # ['a', 'b', 'c']  等价于 list(d.keys()),更短
list(d.values())
list(d.items())         # [('a', 1), ('b', 2), ('c', 3)]

keys()items() 支持集合运算

因为键唯一,keys() 表现得像 set

d1 = {"a": 1, "b": 2}
d2 = {"b": 9, "c": 3}
d1.keys() & d2.keys()       # {'b'}        共同的键
d1.keys() - d2.keys()       # {'a'}        只在 d1 里的键
d1.keys() | d2.keys()       # {'a','b','c'}

values() 不支持(值可以重复,不构成集合)。

遍历时不能改结构

for k in d:
    if d[k] == 0:
        del d[k]        # ❌ RuntimeError: dictionary changed size during iteration

正确写法是先把键固化成列表:

for k in list(d):       # list(d) 是一份快照
    if d[k] == 0:
        del d[k]

# 或者重建
d = {k: v for k, v in d.items() if v != 0}

只改值不改结构是允许的for k in d: d[k] += 1 没问题。 出问题的是增删键(会触发 rehash)。


7.4 插入有序性(3.7+)

Python 3.7 起,dict 保证按插入顺序迭代(3.6 已经是实现细节,3.7 写进语言规范)。

d = {}
d["z"] = 1
d["a"] = 2
d["m"] = 3
list(d)                 # ['z', 'a', 'm']   ← 插入顺序,不是排序!

这带来两个能力:

list(dict.fromkeys(a))          # 保序去重!比 list(set(a)) 强,见第 8 章
k, v = d.popitem()              # 弹出最后插入的一对,可以当栈用

但必须分清「插入有序」和「按键有序」

Python dict C++ std::map C++ std::unordered_map
底层 哈希表 红黑树 哈希表
迭代顺序 插入顺序 键升序 无序
单次查找 \(O(1)\) 平均 \(O(\log n)\) \(O(1)\) 平均
找最小键 / lower_bound 不支持 \(O(\log n)\) 不支持

Python 没有 std::map 需要「按键有序遍历」就自己 sorted(d)\(O(n \log n)\)); 需要动态的 lower_bound 就得上树状数组 / 有序列表,见 116-平衡树与有序集合

另外永远不要依赖字典顺序来产生题目要求的输出顺序。 题目说「按字典序输出」就老老实实 sorted(), 说「按出现顺序输出」才可以直接迭代 dict

排序输出的三个常用写法:

for k in sorted(d):                             # 按键排序
    ...
for k, v in sorted(d.items(), key=lambda kv: -kv[1]):    # 按值降序
    ...
for k, v in sorted(d.items(), key=lambda kv: (-kv[1], kv[0])):   # 值降序 + 键升序
    ...

7.5 字典推导式

{k: v for k, v in pairs}
{x: x * x for x in range(5)}                    # {0:0, 1:1, 2:4, 3:9, 4:16}
{v: k for k, v in d.items()}                    # 反转键值
{k: v for k, v in d.items() if v > 0}           # 过滤
{x: i for i, x in enumerate(sorted(set(a)))}    # ★ 离散化:值 → 排名

最后一行是离散化的标准模板:把任意大小的值映射到 \(0 \dots m-1\), 之后就能用数组代替字典(更快)。见 41-桶计数与离散化

推导式的完整语法(多重循环、条件)见 09-推导式


7.6 dict 作哈希表:三大用法

用法一:计数

五种写法,从慢到快:

# ① 最笨
if x in cnt:
    cnt[x] += 1
else:
    cnt[x] = 1

# ② get,一行
cnt[x] = cnt.get(x, 0) + 1

# ③ setdefault
cnt.setdefault(x, 0)
cnt[x] += 1

# ④ defaultdict
from collections import defaultdict
cnt = defaultdict(int)
cnt[x] += 1

# ⑤ Counter,最短
from collections import Counter
cnt = Counter(a)

实战选择:

  • 一次性统计整个序列Counter(a),它在 C 层批量计数,最快。
  • 边算边计数(键是临时算出来的)defaultdict(int)cnt.get(x, 0) + 1
  • 值域小且是连续整数别用字典,用列表cnt = [0] * 26 快 3–5 倍。

值域已知且不大时,永远优先用 list 而不是 dict 列表索引是一次指针偏移,字典要算哈希、探测、比较。 字母计数写 cnt = [0]*26; cnt[ord(c)-97] += 1,别写 Counter(s)

用法二:记忆化

把指数级的重复递归压成多项式:

memo = {}


def f(i, j):
    key = (i, j)                    # 元组当复合键,见第 6 章
    if key in memo:
        return memo[key]
    if i == 0:
        res = 1
    else:
        res = f(i - 1, j) + f(i, j - 1)
    memo[key] = res
    return res

标准库有现成的装饰器:

from functools import lru_cache


@lru_cache(maxsize=None)
def f(i, j):
    ...

lru_cache 底层就是一个 dict,但它在 C 层实现,比手写 memo 快。 参数必须可哈希(所以不能传 list,要传 tuple)。 详见 62-记忆化搜索与剪枝

Python 3.9 新增的 functools.cache 等价于 lru_cache(maxsize=None),写起来更短。 3.9 环境下两者都可用。

用法三:映射与判重

pos = {x: i for i, x in enumerate(a)}       # 值 → 下标(重复值只留最后一个)
seen = {}                                    # 记录「第一次出现的位置」
for i, x in enumerate(a):
    if x in seen:
        print(seen[x], i)                    # 两数之和 / 找重复的经典写法
    else:
        seen[x] = i

7.7 复杂度与两个陷阱

操作 平均 最坏
d[k] 查 / 改 / in \(O(1)\) \(O(n)\)(全部哈希冲突)
插入 / 删除 \(O(1)\) 均摊 \(O(n)\)(触发 rehash)
遍历 \(O(n)\) \(O(n)\)
len \(O(1)\) \(O(1)\)

陷阱一:内存

dict 每个条目要存哈希值、键指针、值指针,加上为了低冲突而保留的空槽, 一个 \(10^6\) 项的 dict 大约要 80–150 MB。同样规模的 list 只要 8 MB。

值域是连续小整数时一律用 list 这既是速度问题也是内存问题。

陷阱二:被卡哈希

CPython 的 dict开放寻址:哈希值先对表长取余定位到一个槽, 槽被别人占了就按固定规则再探测下一个,直到找到空槽或目标键。 正常情况下这条探测链只有一两步,所以查找是 \(O(1)\)

而 CPython 里 hash(int) 基本就是整数本身(小整数时 hash(n) == n)。 出题人只要构造大量哈希值同余于表长的键(比如全是 \(2^{20}\) 的倍数), 这些键就会挤进同一条探测链,\(O(1)\) 退化成 \(O(n)\),整体变 \(O(n^2)\)

自保办法是给键加一个随机扰动:

import random

RND = random.getrandbits(32)
d[x ^ RND] = v                      # 打散哈希分布

字符串键相对安全:CPython 默认开启哈希随机化(PYTHONHASHSEED 每次运行不同), 出题人无法离线构造。但整数键没有这层保护, 遇到「明明是 \(O(n)\) 却 TLE」的哈希题就往这上面想。见 36-哈希与字符串哈希


7.8 defaultdictCounter 预告

这两个是 collections 里的 dict 子类,竞赛里出场率极高,这里先建立印象, 完整 API 见 14-标准库速查

defaultdict

访问不存在的键时,自动调用工厂函数创建默认值:

from collections import defaultdict

cnt = defaultdict(int)          # 默认 0
cnt[x] += 1                     # 不用先判断存在

g = defaultdict(list)           # 默认空列表
g[u].append(v)                  # 邻接表一行搞定

s = defaultdict(set)
s[u].add(v)

defaultdict 的副作用一个不存在的键也会创建它。

g = defaultdict(list)
if g[5]:            # 只是想判断一下
    ...
print(len(g))       # 1 ← 键 5 被凭空创建了
遍历过程中这样读会直接引发「遍历时修改字典」的 RuntimeError。 只读判断请用 g.get(5)5 in g

Counter

专门做计数的 dict 子类:

from collections import Counter

c = Counter("abracadabra")          # Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
c["a"]                              # 5
c["z"]                              # 0     ← 不存在返回 0,不抛 KeyError
c.most_common(3)                    # [('a', 5), ('b', 2), ('r', 2)]  按计数降序
c.most_common()                     # 全部,按计数降序
sum(c.values())                     # 总数
c1 + c2, c1 - c2, c1 & c2, c1 | c2  # 计数的加 / 减 / 取小 / 取大

Counter 可以直接当多重集合(multiset)用,见 34-集合与多重集合

most_common 的排序规则要小心:计数相同的元素按插入顺序排(3.7+), 不是按键的字典序。题目要求「频次相同按字典序」时不能直接用 most_common, 必须显式 sorted。下面的例题正是考这个。


7.9 例题:BISHI23 小红书推荐系统(简单,哈希 / 排序)

给定一行由小写字母和空格组成的搜索记录(长度 \(\le 10^5\))。 出现次数 \(\ge 3\) 的单词称为关键词。输出所有关键词, 按频次从高到低频次相同的按字典序升序。 题面见 BISHI23 原题(牛客)

三步:分词 → 计数 → 排序输出。

import sys


def main():
    words = sys.stdin.buffer.read().split()     # 按任意空白切分,自动处理连续空格
    cnt = {}
    for w in words:
        cnt[w] = cnt.get(w, 0) + 1

    # 频次降序 + 字典序升序:把 -频次 放在元组第 0 位,键放第 1 位
    keys = sorted((-c, w.decode()) for w, c in cnt.items() if c >= 3)
    sys.stdout.write("\n".join(w for _, w in keys) + "\n")


main()

四个要点:

  1. split() 无参版天然处理连续空格(见 04-字符串)。 如果写成 split(" ")"a b" 会切出一个空串,凭空多一个「空单词」。

  2. 双关键字排序用元组(-c, w) 让 Python 先比 -c(等价于按 c 降序), 再比 w(字典序升序)。这就是 06-元组与序列通论 讲的元组字典序比较。

  3. 不能用 Counter.most_common()。它在计数相同时按插入顺序排, 而题目要求字典序。样例里 gamekou 都出现 3 次, kou 先出现,most_common 会把 kou 排在前面 —— 直接 WA。

  4. bytes.decode()。从 buffer.read() 读出来的是 bytes, 排序用 bytes 也没问题(ASCII 下字典序一致), 但 "\n".join 要求元素是 str。这里在建元组时就顺手解码了。

复杂度:设有 \(m\) 个不同单词,\(O(L + m \log m)\)\(L\) 是总长度。\(10^5\) 规模轻松通过。

Counter 写会更短,但排序还是得自己写

from collections import Counter
import sys

cnt = Counter(sys.stdin.buffer.read().split())
# 排序键是元组 (-c, 单词):元组按字典序逐项比较,
# 次数取负号就把「次数降序」翻译成了升序排序,第二项再按单词升序,
# 一次 sorted 同时满足「频次高的在前,同频按字典序」两个要求
keys = sorted((-c, w.decode()) for w, c in cnt.items() if c >= 3)
print("\n".join(w for _, w in keys))

举一反三:「按频次降序 + 同频按字典序」是词频题的标准输出要求, 记住 sorted((-c, k) for ...) 这个模板,以后照抄。


7.10 本章速查

场景 写法
空字典 d = {}{} 不是空集合,空集合是 set()
安全取值 d.get(k, 0),别写 if k in d: ... else: ...
计数(整段序列) Counter(a)
计数(边算边加) cnt[x] = cnt.get(x, 0) + 1defaultdict(int)
值域小且连续 list 不用 dict,快 3–5 倍
邻接表 g.setdefault(u, []).append(v)defaultdict(list)
两个列表压成字典 dict(zip(keys, vals))
反转键值 {v: k for k, v in d.items()}
离散化 {x: i for i, x in enumerate(sorted(set(a)))}
保序去重 list(dict.fromkeys(a))
记忆化 @lru_cache(maxsize=None),参数必须可哈希
遍历键值 for k, v in d.items():
遍历时删键 for k in list(d): 固化快照
按键排序输出 for k in sorted(d):
按值降序 + 键升序 sorted(d.items(), key=lambda kv: (-kv[1], kv[0]))
键的要求 必须可哈希;1/1.0/True 是同一个键
有序表需求 Python 没有 std::map,见第 116 章
被卡哈希 键异或一个随机数 x ^ RND