任务
需要根据排名顺序从序列中获得第n个元素(比如,中间的元素,也被称为中值)。如果序列是已经排序的状态,应该使用seq[n],但如果序列还未被排序,那么除了先对整个序列进行排序之外,还有没有更好的方法?
解决方案
如果序列很长,洗牌洗得很充分,而且元素之间的比较开销也大,那么也许还能找到更好的方式。排序的确很快,但不管怎样,它(一个长度为n的充分洗牌的序列)的时间复杂度仍然是 O(nlogn),而时间复杂度为O(n)的取得第n个最小元素的算法也的确是存在的。下面我们给出一个函数来实现此算法:
import random
def select(data,n):#寻找第n个元素(最小的元素是第0个)#创建一个新列表,处理小于0的索引,检查索引的有效性data = list(data)if n < O:n += len(data)if not 0 <= n < len(data):raise ValueError,"can't get rank %d out of %d" %(n,len(data))#主循环,看上去类似于快速排序但不需要递归while True:pivot = random.choice(data)pcount = 0under:over= [ ],[ ]uappend,oappend = under.append,over.appendfor elem in data:if elem < pivot:uappend(elem)elif elem > pivot:oappend(elem)else:pcount += 1numunder = len(under)if n < numunder:data =underelif n < numunder + pcount:return pivotelse:data = overn -= numunder +pcount
讨论
本节解决方案也可用于重复的元素。举个例子,列表[1,1,1,2,3]的中值是1,因为它是将这5个元素按顺序排列之后的第3个。如果由于某些特别的原因,你不想考虑重复而需要缩减这个列表,使得所有元素都是唯一的(比如,通过18.1节提供的方法),可以完成这一步骤之后再回到本节的问题。
输入参数 data 可以是任意有边界的可迭代对象。首先我们对它调用 list 以确保得到可迭代的对象,然后进入持续循环过程。在循环的每一步中,首先随机选出一个轴心元素以轴心元素为基准,将列表切片成两个部分,一个部分“高于”轴心,一个部分“低于”轴心,然后继续在下一轮循环中对列表的这两个部分中的一个进行深入处理,因为我们可以判断第n个元处于哪一个部分,所以另外一个部分就可以丢弃了。这个算法的思想很接近经典的快速排序算法(只不过快速排序无法丢弃任何部分,它必须用递归的方法,或者用一个栈来替换递归,以确保对每个部分都进行了处理)
随机选择轴心使得这个算法对于任意顺序的数据都适用(但不同于原生的快速排序,某些顺序的数据将极大地影响它的速度),这个实现花费大约log2N时间用于调用random.choice。另一个值得注意的是算法统计了选出轴心元素的次数,这是为了在一些特殊情况下仍能够保证较好的性能,比如 data 中可能含有大量的重复数据。
将局部变量列表 under 和 over 的被绑定方法 append 抽取出来,看起来没什么意义,而且还增加了一点小小的复杂性,但实际上,这是Python 中一个非常重要的优化技术。为了保持编译器的简单、直接、可预期性以及健壮性,Python不会将一些恒定的计算移出循环,它也不会“缓存”对方法的查询结果。如果你在内层循环调用 under.append和 over.append,每一轮都会付出开销和代价。如果想把某些事情一次性做好,那么需要自己动手完成。当你考虑优化问题时,你总是应该对比优化前和优化后的效率,以确保优化真正起到了作用。根据我的测试,对于获取range(10000)的第5000个元素这样的任务,去掉优化部分之后,性能下降了50%。虽然增加一点小小的复杂性,但仍然是值得的,毕竟那是50%的性能差异。
关于优化的一个自然的想法是,在循环中调用cmp(elem,pivot),而不是用一些独立的elem<piovt 或 elem>pivot来测试。不幸的是,cmp 不会提高速度;事实上它还有可能会降速,至少当 data的元素是一些基本类型比如数字和字符串的时候,的确是这样。那么,select的性能和下面这个简单方法的性能相比如何呢?
def selsor(data,n):data = list(data)data.sort()return data[n]
在我的计算机上,获取一个3001个整数的充分洗牌的列表的中值,本节解决方案的代码耗时 16ms,而selsor 耗时 13ms,再考虑到sort 在序列部分有序的情况下速度会更快,元素是基本类型且比较操作执行得很快,而且列表长度也不大,所以使用 select 并没有什么优势。将长度增加到30001,这两个方法的性能变得非常接近,都是约170ms。但当我将列表长度修改成 300001,select 终于表现出了优势,它用了2.2s获得了中值,而 selsor 需要 2.5s。
但如果序列中元素的比较操作非常耗时,那么这两个方式刚刚表现出的大致平衡就被彻底打破了,因为这两个方式的最关键的差异就是比较操作执行的次数——select 执行O(n)次,而 selsor 执行O(nlogn)次。举个例子,假如我们需要比较的是某个类的实例,其比较操作的开销相当大(模拟某些四维的坐标点,其前三维坐标通常总是相同的):
class X(obiect):def __init._(self):self.a = self.b = self.c = 23.51self.d = random.random()def _dats(self):return self.a,self.b,self.c,self.ddef __cmp__(self,oth):return cmp(self._dats,oth._dats)
现在,即使只对 201个实例求中值,select就已经表现得比selsor快了。
换句话说,基于列表的 sort 方法的实现的确要简洁得多,实现select 也确实需要多付出一点力气,但如果n足够大而且比较操作的开销也无法忽略的话,select就体现出它的价值了。