Python 的 for 循环和生成器已经把「按需逐个产出」做得很顺手,但遇到「把两个序列交错」「取前 N 个」「按 key 分组」「笛卡尔积」这类组合操作,手写循环既啰嗦又容易错。标准库的 itertools 提供了一批构建和组合迭代器的工具,像代数运算一样把简单的迭代器拼成复杂的数据流。

它的设计哲学是惰性求值–所有函数都返回迭代器,不预先计算全部结果,只在被消费时才产出元素。这意味着它可以处理无限序列、超大文件,内存占用恒定。本文按「无限迭代器 -> 有限组合 -> 过滤与映射 -> 分组 -> 排列组合 -> 实用配方」的顺序拆解,重点讲清楚每个工具解决了什么问题、惰性体现在哪里、什么时候不该用

无限迭代器:countcyclerepeat

这三个函数产出的迭代器没有终点,必须靠外部的 breakislice 截断。

1
2
3
4
5
6
7
8
9
10
11
12
13
import itertools

# count(start, step):从 start 开始每次加 step,无限递增
for i in itertools.count(10, 2):
if i >= 20: break # 10, 12, 14, 16, 18, 20

# cycle:把一个有限序列无限重复(会先缓存整个序列到内存)
for color in itertools.cycle(['red', 'green', 'blue']): # red, green, blue, red, ...
...

# repeat(elem, times):把 elem 重复 times 次;不指定 times 则无限重复
list(itertools.repeat('X', 5)) # ['X', 'X', 'X', 'X', 'X']
list(map(pow, range(5), itertools.repeat(2))) # [0, 1, 4, 9, 16] -- 每个数平方

count 常用于给元素配编号(zip(itertools.count(1), items) 替代 enumerate 的优势是可指定起始值和步长,且不绑定某个可迭代对象);cycle 适合轮询、交替着色;repeat[elem] * n 的区别在于惰性–repeat 只是一个迭代器,按需产出,喂给 map/zip 时更省内存。注意 cycle 会缓存整个序列,不要对大序列用它。

合并与切片:chainislicezip_longest

chain:把多个序列首尾相连

1
list(itertools.chain([1, 2], [3, 4], [5]))   # [1, 2, 3, 4, 5]

[1,2] + [3,4] 优势在惰性:不创建中间列表,逐个产出。处理多个大文件时尤其有用:

1
2
for line in itertools.chain(*(open(f) for f in log_files)):
process(line)

当要拼接的序列本身在一个可迭代对象里时,用 chain.from_iterable,避免 chain(*nested) 解包破坏惰性:

1
list(itertools.chain.from_iterable([[1, 2], [3, 4], [5, 6]]))   # [1, 2, 3, 4, 5, 6]

chain.from_iterable 是展平一层嵌套的标准写法(只展一层,不递归)。要递归展平任意深度,得用生成器:

1
2
3
4
5
6
7
8
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item

list(flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]

islice:对迭代器切片

生成器和迭代器没有下标,不能直接 [start:stop:step]islice 填补这个空缺:

1
2
3
4
gen = (x * x for x in range(100))
list(itertools.islice(gen, 5)) # [0, 1, 4, 9, 16] -- 前 5 个
list(itertools.islice(gen, 2, 6)) # [25, 36, 49, 64] -- 从第 2 个取 4 个
list(itertools.islice(itertools.count(), 0, 10, 2)) # [0, 2, 4, 6, 8]

islice 会丢弃 start 之前的元素(为了定位),所以对无限迭代器也能用。但它不支持负索引(迭代器没法从末尾算起)。经典用法–从无限序列取前 N 个:

1
2
3
4
5
6
7
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a + b

list(itertools.islice(fib(), 10)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

zip_longest:不等长序列的拉链

内建 zip 在最短序列耗尽时停止,zip_longest 填充到最长序列耗尽:

1
2
3
4
5
names = ['Alice', 'Bob', 'Carol', 'Dave']
scores = [90, 85]

list(zip(names, scores)) # [('Alice', 90), ('Bob', 85)] -- Dave 丢了
list(itertools.zip_longest(names, scores, fillvalue=0)) # ..., ('Carol', 0), ('Dave', 0)]

fillvalue 默认 None。当序列长度不一致且不能丢数据时(如对齐两份数据表)用它。

过滤:takewhiledropwhilefilterfalsecompress

这组函数按条件筛选元素,但筛选发生在「迭代过程中」而非「预先计算」,所以对无限序列也适用。

1
2
3
4
5
6
7
8
9
10
data = [1, 2, 3, 4, 1, 2]

# takewhile:条件为真就保留,遇到第一个 False 立即停止(不再看后面)
list(itertools.takewhile(lambda x: x < 3, data)) # [1, 2]

# dropwhile:条件为真就跳过,遇到第一个 False 后开始全部产出
list(itertools.dropwhile(lambda x: x < 3, data)) # [3, 4, 1, 2]

# filterfalse:保留使函数返回 False 的元素(filter 的反义)
list(itertools.filterfalse(lambda x: x % 2, range(10))) # [0, 2, 4, 6, 8] -- 偶数

关键区别:takewhile 在条件首次失败时立即停止dropwhile 在条件首次失败时开始放行(包括后续重新满足条件的元素)。这和 filter 不同–filter 会遍历整个序列逐个判断。

dropwhile 的典型场景是跳过文件开头的前导无效数据:

1
2
3
lines = ['# comment', '', '# another', 'data1', 'data2']
data_lines = itertools.dropwhile(lambda s: s.startswith('#') or s == '', lines)
list(data_lines) # ['data1', 'data2']

compress 用一个「布尔序列」当掩码,保留对应位置为真的元素,等价于 [d for d, s in zip(data, selectors) if s],但在掩码也是流式生成时保持惰性:

1
list(itertools.compress(['a', 'b', 'c', 'd', 'e'], [1, 0, 1, 0, 1]))   # ['a', 'c', 'e']

映射与归约:accumulatestarmappairwise

accumulate:累积运算

1
2
3
4
5
6
import operator

list(itertools.accumulate([1, 2, 3, 4])) # [1, 3, 6, 10] -- 默认累加
list(itertools.accumulate([1, 2, 3, 4], operator.mul)) # [1, 2, 6, 24] -- 累乘
list(itertools.accumulate([3, 1, 4, 1, 5, 9], max)) # [3, 3, 4, 4, 5, 9] -- 累积取最大
list(itertools.accumulate([100, -30, -20, 50], initial=1000)) # [1000, 1100, 1070, 1050, 1100] -- 运行余额

accumulate 产出「前 i 个元素的累积结果」序列,常用于前缀和、运行最大值。第二个参数可以是任何接收两个参数的函数,3.8+ 可加 initial

starmap:解包后再 map

map 把每个元素整体传给函数;starmap 先把每个元素(通常是元组)解包成多个参数再传:

1
2
pairs = [(2, 3), (4, 5), (6, 7)]
list(itertools.starmap(pow, pairs)) # [8, 1024, 279936] -- 内部对每个元素做 pow(*element)

当数据是「参数元组列表」时,它比 map + lambda 解包更清晰。

pairwise:相邻两两配对(3.10+)

1
list(itertools.pairwise([1, 2, 3, 4]))   # [(1, 2), (2, 3), (3, 4)]

用于计算差分、滑动窗口、状态转移。计算相邻时间戳的间隔:

1
2
timestamps = [0, 1.2, 3.0, 3.5, 5.1]
[b - a for a, b in itertools.pairwise(timestamps)] # [1.2, 1.8, 0.5, 1.6]

分组:groupby

groupby连续的相同 key 元素分到一组。注意「连续」二字–它不排序,只把相邻的相同 key 合并。

1
2
3
4
5
6
7
8
data = [('a', 1), ('a', 2), ('b', 3), ('a', 4), ('b', 5)]

for key, group in itertools.groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2)]
# b [('b', 3)]
# a [('a', 4)] ← 又出现一个 a 组,因为和前面的 a 不连续
# b [('b', 5)]

想要「全局按 key 分组」,必须先排序让相同 key 连续:

1
2
3
4
5
data.sort(key=lambda x: x[0])
for key, group in itertools.groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2), ('a', 4)]
# b [('b', 3), ('b', 5)]

「全局分组」其实用 collections.defaultdict 更直接:

1
2
3
4
from collections import defaultdict
groups = defaultdict(list)
for k, v in data:
groups[k].append(v) # {'a': [1, 2, 4], 'b': [3, 5]}
方式需要排序?惰性?适合
groupby(排序后)数据已有序、或能接受排序开销
defaultdict 分组否(全量收集)通用分组,最常用

groupby 的价值在「数据天然有序」时–比如日志按时间排列要按小时分组,此时不必排序,一次遍历搞定且保持惰性。

陷阱:group 迭代器会失效。 groupby 内部共享一个底层迭代器,一旦前进到下一组,前一组的 group 迭代器就失效了。所以必须在拿到 group 时立即消费(转成 list 或处理掉),不能攒着后面再遍历:

1
2
3
4
5
# 正确:立即消费
result = {k: list(g) for k, g in itertools.groupby('aaabbc')}

# 错误:先收集所有 (k, g) 再消费 g,此时每个 g 都是空的
pairs = list(itertools.groupby('aaabbc'))

排列组合:productpermutationscombinations

这组函数生成笛卡尔积、排列、组合,是算法题和枚举场景的利器。都返回迭代器,但结果数量往往是组合爆炸级的。

1
2
3
4
5
6
7
8
9
10
11
12
# product:笛卡尔积,等价于 ((a, b) for a in A for b in B)
list(itertools.product([1, 2], ['a', 'b'])) # [(1,'a'), (1,'b'), (2,'a'), (2,'b')]
list(itertools.product([0, 1], repeat=3)) # 8 个二进制组合

# permutations:排列(有序),从序列取 r 个考虑顺序
list(itertools.permutations([1, 2, 3], 2)) # 6 = P(3,2) 个

# combinations:组合(无序),取 r 个不考虑顺序
list(itertools.combinations([1, 2, 3], 2)) # 3 = C(3,2) 个

# combinations_with_replacement:允许同一元素多次选中
list(itertools.combinations_with_replacement([1, 2, 3], 2)) # 6 个

product 替代多层嵌套 forrepeat 参数用于「同一集合重复取」;permutations 不指定 r 时默认取全部(数量 n!)。数量预警:

函数数量公式n=10, r=3
product(A, repeat=r)n^r1000
permutations(A, r)n!/(n-r)!720
combinations(A, r)n!/(r!(n-r)!)120
combinations_with_replacement(A, r)(n+r-1)!/(r!(n-1)!)220

n=20 时 permutations 全排列是 20! ≈ 2.4×10^18,绝不可能遍历完。用这些函数前先估算规模。需要「按需取前几个」时配合 islice 截断:

1
list(itertools.islice(itertools.permutations(range(20)), 100))   # 只要前 100 个

实用配方(recipes)

itertools 文档里有一组经典配方,把基本工具组合成更高层的模式,展示了「迭代器代数」的威力–简单的积木能拼出复杂的逻辑。

滑动窗口(任意大小,pairwise 是大小为 2 的特例):

1
2
3
4
5
6
7
8
9
10
11
12
from collections import deque

def sliding_window(iterable, n):
it = iter(iterable)
window = deque(itertools.islice(it, n), maxlen=n) # deque 自动维护定长窗口
if len(window) == n:
yield tuple(window)
for item in it:
window.append(item)
yield tuple(window)

list(sliding_window(range(5), 3)) # [(0, 1, 2), (1, 2, 3), (2, 3, 4)]

分块(按固定大小切片,用于批量处理):

1
2
3
4
5
6
def chunked(iterable, n):
it = iter(iterable)
while chunk := list(itertools.islice(it, n)): # 海象运算符每次取 n 个
yield chunk

list(chunked(range(7), 3)) # [[0, 1, 2], [3, 4, 5], [6]]

这里 [iter(iterable)] * n 配合 zip_longest 也是一种写法(同一个迭代器的 n 个引用,zip 每次各取一个实际是从同一迭代器取 n 个),但海象版更直观且最后一块不会补 None

唯一化(去重但保留首次出现顺序,且支持 key):

1
2
3
4
5
6
7
8
9
10
def unique_everseen(iterable, key=None):
seen = set()
for element in iterable:
k = key(element) if key else element
if k not in seen:
seen.add(k)
yield element

list(unique_everseen([1, 3, 1, 2, 3, 2, 4])) # [1, 3, 2, 4]
list(unique_everseen(['A', 'a', 'B'], key=str.lower)) # ['A', 'B']

key 的版本能按「归一化后的值」去重,比 set 灵活得多(set 既不保序也不支持 key)。这些配方展示了 itertools 的精髓:用 count/cycle/chain/islice/zip_longest 几个基本积木,能拼出相当复杂的数据流处理逻辑,且全程保持惰性。

性能与惰性

itertools 的所有函数都返回迭代器,这是它性能优势的根源:

1
2
3
4
5
6
7
8
# 写法 1:每步生成中间列表,O(n) 内存和时间
result = [x * x for x in range(1_000_000) if x % 2 == 0]
top10 = result[:10]

# 写法 2:itertools 全程惰性,只算前 10 个
top10 = list(itertools.islice(
(x * x for x in range(1_000_000) if x % 2 == 0), 10
)) # 只计算 10 个平方,其余 999990 个根本没碰

写法 2 内存是 O(1)、时间是 O(10)。当数据源是无限序列或超大文件时,只有惰性方案才可行。

但要诚实:对已经全部在内存里的小数据,itertools 不一定比列表推导快,因为迭代器协议本身有函数调用开销。它的优势在「能处理大/无限数据」和「组合表达力」,而不是微基准上的速度。

陷阱

生成器只能消费一次itertools 返回的迭代器和所有生成器一样,耗尽即空。需要反复使用就转成 list/tuple 缓存,或用 itertools.tee 复制成两份(但 tee 会缓存已消费的元素,对大迭代器有内存代价,两个副本消费速度差异大时队列会无限增长,这种情况应改成「重新创建迭代器」)。

groupby 忘了排序–它只合并连续相同的 key。要全局分组,先按 key 排序,或直接用 defaultdict

组合爆炸permutations/product/combinations 的结果数量是阶乘或指数级的。调用前先估算 n!n^r,别把大 n 喂给它们,需要前几个时配合 islice 截断。

以为 accumulate 能并行–它是串行的,第 i 个结果依赖第 i-1 个。需要并行前缀和得用专门的算法,不属于 itertools 的范畴。

何时用 itertools,何时不用

场景用 itertools替代方案
拼接多个序列chain / chain.from_iterable[a] + [b](小数据可)
给迭代器切片islice转成 list 再切(大数据不行)
不等长 zipzip_longest-
按前缀条件截取/跳过takewhile / dropwhilefilter(语义不同)
累积运算accumulate手写循环
笛卡尔积 / 排列 / 组合product / permutations / combinations多层 for 循环
相邻配对pairwise手写生成器
全局分组defaultdict(更常用)groupby(数据已有序时)

经验法则:数据大或无限 -> itertools 的惰性是刚需,没得选;数据小且已在内存 -> 列表推导往往更易读,不必强上;要全局分组 -> defaultdict 比「排序 + groupby」更简单;要复杂组合 -> 把 itertools 当积木拼,参考官方 recipes。

总结

itertools 的核心思想是把迭代当作代数来运算chain 是加法(拼接),product 是乘法(笛卡尔积),islice 是切片,accumulate 是前缀归约。这些「运算」都是惰性的–不预先求值,只在被消费时产出,因此能处理无限序列和超大文件,内存占用恒定。

掌握 itertools 的关键不是背 API,而是建立「迭代器是可以代数运算的对象」这个心智模型。当你下次想写「多层 for 嵌套枚举参数」时,想想 product;想「取前 N 个」时,想想 islice;想「按相邻关系处理」时,想想 pairwise。这些选择累积起来,就是「Pythonic 数据处理」和「把所有东西先装进列表」之间的差距。

最后一条提醒:itertools 解决的是表达力和惰性,不是万能加速器。对小数据它不一定比列表推导快,对需要随机访问的场景它天生不擅长(迭代器没有下标)。它的主场是「流式、按需、组合」–契合这个主场时,它是最优雅的工具;不契合时,一个朴素的 for 循环往往更清楚。