Python itertools 详解:迭代器代数与惰性求值
Python 的 for 循环和生成器已经把「按需逐个产出」做得很顺手,但遇到「把两个序列交错」「取前 N 个」「按 key 分组」「笛卡尔积」这类组合操作,手写循环既啰嗦又容易错。标准库的 itertools 提供了一批构建和组合迭代器的工具,像代数运算一样把简单的迭代器拼成复杂的数据流。
它的设计哲学是惰性求值–所有函数都返回迭代器,不预先计算全部结果,只在被消费时才产出元素。这意味着它可以处理无限序列、超大文件,内存占用恒定。本文按「无限迭代器 -> 有限组合 -> 过滤与映射 -> 分组 -> 排列组合 -> 实用配方」的顺序拆解,重点讲清楚每个工具解决了什么问题、惰性体现在哪里、什么时候不该用。
无限迭代器:count、cycle、repeat
这三个函数产出的迭代器没有终点,必须靠外部的 break 或 islice 截断。
1 | |
count 常用于给元素配编号(zip(itertools.count(1), items) 替代 enumerate 的优势是可指定起始值和步长,且不绑定某个可迭代对象);cycle 适合轮询、交替着色;repeat 和 [elem] * n 的区别在于惰性–repeat 只是一个迭代器,按需产出,喂给 map/zip 时更省内存。注意 cycle 会缓存整个序列,不要对大序列用它。
合并与切片:chain、islice、zip_longest
chain:把多个序列首尾相连
1 | |
比 [1,2] + [3,4] 优势在惰性:不创建中间列表,逐个产出。处理多个大文件时尤其有用:
1 | |
当要拼接的序列本身在一个可迭代对象里时,用 chain.from_iterable,避免 chain(*nested) 解包破坏惰性:
1 | |
chain.from_iterable 是展平一层嵌套的标准写法(只展一层,不递归)。要递归展平任意深度,得用生成器:
1 | |
islice:对迭代器切片
生成器和迭代器没有下标,不能直接 [start:stop:step]。islice 填补这个空缺:
1 | |
islice 会丢弃 start 之前的元素(为了定位),所以对无限迭代器也能用。但它不支持负索引(迭代器没法从末尾算起)。经典用法–从无限序列取前 N 个:
1 | |
zip_longest:不等长序列的拉链
内建 zip 在最短序列耗尽时停止,zip_longest 填充到最长序列耗尽:
1 | |
fillvalue 默认 None。当序列长度不一致且不能丢数据时(如对齐两份数据表)用它。
过滤:takewhile、dropwhile、filterfalse、compress
这组函数按条件筛选元素,但筛选发生在「迭代过程中」而非「预先计算」,所以对无限序列也适用。
1 | |
关键区别:takewhile 在条件首次失败时立即停止;dropwhile 在条件首次失败时开始放行(包括后续重新满足条件的元素)。这和 filter 不同–filter 会遍历整个序列逐个判断。
dropwhile 的典型场景是跳过文件开头的前导无效数据:
1 | |
compress 用一个「布尔序列」当掩码,保留对应位置为真的元素,等价于 [d for d, s in zip(data, selectors) if s],但在掩码也是流式生成时保持惰性:
1 | |
映射与归约:accumulate、starmap、pairwise
accumulate:累积运算
1 | |
accumulate 产出「前 i 个元素的累积结果」序列,常用于前缀和、运行最大值。第二个参数可以是任何接收两个参数的函数,3.8+ 可加 initial。
starmap:解包后再 map
map 把每个元素整体传给函数;starmap 先把每个元素(通常是元组)解包成多个参数再传:
1 | |
当数据是「参数元组列表」时,它比 map + lambda 解包更清晰。
pairwise:相邻两两配对(3.10+)
1 | |
用于计算差分、滑动窗口、状态转移。计算相邻时间戳的间隔:
1 | |
分组:groupby
groupby 把连续的相同 key 元素分到一组。注意「连续」二字–它不排序,只把相邻的相同 key 合并。
1 | |
想要「全局按 key 分组」,必须先排序让相同 key 连续:
1 | |
「全局分组」其实用 collections.defaultdict 更直接:
1 | |
| 方式 | 需要排序? | 惰性? | 适合 |
|---|---|---|---|
groupby(排序后) | 是 | 是 | 数据已有序、或能接受排序开销 |
defaultdict 分组 | 否 | 否(全量收集) | 通用分组,最常用 |
groupby 的价值在「数据天然有序」时–比如日志按时间排列要按小时分组,此时不必排序,一次遍历搞定且保持惰性。
陷阱:group 迭代器会失效。 groupby 内部共享一个底层迭代器,一旦前进到下一组,前一组的 group 迭代器就失效了。所以必须在拿到 group 时立即消费(转成 list 或处理掉),不能攒着后面再遍历:
1 | |
排列组合:product、permutations、combinations
这组函数生成笛卡尔积、排列、组合,是算法题和枚举场景的利器。都返回迭代器,但结果数量往往是组合爆炸级的。
1 | |
product 替代多层嵌套 for,repeat 参数用于「同一集合重复取」;permutations 不指定 r 时默认取全部(数量 n!)。数量预警:
| 函数 | 数量公式 | n=10, r=3 |
|---|---|---|
product(A, repeat=r) | n^r | 1000 |
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 | |
实用配方(recipes)
itertools 文档里有一组经典配方,把基本工具组合成更高层的模式,展示了「迭代器代数」的威力–简单的积木能拼出复杂的逻辑。
滑动窗口(任意大小,pairwise 是大小为 2 的特例):
1 | |
分块(按固定大小切片,用于批量处理):
1 | |
这里 [iter(iterable)] * n 配合 zip_longest 也是一种写法(同一个迭代器的 n 个引用,zip 每次各取一个实际是从同一迭代器取 n 个),但海象版更直观且最后一块不会补 None。
唯一化(去重但保留首次出现顺序,且支持 key):
1 | |
带 key 的版本能按「归一化后的值」去重,比 set 灵活得多(set 既不保序也不支持 key)。这些配方展示了 itertools 的精髓:用 count/cycle/chain/islice/zip_longest 几个基本积木,能拼出相当复杂的数据流处理逻辑,且全程保持惰性。
性能与惰性
itertools 的所有函数都返回迭代器,这是它性能优势的根源:
1 | |
写法 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 再切(大数据不行) |
| 不等长 zip | zip_longest | - |
| 按前缀条件截取/跳过 | takewhile / dropwhile | filter(语义不同) |
| 累积运算 | 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 循环往往更清楚。

