Skip to content
This page has been auto-translated and may contain errors.View in English

元组和集合

你已经了解了列表。Python 还有两种集合类型可以解决列表无法解决的问题。元组保存一组固定的值,这些值永远不会改变。集合只保存唯一的值,并且无论集合多大,都能让你立即检查成员关系。

Python 的集合工具箱有四种类型。列表和字典处理大多数通用场景。元组和集合解决特定场景:不可变性成为优势的固定记录,以及优先考虑 O(1) 成员测试的唯一值集合。

除了 listdict,Python 还提供了 tuple(不可变的固定长度序列)和 set(基于哈希表的、由唯一可哈希对象组成的无序集合)。每种类型都有独特的内存模型、可哈希性特征和性能特点,在选择之前值得了解。

元组

元组是一组有序的值,创建后无法更改。圆括号用于定义元组,但它们是可选的。真正使其成为元组的是逗号。单元素元组需要一个尾随逗号。

元组是不可变的序列。是逗号而不是圆括号创建了元组。不可变性使其在所有元素都可哈希时也可哈希,从而开启了列表无法填补的使用场景:字典键、集合成员和固定结构记录。

tuple 是由固定大小 C 数组支持的不可变序列。当所有元素都可哈希时,__hash__ 由元素哈希值计算得出,使元组可作为字典键和集合成员。__getitem__ 支持整数和切片;未实现 __setitem__,因此任何修改尝试都会引发 TypeError。单元素形式 (42,) 需要尾随逗号;没有它,圆括号只是分组。

python
point      = (10, 20)
rgb        = (255, 128, 0)
dimensions = (1920, 1080)
single     = (42,)            # 单元素元组需要尾随逗号
also_tuple = 42, 99           # 圆括号可选;逗号才使其成为元组

通过索引访问的方式与列表完全相同。尝试更改元素会引发 TypeError:

索引、切片和负索引的工作方式与列表完全相同。任何通过索引赋值的尝试都会引发 TypeError;这是有意为之,并非限制。

使用整数和 slice 对象的 __getitem__ 遵循与 list 相同的截断规则。没有 __setitem__:元组类型未注册它,因此 TypeError 在运行时引发,而非解析时。

python
point = (10, 20)
point[0]    # 10
point[1]    # 20
point[-1]   # 20

point[0] = 99    # TypeError: 'tuple' object does not support item assignment

何时使用元组

当你有一小组相关的值,它们属于一个整体且不会改变时,使用元组。坐标 (x, y)、颜色 (r, g, b)、姓名-分数对 ("小明", 87)。这种固定结构向阅读代码的人传达了这组值被视为一个单一单元。

元组传达固定结构:一组值,其中位置具有意义,并且整组被视为一个单元。它们的可哈希性使其可作为字典键,这是列表做不到的。元组所表示的契约是:这些值属于一组,并且不应该改变。

元组是定长记录的惯用类型。其可哈希性使其可用于任何需要 Hashable 的地方:字典键、集合成员、functools.lru_cache 调用签名。其语义契约与列表不同:元组表示一个异构记录,其中位置具有意义;列表表示一个同构序列,其长度和顺序可以变化。

python
locations = {}
locations[(40, -74)] = "New York"   # 元组作为字典键,有效
locations[[40, -74]] = "New York"   # 列表作为字典键,TypeError

解包

解包将元组中的值取出,并在一行中将每个值赋给对应的名称。名称的数量必须与值的数量匹配。使用 * 将剩余元素捕获到一个列表中。

解包适用于任何可迭代对象:元组、列表、字符串。目标名称的数量必须与可迭代对象的长度匹配,除非带星号的目标捕获可变长度的切片。不匹配会引发 ValueError。解包是从函数获取多个返回值的惯用方式。

解包会调用右侧的 __iter__,并将每个产出的值绑定到对应的目标名称。带星号的目标(*rest)将剩余元素收集到 list 中。不匹配在运行时引发 ValueError。扩展解包也适用于 for 头部:for x, y in list_of_pairs 会解包每个迭代项。

python
point = (10, 20)
x, y  = point

print(x)   # 10
print(y)   # 20

first, *rest = [1, 2, 3, 4, 5]
# first = 1, rest = [2, 3, 4, 5]

head, *middle, tail = [1, 2, 3, 4, 5]
# head = 1, middle = [2, 3, 4], tail = 5

命名元组

命名元组是每个位置都有名称的元组。无需记住 point[0] 是 x 坐标,你可以写 point.x。值仍然是不可变的;只是你获得了可读的属性名称而不是数字位置。

namedtuple 生成一个类,其行为与元组完全相同,但增加了命名属性访问。它比完整的类更轻量、不可变且自文档化。当普通元组的位置访问需要注释才能理解时,使用它。

collections.namedtuple 是一个类工厂:它在运行时生成一个 tuple 子类,编译进命名属性访问。生成的类包含 _asdict()_replace()_fields。内存占用与普通元组相同。如需更多控制(默认值、类型注解、可选可变性),dataclasses.dataclass 是现代替代方案;对于带类型注解的元组,typing.NamedTuple 是惯用做法。

命名元组的导入

namedtuple 在 Python 的标准库中,但需要导入。from collections import namedtuple 是本课程中的第一个导入。导入将在模块章节中完整讲解。

python
from collections import namedtuple

Point  = namedtuple("Point",  ["x", "y"])
Player = namedtuple("Player", ["name", "score", "level"])

p = Point(10, 20)
p.x    # 10
p.y    # 20

alice = Player("小明", 87, 5)
alice.name    # "小明"
alice.score   # 87

集合

集合是一组没有保证顺序的唯一值。两次添加相同的值不会有任何效果:集合只保留每个元素的一个副本。使用花括号创建带元素的集合,或使用 set() 创建空集合。

set 是一个自动拒绝重复项的无序集合。无论大小如何,成员测试都是 O(1),这使其成为需要检查某值是否存在于大型集合中时的合适工具。注意:{} 创建的是空字典而不是空集合;空集合请使用 set()

set 是由哈希表支持的、由唯一可哈希对象组成的集合。成员测试、插入和删除的平均时间复杂度均为 O(1)。迭代顺序反映内部哈希位置,在不同运行间不稳定。只有可哈希对象才能成为成员:intstrtuple 可以;listdictset 不可以。{} 被解析为空字典字面量,而非集合。

python
tags     = {"python", "beginner", "tutorial"}
numbers  = {1, 2, 3, 4, 5}
empty    = set()    # 不是 {}(那是空字典)

两次添加相同的值不会改变集合:

python
tags.add("python")   # tags 未变,"python" 已经在其中

何时使用集合

集合适用于三种场景:从列表中删除重复项、快速检查某物是否在大型集合中,以及比较两组以找出它们的共同点或差异。

驱动集合使用的有三种不同的场景:去重(插入时自动)、O(1) 成员测试(相对于 list 的 O(n))和集合运算(|&-^)。当集合很大且频繁检查成员关系时,性能差异显著。

三种典型的集合用例直接映射到哈希表的特性:唯一性(插入时拒绝重复)、O(1) 平均 __contains__(哈希查找)和集合代数(类似位运算的运算符调用 dunder 方法)。O(1) 成员测试在实践中最为重要:对包含 10,000 个元素的集合使用 in 与对包含 10 个元素的集合一样快。

python
# 从列表中删除重复项
raw    = ["cat", "dog", "cat", "bird", "dog", "cat"]
unique = list(set(raw))   # ["cat", "dog", "bird"](顺序不保证)
python
# 快速成员检查
valid_codes = {"USD", "EUR", "GBP", "JPY"}
code        = "EUR"

if code in valid_codes:    # 即使有数千个代码,也是即时查找
    print("Valid")

集合操作

集合支持你在数学中学过的相同操作:并集(任一集合中的所有元素)、交集(两个集合共有的元素)和差集(一个集合中有而另一个没有的元素)。Python 使用运算符符号表示这些操作,每种操作都有等价的方法形式。

Python 的集合运算符映射了数学符号:| 表示并集,& 表示交集,- 表示差集,^ 表示对称差集。每个运算符都有方法形式(.union().intersection() 等),这些方法也接受任何可迭代对象,而不仅是集合。

集合运算符调用 dunder 方法:| 调用 __or__,& 调用 __and__,- 调用 __sub__,^ 调用 __xor__。运算符要求两个操作数都是集合,否则引发 TypeError。方法形式接受任何可迭代对象并在内部转换。原地形式(|=&=-=^=)修改左操作数,等价于 .update().intersection_update() 等。

python
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

a | b    # {1, 2, 3, 4, 5, 6}   (并集:任一集合中的元素)
a & b    # {3, 4}               (交集:两个集合都有的元素)
a - b    # {1, 2}               (差集:在 a 中但不在 b 中)
b - a    # {5, 6}               (反向差集)
a ^ b    # {1, 2, 5, 6}        (对称差集:仅在其中之一)

这些也有方法形式:.union().intersection().difference().symmetric_difference()

修改集合

集合是可变的。.add() 添加一个元素。.update() 从任何列表或其他可迭代对象一次添加多个元素。.remove() 删除一个元素,但如果不存在则引发错误。.discard() 如果元素存在则静默删除,不存在则什么都不做。

.add() 平均时间复杂度为 O(1)。.update() 接受任何可迭代对象,等同于循环调用 .add().remove() 在未命中时引发 KeyError,与 dict.__delitem__ 类似。当不确定元素是否存在时,.discard() 是安全的选择。.pop() 删除任意元素,而不是"最后"一个,因为集合没有顺序。

.add(x)x 进行哈希并插入到表中:平均 O(1)。.update(iterable) 等价于 |=.remove() 在未命中时引发 KeyError.discard() 先进行哈希查找,未命中时跳过删除。.pop() 删除由内部哈希表状态决定的任意元素,而非按插入顺序。

python
tags = {"python", "beginner"}

tags.add("tutorial")          # 添加一个元素
tags.update(["web", "api"])   # 从任何可迭代对象添加多个元素
tags.remove("beginner")       # 删除,如果未找到则引发 KeyError
tags.discard("missing")       # 删除,未找到时无错误
tags.pop()                    # 删除并返回任意元素
tags.clear()                  # 删除所有元素

当你不确定元素是否存在时,使用 .discard()

冻结集合

冻结集合是创建后无法修改的集合。使用它的主要原因是:冻结集合是可哈希的,所以可以用作字典键或存储在其他集合中。

frozensetset 的不可变对应类型。它支持所有读操作和集合代数,但不支持修改。其不可变性使其可哈希,意味着它可作为字典键或另一个集合的成员。

frozenset 实现了 __hash__,由元素哈希的排序归约计算得出,给它一个稳定的哈希值。所有返回新集合的集合代数运算符和方法都受支持;未定义修改方法(addremove 等)。frozenset 是常量查找表的正确类型,这些查找表在运行时不得改变,并且可能需要用作字典键。

python
valid_statuses = frozenset({"active", "paused", "deleted"})
valid_statuses.add("archived")    # AttributeError,frozenset 是不可变的

选择合适的集合类型

四种类型,每种都有明确的角色。问问你需要对数据做什么,正确的选择通常就会变得明显。

集合类型之间的选择取决于哪些操作重要以及你的数据有哪些约束:可变性、顺序、重复处理和查找策略。

集合类型的选择是性能与语义的决策。dictset 通过哈希提供 O(1) 平均查找。listtuple 提供 O(1) 索引访问,但成员测试为 O(n)。tuple 的不可变性换来了可哈希性。frozensettuple 是标准库中两种可哈希的复合类型。

listtuplesetdict
有序是(插入顺序)
可变
允许重复否(键)
访问方式索引索引不适用
使用场景有序、可变的序列固定记录唯一值、快速成员测试键值查找

快速决策规则:

  • 需要按名称查找? → dict
  • 需要可修改的有序集合? → list
  • 有一组固定的相关值? → tuple
  • 需要唯一值或快速成员测试? → set

实际应用

使用元组存储固定记录,使用集合跟踪唯一值:

python
home   = (39.9042, 116.4074)   # 纬度,经度
office = (39.9155, 116.4322)

home_lat, home_lon = home
print(f"Home: {home_lat}, {home_lon}")

# 使用集合跟踪唯一访客
visitors = set()
visitors.add("小明")
visitors.add("小红")
visitors.add("小明")    # 已在集合中,静默忽略
visitors.add("小华")

print(f"Unique visitors: {len(visitors)}")
print(f"小明 visited: {'小明' in visitors}")
print(f"小李 visited:  {'小李' in visitors}")

使用集合跟踪已处理的内容并计算剩余工作:

python
already_processed = {"report_jan.csv", "report_feb.csv"}
all_files         = {"report_jan.csv", "report_feb.csv", "report_mar.csv", "report_apr.csv"}

to_process = all_files - already_processed
print(f"Files to process: {sorted(to_process)}")

for filename in sorted(to_process):
    print(f"Processing {filename}...")
    already_processed.add(filename)

print(f"Done. Total processed: {len(already_processed)}")

使用 frozenset 作为常量查找表,并演示集合代数下的 O(1) 成员测试:

python
ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
SAFE_METHODS    = frozenset({"GET", "HEAD", "OPTIONS"})

# 对 frozenset 的集合代数运算返回普通 set
unsafe_allowed = ALLOWED_METHODS - SAFE_METHODS
print(f"Non-safe allowed methods: {unsafe_allowed}")

# frozenset 是可哈希的,所以可以存储在集合中(普通 set 不可以)
method_groups = {
    frozenset({"GET", "HEAD", "OPTIONS"}),
    frozenset({"POST", "PUT", "PATCH"}),
    frozenset({"DELETE"}),
}
print(f"Method groups: {len(method_groups)}")

method = "POST"
print(f"Allowed: {method in ALLOWED_METHODS}")
print(f"Safe:    {method in SAFE_METHODS}")

frozenset 具有 O(1) 查找特性,并且可以存储在任何需要可哈希类型的地方。对两个 frozenset 对象的集合代数运算返回普通 set;将结果包装在 frozenset() 中以保持其不可变性。