做 Python 开发这几年,我隔三差五就会碰到“列表分组”的需求。可能是把订单按城市分一下,可能是把日志按错误码分一下,也可能是把用户按活跃等级归个档。看起来都是闭眼就能写的小功能,但真到要摆平“数据量不小、分组规则一会儿变一下、组内还得排个序”这些要求时,很多人第一反应还是手搓一个 for 循环加 if 判断。这种写法不是不对,而是当业务变复杂之后,代码会越来越兴奋地往“面条”方向飞奔。这篇文章我把 Python 里常用的列表分组方案按“手搓→半自动→丝滑”的顺序串起来,让你看完能直接抄进项目里。适合所有想提高数据处理效率的人,尤其建议那些已经写过几段循环分组代码、但总觉得不够利索的朋友认真看一看。
1. 为什么需要“一秒分组”:列表分组的场景与手搓痛点
1.1 最常见的分组场景与实现思路
列表分组在真实项目里太常见了。订单系统要把订单按城市、按状态、按金额区间拆开;日志分析要把日志按错误级别、按接口路径归堆;用户运营要把用户按注册渠道、按活跃度等级分类。本质上都是同一件事:给定一个列表,按照某个或某几个业务字段,把所有元素重新组织成多个小列表,每个小列表内部的元素共享同一个业务键。这个业务键可以是字符串、整数、元组,也可以是一个自定义函数算出来的结果。
这里要特别强调一件事:列表分组不是列表切片。列表切片是按位置取出连续的一段,比如list[1:5],它假定数据原本就有某种线性顺序,你只是按下标切一段;而列表分组是按内容的特征去划片,元素原来可能分散在列表的各个角落,你需要把它们重新聚合到一起。切片是切西瓜,一厘米一厘米地切出连贯的瓜肉;分组是分拣快递,把所有发往同一个城市的包裹码到同一个区域。理解了这个区别,你就知道为什么简单的[:]切片搞不定分组需求,必须引入一个“键”的概念。
顺着这个思路往后走,所有分组方案本质上都在重复一个动作:给每个元素算出一个键,然后把元素挂到这个键对应的列表下。差别只在于你怎么表达“键不存在时先建列表”这一步。有的方案把这个动作写在业务代码里,显得特别啰嗦;有的方案写进底层容器里,调用时只需要一行。我们下面要做的就是从最啰嗦的写法,一步步优化到最顺手的写法。
1.2 手搓 for + if 的问题在哪
几乎所有从零开始的 Python 新手都写过这样的代码:
orders = [ {"order_id": 1, "city": "上海", "amount": 88}, {"order_id": 2, "city": "北京", "amount": 45}, ] groups = {} for item in orders: city = item["city"] if city not in groups: groups[city] = [] groups[city].append(item)这段代码能跑,而且逻辑也算直白,但它有几个毛病。最显眼的是那个if city not in groups,每处理一个元素都要判断一次键是否存在,多练几遍之后你会觉得这是在浪费手指头的力气。更麻烦的是,一旦分组条件从“按城市”变成“按城市加金额区间”,你就得在这个 if 前面再套一层 if,代码越长越难读,后面维护的人很容易在多层缩进里迷失方向。
还有一个隐蔽问题藏在“先判断后写入”的模式里。如果你在分组过程中想顺便做一点统计,或者想往某个已有组里追加数据,这个 if 分支会越来越多,逻辑相互纠缠,最后改一个条件就要把所有分支重新捋一遍。我见过不少同事在业务代码里写这样的分组逻辑,明明用标准库几行就能搞定,却因为历史原因一直不敢动。实际上,Python 的dict家族早就提供了更优的默认值处理机制,我们完全可以不用手写这个 if。
2. 打底基础:dict、defaultdict 与分组
2.1 用 dict.setdefault 写出一行式分组
先从半自动方案说起。dict.setdefault(key, default)这个方法的作用是:如果 key 存在,返回对应的值;如果 key 不存在,先把default写入字典,然后返回default。利用它,前面那段手搓代码可以压缩成:
groups = {} for item in orders: groups.setdefault(item["city"], []).append(item)这个写法省掉了显式的 if,逻辑变成了“给我这个城市的列表,没有就新建一个空列表,然后追加当前元素”。很多老手平时就是这么写分组的,简单、够用,而且不会报KeyError。
但 setdefault 有一个很少有人注意到的性能小坑:即使 key 已经存在,setdefault(key, [])仍然会先创建一个新的空列表对象,再把它作为默认值传给方法。如果 key 存在,新列表就被扔掉,白白浪费一次对象创建;如果分组次数达到几十万次,这种浪费会变成一个可以感知的耗时点。对小列表来说无所谓,但在处理大规模数据时,我建议直接换成下一节要讲的 defaultdict。
2.2 defaultdict:省掉条件判断的“分组神器”
collections.defaultdict是dict的子类,它重写了内部的一个__missing__钩子。当你访问一个不存在的 key 时,它不会抛KeyError,而是调用构造时传入的工厂函数生成一个默认值。用来做列表分组,就是下面这个样子:
from collections import defaultdict groups = defaultdict(list) for item in orders: groups[item["city"]].append(item)这已经非常接近“一秒分组”的手感了:你根本不需要考虑 key 是否存在的问题,直接groups[city].append(item),剩下的交给 defaultdict 处理。因为默认值工厂是list,每次遇到新城市时会自动创建一个空列表,然后返回给你追加。
这里必须提醒一个容易踩的坑:defaultdict 的第一个参数必须是一个“可调用对象”,也就是一个可以被调用的类型或函数,比如list、dict、int,或者你自己写的lambda。有人会写成defaultdict(list()),以为传一个空列表进去就完事了。结果运行时会告诉你第一个参数不可调用,因为list()求值出来的是一个列表对象,不是一个工厂。这个错误非常常见,尤其是从其他语言转过来的朋友,一定要记住defaultdict(list)不要带括号。
如果你要做嵌套分组,比如先按城市再按商品类别分组,defaultdict 同样能帮你自动补全内层字典:
nested_groups = defaultdict(lambda: defaultdict(list)) nested_groups["上海"]["手机"].append(item)这种写法在构造多级业务报表时特别顺手,不会出现KeyError,也不会让缩进层次变得像金字塔一样恐怖。
2.3 保持插入顺序:Python 3.7+ 的自然优势
用 defaultdict 或普通 dict 分组时,组出现的顺序跟列表里每个 key 第一次出现的顺序一致,这是 Python 3.7 以后官方保证的字典有序特性带来的收益。举个例子,如果订单列表里第一个出现的城市是“上海”,那么最后遍历groups时,上海这个组一定在最前面;不管后面有多少北京的订单,北京也只能排在上海后面。
这个特性非常契合日常业务直觉。很多时候我们不需要刻意排序,只希望数据按照源文件或接口返回的顺序自然呈现,这时 defaultdict 是无价的。如果强制用sorted(groups)遍历,反而会打乱原始顺序,可能违背产品需求。反观后面要讲的itertools.groupby,它天然要求输入数据已经排好序,分组结果也跟排序后的顺序绑定。所以在动手写分组前,先问自己一句话:分组后的顺序,到底需不需要跟原始列表保持一致?这个问题能帮你快速决定用 defaultdict 还是 groupby。
3. groupby:排序-分组-排序的组合技
3.1 先用 sorted 把相同 key 挪到相邻位置
itertools.groupby是 Python 标准库里的一个分组利器,但它有一个容易让人翻车的特性:它绝不会帮你做全局排序,只会把「连续出现」的相同 key 元素合并成一组。什么意思?如果列表里相同 key 的元素没有紧挨在一起,groupby 就会把它们当成几组分别输出。
比如列表["A", "B", "A"],直接 groupby 会得到两组 A,因为两个 A 中间隔了一个 B。要想一次性把相同的 A 聚到一组里,必须先排序成["A", "A", "B"]。所以正确的 groupby 使用姿势永远是:先 sorted,再 groupby。
from itertools import groupby from operator import itemgetter orders.sort(key=itemgetter("city")) for city, group in groupby(orders, key=itemgetter("city")): print(city, list(group))这里用operator.itemgetter而不是 lambda,好处有两个:一是写法更短,二是性能更好。itemgetter 是 C 层实现的函数,在元素非常多的时候比 lambda 快一点。不过速度差异一般不大,主要优势还是代码干净。
顺带说一句,这个“先排序、再分组”的思路,其实是很多经典框架的分组模型。你看 MapReduce 里的 Shuffle 阶段,本质就是先把 key 排序,再把相同 key 的记录分到同一个 Reduce 任务里。大数据的处理思路跟你今天手写的 Python 代码是同构的。理解了 groupby + sorted 的原理,再去看那些分布式框架的“排序—分组排序”逻辑,就不会觉得它是天书了。
3.2 groupby 返回的迭代器只能消费一次
groupby 的返回值本身是一个迭代器,每次遍历它会产出(key, group)二元组。这个 group 不是一个列表,而是一个生成器,它跟底层数据流是共享迭代状态的。换句话说,同一组内容你遍历一次就没了,想再遍历第二次,得到的一定是空。
很多人在调试时写过类似下面的代码:
for key, group in groupby(data, key=itemgetter("city")): print(len(list(group))) # 这里已经消费完了 print(list(group)) # 这里永远是空列表第一次list(group)把当前组的元素全部取了出来,第二次再取自然什么都没了。正确的做法是在第一次拿到 group 时立刻转成列表保存,后续所有计算都用这个列表:
for key, group in groupby(data, key=itemgetter("city")): items = list(group) print(key, items)这个坑非常隐蔽,因为代码看起来完全没问题,只有运行结果会莫名其妙地变少。我建议刚开始用 groupby 的人都要养成“拿到 group 先 list 一下”的习惯,宁可在不需要时多占一点内存,也别在调试时被空迭代器折磨一晚上。
3.3 分组后再按组内字段排序的写法
groupby 最大的价值在于,它可以同时解决“分组”和“组内排序”两个需求。做法是把所有参与排序的字段都放进sorted的 key 里,但 groupby 的 key 只使用分组的那个字段。举个例子,需求是按城市分组,组内按金额从高到低排,那么分组前的排序键应该是城市升序、金额降序,分组键则只取城市:
sorted_orders = sorted(orders, key=lambda x: (x["city"], -x["amount"])) for city, group in groupby(sorted_orders, key=lambda x: x["city"]): city_orders = list(group) # city_orders 里已经是按金额降序排列的订单这里要注意一个小细节:金额降序用-x["amount"]实现,而不是直接对元组用reverse=True。因为reverse=True会把城市也变成降序,如果你希望城市升序、金额降序,就必须在 key 里手动加负号。同理,日期字段想倒序可以转成时间戳再加负号,字符串字段想倒序就比较麻烦,通常先按字符串升序排序后再reversed,或者单独用cmp_to_key。反正记住一个原则:同一个排序里不同字段的升降序要求不一致时,优先在 key 函数里想办法,别图省事直接 reverse。
这种“排序-分组-排序”的组合技,用一句话总结就是:你想要的组内顺序,在排序阶段就已经决定好了, groupby 只是负责按 key 切块而已。这也是为什么它能模拟出 SQL 里那种“分组后组内排序”的效果,却依然保持着 Python 列表操作的简洁感。
4. 实战案例:订单列表按城市和金额区间一键分组
4.1 准备一份订单数据
光讲理论不过瘾,我直接给你一个能跑的例子。假设我们从某个接口拿到一批订单,结构非常简单,每个订单有订单号、城市和金额三个字段。列表是这样的:
orders = [ {"order_id": 1, "city": "上海", "amount": 88}, {"order_id": 2, "city": "北京", "amount": 45}, {"order_id": 3, "city": "上海", "amount": 120}, {"order_id": 4, "city": "广州", "amount": 66}, {"order_id": 5, "city": "北京", "amount": 102}, {"order_id": 6, "city": "上海", "amount": 30}, {"order_id": 7, "city": "广州", "amount": 95}, {"order_id": 8, "city": "北京", "amount": 55}, ]现在需求有两层:先按城市分组,然后在每个城市内部,再按金额区间分成“低客单(小于 50)、中客单(50 到 100 之间)、高客单(大于 100)”,组内订单按金额从高到低排列。这个需求如果用嵌套 for 循环一层层 if 下去,代码会非常绕,但用我们前面讲的先排序再 groupby 就很顺。
4.2 自定义 key 函数实现多级分组
先定义金额区间函数:
def amount_level(money): if money < 50: return "低客单" if money <= 100: return "中客单" return "高客单"定义完以后,排序 key 要同时包含城市、金额区间、金额倒序这三部分。groupby 的分组 key 则只要城市和区间两部分的组合。整体代码如下:
from itertools import groupby from collections import defaultdict sorted_orders = sorted( orders, key=lambda x: (x["city"], amount_level(x["amount"]), -x["amount"]) ) result = {} def group_key(item): return (item["city"], amount_level(item["amount"])) for (city, level), group in groupby(sorted_orders, key=group_key): items = list(group) result.setdefault(city, []).append({ "level": level, "items": items, "count": len(items), "total_amount": sum(item["amount"] for item in items), })这段代码里有一个很关键的设计:排序 key 用了三元组(city, amount_level(amount), -amount),而分组 key 用了二元组(city, amount_level(amount))。因为排序 key 比分组 key 多了一个金额倒序,所以同一个城市、同一个区间里的订单,在进入 groupby 之前就已经排好序了。 groupby 按二元组分完组以后,组内自然就是金额从高到低的状态,完全不需要额外排序。
如果你不想写单独的group_key函数,也可以直接写成 lambda 放在调用处,但一旦分组逻辑变复杂,单独抽函数是更推荐的做法,单元测试也好写,出问题也好排查。
4.3 输出分组统计结果
最后一步是把分组结果整理成可读的内容。我们已经有result这个字典,它的结构是“城市 → 多个区间分组列表”。遍历打印:
for city, groups in result.items(): print(f"城市: {city}") for g in groups: print(f" {g['level']}: 订单数={g['count']}, 总金额={g['total_amount']}") for item in g['items']: print(f" #{item['order_id']} 金额={item['amount']}")输出效果大致如下:
城市: 北京 高客单: 订单数=1, 总金额=102 #5 金额=102 中客单: 订单数=2, 总金额=100 #8 金额=55 #2 金额=45 城市: 广州 中客单: 订单数=2, 总金额=161 #7 金额=95 #4 金额=66 城市: 上海 高客单: 订单数=1, 总金额=120 #3 金额=120 中客单: 订单数=1, 总金额=88 #1 金额=88 低客单: 订单数=1, 总金额=30 #6 金额=30整个过程中,你只需要写一个金额区间函数、一个排序 key、一个分组 key,剩下的查找、聚合、计数全是标准库自动完成的。对比最初那个for + if的写法,是不是已经有“手搓”到“丝滑”的感觉了?这个模式可以直接套进很多业务场景,比如按用户等级分组后看消费金额、按接口分组后看平均耗时,本质上都是换汤不换药。
5. 分组过程的常见坑与排查方法
5.1 五个高频问题速查表
以下是我在这些年写分组代码时踩过的坑,整理成一张速查表,方便你直接对照排查。
| 问题 | 现象 | 解决办法 |
|---|---|---|
| groupby 前没排序 | 相同 key 被拆成好几组,统计结果看起来“重复” | 先sorted,再groupby |
| 反复使用 group 迭代器 | 第二次遍历 group 永远是空列表 | 拿到 group 后立刻list(group)保存 |
defaultdict(list())写错 | 运行报错:first argument must be callable | 改成defaultdict(list),不要带括号 |
| setdefault 高频使用 | 数据量大时性能下降明显 | 改用 defaultdict,避免创建无用默认值 |
| 排序和分组 key 不一致 | 看起来分组成功,但组内顺序完全随机 | 确保排序 key 前半段与分组 key 完全相同 |
这张表里最容易被忽视的是最后一行。很多人写完 groupby 之后发现组内顺序不对,第一反应是“我明明在 groupby 里指定了排序啊”,然后开始反复改代码。其实 groupby 本身不负责排序,它只负责聚合相邻的相同 key 元素,所有顺序逻辑都要靠排序阶段完成。如果排序 key 和分组 key 的字段顺序不一致,分组时就会把原本应该在一起的元素切开,组内顺序自然一团糟。
5.2 从报错信息反推分组逻辑问题
分组代码出问题的时候,报错信息往往能帮你快速定位。最常见的错误是TypeError: '<' not supported between instances of 'int' and 'str',这通常说明你在排序 key 里混合了不同类型的数据,比如一个字段的值既有数字又有字符串。出现这种问题,建议先用一行代码检查这个字段的类型分布:
print(set((type(item["amount"]).__name__) for item in orders))看看结果里是不是同时存在int和str。如果确实混了类型,先统一类型再排序,别指望 Python 帮你自动判断。
另一个常见问题是分组结果缺失部分数据。排查思路是先看分组 key 到底有几种取值:
print({group_key(item) for item in orders})如果集合里出现两个看起来相似的 key,比如一个叫"上海"、一个叫"上海 ",多半是数据里有空格或大小写不一致。这种问题在日志和手工录入的数据里尤其常见,处理办法是在 key 函数里提前做清洗,比如item["city"].strip().lower()。
还有一次我遇到的情况是,分组后列表内容没问题,但result字典里有些城市对应的组是空列表。后来发现是因为我在 groupby 之后立刻消费了 group 迭代器,结果要统计数量的时候已经没有内容了。遇到这种诡异现象,先看代码里是不是把list(group)的结果放在了一个变量里,然后处处复用这个变量;千万别对着同一个 group 反复转换。
6. 大数据量场景下的分组选型与扩展
6.1 defaultdict vs groupby:O(n) 与 O(n log n) 的选择
分组方案的选择,本质上是在时间复杂度和功能丰富度之间做权衡。defaultdict 分组是纯散列表操作,时间复杂度是 O(n),你只需要遍历一遍原始列表,把每个元素追加到对应键的列表里就行。如果你只需要按城市把订单归堆,根本不需要排序,用 defaultdict 是绝对最优解。
groupby 就不同了,它要先对全量数据做一次排序,时间复杂度至少 O(n log n),数据量越大,排序成本越明显。但 groupby 换来的好处是:你可以顺便实现组内排序,还可以让输出结果天然按 key 有序。所以我的经验是,顺序重要就选 groupby,顺序无所谓就首选 defaultdict。如果既想保留顺序,又不想让同一 key 被拆开,那就必须用 sorted 合并相同 key,不要指望字典“自动去重”。
如果是百万级以上的数据,我还会考虑只做分组不做排序,用 defaultdict 分完组后,再单独对每个组做 sort。这样总的排序成本可能低于对全局数据排序,因为组内数据量通常更小。尤其当业务上只有少数几个大组时,这种“先分组再局部排序”的模式会显著快过“全局排序再分组”。
6.2 流式分组与无限迭代器的处理
很多人以为列表分组只能处理“已经完整加载到内存里的列表”,其实不一定。如果数据源是日志流、数据库游标或网络接口,你完全可以边读边分组,用 defaultdict 做增量聚合,并适时把结果写出去。这时候 sorted + groupby 就无能为力了,因为排序必须等到全量数据到齐才能开始。
处理流式数据时有个经典做法:边读取边放进 defaultdict,同时维护一个总计数。比如:
from collections import defaultdict counter = defaultdict(int) total = 0 for order in fetch_orders(): counter[order["city"]] += 1 total += 1这样内存中只保存每个城市的计数,不保存所有明细,适合做大规模统计。如果你确实需要每个组里的明细数据,那无论用什么方案都要在内存里维护一个大列表,除非你选择把分组结果落盘,比如写入 CSV 或数据库表。这里的关键是:分清你到底是“只要分组统计结果”,还是“必须保留详细明细”,这会直接影响你的数据结构和内存策略。
6.3 让分组结果直接参与统计与排序
分组并不是终点。分组之后你常常还要算总和、平均数、最大值,或者筛选出每个组里满足条件的元素。这些操作完全可以在迭代 group 的时候顺手做掉,不必再单独写一轮循环。比如在 groupby 的分组循环里,一边list(group)一边累加总金额、统计订单数,最后把结果拼成一个字典,既省时间又让逻辑集中在一处。
如果业务复杂度已经超过了标准库的舒适区,比如要做多维度透视、复杂聚合、缺失值填充,直接引入 pandas 会是更好的选择。pandas 的groupby在底层做了大量 C 级优化,用的也是类似的“分组键 + 聚合函数”模型。但如果你只是处理几千条订单的小脚本,真没必要为一个分组功能搭上 pandas 那套环境,标准库的 collections 和 itertools 足够你优雅地完成任务。
最后再分享一个我自己的实测体会:大多数分组需求其实用不到特别复杂的算法,别急着上高性能框架,先用字典和 groupby 想一想,搞清楚你要的到底是“保持原顺序”还是“按 key 排序”,是“只要计数”还是“必须保留明细”。想清楚之后,代码往往不会超过二十行。列表分组从手搓到丝滑,核心不是背几个函数,而是理解每种方式背后的排序与存储逻辑。你现在再看自己项目里的那堆循环,应该已经知道怎么改写了。