跳到正文
效率工具实用笔记关于本站
表格与数据

相邻分组与整体分组不同:正确理解itertools.groupby

约 8 分钟阅读AI 辅助整理

groupby只把相邻且键相同的元素归为一组,键值再次出现会另起一组;它不等同于SQL的GROUP BY。本文用自建记录演示不排序与先排序的差别,并说明分组迭代器共享底层数据、需要及时转成列表保存。

本文要点

  1. 先在练习目录里准备一份自建数据,不要拿真实文件直接试

  2. 如果你要的是“每个读者一组”,就得在分组前先排序,而且排序用的键要和 groupby 的 key 一致

  3. 上面两段示例里都写了 list(group),这不是多余的

相邻分组与整体分组不同:正确理解itertools.groupby的知识要点示意图
依据已审核正文摘取要点制作的示意图,非实物照片;请结合正文中的条件与范围阅读。要点摘自已审核正文;完整条件与说明见正文。查看高清图解 ↗
本文目录

先明确问题:为什么同一批记录会被分成好几组

假设你在练习目录里整理了一份自建的借阅记录,每条记录写成一个小元组,第一个位置是读者编号,第二个位置是书名。你希望按读者编号把记录归到一起,于是用了 itertools.groupby。结果发现编号 A 出现了两次,输出里就有两个 A 组,而不是一个。

这不是代码写错了,而是 groupby 的工作方式决定的。按 Python 官方 itertools 文档的说明,groupby 返回的是“连续的键和分组”:每当键函数算出的值发生变化,它就断开当前组、开一个新组。文档把这种行为类比为 Unix 的 uniq 过滤器,并明确指出它“不同于 SQL 的 GROUP BY”——SQL 会把相同的元素聚在一起,不管它们在输入里是什么顺序。

所以理解 groupby 的关键只有一句:它看的是相邻关系,不是整体关系。本文只讲这一件事,用自建的简单记录演示不排序和先排序的差别,以及分组迭代器为什么必须及时保存。所有数据都是练习用的自建示例,不是任何真实借阅记录。

自建记录:不排序时同一编号会被切成几段

先在练习目录里准备一份自建数据,不要拿真实文件直接试。下面这段只把数据写进脚本,不读取任何外部文件:

from itertools import groupby

records = [
    ("A", "book1"),
    ("B", "book2"),
    ("A", "book3"),
    ("A", "book4"),
    ("B", "book5"),
]

for reader, group in groupby(records, key=lambda r: r[0]):
    print(reader, list(group))

这段数据的编号顺序是 A、B、A、A、B。运行后你会看到 A 出现了两次:第一次是 book1 单独一组,第二次是 book3、book4 合成一组;B 同样分成两组。

原因就是键值从 A 变成 B 时,groupby 断了一次组;再从 B 变回 A 时,它又断了一次。它不会回头把前面那个 A 组找回来合并。这就是“相邻分组”和“整体分组”的差别:整体分组会把所有 A 放在一起,相邻分组只认连续出现的 A。

先按同一个键排序,再分组才得到整体效果

如果你要的是“每个读者一组”,就得在分组前先排序,而且排序用的键要和 groupby 的 key 一致。文档的原话是:通常需要先按同一个键函数排好序。

from itertools import groupby

records = [
    ("A", "book1"),
    ("B", "book2"),
    ("A", "book3"),
    ("A", "book4"),
    ("B", "book5"),
]

records.sort(key=lambda r: r[0])

for reader, group in groupby(records, key=lambda r: r[0]):
    print(reader, list(group))

排序后编号变成 A、A、A、B、B,相邻的 A 连在一起,groupby 就只输出一个 A 组和一个 B 组。

这里有一个容易踩的坑:排序键和分组键必须是同一个。如果你按书名排序、却按读者编号分组,相邻关系对不上,结果仍然会被切碎。判断方法很简单——先问自己“我按什么排的”,再看 groupby 的 key 是不是同一个函数。

分组迭代器共享底层数据:为什么必须立刻转成列表

上面两段示例里都写了 list(group),这不是多余的。文档明确说明:返回的 group 本身是一个迭代器,它和 groupby 共享同一个底层可迭代对象;当 groupby 对象向前推进时,前一个分组就不可见了。

换句话说,如果你只把 group 存下来、等循环结束后再去读,读到的很可能是空的,或者内容已经不对。文档给出的做法是把每个分组迭代器存成列表,例如:

from itertools import groupby

records = [("A", "book1"), ("B", "book2"), ("A", "book3")]
records.sort(key=lambda r: r[0])

groups = []
keys = []
for k, g in groupby(records, key=lambda r: r[0]):
    groups.append(list(g))
    keys.append(k)

print(keys)
print(groups)

这样 keys 和 groups 一一对应,循环结束后仍然可以查看。如果你确实需要延迟处理,就要在循环内部当场把 group 消费掉,而不是把迭代器留到后面。

另外,文档还提到一个相关用法:如果只想取每个分组里的第一个元素,可以配合 map 和 itemgetter(0) 直接从 groupby 的结果上取,这也是官方 recipes 里 unique_justseen 的写法。这属于进阶用法,初学时先把“及时转列表”这一步做对即可。

检查结果:用三行输出判断分组是否符合预期

写完代码后,可以用一个简单的检查顺序确认结果对不对,以下为编辑整理的核对方法,不是对某台机器的实测记录:

1. 先看键的个数。不排序时,键会出现多次;排序后,每个不同的键只出现一次。如果排序后键仍然重复,说明排序键和分组键不一致。

2. 再看每组的内容。把每组转成列表打印出来,确认组内元素的键值确实相同。

3. 最后看总数。把所有组里的元素个数加起来,应该等于原始记录条数。如果少了,通常是某个 group 迭代器被提前消费掉了。

这三步不需要额外工具,只用 print 就能完成。它帮你区分两类问题:一类是分组逻辑本身不对(键重复),另一类是保存方式不对(元素丢失)。

适用限制:groupby 适合什么、不适合什么

groupby 适合的场景是:数据已经按某个键排好序,你想顺序扫描、逐组处理,并且不需要回头合并。它的好处是惰性处理,不需要一次性把全部数据读进内存。

它不适合的场景是:数据顺序混乱、你又希望相同键的元素合并成一组。这种情况下要么先排序,要么改用别的做法。文档把 groupby 和 SQL 的 GROUP BY 明确区分开,正是提醒这一点。

还有两个边界值得记住:一是 key 省略或为 None 时,默认用元素本身作为键;二是本文所有示例都只操作自建数据,不涉及读取真实文件、不涉及删除或修改系统内容。动手前建议把练习脚本放在单独的练习目录里,例如 exercise/,不要拿重要数据直接试。

参考资料:Python 官方 itertools 文档(groupby 一节)。

把判断顺序固定下来:先问排序,再问保存

最后把整件事压缩成两个问题,遇到 groupby 结果不符合预期时按顺序问:

第一问:输入数据是不是已经按分组键排好序了?没有就排序,且排序键要和分组键一致。

第二问:每个 group 是不是在循环内部就消费掉了?没有就当场 list(group),或者按官方 recipes 的思路只取需要的元素。

这两问对应的是 groupby 最容易出错的两处:相邻分组的前提是排序,共享底层数据的前提是及时消费。把这两点分开检查,比笼统记住“groupby 要排序”更有用,因为排序解决的是分组边界问题,及时消费解决的是数据可见性问题,两者不是一回事。

观看知识要点视频 18 秒

18秒无旁白要点导览,文字来自已审核正文;完整步骤、条件及参考资料见下方。

参考资料

以下原始资料提供本文依据;请结合日期、适用条件和产品说明查阅。

请结合原始资料的适用条件阅读。

返回表格与数据 →