← 返回题目列表

Python 的 itertools 模块有哪些常用工具?为什么说它省内存?

中等 第 21 / 27 题 更新于 2026/07/31
itertools迭代器惰性求值标准库

简化版

**itertools 是标准库里的「迭代器工具箱」——它提供的每个函数返回的都是「惰性迭代器」(用一个吐一个、不预先把结果都算出来放进列表),所以处理大数据或无限序列时内存是 O(1) 而不是 O(n)。**常用的三类:① 无限迭代器count(start, step)(无限计数)、cycle(iterable)(无限循环)、repeat(obj, n)(重复),必须配 islicetakewhile 之类截断,否则死循环;② 组合/切分现有序列chain(把多个序列首尾拼成一个,不复制数据)、islice(对迭代器切片)、takewhile / dropwhile(按条件截取/跳过)、compress(按布尔掩码过滤)、accumulate(累计求和/累计最值)、groupby相邻相同键分组)、pairwise(3.10+,相邻两两配对);③ 组合学product(笛卡尔积,替代多层嵌套 for)、permutations(排列)、combinations(组合)。最大的坑groupby 只按「相邻」分组,没排序就直接 groupby 会把同一个键切成好几段;其次是迭代器「一次性」——遍历完就空了,想复用得 tee 或转成列表。核心记忆:itertools = 惰性迭代器工具箱,省内存靠「用一个算一个」,groupby 必须先排序。

详细版

常用工具速查

分类函数作用例子
无限count(10, 2)10,12,14,…islice 截断
无限cycle('AB')A,B,A,B,…轮询/循环分配
无限repeat(0, 3)0,0,0补位、配 map
连接chain(a, b)首尾拼接、不复制遍历多个列表
连接chain.from_iterable(ll)展开二维打平嵌套列表
切分islice(it, 2, 10, 2)迭代器切片大文件取第 N 行
条件takewhile / dropwhile满足条件时取/丢已排序数据提前停
条件compress(data, mask)按布尔掩码选掩码过滤
累计accumulate(xs, func)前缀和/前缀最值累计营收
分组groupby(xs, key)相邻同键分组必须先排序
配对pairwise(xs)(3.10+)(x0,x1),(x1,x2)…求差分
组合学product / permutations / combinations笛卡尔积/排列/组合参数网格、抽样
from itertools import (count, cycle, islice, chain, accumulate,
                       groupby, takewhile, product, combinations, tee)

# ① 无限迭代器必须截断
print(list(islice(count(10, 2), 5)))        # [10, 12, 14, 16, 18]
print(list(islice(cycle('AB'), 5)))         # ['A','B','A','B','A']

# ② chain 不复制数据,只是"接着遍历下一个"
a, b = [1, 2], [3, 4]
print(list(chain(a, b)))                    # [1, 2, 3, 4](没有新建大列表存中间结果)

# ③ accumulate 前缀和 / 前缀最大值
print(list(accumulate([3, 1, 4, 1, 5])))                 # [3, 4, 8, 9, 14]
print(list(accumulate([3, 1, 4, 1, 5], max)))            # [3, 3, 4, 4, 5]

# ④ groupby 的头号坑:只分"相邻"
data = [('a', 1), ('b', 2), ('a', 3)]
print({k: [v for _, v in g] for k, g in groupby(data, key=lambda x: x[0])})
# {'a': [3], 'b': [2]}   ← 'a' 被切成两段,后一段覆盖了前一段!
data.sort(key=lambda x: x[0])               # 必须先按同一个 key 排序
print({k: [v for _, v in g] for k, g in groupby(data, key=lambda x: x[0])})
# {'a': [1, 3], 'b': [2]} ← 正确

# ⑤ takewhile 在已排序数据上提前停(比 filter 扫全表快)
print(list(takewhile(lambda x: x < 4, [1, 2, 3, 9, 1])))  # [1, 2, 3](遇到 9 就停)

# ⑥ product 替代多层嵌套 for(做参数网格)
print(list(product([0.1, 0.01], ['adam', 'sgd'])))
# [(0.1,'adam'), (0.1,'sgd'), (0.01,'adam'), (0.01,'sgd')]
print(list(combinations('ABC', 2)))         # [('A','B'), ('A','C'), ('B','C')]

# ⑦ 迭代器是一次性的
g = (x for x in range(3))
print(list(g), list(g))                     # [0, 1, 2] []  ← 第二次是空的

⚠️ 两个必须记住的机制:① 惰性 = 省内存但「一次性」chainisliceaccumulate 返回的都是迭代器,不是列表——它们只在你 for 的时候一个一个吐值,中间不落地,所以处理 10GB 日志内存也不涨;代价是遍历一次就耗尽,第二次遍历得到空,也不能 len() 和索引。想反复用要么 list(...) 落地,要么 tee 复制。② groupby 是「压缩相邻重复」而不是「SQL 的 GROUP BY」。它内部只记住「当前键」,键一变就开新组,所以同一个键分散在序列各处时会被切成多段。正确用法永远是 xs.sort(key=f) 之后再 groupby(xs, key=f)(两个 key 必须是同一个);如果只是想做 SQL 式聚合、且不在乎顺序,用 collections.defaultdict(list) 一次遍历更直观也更不容易错。另外 groupby 吐出的分组 g 也是共享底层迭代器的惰性对象,一旦推进到下一组,上一组的 g 就失效了——所以要在组内立刻 list(g),别把 g 攒起来事后再用。

完整版教学

一、为什么要有 itertools:惰性 vs 落地

同一件事的两种写法(读 100 万行日志、取前 10 条 ERROR):

写法 A(落地):
  lines  = f.readlines()                 # 100 万行全进内存 → 几百 MB
  errors = [l for l in lines if 'ERROR' in l]   # 又建一个大列表
  top10  = errors[:10]                   # 只要 10 条,却算了全部
  峰值内存 ≈ 原始数据 + 中间列表

写法 B(惰性,itertools 思路):
  errors = (l for l in f if 'ERROR' in l)       # 生成器,不算
  top10  = list(islice(errors, 10))             # 拿够 10 条就停
  峰值内存 ≈ 一行的大小(O(1))
  实际扫描 ≈ 扫到第 10 条 ERROR 为止,不是全表

核心差别:
  落地:先把所有中间结果算出来放内存 → 内存 O(n)、计算 O(n)
  惰性:需要一个才算一个、随时可中断 → 内存 O(1)、计算"按需"

itertools 的每个函数都遵守两条规矩:
  ① 接受"可迭代对象"(列表、生成器、文件、另一个 itertools 结果)
  ② 返回"迭代器"(不是列表)→ 可以任意串联、串联不落地
  → 所以能拼成流水线:chain → filter → islice → map,全程只走一遍数据

itertools 的设计动机就一句话:把「先把中间结果全算出来放进列表」变成「需要一个才算一个」。上面的算例里,写法 A 为了拿 10 条 ERROR,付出了「100 万行全进内存 + 建两个大列表 + 遍历全表」的代价;写法 B 的峰值内存只有一行的大小,而且扫到第 10 条就停了。这个差别在数据量小时看不出来,数据量一大就是「能跑」和「OOM」的区别。更关键的是可组合性:因为每个函数都是「吃迭代器、吐迭代器」,你可以把 chain → 生成器过滤 → islice → map 串成一条流水线,数据从头到尾只走一遍、中间一个临时列表都不产生——这就是 Python 版的「流式处理」。

二、无限迭代器:count / cycle / repeat

count(start=0, step=1)  → start, start+step, start+2*step, ...  永不停止
cycle(iterable)         → 把序列无限循环(内部会缓存一份原序列!)
repeat(obj, times=None) → 重复 obj,times 省略就是无限

典型用法(都必须有"刹车"):
  islice(count(1), 5)                 → 1,2,3,4,5      (按个数刹车)
  takewhile(lambda x: x < 20, count(1, 3))  → 1,4,7,10,13,16  (按条件刹车)
  zip(count(1), ['a','b','c'])        → (1,'a'),(2,'b'),(3,'c')  (被短的一方刹车)
  cycle(['worker1','worker2','worker3']) → 轮询分配任务
  map(pow, range(5), repeat(2))       → 0,1,4,9,16     (repeat 当"常量参数列")

两个要注意的地方:
  ① cycle 会在内部保存一份原序列的副本 → 原序列很大时它并不省内存
     cycle(一个 100 万元素的生成器) → 内存里躺着 100 万个元素
  ② 忘记刹车 = 死循环 + 内存爆炸
     list(count(1))     # ✗ 程序卡死,内存持续增长直到 MemoryError
     for i in count():  # ✗ 循环体里必须有 break

无限迭代器解决的是「我不知道要多少个,边生成边判断」的场景:分页拉接口时用 count(1) 生成页码、直到某页返回空才 break;轮询多个下游节点用 cycle(nodes);给 map 补一列常量参数用 repeat(2)。它们本身不占内存count 只存当前值和步长),但必须有刹车——islice 按个数刹、takewhile 按条件刹、zip 靠另一边的短序列刹。唯一的例外是 cycle:它为了能「循环第二遍」,必须在内部缓存一份原序列,所以对超大序列用 cycle 并不省内存,这点常被忽略。

三、流水线工具:chain / islice / takewhile / compress / accumulate

chain(a, b, c)            首尾相接(不复制、不合并成新列表)
chain.from_iterable(lol)  展开一层嵌套:[[1,2],[3]] → 1,2,3
islice(it, stop) / islice(it, start, stop, step)
                          迭代器的"切片",但 ★不支持负数索引★(因为不能回头)
takewhile(pred, it)       从头取,第一次 pred 为假就停(后面全不要)
dropwhile(pred, it)       从头丢,第一次 pred 为假开始全要
compress(data, selectors) 按布尔掩码挑:compress('ABCD',[1,0,1,0]) → A,C
accumulate(it, func=add)  前缀累计:不断把"上一个结果"和"当前值"喂给 func

takewhile vs filter 的关键区别(在已排序数据上差别巨大):
  数据 = [1, 2, 3, 100, 4, 5](假设已按时间排序,要"小于 10 的前缀")
  filter(lambda x: x<10, 数据)     → 1,2,3,4,5   扫完全部 6 个元素
  takewhile(lambda x: x<10, 数据)  → 1,2,3       扫到 100 就停(只看 4 个)
  → 在 1000 万行、只需要开头一小段时,takewhile 是"提前退出",filter 是"全表扫描"

accumulate 的算例:
  accumulate([3, 1, 4, 1, 5])          → 3, 4, 8, 9, 14        (前缀和)
  accumulate([3, 1, 4, 1, 5], max)     → 3, 3, 4, 4, 5         (前缀最大值)
  accumulate([1,2,3], func=lambda a,b: a*b)  → 1, 2, 6         (前缀积)
  accumulate([1,2,3], initial=100)     → 100, 101, 103, 106    (3.8+ 指定初值)

这一组是「流水线」的主力。chain 常被误以为会合并出一个新列表,其实它只是记住「当前在第几个序列的第几个位置」,遍历完 a 就接着遍历 b,所以拼接 10 个百万级列表也是 O(1) 内存。islice 是迭代器版的切片,注意它不支持负数索引——迭代器不能回头,islice(it, -1) 直接报错,想取最后 N 个得用 collections.deque(it, maxlen=N)takewhilefilter 的区别值得单独记:filter 会扫完全部元素,takewhile 遇到第一个不满足就停,在「已排序、只要前缀」的场景(按时间排序的日志取今天之前的部分)能把 O(n) 变成 O(命中数)。accumulate 则是把「循环里维护一个累计变量」这个模式一行化,换个 func 就从前缀和变成前缀最值、前缀积。

四、groupby:为什么必须先排序

groupby 的真实语义 = "压缩连续重复段"(像 Unix 的 uniq -c),不是 SQL 的 GROUP BY

内部逻辑(伪代码):
  当前键 = 哨兵
  for 元素 in 序列:
      k = key(元素)
      if k != 当前键:        ← 只和"上一个"比,不查历史
          开一个新组
          当前键 = k
      把元素放进当前组

算例(销售记录按城市分组):
  data = [('北京',10), ('上海',20), ('北京',30)]

  ✗ 直接 groupby:
     北京 → [10]      第 1 条
     上海 → [20]      第 2 条(键变了,开新组)
     北京 → [30]      第 3 条(键又变了,★又开一个新组★)
     结果被切成 3 组;如果你用 dict 收集,后面的 '北京' 还会覆盖前面的 → 得到 {'北京':[30],'上海':[20]},数据丢了

  ✓ 先排序(用同一个 key):
     data.sort(key=lambda x: x[0])
     → [('上海',20), ('北京',10), ('北京',30)]
     上海 → [20]
     北京 → [10, 30]   ✓ 正确

第二个坑:分组对象 g 是"共享底层迭代器"的惰性视图
  groups = [(k, g) for k, g in groupby(data, key=f)]   # ✗ 存起来事后用
  → 循环推进到下一组时,上一组的 g 已经被"消费掉"了,事后 list(g) 得到空
  ✓ 组内立刻落地:[(k, list(g)) for k, g in groupby(data, key=f)]

什么时候该用 groupby、什么时候用 defaultdict:
  已排序 / 想按"连续段"切(如按状态切时间段、压缩连续重复)→ groupby(O(n)、不用额外内存)
  只想按键聚合、数据没排序 → defaultdict(list) 一遍搞定(O(n),比"排序 O(n log n) + groupby"更快也更不容易错)

groupby 是 itertools 里最容易写出 bug 的函数,因为名字让人想到 SQL 的 GROUP BY,实际语义却是 Unix 的 uniq——它只和「上一个元素的键」比较,键一变就开新组,从不回头查这个键之前出没出现过。上面的算例里,三条记录被切成了三组,用字典收集时后面的「北京」还会覆盖前面的,数据静悄悄地丢了,且不报错。所以铁律是:sort(key=f),再 groupby(xs, key=f),两个 f 必须是同一个。第二个坑是分组对象 g 的惰性:它和外层共享同一个底层迭代器,外层一往前走、内层就失效,因此必须在组内立刻 list(g)。反过来说,如果你的需求本来就是「按连续段切分」(把日志按状态切成若干时间段、把连续重复值压缩),那 groupby 正是最合适的工具,而且不需要排序。

五、组合学工具:product / permutations / combinations

product(A, B)           笛卡尔积,等价于两层嵌套 for(元素可来自同一位置重复)
product(A, repeat=3)    A×A×A,等价于三层嵌套 for
permutations(A, r)      排列:顺序敏感,(A,B) 和 (B,A) 算两个
combinations(A, r)      组合:顺序无关,只保留 (A,B)
combinations_with_replacement(A, r)  组合且允许重复取同一个元素

数量对照(记住这个表,能防"跑不完的循环"):
  n=5, r=3 时
  ┌───────────────────────────┬──────────────┬──────┐
  │ 函数                       │ 公式          │ 个数 │
  ├───────────────────────────┼──────────────┼──────┤
  │ product(A, repeat=3)       │ n^r = 5³      │ 125  │
  │ permutations(A, 3)         │ n!/(n-r)!     │  60  │
  │ combinations(A, 3)         │ C(n,r)        │  10  │
  │ combinations_with_repl.    │ C(n+r-1, r)   │  35  │
  └───────────────────────────┴──────────────┴──────┘

规模爆炸的现实感:permutations(range(12)) 有 4.79 亿个 → 别 list() 它
  惰性帮你的是"内存",帮不了你"时间":迭代器不会替你减少组合总数

实用例子:超参数网格搜索(替代三层嵌套 for)
  for lr, bs, opt in product([1e-2, 1e-3], [16, 32], ['adam', 'sgd']):
      train(lr, bs, opt)          # 2×2×2 = 8 组,一层循环写完

  ★ product 的一个隐藏行为:它会先把每个输入"物化"成元组
    (因为要反复重来一遍),所以传生成器给它并不省内存

这三个函数把「多层嵌套 for」和「手写递归枚举」压成一行。选择很简单:看顺序算不算数、能不能重复取——顺序算数用 permutations,不算数用 combinations,跨多个集合取一个用 product。上表要记住,因为组合数增长极快:permutations(range(12)) 有 4.79 亿项,即使是惰性的也要跑到天荒地老——惰性省的是内存,不是时间,这是新手最常见的误解。另外 product 为了能反复遍历后面的维度,会先把每个输入参数物化成元组,所以给它传生成器并不能省内存,这点和 chainislice 不同。

六、tee 的陷阱与「一次性」的代价

迭代器是一次性的:
  it = iter([1,2,3])
  list(it)  → [1,2,3]
  list(it)  → []          ← 已耗尽

想遍历两遍怎么办?
  ① 数据不大 → 直接 list(...) 落地,最简单
  ② 数据大、两个消费者"步调一致" → tee
  ③ 数据是文件 → f.seek(0) 重新读

tee(it, 2) 的内存陷阱:
  a, b = tee(it, 2)
  tee 内部维护一个"共享缓冲区":先走的那个读过的元素,
  必须留着给后走的那个 → 两者进度差多少,缓冲区就存多少

  list(a)              # a 一口气读完 100 万条
  for x in b: ...      # 此时缓冲区里已经攒了 100 万条!
  → 内存和"全部 list 出来"一样,没省到

  正确姿势:两个消费者交替前进(进度差小),缓冲区才小
  ★ 还有一条铁律:tee 之后就不要再碰原来的 it 了(会让两个分支都丢数据)

其他"一次性"的连带限制:
  len(迭代器)      ✗ TypeError(不知道长度)
  迭代器[0]        ✗ TypeError(不支持索引,用 next() 或 islice)
  if 迭代器:       ⚠ 永远为真(空迭代器也是真值!判空要用 next(it, 哨兵))

惰性的代价就是「一次性」,由此衍生出一串限制:不能 len()、不能索引、不能用 if it: 判空(空迭代器也是真值,这个坑会静默地让分支走错),正确判空是 next(it, None) is None。要遍历两遍时,先问「数据大不大」:不大就直接 list() 落地,别炫技;大且两个消费者步调一致才用 teetee 的原理是维护一个共享缓冲区,先跑的消费者读过的元素必须留着给后跑的,所以一旦一个跑到底、另一个还没开始,缓冲区里就攒了全部数据——内存和落地成列表一模一样,白折腾。另外 tee 之后原迭代器就不能再单独使用了,否则两个分支都会莫名其妙缺数据。

记忆钩子:「itertools = 惰性迭代器工具箱,一句话记三件事:①『吃迭代器、吐迭代器』所以能串成流水线、全程只走一遍数据、内存 O(1);②无限的(count/cycle/repeat)必须配刹车(islice 按个数、takewhile 按条件、zip 靠短边);③groupby 是 uniq 不是 SQL GROUP BY——只切相邻,必须先 sort(key=f) 再 groupby(key=f),且组内要立刻 list(g)。代价是『一次性』:不能 len、不能索引、if it 恒为真、遍历完就空;要复用先想 list() 落地,tee 只在两个消费者步调一致时才真的省内存。最后记住:惰性省的是内存不是时间,permutations(range(12)) 有 4.79 亿项,迭代器不会替你少算一个。」

七、常见误区与追问

  • 误区:groupby 就是 SQL 的 GROUP BY,直接用就能按键聚合。 它的真实语义是「压缩连续重复段」(等价于 Unix 的 uniq)——内部只拿当前元素的键和上一个元素的键比较,一旦不同就开新组,从不回头查这个键之前是否出现过;所以 [('北京',10),('上海',20),('北京',30)] 会被切成三组,用字典收集时后面的「北京」还会覆盖前面的,数据丢了却不报错。正确写法永远是先 data.sort(key=f)groupby(data, key=f)(两个 f 必须是同一个函数);如果只是想做键聚合、不在乎顺序,collections.defaultdict(list) 一次遍历 O(n) 更快也更不容易错(排序是 O(n log n))。
  • 误区:itertools 是惰性的,所以 permutations(range(12)) 也能轻松跑完。 惰性省的是内存,不是时间——permutations(range(12)) 共 12! = 479001600 项,迭代器只是让你不必把这 4.79 亿个元组同时放内存里,但你要一个个走完它们,仍然是 4.79 亿次循环;组合学函数的规模是阶乘/指数级增长的,用之前先按 n^rn!/(n-r)!C(n,r) 估一下量级,必要时用 islice 只取前若干个、或改用采样/剪枝算法。
  • 误区:chaincycle 一样都不占内存。 chain 确实不占——它只记录「当前在第几个输入的第几个位置」,遍历完一个接着下一个,拼接 10 个百万级列表也是 O(1) 附加内存;但 cycle 必须能「循环第二遍」,所以它会在内部缓存一份原序列的完整副本,对一个百万元素的生成器用 cycle,内存里就实实在在躺着一百万个元素。同理 product 也会把每个输入参数先物化成元组(因为后面的维度要反复重来),所以给 product 传生成器同样不省内存。
  • 误区:用 if it: 可以判断迭代器是不是空的。 迭代器没有定义 __bool____len__,真值测试会退化成「对象存在即为真」,空迭代器也是 True,这会让 if it: 处理(it) else: 兜底() 这样的代码静默走错分支且极难排查。判空的正确写法是取一个哨兵值:first = next(it, None),如果是 None 说明空;如果不空、后面还要用这个元素,记得用 chain([first], it) 把它接回去。同理迭代器也不能 len() 和索引,需要长度就只能落地成列表(或自己计数)。
  • 误区:想遍历两遍就用 tee,它比 list() 省内存。 不一定——tee 的原理是维护一个共享缓冲区:先跑的那个分支读过的元素,必须一直留着给后跑的分支用,所以两个分支的进度差多少,缓冲区就存多少。如果你 list(a) 一口气把 a 读完再遍历 b,缓冲区里就攒满了全部数据,内存和直接 list() 落地完全一样,还多了一层封装的开销。tee 只在「两个消费者交替前进、进度差很小」时才有意义;另外 tee 之后绝对不要再单独推进原迭代器,否则两个分支都会丢数据。
  • 追问:takewhilefilter 到底差在哪,为什么说前者能「提前退出」? filter遍历完整个序列,对每个元素判断一次,保留所有为真的;takewhile 只取开头连续满足条件的一段,一旦遇到第一个为假的元素就立刻停止,后面的元素连看都不看(哪怕后面还有满足条件的)。对 [1,2,3,100,4,5] 求「小于 10」,filter 给出 1,2,3,4,5(扫 6 个元素),takewhile 给出 1,2,3(扫 4 个就停)。差别在有序数据上最有价值:一个按时间倒序排列的千万行日志,要「最近一小时的记录」,用 takewhile 扫到第一条超过一小时的就停(可能只读几百行),用 filter 要把千万行全读一遍。对应地还有 dropwhile(丢掉开头满足条件的一段,之后全要,同样只判断到第一次为假为止)。
  • 追问:accumulatefunctools.reduce 有什么区别? 两者都是「把二元函数依次作用到序列上」,区别是输出reduce 只返回最终那一个结果(reduce(add, [3,1,4])8),accumulate 返回每一步的中间结果组成的迭代器(accumulate([3,1,4])3, 4, 8)。所以求总和、求最终乘积用 reduce(或直接 sum/math.prod);要「前缀和」「累计营收曲线」「历史最高水位线」这类每一步都要的场景用 accumulate。另外 accumulate 默认的函数是加法(reduce 必须显式传),且 3.8 起支持 initial= 指定初值(此时输出会比输入多一项,因为初值本身也是第一个输出)。
  • 追问:groupby 吐出的分组对象为什么不能存起来事后再用? 因为 groupby 全程只维护一个底层迭代器,它吐给你的 (key, group) 里的 group 不是一个装好数据的列表,而是一个「共享底层迭代器、只负责在键不变时继续吐值」的惰性视图。当外层循环推进到下一组时,groupby 必须先把当前组剩下的元素跳过去才能找到下一组的开头,于是上一组的 group 就被消费掉了、变成空的。所以 [(k, g) for k, g in groupby(...)] 事后遍历会全是空组,正确写法是在组内立刻落地:[(k, list(g)) for k, g in groupby(...)];同理也不能先把所有组收集起来再按需处理,要处理就在当前组内处理完。

八、加强记忆

itertools 是标准库的「惰性迭代器工具箱」,所有函数都遵守「吃可迭代对象、吐迭代器」的规矩,因此能串成流水线:chain → 过滤 → islice → map 全程数据只走一遍、中间不落地、内存 O(1)。按用途分三组记:无限型 count/cycle/repeat(必须配刹车:islice 按个数、takewhile 按条件、zip 靠短边;注意 cycle 会缓存整份原序列所以并不省内存);流水线型 chain(拼接不复制)、islice(迭代器切片、不支持负索引)、takewhile/dropwhile(有序数据上能提前退出,比 filter 全表扫描快)、compress(布尔掩码)、accumulate(前缀和/前缀最值,reduce 只给最终值、它给每一步)、groupbypairwise组合学型 product/permutations/combinations(一行替代多层嵌套 for,但规模是指数/阶乘级,permutations(range(12)) 就有 4.79 亿项)。两条最容易踩的红线:① groupbyuniq 不是 SQL 的 GROUP BY,只切相邻同键段,必须 sort(key=f) 后再 groupby(key=f),且分组对象要在组内立刻 list(g)(它和外层共享底层迭代器,外层一走它就失效);② 惰性的代价是一次性——遍历完就空、不能 len()、不能索引、if it: 恒为真(判空要 next(it, None)),要复用先想 list() 落地,tee 只在两个消费者步调一致时才真省内存。最后记牢一句:惰性省的是内存,不是时间