有一批订单数据要排序:先按客户等级从高到低,等级相同再按下单金额从高到低,金额也相同就按下单时间从早到晚。听上去简单,但真上手你会发现 sorted(orders, reverse=true) 根本满足不了——因为「时间」那一级是升序,其它两级是降序,reverse 是全局开关,没法只对某一级生效。
这篇就把多级排序里最容易踩的几个坑一次讲清:多字段怎么排、升降序怎么混、none 值怎么办、以及为什么 operator.itemgetter 比 lambda 又快又清楚。
先看朴素写法为什么不够用
初学者常见的第一反应是排两次:
orders = [
{"name": "a", "level": 2, "amount": 300, "ts": 1005},
{"name": "b", "level": 3, "amount": 300, "ts": 1002},
{"name": "c", "level": 3, "amount": 500, "ts": 1009},
{"name": "d", "level": 2, "amount": 300, "ts": 1001},
]
# 想先按 level 降序,再按 amount 降序 —— 于是排两次
r = sorted(orders, key=lambda o: o["amount"], reverse=true)
r = sorted(r, key=lambda o: o["level"], reverse=true)
这段代码其实是对的,但很多人不知道它为什么对,也不知道它的边界。它能工作,靠的是 python 排序的一个关键性质:稳定排序(stable sort)——两个 key 相等的元素,排序后它们的相对顺序保持不变。所以「最后排的字段是最主要的排序依据」,你得从次要字段往主要字段倒着排。
问题来了:字段一多,排好几趟不但绕,性能也差(每趟都是一次完整 o(n log n))。而且一旦某一级要升序、另一级要降序,reverse 全局开关就彻底抓瞎。
正解一:一个 key 返回元组,一次排完
多级排序的标准做法,是让 key 函数返回一个元组。元组比较天生就是「逐元素、从左到右」:先比第 0 个,相等再比第 1 个,以此类推——这正是多级排序的语义。
# 全部升序时,一个元组搞定,一次排序 r = sorted(orders, key=lambda o: (o["level"], o["amount"], o["ts"]))
那如果 level、amount 要降序,ts 要升序呢?对数值字段,最简单的技巧是取负号让它反向:
r = sorted(orders, key=lambda o: (-o["level"], -o["amount"], o["ts"]))
for o in r:
print(o["name"], o["level"], o["amount"], o["ts"])
# c 3 500 1009
# b 3 300 1002
# a 2 300 1005
# d 2 300 1001
-level 和 -amount 让这两级变成降序,ts 保持升序,一趟排完,清晰又高效。
字符串字段要降序怎么办?取负号行不通
-o["name"] 会直接 typeerror,字符串没法取负。这时有两种思路。
思路一:分两趟排,利用稳定性。把要降序的字符串字段单独拎出来,作为最主要的一级最后排:
data = [
{"city": "北京", "score": 90},
{"city": "北京", "score": 85},
{"city": "上海", "score": 88},
]
# 想 city 降序、score 降序
r = sorted(data, key=lambda x: -x["score"]) # 先排次要键
r = sorted(r, key=lambda x: x["city"], reverse=true) # 再排主要键(字符串降序用 reverse)
思路二:用 functools.cmp_to_key 写自定义比较。当排序规则复杂到没法用「取负 + 元组」表达时(比如按业务字典序、多字段各自方向不同且含字符串),自定义 comparator 最直白:
from functools import cmp_to_key
def cmp(a, b):
# 返回负数表示 a 排在前,正数表示 b 排在前,0 表示相等
if a["city"] != b["city"]:
return -1 if a["city"] > b["city"] else 1 # city 降序
return b["score"] - a["score"] # score 降序
r = sorted(data, key=cmp_to_key(cmp))
cmp_to_key 灵活,但每次比较都要调一次 python 函数,数据量大时明显更慢——能用元组 key 就别用它。
用 itemgetter 替代 lambda:更快也更清楚
当所有字段方向一致时,operator.itemgetter 是比 lambda 更好的选择。它是 c 实现的,取多个字段直接返回元组,省掉了 python 层的函数调用开销:
from operator import itemgetter
# 等价于 key=lambda o: (o["level"], o["amount"]),但更快
r = sorted(orders, key=itemgetter("level", "amount"))
对象属性排序则用 attrgetter:
from operator import attrgetter
from dataclasses import dataclass
@dataclass
class user:
name: str
age: int
users = [user("tom", 30), user("amy", 25), user("tom", 22)]
r = sorted(users, key=attrgetter("name", "age")) # 先按 name 再按 age
简单实测感受下差距(百万级数据,itemgetter 通常比等价 lambda 快 20%~40%):
import timeit
data = [{"a": i % 100, "b": i} for i in range(1_000_000)]
t1 = timeit.timeit(lambda: sorted(data, key=lambda x: (x["a"], x["b"])), number=3)
t2 = timeit.timeit(lambda: sorted(data, key=itemgetter("a", "b")), number=3)
print(f"lambda: {t1:.3f}s itemgetter: {t2:.3f}s")
注意 itemgetter 只能整体升序或整体降序(配合 reverse),它不解决「混合方向」——混合方向还是回到「取负元组」或分趟排。
别忽略 none:排序里最常见的崩溃
真实数据里字段常有 none,而 none 和数字、字符串都没法比较,sorted 会直接抛 typeerror: '<' not supported between instances of 'nonetype' and 'int'。
稳妥做法是在 key 里把 none 归一化到一个边界值,让它统一排在最前或最后:
rows = [{"score": 90}, {"score": none}, {"score": 70}]
# none 视为 -inf,升序时排最前;想排最后就用 float("inf")
r = sorted(rows, key=lambda x: (x["score"] is none, x["score"] or 0))
这里用了个小技巧:元组第一位放 x["score"] is none(布尔值 false < true),保证所有 none 被挤到一侧;第二位再对非 none 的值正常排。这样既不崩,none 的位置也完全可控。
小结
- 多级排序的正解是一个 key 返回元组,元组逐元素比较天然就是多级语义,一趟排完,别排好几趟。
- 数值字段要降序用取负号;字符串字段降序用
reverse(分趟排)或cmp_to_key。 - 混合升降序时,
reverse这个全局开关帮不上忙,靠取负元组或自定义 comparator。 - 字段方向一致时优先用
itemgetter/attrgetter,c 实现,比 lambda 更快更清晰。 - 数据里有
none一定要在 key 里归一化,否则typeerror当场崩。
一句话记忆:多级排序就是「让 key 返回元组」,降序靠取负,none 靠归一化。
到此这篇关于python多级排序之混合升降序、none值与itemgetter提速的文章就介绍到这了,更多相关python多级排序实战内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论