跳转至

容器集合 课件

引言

1.1 对象与类型 里,每种类型都只装一个值。然而,一门编程语言真正的威力,来自它把许多值聚到一起、并把整组当作一个东西来对待的能力——一份学生名册、一张图像的像素、一张测量数据表。做到这一点的类型就是容器(container),它们是大多数真实 Python 程序耗费时间的地方。

本页介绍三大容器家族,区分它们的问题是元素是怎么存放的? 序列(sequence)按顺序保存元素,因此每个元素都有一个可供索引的位置。集合(set)只保存不重复的元素,丢弃重复项、也不记顺序。映射(mapping)把每个元素与一个键配对,于是你按名字而非位置来查找。三者都见过之后,第 4 节会回到 1.1 的第二条轴——可变性——因为它悄悄支配着它们的行为;第 5 节再讲切片,一次读取整段子序列。本页代码可运行,请边读边试。

1. 序列:列表、元组与 range 课件

你最常用到的容器是有序的序列。两位主力是列表(list)元组(tuple)。两者都保存一组有序的元素;最关键的区别在于可变性。列表用方括号 [] 写出,是可变的;元组用圆括号 () 写出,是不可变的。

下面的示例各建一个并打印出来——注意两者都能混放不同类型的元素。

示例:一个列表和一个元组
my_list  = [1, 2, 3, "hello", 4.5]   # 可变
my_tuple = (1, 2, 3, "world", 6.7)   # 不可变
print(my_list)
print(my_tuple)

第三种序列 range 表示一个整数的等差数列,却并不把它们全部存下来——range(1, 101) 代表 1 到 100,但只保存起点、终点和步长。它是生成数字序列的惯用方式,常常直接交给 list()tuple()

示例:用 range 生成数字
numbers = list(range(1, 101))  # 1, 2, ..., 100
print(numbers[:10], "...")     # 只看前十个
print(len(numbers))            # 100

range 最多接受三个参数——range(stop)range(start, stop)range(start, stop, step)——而且和切片一样,stop 是被排除的。负步长向下计数,空 range 也完全合法。

示例:range 的几种形式
print(list(range(5)))          # [0, 1, 2, 3, 4]
print(list(range(2, 8)))       # [2, 3, 4, 5, 6, 7]
print(list(range(0, 10, 3)))   # [0, 3, 6, 9]
print(list(range(5, 0, -1)))   # [5, 4, 3, 2, 1] —— 向下计数
print(list(range(1, 1)))       # [] —— 空 range
课堂练习:构建序列
  1. 创建一个包含若干不同类型元素的列表;打印整个列表,并用索引打印其中一个元素(索引从 0 开始)。
  2. 对元组做同样的事。
  3. range() 构建一个包含 1 到 100 的列表,再构建一个这样的元组。

序列共享一套通用工具——索引、用 in 做成员检查、用 len() 取长度,以及切片(第 5 节)。它们还有方法(method),即附着在对象上、用点号调用的函数。

核心概念:方法

方法是隶属于某个对象、并以点号在其上调用的函数,例如 my_list.append(4)。由于 列表是可变的,许多列表方法会原地修改列表;元组是不可变的,因此没有这类方法。

大多数列表方法原地改动列表——它们修改调用者本身,并且通常返回 None 而不是一个新列表。这一点值得说两遍,因为每个人都会栽一次:l.append(4) 给你的是 None,不是那个列表。

你会一直用到的有四个:append()(在末尾添加一个元素)、extend()(把另一个可迭代对象的所有元素加进来)、reverse()(就地反转顺序)和 pop()(移除并返回某个索引处的元素,默认是最后一个)。

示例:四个列表方法
l = [1, 2, 3]

l.append(4)         # 加进来一个元素
print(l)            # [1, 2, 3, 4]

l.extend([5, 6])    # 实参的每一个元素都被加进来
print(l)            # [1, 2, 3, 4, 5, 6]

l.reverse()         # 原地反转;返回 None
print(l)            # [6, 5, 4, 3, 2, 1]

last = l.pop()      # 移除并“返回”最后一个元素
first = l.pop(0)    # ……或者某个索引处的元素
print(last, first, l)   # 1 6 [5, 4, 3, 2]

print(l.append(9))  # None —— 这个方法改动了 l,什么也没返回

appendextend 的差别是最需要留神的:append 把它的实参当作单独一个元素加进来,所以 l.append([5, 6]) 会把一个列表塞进你的列表里,而 extend 会把它拆开。两个都试一遍,比较一下。

易错点:(5) 不是元组

l.extend((5)) 会失败,报 TypeError: 'int' object is not iterable(5) 里的 括号只是用来分组的——它并不造出一个元组。造出元组的是那个逗号(5,) 才是 一个单元素元组,l.extend((5,)) 就能用。这也正是 type((5))int、而 type((5,))tuple 的原因。

现在把列表和元组并排放着看。凡是只读取的操作两者都行;凡是要改动的只有列表行。整张表都由这一条规则生出来。

操作 l = [1, 2, 3] t = (4, 5, 6) 为什么
l[1] —— 按索引读取 读取从不改动
1 in l —— 成员检查 读取
len(l) 读取
l[1] = 99 —— 赋值 TypeError 改动了对象
del l[1] TypeError 改动了对象
.append() .extend() .reverse() .pop() AttributeError 元组没有这些方法
l += [6, 7] id 不变 id 改变 见第 4 节

请仔细看最后一行:+= 对两者都合法,但对各自意味着不同的事情。对列表,它原地改动那个对象,所以标识不变;对元组,它只能造出一个全新的对象。第 4 节会回到这一点,因为它最清楚地展示了可变性到底意味着什么代价。

2. 集合 课件

如果说序列精心记着元素的顺序,那么集合则刻意把顺序忘掉。集合是一组互不相同的元素的无序汇集,与数学中的集合相呼应:{1, 2}{2, 1, 1} 是同一个集合。集合是可变的,它不可变的对应物是 frozenset。它擅长两件事:快速的成员检查与去重。

下面的示例一次展示这两件事,以及如何从另一个集合体构建集合。

示例:创建集合与去重
seen = {1, 2, 2, 3}
print(seen)                  # {1, 2, 3} —— 重复的 2 没了
print(2 in seen)             # True —— 成员检查很快

nums = set([1, 1, 2, 3, 3])  # 从列表构建集合
print(nums)                  # {1, 2, 3}
print(set())                 # set() —— 空集合({} 是空字典!)

因为集合与数学集合相呼应,它支持那些熟悉的集合运算,每一种都既有方法形式、也有运算符形式。

示例:集合运算
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}
print(a ^ b)        # 对称差   {1, 2, 5, 6}
print({1, 2} <= a)  # 子集判断 True

集合的元素必须是可哈希的(hashable),这正是集合能装数字、字符串、元组、却不能装列表的原因。当你需要一个不可变的集合——例如用作字典的键或另一个集合的成员——就用 frozenset

深入了解:“可哈希”是什么意思?

一个对象如果有一个在其生命周期内永不改变的哈希值,它就是可哈希的;正是这一点让 Python 能把它放进那张令集合成员检查和字典查找飞快的内部表里。不可变的内置类型(数字、 字符串、只含可哈希元素的元组)是可哈希的;可变的(列表、字典、集合)则不是——这正是 为什么 list 不能做集合元素或字典键,而 frozenset 可以。这就是第 4 节那条 可变/不可变界线的实际回报。

课堂练习:集合
  1. numbers = [1, 2, 2, 3, 4, 4, 5] 得到一个只含唯一值的列表。
  2. a = {1, 2, 3, 4}b = {3, 4, 5, 6},求出在 ab、但不同时在两者中的值。
  3. 试着把一个列表加入集合,读读那个错误;再换成元组试试,看看它能成功。

3. 映射:字典 课件

三大家族中的最后一个干脆抛开位置,转而把每个值与一个你自定的键配对。字典(dict保存键—值对,是日常 Python 中最重要的容器。它是可变的,让你按键、而非按位置来查找一个值。

下面的示例用两种方式创建字典,查找一个值,并分别添加和更新一个键值对。

示例:创建并使用字典
scores = {"Ada": 95, "Bob": 88}   # 用花括号写键: 值
also   = dict(Ada=95, Bob=88)     # 用 dict() 构造函数
print(scores["Ada"])              # 95 —— 按键找到,而非按位置
scores["Cleo"] = 91               # 添加一个新键值对
scores["Ada"] = 100               # 更新一个已有的
print(scores)

[] 查找一个不存在的键会引发 KeyError。当某个键可能不在时,get() 返回 None(或你给定的默认值)而不会崩溃——而 update() 则把另一个字典合并进来。

示例:安全查找与合并
scores = {"Ada": 95}
print(scores.get("Bob"))               # None —— 不崩溃
print(scores.get("Bob", 0))            # 0 —— 你选的默认值
scores.update({"Bob": 88, "Ada": 100}) # 合并:加入 Bob,更新 Ada
print(scores)

要遍历字典,就迭代它的 keys()values()items()——最后一个会把键和值一并交给你,等你在 1.3 用到 for 循环时会不断用到它。

易错点:字典的键必须可哈希

键必须是可哈希的(见集合里的“深入了解”),所以你可以用数字、字符串或元组作键—— 但绝不能用列表。而则可以是任何对象。

课堂练习:字典
  1. 建一个把三个名字映射到年龄的字典,然后打印某个人的年龄。
  2. get() 查找一个不存在的名字,返回 "unknown" 而不崩溃。
  3. 添加一个新人,再更新一个已有的人的年龄。

3.1 字典来自文件:JSON

字典的意义不止于你自己的代码,因为程序之间交换结构化数据最常用的那种格式,看上去几乎就是一部字典。JSON(JavaScript Object Notation)是由名称和值搭起来的纯文本,而标准库能把它直接读成一个 dict

这里出现了两样新语法,两样都值得现在就认识。open() 给你一个文件对象,而 with 语句保证它会被重新关上——哪怕中途出错也一样。写成 with open(...) as f: 就是 Python 里打开文件的方式;不用 with 打开又忘了关,是一个真实存在、只是发作得慢的 bug。

假设 settings.json 的内容是:

{"course": "Python", "year": 2026, "topics": ["objects", "collections"]}

那么:

import json

with open("settings.json", "r") as f:      # 'with' 替你关掉这个文件
    setting_dict = json.load(f)            # JSON 文本 -> 一部真正的 dict

print(setting_dict)
print(setting_dict["year"])                # 2026
print(setting_dict.items())                # dict_items([...]) —— 键值对

结果就是一部普通的字典;json.load 并没有自己的什么特殊类型。也请注意,嵌在里面的 ["objects", "collections"] 变成了一个普通的 Python 列表,所以整个结构都是由你已经认识的那些容器搭起来的。

最后那一行有点意思。setting_dict.items() 打印出来并不像一个列表——它显示成 dict_items([...])。它是一个视图(view):一扇朝着这部字典的活窗口,而不是它的一份副本。改动这部字典,视图也跟着变。这是你第一次瞥见一条贯穿 1.4 的线索——Python 常常交给你一个描述数据的东西,而不是数据的第二份拷贝。

示例:不用文件的 json

这一页上的可运行单元没有文件系统可读,所以请用 json.loads(带一个 s,表示 string)来解析你手里已有的文本。结果和 json.load(f) 会给你的完全一样:

import json

text = '{"course": "Python", "year": 2026, "topics": ["objects", "collections"]}'
setting_dict = json.loads(text)

print(type(setting_dict))        # <class 'dict'>
print(setting_dict["topics"])    # ['objects', 'collections']
print(setting_dict.items())

setting_dict["year"] = 2027      # 视图会反映这次改动
print(setting_dict.items())

4. 可变性与标识 课件 课件

我们在 1.1 的两条轴中已经见过可变性;现在手里有了容器类型,可以好好讲讲它了,因为它悄悄支配着赋值、比较、乃至字典键的行为。

列表与元组之间那道根本性的区别,会在好几种操作里显现出来。下面的示例表明:两者都能按索引读取元素,但只有列表允许你重新赋值或删除元素。(那行会在元组上失败的语句被注释掉了——取消注释即可看到错误。)

示例:只有列表能被修改
l = [1, 2, 3]
t = (4, 5, 6)

print(l[0], t[0])  # 读取对两者都有效
l[0] = 100         # 可以:列表是可变的
del l[1]           # 可以
print(l)
# t[0] = 100       # TypeError:元组是不可变的

可变性还解释了 += 的一个微妙行为。下面的示例表明:对可变的列表,+= 原地修改对象,因此其标识不变;对不可变的元组,+= 必须构造一个全新的对象,因此其标识改变。

示例:+= 与对象标识
l = [1, 2, 3]; before = id(l); l += [4]; print(before == id(l))  # True  (同一个对象)
t = (1, 2, 3); before = id(t); t += (4,); print(before == id(t)) # False (新对象)
易错点:两个名称,同一个可变对象

因为名称只是一张标签,b = a 会让两个名称指向同一个对象。如果那个对象是可变的, 通过一个名称所做的改动,会从另一个名称看到:

a = [1, 2, 3]
b = a
b.append(4)
print(a)   # [1, 2, 3, 4] —— 意外吧?

这种别名(aliasing)对不可变对象无害(你本就无法改变它们),却是可变对象的一大 经典 bug 来源。

从图上看,两个名称共享同一个列表,因此通过 b 所做的修改会从 a 看到:

memory: 堆内存 objects: o1: list [1, 2, 3, 4] @ 0x5f3a20 names: a -> o1 b -> o1
名称指向对象:a 和 b 是同一个列表上的两张标签,所以通过任一名称修改它,都会同时改变另一个名称看到的内容。

这里还有一个实际的回报:正是不可变性,让一个对象能被用作字典的键或集合的成员。试着拿一个列表当键,Python 会拒绝,因为键不能在字典脚下悄悄改变。

深入了解:可变/不可变的界线是本质的吗?

一半是。在语言层面,可变性是一个真实、可观察的契约,每一种 Python 实现都遵守它, 也正是它使得不可变对象可被哈希(从而能当字典键或集合成员)。但这条界线也有诚实的 灰色地带。元组是不可变的,可是一个装着列表的元组,却允许你修改里面的那个列表—— 元组自身的引用没有变,但引用所指向的东西可以变。而且,一个类型之所以不可变,往往是 一种设计与实现上的取舍(安全、共享、优化、哈希),而非什么深刻的定律。所以:把可变性 当作一个真实而有用的概念,但要明白这条线是 Python 的设计者画下的,底层有 C 语言层面的 机制在支撑——并非由数学颁布。

课堂练习:可变性实践
  1. 把一个列表的第二个元素设为 -1,确认它成功;再对一个元组做同样的事,读读那个错误。
  2. id() 查看一个列表在原地修改前后的标识——它保持不变吗?然后用一句话解释:为什么对元组的“修改”需要一个新对象。

5. 切片:读取子序列 课件

索引读取一个元素;切片(slicing)读取一整段子序列。其写法是 sequence[start:stop:step],其中 start 是要包含的第一个索引(默认 0),stop 是要排除的第一个索引,step 是步长(默认 1;负步长表示反向)。冒号是必需的,三个数字各自可选。

下面的示例用几种方式对一个列表切片——改改数字再运行,建立直觉。

示例:对列表切片
xs = list(range(10))  # [0, 1, ..., 9]
print(xs[2:5])        # [2, 3, 4]
print(xs[:3])         # [0, 1, 2]
print(xs[::-1])       # [9, 8, ..., 0] —— 反转
课堂练习:这些是切片吗?

对下面每一项,判断它写成 seq[…] 时是否是一个有效的切片。如果是,说出它选中了 哪些元素;如果不是,说明问题出在哪里。先自己推一遍,再用一个真实的列表 (比如 seq = list(range(10)))验证。

  1. 1:2:1
  2. 2:4:7
  3. 9:1:-1
  4. a:b:c
  5. 1.5:2.3:3.14
  6. a:2:3
  7. 6:7
  8. :-5:-1
  9. ::-1

其中有两项,取决于语法之外的某样东西——说说是什么。

深入了解:切片本身也是一个对象

写法 start:stop:step 会构造一个 slice 对象,你也可以显式地创建并复用它:

s = slice(1, 10, 2)
print(list(range(20))[s])  # [1, 3, 5, 7, 9]

所以 slice 是一种类型,而 xs[1:10:2]xs[slice(1, 10, 2)] 的语法糖。

小结

容器是 Python 程序存放数据的地方。把任何内置容器放到 1.1 的两条轴上——它装什么它能不能变

类别 类型 可变?
序列 list / tuplerange list 可变,tuple/range 不可变
集合 set / frozenset set 可变,frozenset 不可变
映射 dict 可变

可变性不只是一个标签:它解释了别名现象、+= 的行为,以及为什么只有不可变(可哈希)的对象才能当字典键或集合成员。有了类型(1.1)与容器(1.2)在手,1.3 控制流程 将让它们动起来——遍历它们、检验它们。