常用的排序算法(主要指面试中)包含两大类,一类是基础比较模型的,也就是排序的过程,是建立在两个数进行对比得出大小的基础上,这样的排序算法又可以分为两类:一类是基于数组的,一类是基于树的;基础数组的比较排序算法主要有:冒泡法,插入法,选择法,归并法,快速排序法;基础树的比较排序算法主要有:堆排序和二叉树排序;基于非比较模型的排序,主要有桶排序和位图排序(个人认为这两个属于同一思路的两个极端)。
对于上面提到的这些排序算法,个人认为并没有优劣之分,主要看关注点,也就是需求。综合去看待这些算法,我们可以通过以下几个方面(不完全)判断:时间复杂度,空间复杂度,待排序数组长度,待排序数组特点,程序编写复杂度,实际程序运行环境,实际程序可接受水平等等。说白了就是考虑各种需求和限制条件,程序快不快,占得空间,排序的数多不多,规律不规律,数据重合的多不多,程序员水平,运行的机器高配还是低配,客户或者用户对运行时间的底线等等。
抛开主观的这些因为,从技术上讲,时间复杂度和空间复杂度,是最为关心的,下面是这些排序算法的一个总结和特点——分类和总结完全是个人体会,请不要拿教科书上的东西较真。
冒泡法:对比模型,原数组上排序,稳定,慢
插入法:对比模型,原数组上排序,稳定,慢
选择法:对比模型,原数组上排序,稳定,慢
归并法:对比模型,非原数组上排序,稳定,快
快速法:对比模型,原数组上排序,不稳定,快
堆排序:对比模型,原数组上排序,不稳定,快
二叉树排序:对比模型,非数组上排序,不稳定,快
桶排序:非对比模型,非原数组上排序,不稳定,快
位图排序:非对比模型,非原数组上排序,不稳定,快
现在开始正经的东西,逐一讨论一下这些排序算法;事实上,理解了算法本身的意义,伪代码很容易写出来,但是写代码是另外一回事——算法忽略常量,忽略对于复杂度影响不大的东西,但是写代码的时候,却必须关心这些:
1. 冒泡法
入门级算法,但是它的思路很具有特点:循环,两两向后比较。具体方法是针对循环中的每一元素,都对它后面的元素循环比较,交换大小值,每次循环“冒”一个最大值(或最小值)放在里层循环初始的地方;python中的代码如下:
def bubbleSort(L):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
for i in xrange(length):
for j in xrange(length-1-i):
if L[j] < L[j+1]:
temp = L[j]
L[j] = L[j+1]
L[j+1] = temp
return L
冒泡法的优点是稳定,不需要大量额外的空间开销,而且容易想到。很多面试人员知道快速排序,但是不知道冒泡法——大部分是
培训学校出来的,快速排序稍微改动一些就不知道怎么办了,但是冒泡法,虽然不知道,但是解释和优化起来,确很容易。毕竟对于
编程来说,嵌套一个循环和判断,是最基本的。
2. 选择排序
选择法也算是入门的一种排序算法,比起冒泡法,它的方法巧妙了一些,它的出发点在于“挑”,每次挑选数组的最值,与前置元素换位,然后继续挑选剩余元素的最值并重复操作。个人认为选择排序的意义不在于排序本身,而在于挑选和置换的方法,对于一些问题很有帮助。先看一下选择排序的python实现:
def selectSort(L):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
def _max(s):
largest = s
for i in xrange(s,length):
if L[i] > L[largest]:
largest = i
return largest
for i in xrange(length):
largest = _max(i)
if i!=largest:
temp = L[largest]
L[largest] = L[i]
L[i] = temp
return L
和冒泡排序一样,稳定,原位排序,同样比较慢。但是它的挑选和置换的方法,确实巧妙的,比如另一个面试提:0~100的已经排序的序列,如何随机打乱它的顺序,当然也可以变成如何最快的生成0~100的随机数。一种比较好的方法,就是随机在数组里挑选元素,然后置换这个数据和最后一个元素的位置,接下来在不包含最后一个元素的数组里继续找随机数,然后继续后置。
这个shuffle的方法,事实上难倒了很多面试的同学,甚至有些链表和树什么的都已经用到了。选择排序的思维可以轻松搞定这个问题。
3. 插入排序
冒泡,选择和插入,在排序算法中算最为入门的,虽然简单,但是也都各自代表着常用的编程方法。插入法和之前两个排序对比,并不在于如何按顺序的“取”,而在于如何按数序的“插”。具体方法是,顺序地从数组里获取数据,并在一个已经排序好的序列里,插入到对应的位置,当然,最好的放置已经排序的数据的容器,也是这个数组本身——它的长度是固定的,取了多少数据,就有多少空位。具体
Python实现如下:
def insertSort(L):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
for i in xrange(1,length):
value = L[i]
j = i-1
while j>=0 and L[j]<value:
L[j+1] = L[j]
j-=1
L[j+1] = value
return L
前面这三个排序方法,冒泡,选择和插入,在比较模型中速度很慢,它的原因是这样的,这三种方法,都不可避免的两两排序,也就是任意两个元素都相互的做过对比,所以它们不管给定的数组的数据特点,都很稳定的进行对比,复杂度也就是NXN。
但是有没有方法,不进行两两的对比呢?我们知道有些递归算法中,重复的操作可以通过迭代进行传递,或者使用容器将之前重复计算的部分存储起来,对于对比模型中的比较,也是有一些办法去除一些对比操作。比如去传递比较的结果,或者隔离的进行比较等等。一种经典的方法,就是分治法。
分治法并不是一种特定的算法,就像动态算法一样,只是一个解决问题的思路,并不是解决具体问题的方法。它使用在那种不断的重复去处理同一个小问题的情况下,也就是“分而治之”,大事化小,小事化无。经典的分治法包括归并排序和快速排序,它们的方法,都是先分,再合。
4. 归并排序
伟大的计算机先驱冯诺依曼提出来的一种办法,说到这里不得不感叹一下早起这些科学家的智慧了。归并排序的“分”和“合”的核心,就是将两个已经排序好的数组,合成一个排序的数组;如何构造两个已经排序好的数组呢?既然同样是排序,依然使用归并去递归处理。
具体的方法是,每次都将待排序的数组从中间分成两个数组,分别排序这两个数组,然后将它们再合并。所以归并排序的核心在于如何合并两个已经排序的数组——这貌似是一个面试题的原题,当然如果了解了归并算法,这道题也就无所谓了。解决合并的关键,一般的方法是准备一个新的空数组,然后需要三个指针,分别指向两个待合并数组和这个新数组,之后的操作,就是每次比较指向两个数组指针位置的指,选择大的那个放入新数组指针位置,然后被选择的数组指针后移,同时指向新数组的指针也后移。用指针来解释并不是什么好办法,更确切的描述应该是一个索引位置。当然Python的语法中,是很好解释的:
def mergeSort(L,start,end):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
def merge(L,s,m,e):
left = L[s:m+1]
right = L[m+1:e+1]
while s<e:
while(len(left)>0 and len(right)>0):
if left[0]>right[0]:
L[s] = left.pop(0)
else:
L[s] = right.pop(0)
s+=1
while(len(left)>0):
L[s] = left.pop(0)
s+=1
while(len(right)>0):
L[s] = right.pop(0)
s+=1
pass
if start<end:
mid = int((start+end)/2)
mergeSort(L,start,mid)
mergeSort(L,mid+1,end)
merge(L,start,mid,end)
归并排序在比较模型中,是速度较快的一种,由于每次都选择中间位置,所以它是稳定的,而且属于同一数组中的数据本身并不需要相互比较,它减少了比较的次数,只需要大约N次这样的比较,但是由于它需要不停的将数组等分,所以复杂度是Nlog2(N)。如果真的理解了归并排序,我想之前提到的那个面试题,肯定不是问题,另外,如果每次并不是两等分,而是在1/10的位置进行划分呢,它的复杂度又是多少呢?有时候我面试的时候会这么去问。
5. 快速排序
作为排序算法中老大级的快速排序,绝对是很多人的老大难。难就难在伪代码到代码的转换上——对与它的“分”和“合”,大部分人都能搞明白:选取待排序数组中的一个元素,将数组中比这个元素大的元素作为一部分,而比这个元素小的元素作为另一部分,再将这两个部分和并。
如果不考虑空间的申请,也就是不在元素组就行排序的话,这个算法写起来就是基本的递归调用,在python中尤为突出,如下:
def quickSortPython(l):
assert(type(l)==type(['']))
length = len(l)
if length==0 or length==1:
return l
if len(l)<=1:
return l
left = [i for i in l[1:] if i>l[0]]
right = [i for i in l[1:] if i<=l[0]]
return quickSortPython(left) +[l[0],]+ quickSortPython(right)
python的这种列表推导的写法,简化了代码书写,却牺牲了资源——这也就是快速排序难的部分,需要在原数组进行排序,也就是不使用额外的空间。
解决这个问题的关键,是在进行“分”的时候,不只从数组的一边进行比较,而是从数组的两边同时进行比较,然后相互补位。代码如下:
def quickSort(l,s,e):
assert(type(l)==type(['']))
length = len(l)
if length==0 or length==1:
return l
def partition(l,start,end):
pivot = l[start]
while start<end-1:
while end>start and l[end]<pivot:
end-=1
l[start] = l[end]
while end>start and l[start]>pivot:
start+=1
l[end] = l[start]
l[start] = pivot
return start
pass
#random pivot
def random_partition(l,start,end):
i = random.randint(start,end)
temp = l[i]
l[i] = l[start]
l[start] = temp
return partition(l,start,end)
if s<e:
m = partition (l,s,e)
quickSort(l,s,m-1)
quickSort(l,m+1,e)
return l
上面的代码,有一部分并没有使用,也就是random_partition这个函数。解释这个需要先讨论一下快速排序的特点。快速排序在原数组排序,所以空间复杂度很好,但是它的时间消耗呢?它在“分”的时候,和归并算法不同的,是归并算法选取的是“位置”,而快速排序选取的是“值”。我们能保证每次的位置都是中间位置,但是我们不能保证每次递归的时候,每次的Pivot都是最中间的值。
这就导致了快速排序算法的不稳定性,我们无法确定给定的待排序数组,如果给定的是一个已经排序的数组,而每次“分”的时候又选取了它的最值,那么结果是极端的——不仅每次“分”的时候需要对比N次,而且最终会被划分为N份,也就是最糟糕的情况——NxN的复杂度。还记得我最后在归并算法里提到的问题吗,如果按照1/10去分组的情况,其实这里同样适用,通过归并算法可以知道,最好的情况,是每次都正好分到了中间位置上,这时候的复杂度和归并算法一样,是Nlog2N。
由于我们不可能去改变用户的输入,只能从程序角度进行优化,所以在每次选取pivot的时候,随机的进行选取,从整体的概率角度来将,它的复杂度趋于最优。
上面这几种,是比较模型中数组形式进行比较的,如果熟悉数据结构的话,当然会想到数组的另一个表示方式——树。使用树的方法进行对比的排序,这里讨论两个方法,堆排序和二叉树排序。
6. 堆排序
对于没有学过数据结构的我来说,第一次看到堆排序的时,各种定义和公式,让我感觉脑袋疼。在这里讨论这种排序的时候,我也不想用那种让我脑袋疼的办法。
首先要知道的是,数组可以又一个二叉树来表示,既然是二叉树,它的表示也就是第一层一个节点,第二层两个节点,第三层四个节点,第四层八个节点。。。数组元素的放置位置就是挨着放,第一个元素放在第一层的唯一一个点,第二层的两个点放接下来的两个元素,即元素2和3,第三层的四个点,继续接下来的4个元素,即元素5、6、7、8。。。一直这么放下去,由于是二叉树,每次两分,所以树的深度是log2N。对于每一个节点,它的根节点在它的下一层,数组上的位置,就是2倍。
这就是一个数组的二叉树形式的理解,这是堆排序的基础(事实上这并不需要代码完成)。接下来的任务,是要把这个二叉树改造成所谓的堆。堆可以这样去理解,也就是对于二叉树来说,父节点的值大于子节点。在上面数组对应的二叉树中,我们需要将它改造成一个父节点值大于子节点值的二叉树。办法是从后向前的遍历每个父节点,每个父节点和两个子节点进行对比,并进行调整,直到形成一个堆——这个时候,根节点的值是最大的。
将这个跟节点的值和数组最后一个值进行换位,后然继续上面的调整,形成堆,找到根节点,与倒数第二个值换位。。。以此类推,直到数组排序完毕。这就是所谓的堆排序,它的python代码如下:
def heapSort(L):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
def sift_down(L,start,end):
root = start
while True:
child = 2*root + 1
if child > end:break
if child+1 <= end and L[child] > L[child+1]:
child += 1
if L[root] > L[child]:
L[root],L[child] = L[child],L[root]
root = child
else:
break
for start in range((len(L)-2)/2,-1,-1):
sift_down(L,start,len(L)-1)
for end in range(len(L)-1,0,-1):
L[0],L[end] = L[end],L[0]
sift_down(L,0,end-1)
return L
由于堆排序的堆的高度为log2N,而它每次调整的时候需要对比的次数趋向于N,所以整体的时间复杂度是N*log2N,但是它并不稳定的一种算法,依赖于给定的待排序数组。另外,堆排序是在原来的数组(二叉树)上进行调整和换位,并没有申请多余的空间。和冒泡一类两两相比的排序算法比较,堆排序主要是使用二叉树构建堆的方式,传递的排序结果。
但是事实上,每次根节点和后面元素置换的同时,二叉树其他节点并没有改变,所以我们可以使用额外的空间来记录这些节点的排列情况,提高排序速度。
7. 二叉树排序
这是另一个使用树进行排序的方法,和堆排序不同的是,这种方法需要这正的构建二叉树,而不是使用数组的二叉树形式。它的核心在与构建二叉树时的顺序以及输入二叉树时的顺序。
具体方法是,依次读取待排序数组的元素,并将其添加为一个二叉树的节点;添加的时候,按值的大小放在节点的左右,如果左右节点已经被占用,则递归到子节点进行添加。二叉树输出的时候,采取前序遍历或者后序遍历的方式输出。具体的Python代码如下:
def binaryTreeSort(l):
assert(type(l)==type(['']))
length = len(l)
if length==0 or length==1:
return l
class Node:
def __init__(self,value=None,left=None,right=None):
self.__value = value
self.__left = left
self.__right = right
@property
def value(self):
return self.__value
@property
def left(self):
return self.__left
@property
def right(self):
return self.__right
class BinaryTree:
def __init__(self,root=None):
self.__root = root
self.__ret=[]
@property
def result(self):
return self.__ret
def add(self,parent,node):
if parent.value>node.value:
if not parent.left:
parent.left = node
else:
self.add(parent.left,node)
pass
else:
if not parent.right:
parent.right = node
else:
self.add(parent.right,node)
def Add(self,node):
if not self.__root:
self.__root = node
else:
self.add(self.__root, node)
def show(self,node):
if not node:
return
if node.right:
self.show(node.right)
self.__ret.append(node.value)
if node.left:
self.show(node.left)
def Show(self):
self.show(self.__root)
b = BinaryTree()
for i in l:
b.Add(Node(i))
b.Show()
return b.result
按之前提到的,我们需要构建节点和二叉树的对象或者结构,然后进行遍历排序。本身需要构建二叉树和遍历输入,所以复杂度不如好的直接排序算法;如果不考虑空间开销和输出遍历,它整体的复杂度还是N*log2N的。所以整体的复杂度介于冒泡算法等普通排序算法和快速排序等高级排序算法之间。
文中要讨论的基于比较模型的排序算法暂时只讨论这么多,最后讨论二叉树排序,是为了引深一个问题——比较模型的排序算法复杂度还能在优化吗?答案是不行的,纯比较模型的排序算法,最好的时间复杂度就是N*log2N了。我们可以改造二叉树排序来证明这一点,当然还是以大白话为主,我不喜欢繁琐的公式。
这个问题的证明,是需要一套模型理论的,即决策树。我们抛开各种理论,可以简单的认为,这就是一个二叉树。这个二叉树的最终展开,就是所有的决策,在这里就是一个待排序数组的所有数序集合,一个N个元素的所有排序个数为N!个。也就是说,从这个二叉树的根节点开始,最终会有N!个子节点。那么这个二叉树的深度,也就是最终执行的次数。实际上,也就是2^h=N!,通过数学推导,可以得到h<N*log2N。推理过程就是两边同时取Log,但这不是这里的重点,重点是基于比较模型的排序算法,时间复杂度不会小于N*log2N。
如果想要在比较模型上继续提高排序速度,在模型本身上没有可以改进的空间,只能使用其他办法——比如刚才提到的空间换时间的方法,使用其他空间存储一些重复的对比,或者使用混合的比较模型。
事实上,大多数内置的排序算法都是混合型的,我们的目的是加快排序的速度,而不是模型本身。一种广泛采取的排序算法,是在数据量很大的时候,采取快速排序的方式,而在当分组很小的时候,使用其他稳定的排序方法。这样的混合型算法,综合效果是最好的,也就是一般内置排序使用的方法。
除了建立在比较模型上的排序算法,还有一些其他的排序算法,它们并非比较的去排序,而是其他的方法,基本上很难想到。其中一个比较简单的,是桶排序。
8. 桶排序
桶排序是一种计数排序方法,用标记过号码的桶,去装待排序数组中的数据,数组元素的值对应着桶的编号,最后按桶的标号取出。具体的方式是,获取待排序数组的最大值,以这个最大值建立数组,并将所有元素置为0,遍历待排序数组,如果元素的值和桶的编号相等,则桶的值自动加一。遍历完毕后,按照桶的编号倒序输入。具体pythono实现如下:
def countSort(l):
assert(type(l)==type(['']))
length = len(l)
if length==0 or length==1:
return l
m = max(l)
ret = []
storage = [0]*(m+1)
def count(x):
storage[x]+=1
def pop(x):
tem = storage[x]
while tem>0:
ret.append(x)
tem-=1
map(lambda x:count(x),l)
map(lambda x:pop(x),xrange(m,0,-1))
return ret
这种计数排序的方法并不是用于数序很大的情况,而且数据越紧凑排序效果越好。当然这样的算法还有可以提高的地方,那就是除了找到待排序数组的最大值以外,还可以找到它的最小值,以缩短申请的空间。但是提高的效果有限。这样的算法对环境要求很高,但是如果满足这样的环境,它的排序效果,非常高效。比如百度百科中的一个例子:
海量数据
一年的全国高考考生人数为500 万,分数使用标准分,最低100 ,最高900 ,没有小数,你把这500 万元素的数组排个序。
分析:对500W数据排序,如果基于比较的先进排序,平均比较次数为O(5000000*log5000000)≈1.112亿。但是我们发现,这些数据都有特殊的条件: 100=<score<=900。那么我们就可以考虑桶排序这样一个“投机取巧”的办法、让其在毫秒级别就完成500万排序。
方法:创建801(900-100)个桶。将每个考生的分数丢进f(score)=score-100的桶中。这个过程从头到尾遍历一遍数据只需要500W次。然后根据桶号大小依次将桶中数值输出,即可以得到一个有序的序列。而且可以很容易的得到100分有***人,501分有***人。
实际上,桶排序对数据的条件有特殊要求,如果上面的分数不是从100-900,而是从0-2亿,那么分配2亿个桶显然是不可能的。所以桶排序有其局限性,适合元素值集合并不大的情况。
当给定的待排序数组没有重复数据,而且数据量非常大,即属于桶排序情况的一种极端特殊的情况,使用桶排序的话,我们需要很大的空间消耗,并且桶中的计数,对于结果意义不大。
我们可以将其改造和优化,优化的部分就是如果减少空间的消耗,而不关心桶的计数——数量只有0或者1,而不是>1。0或者1的特点,给了我们启发——我们可以使用计算机的位进行存储。这种方法就是位图排序。
10. 位图排序
如果使用其他语言,可能很难想到这种方法。它使用位图的方法记录待排序数组中元素的数据,在一些高级的编程语言中,开辟二维数据空间很容易做到——但是在C中,位图是使用位操作实现的,这大大的提高了空间的使用率,而且,使用位运算,效率远大于除和余的操作。
白话的解释这种算法,就是在桶排序的大思路下,不在使用桶记录数据,而是使用bit位。如果申请一个int的二维数组,每个位置上只放1和0,那么太浪费空间了——在32位的操作
系统上,一个Int完全可以存储32位的标记,算法的核心就是如何找到这个位置。为了更好的使用位图的方式,这里我采用位运算进行编写,对应的除和余可以达到同样的操作,但是性能很低,python的代码如下:
def setSort(L):
assert(type(L)==type(['']))
length = len(L)
if length==0 or length==1:
return L
BIT = 32
SHIFT = 5
MASK = 0x1f
N = 1+len(L)/BIT
a = [0]*N
ret = []
def clearmap(i):
a[i>>SHIFT] &= ~(1<<(i & MASK))
def setmap(i):
a[i>>SHIFT] |=(1<<(i & MASK))
def showmap(i):
for i in xrange(N):
for j in xrange(32):
if a[i]&(1<<j):
ret.append(32*i+j)
map(lambda x: clearmap(x),L)
map(lambda x: setmap(x),L)
map(lambda x: showmap(x),xrange(N))
if ret:
return ret
这种方法很巧妙,但是使用范围比较窄。
《编程珠玑》有过类似的问题,书中就是用这种方法实现的:
假设整数占32位,1M内存可以存储大概250000个整数,第一个方法就是采用基于磁盘的合并排序算法,第二个办法就是将0-9999999切割成40个区间,分40次扫描(10000000/250000),每次读入250000个在一个区间的整数,并在内存中使用快速排序。
尽管这种排序方法使用范围比较小,但是在算法设计上,给了我们很大的思考空间——比如哈希结构的设计,一些面试题可能用涉及到使用数组去构建哈希表或者字典,本质上都是用空间定位换取时间。当然这里不深入讨论。
讨论完这些常用的排序算法后,需要对它们进行一下测试,python中的测试和分析还是比较容易的,可以借用unittest直接编写。当然还需要一些准备:
乱序数组:
L = range(5000)
random.shuffle(L)
使用装饰器用来计算时间(语法糖),时间计算上尽可能使用time.clock(),
windows系统上它和时钟时间是一致的,更加精确:
def timeCount(func):
def wrapper(*arg,**kwarg):
start = time.clock()
func(*arg,**kwarg)
end =time.clock()
print 'used:', end - start
return wrapper
一个执行的类,用来invode方法,并打印信息:
class Executor:
def __init__(self, func, *args, **kwargs):
self.func = func
self.args = args
self.kwargs = kwargs
self.do()
@timeCount
def do(self):
print '-----start:',self.func,'-----'
self.ret = self.func(*self.args, **self.kwargs)
def __del__(self):
print '-----end-----'
其他一些Python语法说明:
对于两个值交换的方法,python风格的方式为a,b=b,c;例子中两种方式都有;
如果需要大容量的数组,使用range(N)生成;如果只是进行遍历迭代,请使用xrange(N),它不会占据很大空间,只是一个迭代工具;
接下来的是对方法的调用:
class TestSort(unittest.TestCase):
def test_01_bubbleSort(self):
Executor(bubbleSort,L[:])
pass
def test_02_selectSort(self):
Executor(selectSort,L[:])
pass
def test_03_insertSort(self):
Executor(insertSort,L[:])
pass
def test_04_mergeSort(self):
Executor(mergeSort,L[:],0,len(L)-1)
pass
def test_05_heapSort(self):
Executor(heapSort,L[:])
pass
def test_06_binaryTreeSort(self):
Executor(binaryTreeSort,L[:])
pass
def test_07_quickSort(self):
Executor(quickSort,L[:],0,len(L)-1)
pass
def test_08_quickSortPython(self):
Executor(quickSortPython,L[:])
pass
def test_09_countSort(self):
Executor(countSort,L[:])
pass
def test_10_setSort(self):
Executor(setSort,L[:])
pass
def test_11_builtinSort(self):
Executor(sorted,L[:])
pass
if __name__=="__main__":
unittest.main()