第14条 用 sort 方法的 key 参数来表示复杂的排序逻辑
内置的列表类型提供了名叫 sort 的方法,可以根据多项指标给 list 实例中的元素排序。在默认情况下,sort 方法总是按照自然升序排列列表中的元素。例如,如果列表中的元素都是整数,那么它就按数值从小到大排列。
python
numbers = [93, 86, 11, 68, 70]
numbers.sort()
print(numbers)
>>>
[11, 68, 70, 86, 93]
凡是具备自然顺序的内置类型几乎都可以用 sort 方法排列,例如字符串、浮点数等。但是,一般的对象又该怎样排序呢?比如,这里定义了一个 Tool 类表示各种建筑工具,它带有 __ repr __ 方法,因此我们可以把这个类实例打印成字符串。(参见 第75条)。
python
class Tool:
def __init__(self, name, weight):
self.name = name
self.weight = weight
def __repr__(self):
return f'Tool({self.name!r}, {self.weight})'
tools = [Tool('level', 3.5),
Tool('hammer', 1.25),
Tool('screwdriver', 0.5),
Tool('chisel', 0.25),
]
print(tools)
>>>
[Tool('level', 3.5), Tool('hammer', 1.25), Tool('screwdriver', 0.5), Tool('chisel', 0.25)]
如果仅仅这样写,那么这个由该类的对象所构成的列表是没办法用 sort 方法排序的,因为 sort 方法发现,排序所需要的特殊方法并没有被定义在 Tool 类中。
python
tools.sort()
>>>
Traceback ...
TypeError: '<' not supported between instances of 'Tool' and 'Tool'
如果某些类像整数 (int) 那样具有自然顺序,那么可以定义一些特殊的方法(参见 第73条),这样我们无需额外的参数就能直接在由这种类的实例所构成的列表上调用 sort 方法来排序了。(但更为常见的情况是,很多对象需要在不同的情况下按照不同的标准排序,此时定义自然排序就没有意义了。)
这样排序标准通常是针对对象中某个属性(attritube)。我们可以把这样的排序逻辑定义为函数,然后将这个参数传给 sort 方法的key 参数。key 所表示的函数本身应该带有一个参数,这个参数指代列表中有待排序的对象,函数返回的应该是个可比较的值(也就是具备自然顺序的值),以便 sort 方法以该值为标准值给这些对象排序。
下面用 lambda 关键字定义这样的一个函数,把它传给 sort 方法的 key 参数,让我们能狗按照 name 的字母顺序排列这些 Tool 对象。
python
print("Unsorted: ", repr(tools))
tools.sort(key=lambda x: x.name)
print("\nSorted:", tools)
>>>
Unsorted: [Tool('level', 3.5), Tool('hammer', 1.25), Tool('screwdriver', 0.5), Tool('chisel', 0.25)]
Sorted: [Tool('chisel', 0.25), Tool('hammer', 1.25), Tool('level', 3.5), Tool('screwdriver', 0.5)]
如果想用另一项标准(比如 weight),那只需要定义另一个变量 lambda 函数并将其传给 sort 方法的 key 参数就可以了。
python
tools.sort(key=lambda x: x.weight)
print(tools)
>>>
[Tool('chisel', 0.25), Tool('screwdriver', 0.5), Tool('hammer', 1.25), Tool('level', 3.5)]
在编写传给 key 参数的 lambda 函数时,可以像刚才那样返回对象的某个属性,如果对象是序列、元组、字典,那么还可以返回其中的某个元素。其实,只要是有效的表达式,都可以充当 lambda 函数的返回值。
对于字符串这样的基本类型,我们可能需要通过 key 函数先对它的内容做一些变换,并根据变换之后的结果来排序。例如,下面的这个 places 列表中存放着表示地点的字符串,如果想在排序的时候忽略大小写,那我们可以用 lower 方法把待排序的字符串处理一下(因为对于字符串来说,自然顺序就是它们在词典里面的顺序,而词典中的大写字母在小写字母之前。)
python
places = ['home', 'work', 'New York', 'Pairs']
places.sort()
print("Case sensitive", places)
places.sort(key=lambda x: x.lower())
print('Case insensitive:', places)
>>>
Case sensitive ['New York', 'Pairs', 'home', 'work']
Case insensitive: ['home', 'New York', 'Pairs', 'work']
有时需要用多个标准来排序。例如,下面的列表中有一些电动工具,我们想以 weight(重量)为首要指标来排序,在重量相同的情况下,再按 name(名称)排序。
python
power_tools = [
Tool('drill', 4),
Tool('circular saw', 5),
Tool('jackhammer', 40),
Tool('sander', 4),
]
在 Python 语言里,最简单的方案是利用元组 (tuple) 类型实现。元组是一种不可变的序列,能够存放任意的 Python 值。两个元组之间是可比较的,因为这种类型本身已经定义了自然顺序,也就是说,sort 方法所要求的特殊方法(例如 __ lt __),它都已经定义好了。元组在实现这些特殊方法时会依次比较每个位置都两个对应元素,直到能够确定大小为止。下面,我们来看其中一个工具比另外一个工具重的情况,在这种情况下,只需要根据元组的第一个元素(重量)就可以确定这两个元组的大小。
python
saw = (5, 'circular saw')
jackhammer = (40, 'jackhammer')
assert not (jackhammer < saw) # Matches expectations
如果两个元组的首个元素相等,就比较第二个元素,如果仍然相等,就继续往下比较,下面演示两个重量相等但名称不相同的两个元组。
python
drill = (4, 'drill')
sander = (4, 'sander')
assert drill[0] == sander[0] # same weight
assert drill[1] < sander[1] # Alphabetically less
assert drill < sander # Thus, drill come first
利用元组的这项特性,我们可以用工具的 weight 与 name 构造一个元组。下面就定义这样一个 lambda 函数,让它返回这种元组,把首要指标(也就是 weight)写在前面,把次要指标(也就是 name)写在后面。
python
power_tools.sort(key=lambda x: (x.weight, x.name))
print(power_tools)
>>>
[Tool('drill', 4), Tool('sander', 4), Tool('circular saw', 5), Tool('jackhammer', 40)]
这个做法有个缺点,就是 key 函数所构成的这个元组只能按同一个排序方向对比它所表示的各项指标(要是升序,就得是升序;要是降序,就都得是降序),所以不太好实现 weight 按降序排而 name 按升序排的效果。sort 方法可以指定 reverse 参数,这个参数会同时影响元组的各项指标(例如在下面的例子中, wight 和 name 都会按照降序处理。所以 'sander' 会出现在 'drill' 的前面,而不是像刚才的例子那样出现在后面)。
python
power_tools.sort(key=lambda x: (x.weight, x.name), reverse=True)
print(power_tools)
>>>
[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('sander', 4), Tool('drill', 4)]
如果其中一个指标是数字,那么可以实现 key 函数时,利用一元减操作符让两个指标按照不同的方向排序。也就是说,key 函数在返回这个元组时,可以单独对这项指标取相反数,并保持其他指标不变,这就相当于让排序算法单独在这项指标上采用逆序。下面就演示这样按照重量从大到小、名称从小到大的顺序排列(这次,'sander' 会排在 'drill' 的后面)。
python
power_tools.sort(key=lambda x: (-x.weight, x.name))
print(power_tools)
>>>
[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]
但是,这个技巧并不适合所有的类型。例如,若想在指定 reverse = True 的前提下得到相同的排序结果,那我们可以试着对 name 运用一元减操作符,试试能不能做出重量从大到小、名称从小到大的效果。
python
power_tools.sort(key=lambda x: (x.weight, -x.name), reverse=True)
>>>
Traceback ...
TypeError: bad operand type for unary -: 'str'
可以看到,str 类型不支持一元减操作符。在这种情况下,我们应该考虑 sort 方法的一项特征,那就是这个方法是个稳定的排序算法。这意味着,如果 key 函数认定两个值相等,那么这两个值在排序结果中的先后顺序会与它们在排序之前的顺序一致。于是,我们可以在同一个列表上多次调用 sort 方法,每次指定不同的排序指标。下面,我们就利用这项特征实现刚才想要达成的那种效果。把首要指标(也就是重量)降序放在第二轮,把次要指标(也就是名称)放在第一轮。
python
power_tools.sort(key=lambda x: x.name)
power_tools.sort(key=lambda x: x.weight, reverse=True)
print(power_tools)
>>>
[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]
为什么这样可以得到正确的结果呢?我们分开来看,先看第一轮排序,也就是按照名称升序排列:
python
power_tools.sort(key=lambda x: x.name)
print(power_tools)
>>>
[Tool('circular saw', 5), Tool('drill', 4), Tool('jackhammer', 40), Tool('sander', 4)]
然后执行第二轮,也就是按照重量降序排列。这时,由于 'sander' 与 'drill' 所对应的两个 Tool 对象重量相同,key 函数会判断这两个对象相等。于是,在 sort 方法的排序结果中,它们之间的先后次序就跟在第一轮结束的次序相同。所以,我们在实现了按重量降序排列的同时,保留了重量相同的对象在上一轮排序结束时的相对次序,而上一轮是按照名称升序排列的。
python
power_tools.sort(key=lambda x: x.weight, reverse=True)
print(power_tools)
>>>
[Tool('jackhammer', 40), Tool('circular saw', 5), Tool('drill', 4), Tool('sander', 4)]
无论有多少项排序指标都可以按照这种思路来实现,而且每项指标可以分别按照各自的方向来排,不用全都是升序或全都是降序。只需要倒着写即可,也就是把把最主要的那项排序指标放在最后一轮处理。
尽管这两种思路都能实现同样的效果,但只调用一次 sort,还是要比多次更加简单。所以,在实现多个指标按不同方向排序时,应该优先考虑让 key 函数返回元组,并对元组的相应参数取相反数。