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 第一次出现在哪

一个逗号的差别

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 的收口。选对容器,代码会短一半、快十倍;选错了,怎么写都别扭。

listtuplestrdictset
写法[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() 处理输入必备
  • 遍历时不要修改容器——所有容器都一样
  • 可变对象作参数会被函数改掉,不可变的不会
  • 选容器问三件事:要按位置访问吗?数据有名字吗?只关心有没有吗?

练习

  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。