
上个月接了个需求帮人优化一份成绩排名脚本。规则说复杂也不算复杂总分降序总分相同看语文语文还相同看数学数学再相同按姓名拼音升序。换成你第一反应是不是写个key函数在里面一顿if-else我当时也这么干写完发现代码丑是一回事最要命的是想加一条规则就得改函数内部越改越乱。后来把Python内置的排序函数sorted()连带着它的兄弟方法list.sort()重新系统捋了一遍才发现真正吃透key参数 排序稳定性 多级排序这三个点上面那种需求只需要三行代码就能搞定。这篇文章就聊聊我在这一轮整理里沉淀下来的东西从基础区别到自定义排序规则再到性能陷阱中间穿插了不少实际业务场景。适合已经写了一阵子Python、但每次遇到排序都要翻文档查参数的读者。看完你至少能保证大部分排序需求不用再百度怎么按字典值排序、怎么按对象属性排序自己心里有谱。1. 先解决基础选择题sort()与sorted()到底该用哪个1.1 两者最本质的区别返回值与原对象是否变化很多初学者最容易在这两个API上面栽跟头因为用起来太像了。list.sort()是列表自带的方法它会在原地修改当前列表把列表自身的顺序排好然后返回None。sorted()是Python内置函数它接收任意可迭代对象返回一个全新的列表原数据保持不变。用一个例子看得很清楚nums [5, 2, 8, 1] result nums.sort() print(result) # None print(nums) # [1, 2, 5, 8] nums2 [5, 2, 8, 1] new_nums sorted(nums2) print(new_nums) # [1, 2, 5, 8] print(nums2) # [5, 2, 8, 1]原列表没动经典坑就是result nums.sort()之后result 变成None然后你拿着None去遍历、去切片程序报错或者输出空结果排查半天才发现问题出在这一行。为什么要设计两套一个很重要的原因是list.sort()原地排序节省内存一个百万级别的列表每轮排序都生成新列表内存占用瞬间翻倍服务器可能直接撑不住。而sorted()的定位是更通用的排序入口它不要求对象必须是列表字符串、元组、字典、集合、生成器都可以喂给它。所以如果你不介意原列表被打乱顺序优先用list.sort()如果你还要保留原数据做后续处理或者要排序的对象本身不是列表就选sorted()。1.2 不同可迭代对象的排序差异sorted()比list.sort()覆盖面广得多这决定了它才是通用排序首选。text banana print(sorted(text)) # [a, a, a, b, n, n] scores {Python: 88, Java: 79, Go: 92} print(sorted(scores)) # [Go, Java, Python]注意返回的是排好序的键列表 gen (x for x in [c, a, b]) print(sorted(gen)) # [a, b, c]生成器被一次性消费完返回新列表字符串排序是按字符的Unicode码点逐个比较的字典排序时默认对它所有的键排序所以结果是一个键列表元组排序时逐元素比较集合排序则先把元素转为列表再排。这些场景list.sort()一个都干不了因为它只能作用于列表对象本身。提示对字典排序通常不会直接调用sorted(scores)更常见的是sorted(scores.items(), key...)按值或按键值对规则来排这个后面专门展开。2. 自定义排序的灵魂key参数怎么写才高效2.1 key的本质装饰—排序—去装饰模式sorted()和list.sort()都支持一个叫key的参数这才是自定义排序规则的核心入口。很多人对它的理解停留在key就是排序依据的字段这么说没错但不够深入。key参数本质上做的是三件事装饰对每个原始元素调用一次key函数拿到一个排序用的中间值排序按照中间值的大小来排列所有原始元素去装饰把排序结果还原成原始元素。这就像运动会入场式每个运动员先领一个号码牌排序只认号牌排完之后公布的是运动员名字而不是号牌。官方文档里把这套机制称为Decorate-Sort-Undecorate模式也就是著名的DSU方案。words [banana, apple, cherry, date] print(sorted(words, keylen)) # [date, apple, banana, cherry]这里keylen表示每个单词先算出长度作为中间值然后按长度升序排。核心要点是key函数决定了拿什么量去比而不是直接修改元素本身。所以你可以用str.lower忽略大小写排序用abs按绝对值排序用len按长短排序它们返回的都不是最终展示内容只是比较依据。2.2 lambda在key里的写法与可读性权衡最常见的自定义写法是lambda表达式。形如keylambda x: x[1]的含义是把每个原始元素传给x然后取x的下标1位置的值作为排序依据。举几个高频场景students [(A同学, 88), (B同学, 92), (C同学, 79)] print(sorted(students, keylambda x: x[1])) # [(C同学, 79), (A同学, 88), (B同学, 92)] names_case [Alice, bob, CHarlie, dave] print(sorted(names_case, keystr.lower)) # [Alice, bob, CHarlie, dave] nums [-3, 5, -1, 2, -4] print(sorted(nums, keyabs)) # [-1, 2, -3, -4, 5]关于lambda我的经验是如果排序规则超过一行就别硬塞在lambda里写。比如规则是取名字的最后一个字符、转成小写、再按它排序这依然适合lambda但规则一旦涉及多个字段、多种方向、多次判断lambda就会变成一串别人看不懂、自己三天后也看不懂的谜语。这时候应该抽一个具名函数出来def sort_key(item): # 规则先按分数降序再按名字小写升序 return (-item[score], item[name].lower()) sorted(student_list, keysort_key)具名函数还有一个好处排序规则可以复用。如果几个地方用了同一个规则函数名自己就是一个文档比在每个sorted()调用里重写一遍lambda健康得多。2.3 一个小实验证明key被调用了多少次很多时候你会听说用key比写cmp效率高但不知道为什么。做个实验直观看看def noisy_key(x): print(fkey({x}) 被调用了一次) return x sorted([5, 3, 1, 4, 2], keynoisy_key)运行时会看到输出恰好5行也就是说key函数对每个元素只调用一次。Python底层的Timsort排序在做元素比较时直接拿已经算好的key值比而不是每次都重新调用key函数去现场计算。这是一个非常重要的性能设计如果每次比较都要重新执行key逻辑一个包含n个元素的列表会额外付出O(n log n)次函数调用而DSU方案把调用次数压缩到了恰好n次。排序算法本身无论如何都需要比较但计算比较依据这件事只做一遍这是优秀工程取舍的典范。3. 多级排序从一次排名需求说起3.1 用元组做key天然支持多级排序回到开头的排名需求总分降序、总分相同看语文、语文相同看数学、数学相同看姓名拼音升序。正确的姿势是让key返回一个元组元组内顺序就代表排序优先级。Python元组比较遵循一个原则从左到右逐字段比较第一个能分出大小的字段直接决定结果后续字段不再参与。所以只要把主排序字段放最前面次级字段依次往后排天然就实现了多级排序。data [ (张三, 260, 92, 88), (李四, 260, 90, 95), (王五, 250, 98, 86), ] # 总分降序、语文降序、数学降序、姓名升序 result sorted(data, keylambda x: (-x[1], -x[2], -x[3], x[0])) print(result) # [(张三, 260, 92, 88), (李四, 260, 90, 95), (王五, 250, 98, 86)]这个例子里有个实战技巧数字字段要降序就取负号-x[1]会先把260变成-260、250变成-250排序时-260更小所以排前面。但字符串不能取负号所以姓名要保持升序就直接放原值。多级排序最需要留意的也就是这个混合方向时数字降序靠负号字符串降序就要换思路了。3.2 稳定排序的经典用法连续sort元组key是最直观的多级方案但还有一种同样实用且经常在框架代码里见到的做法利用稳定性连续排序。Python的排序算法是Timsort它是稳定排序意思是当两个元素的key相等时它们保持排序前的相对顺序。稳定性的价值在于你可以先按次要条件排一遍再按主要条件排一遍后一次排序不会打乱前面已经稳定的次要顺序。注意顺序是先次要、后主要students [ {name: 张三, total: 260, chinese: 88}, {name: 李四, total: 260, chinese: 90}, {name: 王五, total: 250, chinese: 95}, ] # 先排次要字段语文降序 students.sort(keylambda x: x[chinese], reverseTrue) # 再排主要字段总分降序 students.sort(keylambda x: x[total], reverseTrue) print(students) # 总分相同的张三和李四语文成绩依然是降序关系 # [{name: 李四, total: 260, chinese: 90}, # {name: 张三, total: 260, chinese: 88}, # {name: 王五, total: 250, chinese: 95}]如果你颠倒顺序先排total再排chinese那语文的顺序就会被打乱因为total相同时sort会保持上一次排序按chinese排过的相对顺序但total不相同时后一次排序会按total重新排列而chinese字段对于不同total的人就没有约束力了。所以口诀就是先排不重要的后排重要的。连续sort的好处是每一步规则都肉眼可见适合团队协作时别人review缺点是对多个字段同时降序处理起来容易绕晕。我的习惯是两层规则用连续sort三层以上直接用元组key。3.3 反转与多字段reverse到底影响了什么reverseTrue是一个整体开关它把key算出的最终比较结果整体翻转。当你使用keylambda x: (x[0], x[1])时reverseTrue会让第一个字段变成降序同时第二个字段也变成降序。这在多级排序里特别容易踩雷data [(apple, 2), (banana, 1), (cherry, 2)] # 我想要名字升序、数字降序 # 直接 reverseTrue 会把名字变成降序方向搞反想在多字段场景下精确控制每一级的升降序数字字段就用负号字符串字段则要利用稳定排序做两步处理或者改用cmp_to_key自定义比较函数。这个坑我后面专门展开这里先记住一个结论reverse只能负责最外层的整体方向管不了元组内部每个字段的方向。4. 字典与对象排序最常见的两类业务场景拆解4.1 字典按值排序该有的姿势一次讲全字典排序是出现频率最高的需求几乎每个做数据清洗、排行榜的人都遇到过。核心是sorted(dict.items(), key...)因为items()返回(键, 值)的视图key函数从每个元组中取出你想要参与比较的成分。d {Rust: 78, JavaScript: 95, Python: 89, Go: 95} # 写法1lambda取第二个元素 result1 sorted(d.items(), keylambda item: item[1], reverseTrue) # 写法2operator.itemgetter(1) from operator import itemgetter result2 sorted(d.items(), keyitemgetter(1), reverseTrue) # 写法3值降序值相同再按键升序 result3 sorted(d.items(), keylambda item: (-item[1], item[0])) print(result1) # [(JavaScript, 95), (Go, 95), (Python, 89), (Rust, 78)]写法1和写法2结果完全一样但运行效率有差异itemgetter是内置的C语言实现属性提取速度比手写lambda快数据量上了十万百万级才看得出差距。写法3解决了一个高频问题——如果两个语言分数相同你想让名字也有个稳定顺序就可以在key里把键作为第二字段一起带上。排序结果是一个元组列表如果希望后续能像字典一样按键取值Python 3.7直接dict(result1)就能按排序后的顺序构建字典更早版本则用collections.OrderedDict(result1)。注意顺序是保留的因为新版本字典底层就是有序的。4.2 对象按属性排序attrgetter的妙处与陷阱业务数据很少是裸元组更多时候是类对象或者字典。按对象属性排序推荐用operator.attrgetterfrom operator import attrgetter class Student: def __init__(self, name, score, age): self.name name self.score score self.age age students [ Student(Zhang, 85, 20), Student(Li, 92, 19), Student(Wang, 85, 21), ] # 按分数降序分数相同按年龄升序 sorted_students sorted(students, keylambda s: (-s.score, s.age)) # 也可以这样先score再age但注意reverseTrue会让两列都降序 sorted_students2 sorted(students, keyattrgetter(score, age)) print([(s.name, s.score, s.age) for s in sorted_students2]) # [(Li, 92, 19), (Zhang, 85, 20), (Wang, 85, 21)]attrgetter(score, age)会返回一个(student.score, student.age)元组天然支持多级排序。但要特别提醒它不支持取负。如果你想实现score降序、age升序直接keyattrgetter(score, age), reverseTrue会把年龄也变成降序结果就错了。这种混合方向的需求要么lambda s: (-s.score, s.age)要么走两步稳定排序。4.3 一个排行榜小案例数据准备的标准套路排行榜这种需求在业务代码里太常见了我把常见套路完整贴一遍from operator import itemgetter rankings {userA: 320, userB: 500, userC: 320, userD: 128} # 需求积分高的在前积分相同按用户名升序 top_list sorted(rankings.items(), keylambda item: (-item[1], item[0])) # 如果只要前两名 top2 top_list[:2] print(top2) # [(userB, 500), (userA, 320)]这里有个细节切片的操作对象是排好序的列表所以先排序、后切片、再处理顺序别弄反。数据量大的时候不要先list(rankings)再把分数查出来拼成一个新结构再排序直接用items()和key函数一步到位可读性最好。5. cmp_to_key遇到需要比较函数的排序再掏出来5.1 什么时候轮到比较函数出场key函数的逻辑是每个元素独自算出依据然后依据之间互相比较。绝大多数场景都能用它解决但有一种情况会让key很尴尬排序依据必须同时参考两个元素才能决定。举个例子业务规则是整数排在最前面、字符串排在后面整数内部按数值大小排字符串内部按字典序排。如果你写keylambda x: ...你会发现很难把类型不同这个信息压缩进一个标量里。虽然理论上可以给整数和字符串各设计一个编码但代码会非常绕而且读起来完全不像人话。这时候就该functools.cmp_to_key出场了——它把一个传统的比较函数转换成key对象让排序算法调用它来比较任意两个元素。5.2 Python 3为什么砍掉了cmp参数如果你用过Python 2会记得list.sort()里可以直接传cmp参数让它接收两个原始元素、返回负数/零/正数表示大小关系。Python 3里这个参数被移除了原因就是性能。key方案中函数调用次数是O(n)而cmp方案中排序算法每一轮比较都要调用一次比较函数Timsort平均比较次数是O(n log n)差距在大量数据上会被放大得极其可怕。所以官方思路很明确默认只提供key真遇到必须两两比较的场景再自己通过cmp_to_key把比较函数包一层。写比较函数的基本规则是返回负数a应该排在b前面返回0a和b排序等价返回正数b应该排在a前面。from functools import cmp_to_key def compare(a, b): # 先按字符串长度升序 if len(a) ! len(b): return len(a) - len(b) # 长度相同按字典序升序 if a b: return -1 if a b: return 1 return 0 words [banana, apple, cherry, date, kiwi] print(sorted(words, keycmp_to_key(compare))) # [date, kiwi, apple, banana, cherry]这个规则用key写其实也不难keylambda w: (len(w), w)。我故意用了一个key也能解决的例子是想说明能理解cmp的写法是为了应对key解决不了的规则。日常优先key这是性能原则也是代码洁癖原则。5.3 一个真正适合cmp_to_key的方案混合类型排序下面这个规则很适合cmp_to_key把整数排在字符串前面整数按数值升序字符串按字典序升序。from functools import cmp_to_key def mixed_compare(a, b): # 两个整数按数值大小 if isinstance(a, int) and isinstance(b, int): return a - b # 整数排在字符串前面 if isinstance(a, int): return -1 if isinstance(b, int): return 1 # 两个字符串按字典序 if a b: return -1 if a b: return 1 return 0 mixed_list [banana, 3, apple, 1, cherry, 2] print(sorted(mixed_list, keycmp_to_key(mixed_compare))) # [1, 2, 3, apple, banana, cherry]直觉上你会觉得用key肯定也能处理确实你可以给不同类型设计一个复合元组比如字符串映射成某种标记、整数映射成另一种标记。但代码会变得难以阅读而cmp_to_key把规则写得跟口语一样直白。代价就是性能混合列表里有n个元素比较函数可能被调用成千上万次。所以使用前务必确认数据量不大或者这个自定义比较逻辑本身非常廉价。能用key的绝不掏cmp掏cmp的必有其不得已之处这是我长期实践下来的原则。6. 实测之后的排序性能细节与高频坑6.1 不同key写法的性能差异我给同样一份10万元素的数据跑过简单计时大致结果是这样写法相对耗时说明keylambda x: x[0]基准lambda每次都要执行一次Python函数调用keyitemgetter(0)约快20%~30%C语言实现的取值函数keyattrgetter(score)约快10%~20%对象属性提取更直接keycmp_to_key(compare)通常慢一个数量级每次比较都进入Python函数这不是精确的基准数字不同机器、不同数据结构差异很大但量级关系和趋势是稳定的。所以我的建议很明确拿不准性能时大列表优先itemgetter/attrgetter规则能写进key就别去碰cmp_to_key。6.2 高频坑None、混合类型、字符串数字、浅拷贝这几类坑几乎是排序话题下的常客逐一说明坑一sort后忘了原对象被改nums [3, 1, 2] new_nums nums.sort() # new_nums 是 None这种问题最隐蔽因为程序往往不会立刻报错直到后续某处拿到None去遍历才崩。排查时可以想想是否顺手把sort方法返回值赋给了变量。坑二列表里有Nonenums [3, None, 2, None, 1] sorted(nums) # TypeError: not supported between instances of NoneType and int解决方案是给None设计一个排序位置sorted(nums, keylambda x: (x is None, x)) # [1, 2, 3, None, None]None永远跑最后原理x is None对None是True对数字是FalseFalse排在True前面所以数字先出现同组内再按x的值比大小。如果想None排最前把布尔判断反过来即可。坑三混合数字和字符串sorted([1, 2, 3]) # TypeError: not supported between instances of str and int默认情况下Python不允许不同类型直接比较大小。如果业务数据本身就把类型混在一起就得按5.3里的方案提供自定义比较规则不能指望sorted自己搞明白。坑四字符串数字排序nums_str [10, 2, 1, 20] print(sorted(nums_str)) # [1, 10, 2, 20] - 字典序不是数值序 print(sorted(nums_str, keyint)) # [1, 2, 10, 20] - 转成int后再比注意keyint只影响比较依据返回结果依然是原始字符串列表不会把元素真的变成整数。坑五sorted是浅拷贝rows [[b], [a], [c]] new_rows sorted(rows, keylambda x: x[0]) new_rows[0][0] zzz print(rows) # [[zzz], [a], [c]]原列表里的内层列表被影响了sorted()生成的是新列表但内层元素仍然是原对象的引用。如果要完全隔离需要在排序后用copy.deepcopy处理内层可变对象。6.3 几条实战建议第一如果确定可以修改原列表优先用list.sort()省内存需要保留原数据的场景毫不犹豫选sorted()。这个选择直接影响程序的内存峰值尤其在处理几十万条记录时。第二写排序规则建议遵循这个顺序先想能不能用keylambda一句话说清规则稍微复杂就换成具名函数字段方向混着需要细腻控制时考虑元组key或连续sort以上都绕不过去再考虑cmp_to_key。第三多级排序写完后强烈建议自己构造几条边界数据测一下全部字段相同、主字段相同但次要字段不同、空列表、列表里有None等情况。排序这件事看似简单出bug的隐藏方式比大多数人想象的多。测试最快的办法是把数据缩到5条以内打印key函数的返回值肉眼检查优先级是否符合业务预期。最后再分享一个我自己的小习惯当排序规则牵扯到两个以上字段时我不会直接写进业务代码而是在旁边加一行注释把为什么这么排用一句话讲清楚比如# 先按金额倒序再按创建时间倒序保证同金额的订单最新的在前面。三个月后回来看代码这点注释比什么都值钱。