做 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(keyitemgetter(city)) for city, group in groupby(orders, keyitemgetter(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, keyitemgetter(city)): print(len(list(group))) # 这里已经消费完了 print(list(group)) # 这里永远是空列表第一次list(group)把当前组的元素全部取了出来第二次再取自然什么都没了。正确的做法是在第一次拿到 group 时立刻转成列表保存后续所有计算都用这个列表for key, group in groupby(data, keyitemgetter(city)): items list(group) print(key, items)这个坑非常隐蔽因为代码看起来完全没问题只有运行结果会莫名其妙地变少。我建议刚开始用 groupby 的人都要养成“拿到 group 先 list 一下”的习惯宁可在不需要时多占一点内存也别在调试时被空迭代器折磨一晚上。3.3 分组后再按组内字段排序的写法groupby 最大的价值在于它可以同时解决“分组”和“组内排序”两个需求。做法是把所有参与排序的字段都放进sorted的 key 里但 groupby 的 key 只使用分组的那个字段。举个例子需求是按城市分组组内按金额从高到低排那么分组前的排序键应该是城市升序、金额降序分组键则只取城市sorted_orders sorted(orders, keylambda x: (x[city], -x[amount])) for city, group in groupby(sorted_orders, keylambda x: x[city]): city_orders list(group) # city_orders 里已经是按金额降序排列的订单这里要注意一个小细节金额降序用-x[amount]实现而不是直接对元组用reverseTrue。因为reverseTrue会把城市也变成降序如果你希望城市升序、金额降序就必须在 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, keylambda 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, keygroup_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 groupbyO(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 排序”是“只要计数”还是“必须保留明细”。想清楚之后代码往往不会超过二十行。列表分组从手搓到丝滑核心不是背几个函数而是理解每种方式背后的排序与存储逻辑。你现在再看自己项目里的那堆循环应该已经知道怎么改写了。