元组、字典、字符串与集合
Tuple, Dict, String and Set
列表不是万能的。这一讲补齐另外四种容器,并回答一个更重要的问题——面对一个具体问题,你该选哪一个。
本讲结束后你应当能
- 说清楚元组和列表的区别,以及为什么需要不可变的序列
- 用字典按名字存取数据,并避开 KeyError 和迭代中改大小的坑
- 用集合做去重和成员测试,理解它为什么快
- 熟练使用 split / join / strip 等字符串方法
- 面对一个具体场景,说出该用五种容器中的哪一个,以及为什么
本页目录
列表不够用的地方
L5 讲了列表,它能装一批数据、按位置访问。但有三类问题它做得不好:
- 想要一组不该被改动的数据——比如一个坐标
(3, 4),你不希望有人往里append一个元素 - 想按名字而不是位置查找——“张三的成绩是多少”,而不是”第 37 个人的成绩是多少”
- 想快速判断某个东西在不在里面——列表的
in要从头扫到尾,数据一多就慢
这一讲的四种容器分别回答这三个问题。
元组
就是不能改的列表
元组用圆括号创建,其他方面和列表很像:
t = (1, 2, 3)
print(t[0]) # 索引一样
print(t[1:]) # 切片一样
print(len(t)) # 长度一样
print(3 in t) # 成员判断一样
for x in t:
print(x, end=" ")
print()
区别只有一个:元组不可变。
t = (1, 2, 3)
t[0] = 99
没有 append、remove、sort 这些方法——它们都是原地修改,元组不支持。元组只有两个方法:
t = (1, 2, 2, 3, 2)
print(t.count(2)) # 2 出现几次
print(t.index(3)) # 3 第一次出现在哪
一个逗号的差别
a = (1) # 这不是元组!括号只是普通的分组括号
b = (1,) # 这才是单元素元组,逗号是关键
c = 1, 2, 3 # 括号其实可以省略
print(a, type(a))
print(b, type(b))
print(c, type(c))
打包与解包
这是元组最常用的地方,你其实已经用过了:
# 打包:多个值自动组成元组
point = 3, 4
print(point, type(point))
# 解包:元组自动拆给多个变量
x, y = point
print(f"x={x}, y={y}")
# 交换两个变量——Python 的经典写法
a, b = 1, 2
a, b = b, a
print(a, b)
L3 里”返回多个值”的函数,返回的就是元组:
def min_max(nums: list[int]) -> tuple[int, int]:
return min(nums), max(nums)
result = min_max([3, 9, 5])
print(result, type(result))
low, high = min_max([3, 9, 5]) # 直接解包
print(low, high)
解包时两边数量必须一致,否则报错:
a, b = 1, 2, 3
用 * 可以收集多余的:
first, *rest = [1, 2, 3, 4, 5]
print(first, rest)
*init, last = [1, 2, 3, 4, 5]
print(init, last)
什么时候用元组
- 数据在语义上是一个整体、不该被拆改:坐标、日期、RGB 颜色值
- 需要当字典的键(下面会讲,列表不能当键,元组可以)
- 函数返回多个值
字典
按名字查,而不是按位置
假设要存一个班的成绩。用列表的话,你得记住”第 37 个是张三”——这显然不行。
字典用 键: 值 的形式存储:
scores = {"张三": 87, "李四": 92, "王五": 65}
print(scores["张三"]) # 按名字查
print(len(scores))
print("李四" in scores) # 判断键是否存在
字典的查找速度和它有多大几乎无关。 一万条数据和三条数据,查一次的时间差不多。列表做不到这一点——列表的 in 要从头比到尾。
原因是哈希表:字典把键换算成一个位置,直接跳过去取。这个机制在 L11 展开。
创建
a = {"x": 1, "y": 2} # 字面量
b = dict(x=1, y=2) # 关键字参数
c = dict([("x", 1), ("y", 2)]) # 键值对列表
d = {} # 空字典
print(a, b, c, d)
print(a == b == c)
访问不存在的键
scores = {"张三": 87}
print(scores["赵六"])
KeyError 是字典最常见的错误。三种应对方式:
scores = {"张三": 87}
# 1. 先判断
if "赵六" in scores:
print(scores["赵六"])
else:
print("没有这个人")
# 2. get():不存在时返回 None 或指定的默认值
print(scores.get("赵六"))
print(scores.get("赵六", 0)) # 指定默认值,推荐
# 3. setdefault():不存在时顺便写进去
print(scores.setdefault("赵六", 0))
print(scores) # 赵六被加进去了
增删改
scores = {"张三": 87, "李四": 92}
scores["王五"] = 65 # 新增
scores["张三"] = 90 # 修改(键已存在就是改)
print(scores)
del scores["李四"] # 删除
print(scores)
value = scores.pop("王五") # 删除并返回值
print(value, scores)
赋值语句既是新增也是修改,取决于键在不在。这一点和列表不同——列表给不存在的下标赋值会报 IndexError。
遍历
scores = {"张三": 87, "李四": 92, "王五": 65}
for name in scores: # 默认遍历键
print(name, end=" ")
print()
for name in scores.keys(): # 显式一点
print(name, end=" ")
print()
for score in scores.values(): # 遍历值
print(score, end=" ")
print()
for name, score in scores.items(): # 同时要键和值——最常用
print(f"{name}: {score}")
迭代时不能改变大小
scores = {"张三": 87, "李四": 92}
for name in scores:
if scores[name] < 90:
del scores[name]
和列表一样的道理(L5 讲过),但字典报错更直接:RuntimeError: dictionary changed size during iteration。
正确做法是先收集要删的,再统一删:
scores = {"张三": 87, "李四": 92, "王五": 65}
to_delete = [name for name, s in scores.items() if s < 90]
for name in to_delete:
del scores[name]
print(scores)
或者干脆构造一个新字典:
scores = {"张三": 87, "李四": 92, "王五": 65}
passed = {name: s for name, s in scores.items() if s >= 90}
print(passed)
最后这个写法叫字典推导,和列表推导是一回事。
键必须是不可变的
d = {}
d[(1, 2)] = "元组可以当键" # 元组不可变,可以
d["abc"] = "字符串也可以"
d[42] = "数字也可以"
print(d)
d = {}
d[[1, 2]] = "列表不行"
原因是字典要根据键算出一个固定的位置。如果键可以被改,算出来的位置就会变,之前存的东西就找不到了。能不能当键,取决于它可不可变。
合并
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}
print({**a, **b}) # 解包合并,后面的覆盖前面的
print(a | b) # Python 3.9+ 的写法,等价
c = a.copy()
c.update(b) # 原地更新
print(c)
应用一:统计数量
这是字典最经典的用法:
text = "the quick brown fox jumps over the lazy dog the end"
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
print(counts)
# 按出现次数排序,取前三
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
print(top[:3])
counts.get(word, 0) + 1 这个写法要记住:没见过就当 0,见过就加 1。
标准库还有个专门做这件事的工具:
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the end"
print(Counter(text.split()).most_common(3))
应用二:替代一长串 if-elif
# 啰嗦的写法
def month_days_if(month: int) -> int:
if month in (1, 3, 5, 7, 8, 10, 12):
return 31
elif month == 2:
return 28
else:
return 30
# 用字典查表
MONTH_DAYS = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31}
def month_days(month: int) -> int:
return MONTH_DAYS[month]
print(month_days_if(2), month_days(2))
print(month_days_if(4), month_days(4))
当分支只是”输入映射到输出”时,用字典比 if-elif 更清楚,而且加一条规则只要加一行数据,不用改逻辑。
集合
不重复、无序
s = {3, 1, 4, 1, 5, 9, 2, 6, 5}
print(s) # 重复的自动没了
print(len(s))
集合的两个特点:元素不重复、不记录顺序(所以不能用下标访问)。
s = {1, 2, 3}
print(s[0])
去重
这是集合最常见的用途:
nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
print(list(set(nums))) # 去重,但顺序可能乱
# 要保持原顺序,用字典(键天然不重复且保序)
print(list(dict.fromkeys(nums)))
成员测试很快
big_list = list(range(100000))
big_set = set(big_list)
# 两者结果一样,但代价差很多
print(99999 in big_list)
print(99999 in big_set)
列表的 in 要从头比到尾;集合和字典一样用哈希,几乎一步到位。数据量大、又要反复查”在不在”的时候,先转成集合。
集合运算
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # 并集:在 a 或在 b
print(a & b) # 交集:既在 a 又在 b
print(a - b) # 差集:在 a 不在 b
print(a ^ b) # 对称差:只在其中一个里
# 实际用途:找出两个班共同选修的课
class_a = {"数学", "物理", "英语", "编程"}
class_b = {"物理", "化学", "编程", "体育"}
print("都上:", class_a & class_b)
print("只有 A 上:", class_a - class_b)
print("合起来:", class_a | class_b)
字符串的方法
字符串是不可变序列,L3 讲过基本用法。这里补齐常用方法。所有方法都返回新字符串,不改原来的。
切分与拼接
line = "张三,87,数学"
parts = line.split(",")
print(parts)
print("-".join(parts)) # 用 - 连起来
print("".join(parts)) # 直接拼
text = " the quick brown fox "
print(text.split()) # 不给参数:按任意空白切,且自动忽略多余空白
print(text.split(" ")) # 给了空格:严格按单个空格切,会产生空串
去除空白
s = " hello \n"
print(repr(s.strip())) # 两端
print(repr(s.lstrip())) # 左端
print(repr(s.rstrip())) # 右端
strip() 在处理 input() 和文件读入时几乎是必备的——用户经常多打空格,文件每行末尾有换行符。
大小写与判断
s = "Hello World"
print(s.upper())
print(s.lower())
print(s.title())
print(s.startswith("Hello"), s.endswith("!"))
for s in ["123", "12.3", "abc", "abc123", " ", ""]:
print(f"{s!r:10} isdigit={s.isdigit()!s:<6} isalpha={s.isalpha()!s:<6} isalnum={s.isalnum()}")
查找与替换
s = "the quick brown fox"
print(s.find("quick")) # 返回下标,找不到返回 -1
print(s.find("cat"))
print(s.count("o"))
print(s.replace("quick", "slow"))
print(s) # 原字符串没变
格式化的完整规格
L3 讲了 f-string 的常用格式。完整的格式规格是这样组成的:
{值:[[填充]对齐][正负号][#][0][宽度][,][.精度][类型]}
大部分你用不到,下面这张表按需要查即可:
| 位置 | 写法 | 作用 |
|---|---|---|
| 对齐 | < > ^ | 左 / 右 / 居中 |
| 填充 | 对齐符号前任意字符 | 如 *^10 用星号填充并居中 |
| 正负号 | + - | 总是显示符号 / 只显示负号 / 正数留空格 |
| 千分位 | , _ | 如 {1234567:,} → 1,234,567 |
| 精度 | .n | 小数位数,或字符串截断长度 |
| 类型 | d f e % b o x | 整数 / 定点 / 科学计数 / 百分比 / 各种进制 |
n = 1234567.891
print(f"{n:,.2f}") # 千分位 + 两位小数
print(f"{n:>15,.2f}") # 再加右对齐、宽度 15
print(f"{0.856:.1%}") # 百分比
print(f"{42:*^11}") # 星号填充居中
print(f"{'截断':.1}") # 字符串也能截断
五种容器:该选哪一个
这是 Part II 的收口。选对容器,代码会短一半、快十倍;选错了,怎么写都别扭。
| list | tuple | str | dict | set | |
|---|---|---|---|---|---|
| 写法 | [1,2] | (1,2) | "ab" | {"k":1} | {1,2} |
| 可变 | ✅ | ❌ | ❌ | ✅ | ✅ |
| 有序 | ✅ | ✅ | ✅ | ✅(插入序) | ❌ |
| 可索引 / 切片 | ✅ | ✅ | ✅ | ❌(用键) | ❌ |
| 允许重复 | ✅ | ✅ | ✅ | 键不可重复 | ❌ |
| 能当字典的键 | ❌ | ✅ | ✅ | ❌ | ❌ |
| 查找快慢 | 慢(逐个比) | 慢 | 慢 | 快 | 快 |
按问题选
| 你的需求 | 选它 | 为什么 |
|---|---|---|
| 一批同类数据,要按顺序处理 | list | 有序、可增删 |
| 一组固定不变的值,如坐标 | tuple | 不可变,安全,可当键 |
| 按名字/编号查数据 | dict | 查找快,语义清楚 |
| 判断”在不在”、去重 | set | 查找快,自动去重 |
| 统计每个东西出现几次 | dict | 键存东西,值存计数 |
| 处理一段文本 | str | 有专门的方法 |
| 需要一个”多维的键” | tuple 作键的 dict | 如 d[(x, y)] = value |
两个共通的坑
遍历时修改容器
这条规则对所有容器都成立,前面已经在列表和字典上各遇到一次:
# 列表:静默出错
a = [1, 2, 2, 3]
for x in a:
if x == 2:
a.remove(x)
print("列表:", a) # 不是 [1, 3]
# 集合:直接报错
s = {1, 2, 3}
for x in s:
if x == 2:
s.remove(x)
统一的做法:要么先收集再删,要么构造一个新容器。 后者通常更清楚。
可变对象作参数会被改掉
def add_item(items: list[int], x: int) -> None:
items.append(x) # 直接改了传进来的列表
nums = [1, 2, 3]
add_item(nums, 4)
print(nums) # 外面的列表变了
这不是 bug——L5 讲过,传进去的是引用,不是拷贝。但如果调用者没料到,就是一个很难查的问题。
两种处理方式:
# 1. 明确不改,返回新的
def with_item(items: list[int], x: int) -> list[int]:
return items + [x] # + 产生新列表
nums = [1, 2, 3]
new = with_item(nums, 4)
print(nums, new) # 原来的没变
# 2. 要改就在函数名和文档里说清楚
def append_in_place(items: list[int], x: int) -> None:
"""把 x 追加到 items 末尾。注意:直接修改传入的列表。"""
items.append(x)
nums = [1, 2, 3]
append_in_place(nums, 4)
print(nums)
几个好用的内置函数
nums = [3, 1, 4, 1, 5]
print(sorted(nums)) # 返回新列表,原列表不变
print(sorted(nums, reverse=True))
print(nums)
words = ["banana", "kiwi", "apple"]
print(sorted(words, key=len)) # 按长度排
print(sorted(words, key=str.lower)) # 忽略大小写
names = ["张三", "李四", "王五"]
scores = [87, 92, 65]
for name, score in zip(names, scores): # 并行遍历两个序列
print(f"{name}: {score}")
print(dict(zip(names, scores))) # 直接组成字典
for i, name in enumerate(["a", "b", "c"], start=1): # 带序号遍历
print(i, name)
zip 和 enumerate 能消掉大量 range(len(...)) 的丑陋写法,值得优先使用。
小结
- 元组 = 不可变的列表。决定它的是逗号不是括号。用于不该改的数据、字典的键、返回多值
- 字典 = 按名字存取,查找快。
get(k, 默认值)避免KeyError;items()同时拿键和值 - 字典从 3.7 起保持插入顺序
- 字典的键必须不可变——元组可以,列表不行
- 集合 = 不重复、无序、查找快。用于去重和成员测试,但会打乱顺序
- 字符串方法都返回新字符串;
split()不带参数最实用;strip()处理输入必备 - 遍历时不要修改容器——所有容器都一样
- 可变对象作参数会被函数改掉,不可变的不会
- 选容器问三件事:要按位置访问吗?数据有名字吗?只关心有没有吗?
练习
- 写
word_count(text: str) -> dict[str, int],统计每个单词出现的次数,忽略大小写。 - 写
unique_ordered(items: list[int]) -> list[int],去重且保持原顺序,用集合加速判断。 - 给定两个学生的选课集合,输出:都选的、只有第一个选的、至少一个人选的。
- 写
invert(d: dict[str, int]) -> dict[int, str],把字典的键值对调。想一想:如果原字典有重复的值会怎样? - 用
zip和字典推导,把["a","b","c"]和[1,2,3]合成{"a":1,"b":2,"c":3},一行完成。 - 下面这段代码想统计每个首字母对应哪些单词,但会报错。找出原因并改对:
words = ["apple", "avocado", "banana", "blueberry"] groups = {} for w in words: groups[w[0]].append(w) - 场景选型:为下面每个需求选一种容器,并说明理由。
- 记录一个班 116 名学生的学号到姓名的对应
- 记录一次考试所有人的分数,之后要求平均分和最高分
- 判断一个单词是不是英语常用词表(一万个词)里的
- 表示一个二维平面上的点,要放进字典当键
本讲的配套上机题在 Lab 6。