CS1602计算导论
第 6 讲Part 2 数据的组织AI Level 0

元组、字典、字符串与集合

Tuple, Dict, String and Set

列表几乎什么都能做,但有几类问题它做得不好。这一讲讲元组、字典、集合,补齐字符串的常用方法,并回答一个更重要的问题:遇到一个具体问题,该选哪一种容器。

本讲结束后你应当能

  • 说清楚元组和列表的区别,以及为什么需要不可变的序列
  • 用字典按名字存取数据,并避开 KeyError 和遍历时改变大小的问题
  • 用集合去重和判断成员,并说出它为什么快
  • 熟练使用 split / join / strip 等字符串方法
  • 遇到一个具体场景,说出该用哪一种容器,以及为什么
本页目录

列表做不好的事

L5 讲了列表,它能存放一批数据、按位置访问。但有三类问题它做得不好:

  1. 一组不该被修改的数据:比如一个坐标 (3, 4),不应该有人往里 append 一个元素
  2. 按名字查找,而不是按位置:要问的是「张三的成绩是多少」,而不是「第 37 个人的成绩是多少」
  3. 快速判断某个值在不在里面:列表的 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 负责保留首次出现的顺序。选择容器不一定只选一个,也可以让不同容器配合。

五种容器:该选哪一个

先明确要按位置取值、按键查值,还是只判断成员。容器提供的操作和任务一致,代码会更清楚;数据规模大时,还要考虑操作的代价。

listtuplestrdictset
写法[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(),不要改用动态执行。

练习

  1. 写 word_count(text: str) -> dict[str, int],统计每个单词出现的次数,忽略大小写。
  2. 写 unique_ordered(items: list[int]) -> list[int],去重并保持原顺序,用集合加速判断。
  3. 给定两个学生的选课集合,输出:都选的、只有第一个人选的、至少一个人选的。
  4. 写 invert(d: dict[str, int]) -> dict[int, str],把字典的键和值对调。想一想:如果原字典里有重复的值,会怎样?
  5. 用 zip 和字典推导,把 ["a","b","c"] 和 [1,2,3] 合成 {"a":1,"b":2,"c":3},一行完成。
  6. 下面这段代码想统计每个首字母对应哪些单词,但会报错。找出原因并改对:
    words = ["apple", "avocado", "banana", "blueberry"]
    groups = {}
    for w in words:
        groups[w[0]].append(w)
  7. 场景选型:为下面每个需求选一种容器,并说明理由。
    • 记录一个班 116 名学生的学号到姓名的对应
    • 记录一次考试所有人的分数,之后要求平均分和最高分
    • 判断一个单词是不是在英语常用词表(一万个词)里
    • 表示二维平面上的一个点,要放进字典当键

本讲的配套上机题在 Lab 6。