Python 稳定排序实战:复合键、混合升降序与缺失值

导出成绩表时,需求常是“分数高的在前,同分按姓名排列,未评分的放最后”。一个 reverse=True 无法表达全部规则。把优先级翻译成排序键,再用稳定性处理平局,才能让结果既符合业务,也便于验证。

先区分复制与原地修改

sorted(iterable, key=...) 返回新列表;list.sort(key=...) 修改原列表并返回 None。不要写 rows = rows.sort(),否则变量会变成 None。新列表仍引用原来的记录对象,修改其中的字典也会影响原记录。

key 提取每条记录的比较值,正常完成的一次排序会为每个输入元素调用一次,而非每次比较都调用。昂贵的解析可以放在这里,但网络请求等有副作用的操作应提前完成。Python 排序官方指南

把规则写成从左到右的元组

缺失标记先比较

假设分数只能是整数或 None,姓名都是字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rows = [
{"id": 3, "name": "Bo", "score": 90},
{"id": 2, "name": "An", "score": 90},
{"id": 1, "name": "Cy", "score": None},
{"id": 4, "name": "An", "score": 90},
]

def rank(row):
score = row["score"]
return (score is None,
-score if score is not None else 0,
row["name"])

ordered = sorted(rows, key=rank)
assert [r["id"] for r in ordered] == [2, 4, 3, 1]

元组按字典序比较:前一项相等,才继续看后一项。布尔值 False 排在 True 前面,所以已评分记录先出现;整数取负后,分数越高,键越小;最后再按姓名升序。

键的位置 业务含义 处理方式
第一项 未评分放最后 score is None
第二项 分数降序 已评分时取负
第三项 同分按姓名升序 原始姓名

缺失记录的第二项填零只是占位,它们已被第一项隔开,不会与真实零分混淆。不能用 score or 0 判断缺失,因为零分本身是有效值。

整体反转会改变所有字段

给复合键加 reverse=True,会让整个键按降序排列,不只反转分数。它仍保持相等键的稳定性,却无法直接表达“分数降序、姓名升序”。数字可取负;字符串降序则更适合分轮排序。

用稳定性组合不同方向

稳定排序保证相同键的元素保留输入中的相对次序。因此可以先排次要字段,再排主要字段。下面用姓名降序、分数升序演示:

1
2
3
4
scored = [r for r in rows if r["score"] is not None]
ordered = sorted(scored, key=lambda r: r["name"], reverse=True)
ordered.sort(key=lambda r: r["score"])
assert [r["id"] for r in ordered] == [3, 2, 4]

分数相同时,第二轮保留第一轮排好的姓名次序。这种写法适用于各字段方向不同的情况;轮数增加也意味着更多排序工作。若先排主字段,随后按次字段重排,主次关系就颠倒了。

稳定不等于跨次运行结果固定

示例中的两条 An 记录完整键相同,所以编号 2 保持在 4 前面。如果输入来自没有明确次序的数据源,稳定排序只会保留那个输入次序。需要导出结果可复现时,应追加唯一且稳定的编号作为最后一项:

1
ordered = sorted(rows, key=lambda r: (*rank(r), r["id"]))

先约定边界,再验证结果

不要把数字与数字字符串混排,也不要依赖 None 与整数直接比较。浮点 NaN 不遵循普通数值的大小关系,应先拒绝或归入明确的缺失类别。姓名默认按 Unicode 码点次序排列,若业务要求拼音或特定语言排序,需要另外生成相应排序键。

验证时覆盖零分、负分、全部缺失和完整键相同的记录;同时核对数量不变、优先级正确、同键次序保留。追加唯一编号后,再打乱输入检查结果是否一致。先把这些规则写成预期编号序列,比只看一张“似乎排好了”的表更可靠。