AI 技术博客
返回首页
Python 基础 18 分钟阅读

itertools 起航:无限迭代器与组合工具

Pythonitertools迭代器惰性求值标准库

引言

第十一章《迭代器与生成器》走到第四篇。前三篇我们只使用了语言自带的迭代能力:第一篇讲清了 iter()/next() 与 for 循环的实现原理,第二篇用 yield 手工制造生成器,第三篇深入了 send/close/throw 与生成器表达式。但它们都是”手工造轮子”——真实工程里最常见的迭代需求,Python 早就在标准库中准备好了成套工具,这就是本篇的主角 itertools(迭代工具模块)。

本篇要做的事有三件:第一,认识 itertools 的三大工具族——无限迭代器(count/cycle/repeat)、终止迭代器(accumulate/chain/compress/dropwhile/takewhile/filterfalse/pairwise 等)、组合生成器(product/permutations/combinations/combinations_with_replacement);第二,掌握”截断无限迭代器”的核心技巧 islice,没有它,无限迭代器在大多数场景里都无法落地使用;第三,每个工具配一个可与真实验证的最小例子,输出全部在本机 Python 3.12 实测。下一篇将在这张全景图的基础上深入 groupby/tee/starmap 等进阶实战,第六篇则把迭代器组织成流式数据管道——本篇是它们的共同地基。

前置知识:迭代协议与 for 的原理来自第一篇;yield 与惰性求值来自第二篇;生成器表达式来自第三篇。读者应当已经理解”迭代器是一次性消费、惰性求值”这两个基本事实,本篇的每一个工具都建立在这两点之上。

概念与原理

itertools 是什么:一批返回迭代器的工厂函数

itertools 是 Python 标准库模块,其官方文档把它定位为”创建高效迭代器的函数集合”(Functions creating iterators for efficient looping)。注意”高效”二字:itertools 中绝大多数函数在 CPython 中是用 C 实现的,它们不构造中间列表、不预先计算,而是返回一个迭代器对象,元素在被 next() 驱动时才逐个产生。这与我们第二篇写过的生成器函数在行为上完全一致——你完全可以自己用 yield 写出等价的实现,itertools 的价值在于这些实现经过了高度优化、边界情况处理完备,并且有统一的函数名可供查阅。

从用途上,itertools 的二十来个工厂函数可以分成三族。第一族是无限迭代器:count、cycle、repeat,它们不吃输入、也没有终点,只能靠 islice 或 takewhile 之类的”闸门”掐断。第二族是终止迭代器:它们吃掉一个或多个有限可迭代对象,产出一个(通常是等长或更短的)结果流。第三族是组合生成器:在有限输入上枚举排列、组合与笛卡尔积,输出长度由公式决定。

惰性带来的两个推论

所有 itertools 工具都是惰性的,这会带来两个重要推论。推论一:itertools 函数调用本身几乎不花时间、不占内存,真正的开销发生在消费端。x = count() 这一行执行耗时可以忽略;而 list(islice(count(), 10_000_000)) 才会分配一个存放一千万个整数的列表。推论二:返回的对象是”一次性”迭代器,消费完就空,不能像列表那样重复遍历。这两点贯穿整个模块,也是本篇易错点部分的主题。

为什么需要无限迭代器

直觉上”无限”像是灾难:任何程序都不应该跑不完。但真实场景中,无限迭代器是”生成器表达式”式需求的抽象——我们经常需要的不是一个真正的无限序列,而是”不知道要多长、按需取”的序列。典型例子:为每条请求分配自增 ID,用 count(1) 表达”从 1 开始递增下去,取到哪算哪”,消费方用 islice 限定取多少个;模拟时钟走针用 cycle(['北', '东', '南', '西']);计算语义里用 repeat(0.5, 5) 表示”同一参数算五次”。无限迭代器把”持续供给”这个语义本身封装成对象,消费方决定何时停止。

操作与实现

islice:无限迭代器的”闸门”

先看最关键的截断技巧。islice(iterable, stop) 与前两个参数组合的 islice(iterable, start, stop[, step]) 做的是”对迭代器切片”——只消费并产出指定范围内的元素,超出即止。它和列表切片 list[start:stop:step] 语法几乎一致,但两点本质不同:第一,islice 作用于任意迭代器(包括生成器),而列表切片只能切列表;第二,islice 是惰性的,不复制任何数据。下面的例子生成”从 10 开始、步长 2”的等差序列,只取前 5 个:

from itertools import count, islice

# count(10, 2) 会无限产出 10, 12, 14, ...,靠 islice 掐断
first_five = islice(count(10, 2), 5)
print(list(first_five))

# count 的 step 可以是负数:倒计时
print(list(islice(count(5, -1), 4)))
[10, 12, 14, 16, 18]
[5, 4, 3, 2]

iterate-take 这个模式(is_iterable + 截断 = 有限结果)是 itertools 使用频率最高的套路,下一篇的 zip_longest、第六篇的分块处理都会复用。注意 islice 的 start/stop/step 不支持负数:islice('ABCDE', 2, 0, -1) 会抛出 ValueError: Step for islice() must be a positive integer or None,这与列表切片第 -1 步可以逆序完全不同——迭代器只能向前走,无法回头。

cycle 与 repeat:循环与复读

cycle(iterable) 无限循环重放输入序列;repeat(object[, times]) 无限次(或指定次数)返回同一个对象。注意两者内存语义的差异:cycle 为了重放,必须先把输入序列缓存起来,输入越长缓存越大,所以对长序列做 cycle 要当心内存;repeat 则不同,它始终返回对同一个对象的引用,不复制内容:

from itertools import cycle, repeat, islice

# cycle 循环播放,取前 6 个元素
print(list(islice(cycle("AB"), 6)))

# repeat: 同一种子对象重复 n 次;不写次数则无限
print(list(repeat("ok", 3)))
print(list(islice(repeat(7), 4)))
['A', 'B', 'A', 'B', 'A', 'B']
['ok', 'ok', 'ok']
[7, 7, 7, 7]

repeat 的实用场景包括:给一组数据补上固定默认值(与 zip 配合),以及「相同参数提交多次重试」。repeat('ok', 3) 等价于 ['ok'] * 3,但后者会真的创建三个字符串引用并存进列表,前者不建列表——数据量大时差异可观。

accumulate:累积计算

accumulate(iterable[, func, *, initial=None]) 产出”前缀累积”序列:默认做加法,也可以传入任意二元函数做累积。它的原理与第二篇讲过的生成器完全同构——每次把「当前累计值」与「下一个元素」送入 func,产出新累计值。3.8 起支持 initial 参数,让首元素成为初始值之前的第一个输出:

from itertools import accumulate

print(list(accumulate([1, 2, 3, 4, 5])))          # 前缀和
print(list(accumulate([2, 3, 4], initial=10)))    # 带初始值的前缀和
print(list(accumulate([3, 1, 4, 1, 5], max)))     # 传入 max:前缀最大值
[1, 3, 6, 10, 15]
[10, 12, 15, 19]
[3, 3, 4, 4, 5]

第三个例子把 func 换成内置的 max,就得到了”每时点为止的历史最高值”——这在股票回测、用户留存曲线等场景中是常用操作。accumulate 的内存开销是 O(1):它只保留当前累计值,商汤实践中常用来替代手写循环。

chain 与 compress:拼接与按掩码筛选

chain(*iterables) 把多个可迭代对象首尾相接,产出一个连续序列;compress(data, selectors) 按 selectors(真值序列)保留 data 中对应位置为真的元素——作用等价于”用 0/1 掩码过滤”,但不会物化中间列表:

from itertools import chain, compress

print(list(chain([1, 2], [3], [4, 5])))

# 掩码压缩:A、C、E 保留,B、D、F 丢弃
print(list(compress("ABCDEF", [1, 0, 1, 0, 1, 0])))
[1, 2, 3, 4, 5]
['A', 'C', 'E']

注意 chain 的输入是”多个可迭代对象”本身,把列表拼起来的 [1,2]+[3]+[4,5] 会产生中间新列表(三次复制),chain 则不复制任何元素。compress 的典型场景是从”全量数据”和”稀疏的保留标志”里取交集,比如只导出被勾选的行。

takewhile / dropwhile / filterfalse:三种条件筛选

这三个工具是内置 filter 的变体,行为差异全在”何时开始/结束”上。takewhile(pred, iterable):从头开始,遇到第一个不满足条件的元素即停止(含该元素之前的所有元素);dropwhile(pred, iterable):跳过开头连续的满足条件元素,从第一个不满足处开始输出剩余全部;filterfalse(pred, iterable):输出所有不满足条件的元素(filter 的反面)。takewhile 与 dropwhile 的”条件只在头部生效”特性,常用于跳过注释、表头等前导杂讯:

from itertools import takewhile, dropwhile, filterfalse

print(list(takewhile(lambda x: x < 5, [1, 3, 5, 2, 8])))
print(list(dropwhile(lambda x: x < 5, [1, 3, 5, 2, 8])))
print(list(filterfalse(lambda x: x % 2, range(8))))
[1, 3]
[5, 2, 8]
[0, 2, 4, 6]

对比三个输出可以看清边界语义:takewhile 在 5 处(第一个 >= 5 的元素)卡住,5 本身不输出;dropwhile 在同一个位置开始输出,5 及之后全部保留;filterfalse 则与位置无关,只按谓词过滤。内置 sorted/filter 常用于无序过滤,takewhile/dropwhile 专用于”有序数据的前缀处理”——比如 sorted 文件里只取开头一段。

pairwise:相邻元素配对

pairwise(iterable) 在 Python 3.10 加入,产出相邻元素组成的二元组(i0,i1)、(i1,i2)……,适用于计算相邻差值、检查序列连续性与游程编码等:

from itertools import pairwise

print(list(pairwise([1, 2, 3, 4])))
[(1, 2), (2, 3), (3, 4)]

它的实现思路是:用一个缓存变量记住上一个元素,每次 yield (上一个, 当前)。这也解释了为什么长度不足 2 的输入产出空结果——没有”上一个”可配。

zip 与 zip_longest:并行打包

zip 与 itertools.zip_longest 都是把多个序列”按位置打包成元组”,区别在于长度不齐时:zip 默默截断到最短序列(3.10 起可用 strict=True 让它报错),zip_longest 则以 fillvalue 补足短序列。第六篇的流式处理会大量用到它们,这里先看对比:

from itertools import zip_longest

print(list(zip("ABC", [1, 2])))
print(list(zip_longest("ABC", [1, 2], fillvalue="?")))
[('A', 1), ('B', 2)]
[('A', 1), ('B', 2), ('C', '?')]

product:笛卡尔积

product(*iterables, repeat=1) 产出各输入的笛卡尔积(直接乘积),repeat 参数表示对同一组输入重复相乘的次数。product(A, repeat=n) 等价于 A×A×…×A 共 n 次,这正是”n 位密码空间""全部抛硬币结果”的数学模型:

from itertools import product

print(list(product("AB", range(2))))
print(list(product("HT", repeat=3)))   # 3 次抛硬币的全部 8 种结果
[('A', 0), ('A', 1), ('B', 0), ('B', 1)]
[('H', 'H', 'H'), ('H', 'H', 'T'), ('H', 'T', 'H'), ('H', 'T', 'T'), ('T', 'H', 'H'), ('T', 'H', 'T'), ('T', 'T', 'H'), ('T', 'T', 'T')]

permutations / combinations:排列与组合

最后是组合族三兄弟。permutations(iterable, r):从元素里取出 r 个的有序排列(P(n,r)=n!/(n-r)!);combinations(iterable, r):取出 r 个的无序组合(C(n,r)=n!/(r!(n-r)!));combinations_with_replacement(iterable, r):允许元素重复取用的组合(C(n+r-1,r)):

from itertools import permutations, combinations, combinations_with_replacement

print(list(permutations("ABC", 2)))
print(list(combinations("ABC", 2)))
print(list(combinations_with_replacement("ABC", 2)))
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]
[('A', 'B'), ('A', 'C'), ('B', 'C')]
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'B'), ('B', 'C'), ('C', 'C')]

对照输出理解三类差异:前两者都是 6 个但内容不同——(‘A’,‘B’) 与 (‘B’,‘A’) 在排列中同时出现、在组合中只出现一次(顺序被忽略);combinations_with_replacement 则允许 (‘A’,‘A’) 这种重复选取。它们在参数调优中的用法将在下一篇展开。

易错点与陷阱

陷阱一:把无限迭代器直接转成列表

list(count()) 不会报错,它会一直生产直到内存耗尽,进程可能被操作系统杀掉。这是无限迭代器最常见的误用。正确姿势永远是先截断再消费:list(islice(count(), n))、list(takewhile(lambda x: x < 100, count()))。同理,zip(count(), range(5)) 这类”让无限迭代器作为长度源”的用法是安全的(zip 按最短截断),但任何不加闸门的全量消费都是定时炸弹。

陷阱二:islice 不支持负数,且参数校验严格

列表切片可以 lst[::-1] 逆序、lst[-3:] 取尾部,islice 全部做不到:负数 step 直接抛 ValueError,负数 start 也抛 ValueError(Indices for islice() must be None or an integer: 0 <= x <= sys.maxsize)。迭代器只能单向前进是底层事实(next 没有”回退”),所以”取倒数 3 个”必须先把序列物化为列表或改用 deque 等其他方案。

陷阱三:把 itertools 结果当成可复用序列

第十四篇之前我们用过的列表可以多次遍历:for x in lst 两次都完整。itertools 工具返回的迭代器则是一次性的——it = islice(count(), 3) 后 list(it) 两次,第二次输出是空列表([0, 1, 2] 之后 [])。第三篇生成器表达式也有同样的性质。若确需复用,用 list() 物化(接受内存代价),或用下一篇要讲的 tee 克隆。

陷阱四:pairwise 输出”相邻”而非”全部两两组合”

pairwise([1,2,3,4]) 产出 (1,2)、(2,3)、(3,4) 三条,而组合 semantics 的 combinations([1,2,3,4], 2) 有六条。新手常把 pairwise 当”两两组合”用,在计算差值时得到错误的统计口径。选型口诀:需要相邻关系用 pairwise,需要任意配对用 combinations。

小结

itertools 是标准库中”返回迭代器的工厂函数集合”,全部惰性求值、零中间列表。本篇覆盖了三族工具:无限迭代器 count/cycle/repeat 靠 islice 截断落地;终止迭代器 accumulate/chain/compress/takewhile/dropwhile/filterfalse/pairwise 处理有限流;组合族 product/permutations/combinations/combinations_with_replacement 枚举笛卡尔积、排列与组合。所有输出均在 Python 3.12 实测。掌握这些函数,你手上的迭代能力从”能写”升级为”有工具箱”。

练习与思考题

  1. 用 count 与 islice 生成前 10 个偶数的平方,即 [4, 16, 36, …, 400],再改用 accumulate 能否得到同样的结果(注意 accumulate 不做平方)?说出两者各自适合的场景。
  2. takewhile(lambda x: x < 5, [1, 3, 5, 2, 8]) 输出 [1, 3],而 filter(lambda x: x < 5, …) 输出 [1, 3, 2]。验证两个输出,并解释为什么 takewhile 丢掉了 2——这个差异在”处理有序前缀”时是特性,在”普通过滤”时是陷阱,请各举一例。
  3. 不运行代码,写出 combinations(“ABCD”, 2) 的完整输出;随后用 combination 数量公式 C(4,2)=6 核对。再把 r 改为 3,观察数量如何变为 4——这提示组合工具在”枚举特征子集”时的增长规律。
  4. 阅读 itertools 官方文档(Python 3.12 版),找出本篇未介绍的 batched 与 starmap,说明各自参数含义,并预测 batched(range(10), 4) 的输出。