跳转至

第 6 章 元组与序列通论

配套例题:BISHI24 谐距下标对 来源:菜鸟教程 Python3 元组、Python3 数据结构(元组和序列 / 遍历技巧)

这一章有两条主线。

第一条是元组:一个「不可变的列表」。它在竞赛里的价值不在于「防止误改」, 而在于可哈希——只有可哈希的东西才能当 dict 的键、set 的元素, 而 dict/set 是记忆化、去重、状态判重的基础设施。

第二条是序列通论listtuplestrrangebytes 共享一套操作协议。 把这套协议一次学透,后面所有容器都不用重新学。


6.1 元组的创建

t = (1, 2, 3)
t = 1, 2, 3                     # 括号可以省!逗号才是元组的标志
t = ()                          # 空元组
t = tuple([1, 2, 3])            # 从可迭代对象构造
t = tuple("abc")                # ('a', 'b', 'c')

决定「这是不是元组」的是逗号,不是括号。 括号只是用来消歧义的。

单元素元组必须加逗号

t = (50)        # ❌ 这是整数 50,括号只是普通的运算符括号
type(t)         # <class 'int'>

t = (50,)       # ✅ 长度为 1 的元组
type(t)         # <class 'tuple'>

t = 50,         # ✅ 同上,括号可省

这个坑最常见的触发场景是 % 格式化

"%s" % (1, 2)       # ❌ TypeError:被当成两个参数
"%s" % ((1, 2),)    # ✅ 输出 '(1, 2)'
以及函数返回值——return x, 会返回一个单元素元组,而不是 x。多打一个逗号足以毁掉一道题。

括号什么时候不能省

f(1, 2)             # 两个参数
f((1, 2))           # 一个参数,是元组
a = 1, 2            # a 是元组
a = [(1, 2), (3, 4)]        # 列表里的元组必须带括号

规则:元组作为更大表达式的一部分时,括号是必须的。


6.2 不可变到底意味着什么

t = (1, 2, 3)
t[0] = 9            # ❌ TypeError: 'tuple' object does not support item assignment
t.append(4)         # ❌ AttributeError:元组根本没有 append
del t[0]            # ❌

元组只有两个方法:t.count(x)t.index(x),都是 \(O(n)\)

「修改」元组只能造新的:

t = t + (4,)        # (1, 2, 3, 4)   O(n),循环里用就是 O(n²)
t = t[:2] + (9,) + t[3:]        # 替换第 2 个元素

需要频繁修改就别用元组。元组的不可变不是为了省事,是为了换来「可哈希」。

不可变是「浅」的

这一点必须说清楚:不可变指的是「元组里存的那些引用不能变」, 而不是「引用指向的对象不能变」。

t = ([1, 2], 3)
t[0].append(9)      # ✅ 合法!改的是列表,不是元组
print(t)            # ([1, 2, 9], 3)

t[0] = [5]          # ❌ 这才是修改元组本身

后果直接体现在哈希上:

hash((1, 2))            # ✅ 有值
hash(([1], 2))          # ❌ TypeError: unhashable type: 'list'

元组可哈希 ⟺ 它的每个元素都可哈希。 只要里面藏了一个 list/dict/set, 整个元组就不能当字典键。

类型 可变 可哈希 能当 dict 键 / set 元素
int float str bool None
tuple(元素全可哈希)
tuple(含 list 等)
frozenset
list dict set bytearray

6.3 序列通用操作

Python 里的序列是一族类型,它们共享下面这张表。 只要一个类型是序列,这些操作就都能用——不管它是 listtuplestr 还是 range

操作 含义 list tuple str range
s[i] 索引(支持负数)
s[i:j:k] 切片 ✅(返回 range)
len(s) 长度
s + t 拼接
s * n 重复
x in s 成员判断 \(O(n)\) \(O(n)\) \(O(n)\) \(O(1)\)
min(s) max(s) 极值
sum(s) 求和
s.index(x) 首个下标
s.count(x) 出现次数
s < t 字典序比较
for x in s 迭代
reversed(s) 反向迭代器
s[i] = x 赋值

x in range(...)\(O(1)\),因为 range 用数学公式判断而不是遍历。 所以 if i in range(n) 不会 TLE——但它仍然比 if 0 <= i < n 慢(有函数调用开销), 竞赛里还是写链式比较。

字典序比较

序列之间用 <> 比较,规则是逐元素比较,第一个不同的元素决定结果; 若一方是另一方的前缀,则短的更小

(1, 2) < (1, 3)         # True    第二个元素 2 < 3
(1, 2) < (1, 2, 0)      # True    前缀相同,短的小
[1, 2, 3] < [1, 3]      # True
"abc" < "abd"           # True
(1, "a") < (1, 2)       # ❌ TypeError:str 和 int 没法比

这条规则是元组做多关键字排序的全部原理,见 6.6。


6.4 解包

解包(unpacking)是 Python 相对 C++ 最舒服的语法之一。

基本解包

a, b = 1, 2                     # 右边先打包成元组 (1,2),再拆给左边
a, b = b, a                     # 交换,不需要临时变量
x, y, z = [1, 2, 3]             # 任何可迭代对象都能解包
a, b = map(int, input().split())    # 竞赛第一高频写法

左右个数必须严格相等,否则 ValueError: not enough values to unpack

星号解包(PEP 3132)

first, *rest = [1, 2, 3, 4]     # first=1,  rest=[2,3,4]
*init, last = [1, 2, 3, 4]      # init=[1,2,3], last=4
a, *mid, b = [1, 2, 3, 4, 5]    # a=1, mid=[2,3,4], b=5

带星号的变量永远收集成 list(哪怕右边是元组),且最多只能有一个星号。

竞赛里的典型用法——输入行的第一个数是长度,后面是数组

n, *a = map(int, input().split())       # 一行搞定「先读 n 再读 n 个数」

嵌套解包

(a, b), c = (1, 2), 3

for i, (x, y) in enumerate(points):     # 边遍历边拆坐标
    ...

for u, v, w in edges:                   # 边表:起点、终点、权
    ...

星号在函数调用处:展开参数

print(*a)                       # 等价于 print(a[0], a[1], ...),空格分隔
print(*a, sep="\n")             # 每个元素一行
f(*args, **kwargs)              # 展开位置参数和关键字参数
zip(*matrix)                    # 矩阵转置,见 6.5

print(*a) 是输出数组最短的写法,但它比 sys.stdout.write(" ".join(map(str, a))) 慢。数据量大时换掉。


6.5 zipenumerate 与遍历技巧

enumerate:同时拿下标和值

for i, x in enumerate(a):
    ...
for i, x in enumerate(a, 1):        # 下标从 1 开始
    ...

for i in range(len(a)): x = a[i] 更快也更短——省掉了每次的下标索引。 凡是循环体里同时需要 ia[i] 的,一律用 enumerate

zip:并行遍历多个序列

for x, y in zip(a, b):
    ...
for i, (x, y) in enumerate(zip(a, b)):
    ...

三个必须知道的性质:

  1. zip 以最短的那个为准,多出来的部分直接丢弃,不报错
list(zip([1, 2, 3], [4, 5]))        # [(1, 4), (2, 5)]   第 3 个被静默丢弃

这是个安静的 bug 来源。Python 3.10 加了 strict=True 参数会在长度不等时报错, 但本教程环境是 3.9,不能用。请自己确认长度。

  1. zip(*matrix) 是转置
g = [[1, 2, 3], [4, 5, 6]]
# * 把每一行拆成一个独立实参,等价于 zip([1,2,3], [4,5,6]);
# zip 逐位取一个元素打成一组,取出来的正好是原矩阵的列
list(zip(*g))                       # [(1, 4), (2, 5), (3, 6)]
[list(r) for r in zip(*g)]          # zip 给的是元组,要 list 就再转一次
  1. zip(a, a[1:]) 取相邻元素对
for x, y in zip(a, a[1:]):          # (a0,a1), (a1,a2), ...
    diff = y - x

判断数组是否单调、求相邻差分都靠它:

is_sorted = all(x <= y for x, y in zip(a, a[1:]))

其它遍历工具

reversed(a)                     # 反向迭代器,不复制(比 a[::-1] 省内存)
sorted(a)                       # 返回排好序的新列表
sorted(set(a))                  # 去重后排序 —— 离散化的标准写法
any(x > 0 for x in a)           # 存在
all(x > 0 for x in a)           # 全部
sum(x > 0 for x in a)           # 计数(bool 是 int 的子类,见第 2 章)

dict 的遍历:

for k in d: ...                 # 遍历键
for v in d.values(): ...
for k, v in d.items(): ...      # 最常用

6.6 元组的两大竞赛用途

用途一:可哈希的复合键

vis = set()
vis.add((x, y))                 # 二维坐标判重
if (x, y) in vis: ...

memo = {}
memo[(i, j, k)] = ans           # 多维记忆化

cnt = {}
cnt[(a, b)] = cnt.get((a, b), 0) + 1        # 对「数对」计数

list 做不到这些(unhashable),元组是唯一选择。

性能提示:元组键要为每个元素算哈希再合并,比整数键慢 2–3 倍。 坐标范围已知时,编码成一个整数更快

vis.add(x * m + y)              # 二维压一维
vis.add((x << 20) | y)          # 位移编码,见 46 章
\(10^6\) 级别的 BFS 里这个优化很值。但代码可读性下降,先写对再优化。

用途二:多关键字排序

因为元组按字典序比较(6.3),把关键字按优先级从高到低塞进元组就完成了多关键字排序:

# 按分数降序,分数相同按名字升序
a.sort(key=lambda p: (-p.score, p.name))

# 按第 0 列升序,第 1 列降序
a.sort(key=lambda t: (t[0], -t[1]))

# 直接排元组列表:默认就是按第 0 列、再第 1 列……
pairs.sort()

-x 这个取反技巧只对数字有效。字符串要降序只能分两次排(利用 Timsort 的稳定性):

a.sort(key=lambda p: p.name)                # 先按次关键字升序
a.sort(key=lambda p: p.score, reverse=True) # 再按主关键字降序(稳定,不打乱上一步)
详见 12-自定义排序

同样的原理让元组能直接进堆:

import heapq
heapq.heappush(h, (dist, node))         # 按 dist 排,dist 相同再比 node

35-优先队列与堆

元组 vs 列表:怎么选

维度 tuple list
可变
可哈希 (元素也可哈希时)
创建速度 略快(常量元组会被编译期缓存) 略慢
内存 略小(无预留容量) 略大
方法数 2 个 11 个

选择标准很简单:要放进 set/dict 就用元组,要修改就用列表,其余场景差异可以忽略。


6.7 例题:BISHI24 谐距下标对(入门,排序 / 计数)

给定长度 \(n \le 10^5\) 的数组 \(a\)。若 \(i < j\)\(a_j - a_i = j - i\), 则 \((i,j)\) 是一对谐距下标对。求这样的下标对数量。 题面见 BISHI24 原题(牛客)

关键是把条件移项\(a_j - a_i = j - i\) 两边同时减 \(a_j\)\(i\),等价于:

\[a_i - i = a_j - j\]

于是问题变成:\(b_i = a_i - i\),统计有多少对下标满足 \(b_i = b_j\)

\(b\) 值分组,若某个值出现了 \(c\) 次,它贡献 \(\dbinom{c}{2} = \dfrac{c(c-1)}{2}\) 对。答案就是求和:

\[\text{ans} = \sum_{v} \binom{\text{cnt}[v]}{2}\]
import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    cnt = {}
    for i, tok in enumerate(data[1:1 + n]):     # enumerate 同时拿到下标 i 和值
        k = int(tok) - i                        # b_i = a_i - i
        cnt[k] = cnt.get(k, 0) + 1
    print(sum(c * (c - 1) // 2 for c in cnt.values()))


main()

验证样例:\(a = [1,2,3,4,5,6]\),每个 \(b_i = a_i - i\)(0 起始下标)都等于 \(1\), 所以 \(\text{cnt}[1] = 6\),答案 \(\binom{6}{2} = 15\) ✅。

复杂度\(O(n)\),一遍扫描。

三个本章 / 相邻章的要点:

  • enumerate 是这题的天然工具——条件里同时出现下标和值,正好一次拿全。
  • 下标从 0 还是从 1 无所谓。整体平移 \(b_i\) 不改变「哪些 \(b_i\) 相等」, 所以不用纠结题面是 1-indexed。这类「差值不变量」的题都有这个性质。
  • 答案会超过 \(2^{31}\)\(n = 10^5\) 且全部相等时答案约 \(5 \times 10^9\)。 C++ 要开 long longPython 的 int 无上限,天然不用管—— 这是 Python 打笔试的一个实打实的优势,见 02-数据类型与转换

为什么不能暴力两重循环? \(n = 10^5\)\(\binom{n}{2} = 5 \times 10^9\) 对, C++ 都要跑几十秒。看到「统计满足某等式的下标对数量」, 第一反应就是移项,把 \(i\) 的信息和 \(j\) 的信息分到等号两边,然后哈希计数。 这个套路在 36-哈希与字符串哈希42-前缀和与差分 里会反复出现。

collections.Counter 可以写得更短(见 07-字典):

from collections import Counter

cnt = Counter(int(x) - i for i, x in enumerate(data[1:1 + n]))
print(sum(c * (c - 1) // 2 for c in cnt.values()))

6.8 本章速查

场景 写法
单元素元组 (50,)逗号不能少
元组的本质 逗号才是标志,括号只是消歧义
交换变量 a, b = b, a
读「n 后跟 n 个数」 n, *a = map(int, input().split())
取首 / 尾 first, *rest = a / *init, last = a
展开成实参 print(*a)zip(*matrix)
同时取下标和值 for i, x in enumerate(a):
并行遍历 for x, y in zip(a, b):按最短截断
相邻元素对 zip(a, a[1:])
矩阵转置 list(zip(*g))
判是否有序 all(x <= y for x, y in zip(a, a[1:]))
二维坐标判重 vis.add((x, y));追求速度用 x * m + y
多维记忆化 memo[(i, j)] = v
多关键字排序 key=lambda t: (t[0], -t[1])
堆中带权元素 heappush(h, (dist, node))
元组可哈希条件 所有元素都可哈希;含 list 就不行
去重 + 排序(离散化) sorted(set(a))
反向遍历不复制 reversed(a)