Day14 · 字典与集合
阶段:阶段一 · 核心基础 | 前置:Day13 | 技术基准:Python 3.15.0
学习目标
- 掌握字典(dict)的定义、键值对、增删改查
- 熟练使用
get/setdefault/pop/popitem/update/items - 理解字典的 key 为什么必须是可哈希的不可变对象
- 掌握集合(set)的定义、交并差对称差运算与去重场景
- 认识
frozenset与"默认值是可变对象"这个经典陷阱 - 认识 Python 3.15 新增的内置名字
frozendict(不可变字典)与sentinel(缺省哨兵)
列表和元组靠"下标"找数据,下标是 0、1、2......这种数字。但现实中我们更常按"名字"查数据:通过用户名查手机号、通过商品编号查价格。这种"按键查值"的容器就是字典(dict),它是开发中使用频率最高的数据结构。
一、字典 dict 是什么
字典是键值对(key-value) 的集合,等价于 Java 的 HashMap,底层是哈希表。定义用花括号 {键: 值}。
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18, "lang": "Python"}
print(info) # {'name': '小嘉', 'age': 18, 'lang': 'Python'}
print(info["name"]) # 小嘉 通过 key 取 value
三个核心规则:
- key 必须唯一且不可变(数字、字符串、元组都行;列表不行,因为它可变、不可哈希);
- value 可以是任意类型,随便放;
- 从 Python 3.7 开始,字典保留插入顺序(3.6 是 CPython 实现细节,3.7 写进语言规范),但日常仍应"按 key 查"而不是靠顺序。
二、字典的增删改查
2.1、查:get 与直接下标
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18}
print(info["name"]) # 小嘉
# print(info["city"]) # KeyError:key 不存在会崩溃
# 推荐用 get:key 不存在时返回 None 或默认值,不报错
print(info.get("city")) # None
print(info.get("city", "北京")) # 北京 不存在就用默认值
print(info.get("name", "未知")) # 小嘉 存在就返回真实值
小嘉建议:取值优先用
get(),除非你能 100% 确定 key 存在。直接info["xxx"]一旦 key 拼错就是KeyError。
2.2、增与改
同一个写法 d[key] = value:key 不存在就是新增,存在就是修改。
python
# Author: XiaoJia
info = {"name": "小嘉"}
info["age"] = 18 # 新增
info["age"] = 19 # 修改(key 已存在)
print(info) # {'name': '小嘉', 'age': 19}
2.3、setdefault:key 不在就设默认值
setdefault(key, default):key 存在就返回它的值;不存在就先把 key:default 塞进字典再返回 default。统计词频时特别好用:
python
# Author: XiaoJia
words = ["a", "b", "a", "c", "a", "b"]
count = {}
for w in words:
count[w] = count.get(w, 0) + 1 # 写法一:get
print(count) # {'a': 3, 'b': 2, 'c': 1}
# 写法二:setdefault
count2 = {}
for w in words:
count2.setdefault(w, 0)
count2[w] += 1
print(count2) # {'a': 3, 'b': 2, 'c': 1}
2.4、删:pop / popitem / del
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18, "lang": "Python"}
print(info.pop("age")) # 18 返回被删的值
print(info.pop("city", "没这个key")) # 没这个key 带默认值不报错
print(info.popitem()) # ('lang', 'Python') 弹出最后一项(元组形式)
# del 直接删,但 key 不存在会 KeyError,不推荐裸用
del info["name"]
print(info) # {}
2.5、update:批量合并
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18}
info.update({"age": 19, "city": "北京"}) # 有则改、无则增
info.update(lang="Python") # 也支持关键字参数
print(info) # {'name': '小嘉', 'age': 19, 'city': '北京', 'lang': 'Python'}
三、遍历字典
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18, "lang": "Python"}
# 直接 for 字典,拿到的是 key
for k in info:
print(k, "=", info[k])
# 推荐:items() 一次拿到 key 和 value,只迭代一次
for k, v in info.items():
print(f"{k} -> {v}")
# 单独拿所有 key / value
print(list(info.keys())) # ['name', 'age', 'lang']
print(list(info.values())) # ['小嘉', 18, 'Python']
用 in 判断 key 是否存在(注意 in 判断的是 key,不是 value):
python
# Author: XiaoJia
info = {"name": "小嘉", "age": 18}
print("name" in info) # True
print("小嘉" in info) # False 不判断 value
四、字典生成式
和列表生成式类似,只是换成 {k: v for ...}:
python
# Author: XiaoJia
# 数字 → 平方
print({i: i * i for i in range(1, 6)})
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# 两个列表按位置拼成字典
keys = ["name", "age", "lang"]
values = ["小嘉", 18, "Python"]
print({k: v for k, v in zip(keys, values)})
# 带条件:只保留 value > 1 的
d = {"a": 1, "b": 2, "c": 3}
print({k: v for k, v in d.items() if v > 1}) # {'b': 2, 'c': 3}
五、默认值是可变对象的陷阱
dict.fromkeys(序列, 默认值) 会用同一个默认值给所有 key 初始化。如果默认值是可变对象(比如列表),所有 key 会共享同一个对象------改一个全变:
python
# Author: XiaoJia
keys = ["张三", "李四", "王五"]
# 危险:三个 key 共享同一个列表
bad = dict.fromkeys(keys, [])
bad["张三"].append("数学")
print(bad) # {'张三': ['数学'], '李四': ['数学'], '王五': ['数学']} 全中招!
# 安全做法:用字典生成式,每个 key 都拿到新列表
good = {k: [] for k in keys}
good["张三"].append("数学")
print(good) # {'张三': ['数学'], '李四': [], '王五': []}
这个陷阱 Day15 讲深浅拷贝时还会从另一个角度再碰到。
六、集合 set 是什么
集合是无序、不重复 的容器。它最大的两个用途:自动去重 和集合运算 。定义用花括号 {1, 2, 3},注意空集合只能写 set()({} 是空字典)。
python
# Author: XiaoJia
s = {1, 2, 3, 2, 1}
print(s) # {1, 2, 3} 自动去重
print(type(s)) # <class 'set'>
empty = set() # 空集合必须这样写
print(type({})) # <class 'dict'> {} 是空字典,别搞混
6.1、增删元素
python
# Author: XiaoJia
s = {1, 2, 3}
s.add(4) # 加一个
s.update([5, 6]) # 批量加
print(s) # {1,2,3,4,5,6}
s.remove(1) # 删除,元素不存在会报 KeyError
s.discard(100) # 删除不存在的元素,不报错
print(s) # {2,3,4,5,6}
remove 和 discard 区别:删不存在的元素时,remove 报错、discard 安静跳过。
6.2、集合运算
这是集合的灵魂,对应数学里的集合:
python
# Author: XiaoJia
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # {1,2,3,4,5,6} 并集
print(a & b) # {3,4} 交集
print(a - b) # {1,2} 差集(a 有 b 没有)
print(a ^ b) # {1,2,5,6} 对称差(只在其中一个里)
# 也有方法写法
print(a.union(b))
print(a.intersection(b))
print({1, 2}.issubset({1, 2, 3})) # True a 是 b 的子集
6.3、最常用场景:去重
python
# Author: XiaoJia
nums = [1, 2, 2, 3, 3, 3, 1]
unique = list(set(nums))
print(unique) # [1, 2, 3]
# 注意:set 无序,去重后顺序可能变;需要保序就用别的方法
names = ["a", "b", "a", "c"]
print(list(dict.fromkeys(names))) # ['a', 'b', 'c'] 保序去重技巧
七、frozenset:不可变集合
frozenset 是冻结的、不可变的集合,支持所有集合运算,但不能增删元素。它可哈希,所以能当字典的 key:
python
# Author: XiaoJia
fs = frozenset([1, 2, 3, 3])
print(fs) # frozenset({1, 2, 3})
# 支持集合运算
print(fs & {2, 3, 4}) # frozenset({2, 3})
# fs.add(4) # AttributeError:不能修改
# 可以当字典 key
d = {fs: "不可变集合"}
print(d)
记忆法:list 之于 tuple,就相当于 set 之于 frozenset。
八、字典为什么快:哈希表原理直觉
列表查一个元素要从头挨个比(最坏 O(n)),而字典按 key 取值几乎是瞬间的(平均 O(1))。秘密在于它底层是哈希表(hash table):存进去时,Python 先用一个哈希函数把 key 算出一个数字(哈希值),映射到内存里的某个位置;取的时候再算一次同样的哈希,直接定位过去。所以字典的查找速度和字典里有 10 项还是 100 万项关系不大,这就是它适合"按键查值"的根本原因。
这也解释了两条铁律:
- key 必须可哈希:可哈希意味着能稳定算出哈希值。数字、字符串、元组(且内部全不可变)都可哈希;列表可变、每次内容可能变,哈希值就对不上,所以不能当 key。
- key 不能重复:两个不同的 key 若算到同一个位置叫"哈希冲突",Python 内部会处理,但对外表现就是------后写的同名 key 会覆盖先写的。
九、视图对象与选型建议
9.1、keys/values/items 是动态视图
info.keys() 返回的不是一份普通列表,而是一个动态视图(view) :它永远"实时反映"字典当前的内容。字典改了,视图跟着变;而且它不复制数据,很大的字典也不额外占内存。想得到一份独立的列表就外面包一层 list():
python
# Author: XiaoJia
info = {"name": "小嘉"}
keys = info.keys()
print(list(keys)) # ['name']
info["age"] = 18 # 字典变了
print(list(keys)) # ['name', 'age'] 视图自动跟着变
9.2、defaultdict 预告
Day14 用 get(k, 0) 或 setdefault 做词频,已经有点啰嗦。标准库 collections.defaultdict 可以更简洁------取不存在的 key 时自动给个默认值,不用每次手动判空,Day27 讲标准库时会专门展开。
9.3、到底用列表还是字典
- 数据是"一串同类型的东西、主要按顺序处理或遍历" → 列表;
- 数据是"需要按某个名字/编号快速查" → 字典;
- 需要"去重、求交并差" → 集合。
工程经验:当你发现自己在写 for x in 列表: if x.id == 目标 这种线性查找时,基本就该改用字典了。
十、3.15 容器新视角:frozendict 与 sentinel
前面的 frozenset 让我们见识了"不可变集合"。Python 3.15 给字典家族也补了一个对应物,同时带来一个专门表示"缺省"的小工具。这两个都是内置名字,不用 import,先混个脸熟即可。
10.1、frozendict:不可变字典(PEP 814)
frozendict 就是"冻结的字典":能读、能查、能当 key,但一旦建好就不能增删改。构造方式和字典几乎一样:
python
# Author: XiaoJia
# Python 3.15 新增,frozendict 是内置名字,无需 import
fd = frozendict({"name": "小嘉", "age": 18})
print(fd) # frozendict({'name': '小嘉', 'age': 18})
print(frozendict(city="福州")) # frozendict({'city': '福州'})
# 读和普通字典一模一样
print(fd["name"]) # 小嘉
print(fd.get("age")) # 18
print(list(fd.items())) # [('name', '小嘉'), ('age', 18)]
# 想改?不行------它是冻结的
fd["age"] = 19
# TypeError: 'frozendict' object does not support item assignment
del fd["name"]
# TypeError: 'frozendict' object does not support item deletion
它和 dict 有两个容易踩的区别:它不是 dict 的子类,但它可哈希、能当字典 key:
python
# Author: XiaoJia
fd = frozendict({"a": 1, "b": 2})
print(isinstance(fd, dict)) # False 注意!它不是 dict 的子类
print(type(fd)) # <class 'frozendict'>
# 可哈希 → 能当字典的 key(普通 dict 可变,反而当不了 key)
cache = {fd: "命中缓存"}
print(cache) # {frozendict({'a': 1, 'b': 2}): '命中缓存'}
# 想转回普通字典,外面包一层 dict()
print(dict(fd)) # {'a': 1, 'b': 2}
小嘉提醒:什么时候用它?当你有一份"建好就不该再被改"的键值数据时------比如全局配置项、常量表、发出去之后冻结的请求头。用
frozendict包一层,任何人想偷偷改都会立刻报错,而不是在运行很久后才发现数据被改乱了。
10.2、sentinel:干净的"缺省"标记(PEP 661)
写函数时常需要一个默认值,表示"用户没传这个参数"。直接用 None 的麻烦在于:没法区分"用户没传"和"用户故意传了 None" 。sentinel("名字") 就是一个专门干这个的唯一标记对象:
python
# Author: XiaoJia
# Python 3.15 新增,sentinel 是内置名字
from copy import copy, deepcopy
MISSING = sentinel("MISSING")
print(repr(MISSING)) # MISSING 干净,不带内存地址噪声
print(type(MISSING)) # <class 'sentinel'>
# 复制 / 深复制后仍然是同一个对象
print(copy(MISSING) is MISSING) # True
print(deepcopy(MISSING) is MISSING) # True
它的典型用法是当函数默认值,用 is 判断"到底有没有传":
python
# Author: XiaoJia
MISSING = sentinel("MISSING")
def greet(name=MISSING):
if name is MISSING: # 没传
return "没传名字"
return f"你好,{name}"
print(greet()) # 没传名字
print(greet("小嘉")) # 你好,小嘉
# dict.get 同理:区分"key 不存在"和"key 的值恰好是 None"
info = {"name": None}
print(info.get("name", MISSING) is MISSING) # False key 在,值就是 None
print(info.get("city", MISSING) is MISSING) # True key 才是真不存在
小嘉提醒:
sentinel("MISSING")每次调用都是一个新对象 ,所以一定要把它存进一个变量(如MISSING = sentinel("MISSING")),全程用这个变量、用is比较,不要现场再造一个。
练习题
- 建一个字典存你的姓名、年龄、城市,然后用
get安全地取出"电话"(不存在时返回"未填写")。 - 用字典统计列表
["apple","banana","apple","orange","banana","apple"]里每个单词出现的次数。 - 有两个列表
[1,2,3,4]和[3,4,5,6],用集合求出它们的交集、并集、差集。 - 把列表
[1,2,2,3,3,3]去重,写出至少两种方法。 - 解释:为什么
dict.fromkeys(["a","b"], [])之后给a的列表 append 一个元素,b也跟着变?怎么避免? - 思考题:为什么字典的 key 必须是不可变类型?列表为什么不能当 key?
- 用
frozendict建一份不可变配置{"host": "localhost", "port": 8080},尝试给它赋值一个新 key,观察报什么错;再把它当 key 放进另一个字典里。
小结
- 字典
{k: v}:按键查值,key 唯一且不可变;3.7+ 保留插入顺序。 - 取值优先
get(k, default)防 KeyError;setdefault适合词频统计;update批量合并。 - 遍历用
items()一次拿到键值对;in判断的是 key。 - 字典生成式
{k: v for ...};警惕fromkeys共享可变默认值的陷阱。 - 集合
set():无序不重复,用途是去重和交并差对称差运算;空集合只能set()。 frozenset不可变、可哈希,能当字典 key。- Python 3.15 新特性:内置名字
frozendict是不可变字典(不是 dict 子类,但可哈希、能当 key,dict(fd)转回普通字典);sentinel("MISSING")当"缺省"标记,配合is区分"没传"与"传了 None"。
扩展阅读
- 官方文档 · 映射类型 dict:https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
- 官方文档 · 集合类型 set:https://docs.python.org/3/library/stdtypes.html#set-types-set-frozenset
- 源目录教材参考:《跟小嘉学习Python 程序设计》一、Python 开发环境介绍和搭建.md 第六章第三、四节;python数据结构.docx