元组、字典、字符串与集合
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 第一次出现的下标
count(x) 找不到 x 时返回 0;index(x) 找不到时会报 ValueError。
一个逗号的差别
a = (1) # 这不是元组,括号只是普通的分组括号
b = (1,) # 这才是只有一个元素的元组,关键是逗号
c = 1, 2, 3 # 括号其实可以省略
print(a, type(a))
print(b, type(b))
print(c, type(c))
构造、拼接与重复
tuple() 把可迭代对象转成元组。和列表一样,元组可以用 + 拼接、用 * 重复,得到相应的结果,原来的元组不变:
print(tuple())
print(tuple([1, 2, 3]))
print(tuple("abc"))
t = (1, 2)
print(t + (3,))
print(t * 2)
print(t)
外层不可变,内层仍可能可变
元组和列表一样,元素是对对象的引用。元组不可变,限制的是这些引用不能被替换;引用指向的对象本身可能是可变的。
t = ([1, 2], "fixed")
t[0].append(3)
print(t)
这段代码能运行,因为改的是 t[0] 指向的列表,元组的第 0 项仍然指向同一个列表。
t = ([1, 2], "fixed")
t[0] = [9, 9] # 想让第 0 项改指另一个列表,不允许
所以「元组不可变」不等于「元组里的所有对象都不能变」。这和 L5 的浅拷贝是同一个引用模型。
打包与解包
这是元组最常见的用法,其实前面已经用过了:
# 打包:几个值用逗号隔开,自动组成元组
point = 3, 4
print(point, type(point))
# 解包:元组里的值依次赋给几个名字
x, y = point
print(f"x={x}, y={y}")
# 交换两个变量
a, b = 1, 2
a, b = b, a
print(a, b)
L3 的同步赋值 a, b = b, a 可以按打包、解包来理解:先取得右边两个值,再依次赋给左边。解释器可能优化掉中间元组的创建,执行顺序不变。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
用 * 可以把多出来的收集成一个列表。L4 的 *args 也会收集多余的值,但函数参数里得到的是元组,这里的解包得到的是列表:
first, *rest = [1, 2, 3, 4, 5]
print(first, rest)
*init, last = [1, 2, 3, 4, 5]
print(init, last)
嵌套解包
左边的结构可以和右边对应,这叫嵌套解包:
((x, y), label) = ((3, 4), "point")
print(x, y, label)
每一层都要对应,不能只比较最外层有几项。
什么时候用元组
- 数据是一个整体、不该被拆开修改:坐标、日期、RGB 颜色值
- 要当字典的键:元素都可哈希的元组可以,列表不可以(下面讲)
- 函数返回多个值
字典
按名字查找
假设要存一个班的成绩。用列表的话,查询时要先找出张三在第几条记录,再取成绩。记录一多,这样既麻烦又慢。
字典用 键: 值 的形式存储,按键查找:
scores = {"张三": 87, "李四": 92, "王五": 65}
print(scores["张三"]) # 按名字查
print(len(scores))
print("李四" in scores) # 判断键是否存在
在通常的数据上,字典按键查找的平均代价不会随记录条数线性增长。列表查找则要逐项比较,最坏时检查整个列表。
字典用了哈希表:先用键算出一个数(哈希值),据此寻找可能的存放位置。同一个位置可能对应不同的键,这叫冲突;发生冲突时还要继续查找并比较键。所以它通常查得很快,但不保证每次都只做一次操作。这个机制 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)
两层键访问
一个学生有多门课的成绩,可以让外层字典的键是姓名,值是这个学生的成绩字典;内层字典的键是科目,值是分数:
results = {
"张三": {"数学": 87, "物理": 92},
"李四": {"数学": 65},
}
print(results["张三"])
print(results["张三"]["数学"])
results["张三"]["数学"] = 90
print(results["张三"]["数学"])
results["张三"] 得到内层字典,再用 ["数学"] 取分数。新增一门科目,只修改这个学生的内层字典,不应该替换整份成绩记录。
访问不存在的键
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}")
items() 每一轮给出一个 (键, 值) 元组,for name, score in ... 把它解包成两个名字。
视图跟着字典变化
keys()、values()、items() 返回的是视图,不是列表。视图反映原字典的当前内容;list(...) 则把当时的内容复制到一个列表里。
scores = {"张三": 87, "李四": 92}
view = scores.keys()
snapshot = list(view)
scores["王五"] = 65
print(list(view))
print(snapshot)
视图里能看到新增的王五,已经构造出的 snapshot 不会自动增加。
遍历时不能改变大小
scores = {"张三": 87, "李四": 92}
for name in scores:
if scores[name] < 90:
del scores[name]
本例删除键后改变了字典的大小,因此报 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)
最后这种写法叫字典推导,和 L5 的列表推导是同一个形式,只是花括号里写 键: 值。
键必须可哈希
d = {}
d[(1, 2)] = "元组可以当键" # 两个元素都是可哈希的整数
d["abc"] = "字符串也可以"
d[42] = "数也可以"
print(d)
d = {}
d[[1, 2]] = "列表不行"
字典的键必须可哈希:对象有哈希值,且这个值在对象存活期间保持不变;比较相等的键必须有相同的哈希值。否则字典无法可靠地找到原来存放的记录。
本讲涉及的内置类型中,数和字符串可哈希,列表、字典、集合不可哈希。元组要看元素:所有元素都可哈希,元组才可哈希。
d = {}
d[([1, 2], "fixed")] = "这个元组不能当键"
外层是不可变的元组,里面却含有不可哈希的列表,仍然报 TypeError。可哈希和不可变有联系,但不是同一个概念。
删除末项与清空
pop(key) 删除指定键并返回值;popitem() 删除最后插入的键值对并返回一个元组。clear() 清空字典,返回 None。
d = {"x": 1, "y": 2}
print(d.popitem())
print(d)
print(d.clear())
print(d)
空字典调用 popitem() 会报 KeyError,它不会返回一个空元组。
从一组键创建字典
dict.fromkeys(keys, value) 用一组键和一个共同的初值创建字典,不传初值时用 None:
print(dict.fromkeys(["a", "b", "a"]))
print(dict.fromkeys(["a", "b"], 0))
重复的键只保留一次,顺序以首次出现为准。后面保序去重会用到它。
复制与合并
和列表一样,赋值不复制字典,copy() 或 dict(d) 只复制外层。内层对象仍然共用,L5 的浅拷贝规则在这里也成立。
a = {"scores": [87, 92]}
b = a.copy()
b["scores"].append(65)
print(a)
print(a is b)
print(a["scores"] is b["scores"])
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}
print({**a, **b}) # 展开两个字典,后面的同名键覆盖前面的
print(a | b) # Python 3.9 起,结果相同
c = a.copy()
print(c.update(b)) # 原地更新,返回 None
print(c)
{**a, **b} 中的 ** 把字典的键值对展开进新字典。a | b 也产生新字典;a.update(b) 修改 a。它们遇到重复键时,后面的值覆盖前面的值。
字符串的方法
字符串是不可变的序列,L3 讲过基本用法。字符串的方法都不会修改原字符串,但返回类型不同:
| 方法 | 返回类型 |
|---|---|
upper()、strip()、replace()、join() | 字符串 |
split() | 列表 |
find()、count() | 整数 |
isdigit()、startswith() | 布尔值 |
返回字符串的方法也不保证每次分配一个新对象,不需要处理时可以复用原对象。判断结果应比较值,不要根据地址推测方法做了什么。
切分与拼接
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())) # 右端
处理数据前先判断空白是否有意义。成绩字段两端的空格通常需要去掉;正文的缩进或换行有时需要保留。input() 已经去掉输入行末尾的换行符,但会保留额外输入的空格。
strip(chars) 把 chars 当作要从两端删掉的一组字符,逐个删除,不按完整前缀匹配;中间的字符不受影响。处理单词两端的标点时可以这样用:
print("...hello!?".strip(".,!?;:"))
print("a,b".strip(","))
第一个结果是 hello,第二个仍是 a,b。
大小写与判断
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) # 原字符串没变
find() 和 count() 还可以限定范围,和切片一样含头不含尾:
s = "banana"
print(s.find("an", 2))
print(s.count("a", 0, 4))
结果分别是 3 和 2。
格式说明的常用组成
L3 讲了 f-string 的常用格式。下面是便于查阅的简化结构,不是全部语法;不同类型允许的选项也不同:
{值:[[填充]对齐][正负号][#][0][宽度][分组符][.精度][类型]}
大部分用不到,下面这张表需要时查一下即可:
| 位置 | 写法 | 作用 |
|---|---|---|
| 对齐 | < > ^ = | 左 / 右 / 居中;= 把填充放在符号后、数字前 |
| 填充 | 对齐符号前的任意字符 | 如 *^10 用星号填充并居中 |
| 正负号 | + - | 总是显示符号 / 只显示负号 / 正数前留空格 |
| 千分位 | , _ | 如 {1234567:,} → 1,234,567 |
| 精度 | .n | .nf 控小数位,.ng 控有效数字;字符串最多保留 n 个字符 |
| 类型 | d f e % b o x | 整数 / 定点小数 / 科学计数 / 百分比 / 各种进制 |
| 其他类型 | c s g n | 整数对应的字符 / 字符串 / 通用数值格式 / 本地化数值格式 |
| 大写变体 | E F G 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}") # 字符串也能截取
字典应用:文本与查表
一段文本怎样变成词频表?一行成绩怎样变成按姓名、科目查询的数据?字符串方法负责拆分和清洗,字典负责保存对应关系。先看计数,再组合清洗、排序和分组。
统计次数
这是字典最典型的用法:
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)
counts.get(word, 0) + 1 这个写法要记住:没出现过就当 0,出现过就加 1。
清洗后再计数
实际文本可能混用大小写、带标点,也可能有只含标点的一段。先检查每一步产生的值,再统计:
text = "Dog ... DOG! cat"
words = []
for raw in text.split():
word = raw.strip(".,!?;:").lower()
if word:
words.append(word)
print(words)
结果是 ["dog", "dog", "cat"]。"..." 去掉两端标点后变成空字符串,不能把它当成一个单词。
| 步骤 | 输入与结果 | 为什么需要 |
|---|---|---|
split() | 文本 → 一组原始词段 | 按任意连续空白分词 |
strip(".,!?;:") | "DOG!" → "DOG" | 去掉词段两端的指定标点 |
lower() | "DOG" → "dog" | 大小写不同的同一个词合并统计 |
| 判断是否非空 | "..." → "",跳过 | 不产生空词条 |
get(word, 0) + 1 | 单词 → 累计次数 | 首次出现从 0 加到 1 |
这是一种简单分词规则:中间的标点不拆开。处理自然语言的更复杂规则需要专门的工具,本次实验不要求。
按一个或多个依据排序
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)) # 忽略大小写
key=len 把 len 这个函数传给 sorted:sorted 对每个元素调用一次 len,按得到的结果排序。L3 讲过,函数也是对象,可以赋给名字,也就可以作为参数传递。自己定义的函数一样可以:
counts = {"the": 3, "fox": 1, "dog": 2}
def count_of(pair: tuple[str, int]) -> int:
return pair[1]
print(sorted(counts.items(), key=count_of, reverse=True))
counts.items() 里的每一项是一个 (单词, 次数) 元组,count_of 取出次数,sorted 就按次数从大到小排。
次数相同时还要按单词排序,可以让 key 函数返回一个元组。元组按字典序比较:先比第 0 项,相同再比第 1 项。
counts = {"the": 3, "fox": 1, "dog": 2, "cat": 2}
def order(pair: tuple[str, int]) -> tuple[int, str]:
return (-pair[1], pair[0])
print(sorted(counts.items(), key=order))
负号把「次数从多到少」转成从小到大排;第二项单词用于次数相同时的比较。
标准库里有一个专门用来计数的工具 Counter,most_common(3) 给出出现最多的三个:
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the end"
print(Counter(text.split()).most_common(3))
Counter 可以代替计数过程,但 most_common() 在次数相同时按首次出现的顺序排列。Lab 6 要求按单词的字典序处理并列,不能直接用它代替题目要求的排序:
from collections import Counter
print(Counter("dog cat dog cat".split()).most_common(2))
这里 dog 在前;若要求并列按字典序,cat 应在前。可以用 Counter 计数,再用具名 key 函数排序。
把文本写入嵌套字典
已经知道两层字典的结构,也学过 split()、strip()、int()。现在把一行文本依次处理成记录:
line = " 张三 , 数学 , 87 "
name, subject, raw_mark = line.split(",")
name = name.strip()
subject = subject.strip()
mark = int(raw_mark.strip())
results = {"张三": {"物理": 92}}
student = results.setdefault(name, {})
student[subject] = mark
print(results)
姓名、科目、分数各去掉两端空白,分数还要转成整数。setdefault(name, {}) 让已有姓名取回原来的成绩字典;姓名第一次出现时,先插入一个空字典。于是新科目不会覆盖已有科目。
只写 results.get(name, {})[subject] = mark 不够:姓名不存在时,get() 返回的默认空字典没有插入 results,修改它不会建立这个学生的记录。
字典里的列表:分组
同一组要保存多个值,可以让字典的值是列表。下面按首字符把几个单词放进不同列表:
groups = {}
for word in ["banana", "apple", "avocado", "apple"]:
bucket = groups.setdefault(word[0], [])
bucket.append(word)
print(groups)
setdefault 返回已有列表或刚插入的空列表,再在这个列表里追加。遍历顺序决定了组内顺序,重复的 apple 也会保留。本例只处理非空单词,否则 word[0] 会越界。
用字典查表
下面两种写法都约定月份为 1–12,二月按非闰年处理。只有在这个输入范围内,结果才一致。
# 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])
创建与增删
set() 从可迭代对象取出元素,自动去重。给它一个字典,取出的是键:
print(set("banana"))
print(set([1, 2, 2, 3]))
print(set({"x": 1, "y": 2}))
集合不保证打印顺序。add(x) 添加一个元素,update(it) 添加可迭代对象里的所有元素;它们都原地修改,返回 None。
s = {1, 2}
print(s.add(3))
s.update([3, 4])
print(sorted(s))
s.remove(2)
s.discard(99)
print(sorted(s))
s.clear()
print(s)
remove(x) 找不到元素会报 KeyError;discard(x) 找不到就不做任何操作。原地更新也可以写成 s |= other、s &= other、s -= other、s ^= other。
集合的元素和字典的键一样,必须可哈希。列表不能放进集合,元素都可哈希的元组可以。
去重
这是集合最常见的用途:
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) # 对称差:只在其中一个里
这几个符号和 L4 的位运算是同一组:按位与、按位或、按位异或,对应交集、并集、对称差。
# 实际用途:找出两个班共同选修的课
class_a = {"数学", "物理", "英语", "编程"}
class_b = {"物理", "化学", "编程", "体育"}
print("都上:", class_a & class_b)
print("只有 A 上:", class_a - class_b)
print("合起来:", class_a | class_b)
子集与真子集
集合的比较不是按字典序比较大小。a <= b 表示 a 的每个元素都在 b 里;a < b 还要求两个集合不相等:
a = {1, 2}
b = {1, 2, 3}
print(a <= b)
print(a < b)
print(a <= a)
print(a < a)
>= 和 > 则表示超集与真超集。
保留首次出现的顺序
集合本身不记录顺序,但可以帮列表快速判断是否已经出现过:
items = [3, 1, 3, 5, 1, 7]
seen = set()
result = []
for x in items:
if x not in seen:
seen.add(x)
result.append(x)
print(result)
seen 负责判断是否出现过,result 负责保留首次出现的顺序。选择容器不一定只选一个,也可以让不同容器配合。
五种容器:该选哪一个
先明确要按位置取值、按键查值,还是只判断成员。容器提供的操作和任务一致,代码会更清楚;数据规模大时,还要考虑操作的代价。
| list | tuple | str | dict | set | |
|---|---|---|---|---|---|
| 写法 | [1,2] | (1,2) | "ab" | {"k":1} | {1,2} |
| 可变 | ✅ | ❌ | ❌ | ✅ | ✅ |
| 有序 | ✅ | ✅ | ✅ | ✅(插入顺序) | ❌ |
| 可下标 / 切片 | ✅ | ✅ | ✅ | ❌(用键) | ❌ |
| 允许重复 | ✅ | ✅ | ✅ | 键不能重复 | ❌ |
| 能当字典的键 | ❌ | 元素都可哈希时 | ✅ | ❌ | ❌ |
| 判断在不在 | 逐项比较 | 逐项比较 | 查找子串 | 平均快 | 平均快 |
按需求选
| 需求 | 选择 | 原因 |
|---|---|---|
| 一批同类数据,要按顺序处理 | list | 有序、可增删 |
| 一组固定不变的值,如坐标 | tuple | 元素位置固定;元素都可哈希时可当键 |
| 按名字或编号查数据 | dict | 查找快,意思清楚 |
| 判断「在不在」、去重 | set | 查找快,自动去重 |
| 统计每个东西出现几次 | dict | 键存东西,值存次数 |
| 处理一段文本 | str | 有专门的方法 |
| 需要由几个值组成的键 | 用元组作键的 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) # 外面的列表变了
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)
+= 与 + 不一定是同一种操作
数和字符串上,a += b 可以按 a = a + b 理解。列表上要区分:+ 造新列表,+= 原地扩展列表,所以对别名的影响不同。
a = [1, 2]
b = a
a = a + [3]
print(a, b)
c = [1, 2]
d = c
c += [3]
print(c, d)
不能只看有没有赋值符号,要看操作修改了原对象,还是产生新对象。
容器的真假值
空的字符串、列表、元组、字典和集合都为假,非空时都为真。数值 0 和 None 也为假:
for x in ["", [], (), {}, set(), 0, None, [0], "0"]:
print(repr(x), bool(x))
[0] 虽然只装着一个 0,但列表不是空的,所以为真。if items: 是判断是否非空的常用写法;while items: 则在容器非空时继续循环。
zip 与 enumerate
需要同时遍历多组数据,或者同时得到下标与元素时,用下面两个工具。
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() 返回可以逐项遍历的迭代器,不直接返回列表。想一次看完结果,用 list(zip(...))。默认情况下,它在最短的输入序列结束时就停止:
print(list(zip(["甲", "乙", "丙"], [87, 92])))
丙没有对应的分数,所以不在结果里。需要逐项对应时,先确认两边长度一致。
有了 zip 和 enumerate,大多数 range(len(...)) 的写法都可以省掉。
小结
- 元组是不可变的序列。构成元组的是逗号,不是括号。用于不该修改的数据、字典的键、返回多个值
- 字典按键存取,查找快。
get(k, 默认值)避免KeyError;items()同时取键和值 - 字典从 3.7 起保持插入顺序
- 字典的键和集合元素必须可哈希:数、字符串可以;元组要看元素,列表不行
- 集合不重复、无序、查找快。用于去重和成员判断,但不保证顺序
- 字符串的方法不修改原字符串,但返回类型不同;
split()得到列表,strip()去掉两端空白 - 遍历时不要改变容器的大小,所有容器都一样
- 函数对参数对象的原地修改会影响调用者;元组里嵌套的可变对象也要检查
- 选容器时依次问:要按位置访问吗?数据有名字吗?只关心有没有吗?
参考补充
下面几种写法用于阅读已有代码,需要时查阅,不要求把 API 表逐项背下来。
元组的重新绑定与删除名字
元组不支持替换元素,但名字可以改指另一个元组:
t = (1, 2)
old = t
t = t + (3,)
print(t, old)
del t
print(old)
del t 删除的是名字绑定,不会递归删除对象及其内容;old 仍然能访问原来的元组。
用嵌套字典表示图
往年课件用三节点的加权图展示字典的另一种用法。这里只理解数据表示,不展开图算法:外层键是出发点,内层键是终点,值是边的权重。
graph = {
"A": {"B": 3, "C": 1},
"B": {"C": 4},
"C": {},
}
print(graph["A"]["C"])
print(graph["B"]["C"])
A 到 C 的权重为 1,B 到 C 为 4。空字典表示这个例子里没有从 C 出发的边。两次方括号和成绩单的 results[name][subject] 是同一类访问。
不可变集合
普通集合 set 不可哈希,不能直接作字典键。frozenset 固定成员,不支持增删,可以作键:
key = frozenset({1, 2})
d = {key: "pair"}
print(d[frozenset({2, 1})])
print(key == {1, 2})
集合按成员判断相等,不依赖插入顺序;frozenset 也不保证遍历顺序。
Tab 与 expandtabs()
Tab 会移动到下一个制表位,不是固定插入若干个空格。expandtabs(n) 按间距 n 的制表位把 Tab 展开为空格,不修改原字符串:
print(repr("a\tb".expandtabs(4)))
print(repr("abc\td".expandtabs(4)))
第一行在 a 后面补三个空格,第二行在 abc 后面只补一个。默认间距是 8;终端实际怎样显示 Tab,还取决于终端设置。
string 模块中的常量
不必自己重写一串 ASCII 字母或标点,可以查标准库的常量:
import string
print(string.ascii_letters)
print(string.ascii_lowercase)
print(string.ascii_uppercase)
print(string.digits)
print(string.hexdigits)
print(string.octdigits)
print(string.punctuation)
print(repr(string.whitespace))
还有 string.printable,包含 ASCII 数字、字母、标点与空白。这里的 ASCII 常量不等于所有 Unicode 字符;例如 ascii_letters 不包含汉字。
多行语句
圆括号、方括号和花括号内部允许自然换行,通常优先使用这种写法:
scores = {
"张三": 87,
"李四": 92,
}
total = (
scores["张三"]
+ scores["李四"]
)
print(total)
也可以在行末用反斜线续行,但反斜线后面不能有空格或注释:
total = 87 + \
92
print(total)
数值格式的其他选项
f-string 和 str.format() 共用格式说明,内置 format(value, spec) 也使用同一套说明。这里只查阅,不替代 L3 推荐的 f-string:
print(f"{42:08b}")
print(f"{42:o}")
print(f"{42:#x}")
print(f"{65:c}")
print(f"{3.14:f}")
print(f"{-42:0=6d}")
print("{name}: {score:.1f}".format(name="Ada", score=87.5))
print(format(87.5, ".1f"))
# 给相应进制加前缀;c 把整数当作字符编号;f 未指定精度时默认保留六位小数;= 把填充放在正负号后面。g 会选择适当的数值表示,n 按当前区域设置处理数值,需要时查 Python 的格式说明文档。
eval() 与 exec()
L3 讲过,eval() 计算一个表达式并返回它的值。exec() 执行一段语句,返回 None。这里只演示程序中写死的可信常量:
print(eval("[1, 2, 3]"))
print(eval("3 * 4 + 1"))
print(exec("n = 3\nprint(n * 4)"))
赋值是语句,不能交给 eval():
eval("n = 3")
两者都不能直接执行用户输入或外部文本。 想把形如 "[1, 2, 3]" 的文本解析成字面量数据,可以了解 ast.literal_eval();它不执行一般的表达式,但不可信输入仍需限制长度和嵌套深度。普通数值输入继续用 int()、float(),不要改用动态执行。
练习
- 写
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。