☰
Python列表分组全攻略:defaultdict与groupby高效实践
2026/10/13 2:14:21 网站建设 项目流程

做 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 排序”,是“只要计数”还是“必须保留明细”。想清楚之后,代码往往不会超过二十行。列表分组从手搓到丝滑,核心不是背几个函数,而是理解每种方式背后的排序与存储逻辑。你现在再看自己项目里的那堆循环,应该已经知道怎么改写了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询