近期由于要找工作,经常被问到一些排序算法,确切地说是内部排序算法的实现。参考《算法设计与分析基础(第2版)》,又重温了一遍积极向上的本科时光,幸运的是我还没有感觉到自己已老。
本篇文章包括的排序有:冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序共六中,可分类为:
交换类排序:冒泡排序、快速排序
选择类排序:选择排序、堆排序
插入类排序:插入排序
归并类排序:归并排序
其中冒泡排序、选择排序属于蛮力法,快速排序、归并排序属于分治法,插入排序属于减治法,堆排序属于变治法。
内部排序指的是待排序列可以完全放在内存中的排序,这意味着待排序列不会很大,8、16、32到几百G。基于键值交换的排序最小的时间复杂度为O(nlogn)。
假设以下讨论都是针对整数数组的升序排列。
1. 冒泡排序
有人说冒泡排序之所以还能活跃在人们的脑中是因为它有一个好记的名字。
冒泡排序由2层循环构成,外层循环从数组最后一个元素循环到数组第二个元素,内层循环从数组第一个元素循环到数组倒数第二个元素,所以先写两层循环:
for(int i=array.length-1;i>0;i--) {
for(int j=0;j<i-1;j++) {
...
}
}
当外层循环将要结束当次循环时(i--执行前),数组中最大的元素就放在array[i]中,就好像最大的泡泡冒出来一样;而内层循环每次都比较array[j]和array[j+1],如果前者大于后者,则交换两者的值,所以冒泡排序应该看起来是这样子的:
for(int i=array.length-1;i>0;i--) {
for(int j=0;j<i-1;j++) {
if (array[j]<array[j+1]) {
swap(array[j],array[j+1]);
}
}
}
可以看到两层循环的执行次数为length项自然数列求和1+2+3+...+length,因此算法时间复杂度O(pow(n,2)),冒泡排序由于只交换相邻数组元素,所以是稳定的排序算法。
该吃午饭了,吃完饭继续编辑...Back from lunch...看了一中午电影才回来的
2. 快速排序
由卓越的英国计算机科学家C.A.R.Hoare发明,在Hoare年轻的时候曾为一个俄译英的机器翻译项目工作,再对俄语词典进行排序的过程中,他发明了快速排序算法,Hoare说:“我最早曾考虑过用冒泡排序来做,但经过一番幸运的尝试后,我的第二个念头就是快速排序。”我们不得不赞同他的观点。“我是非常幸运的。以发明一种新的排序方法开始一个人的计算机职业生涯实在是太美妙了!”
不是每个人都有这样的honor的,是吧。实际上快速排序可以由三步完成:
(1)从待排数组中选取一个值(可以是任意一个值)作为枢值,通过比较和交换,将枢值放置于最终其应该所在的位置:位于其位置前面的元素都小于它,位于其位置后面的元素都大于它;
(2)对第一步中该位置前面的子数组(如果存在的话)执行(1)(2)(3);
(3)对第一步中该位置后面的子数组(如果存在的话)执行(1)(2)(3);
从上述描述中可以得出快速排序的主要结构就是:
void quicksort(int[] array, int start, int end) {
if(start<end) {
int position = partition(array, start, end);
quicksort(array, start, position-1);
quicksort(array, position+1, end);
}
}
现在需要把第一步细化:
每次都选取子数组中的最后一个值作为枢值并记录下来,然后分别从子数组第一个、倒数第二个位置开始,向后、向前扫描这个数组,分别记做i、j,
a. 向后扫描直到array[i]>=枢值,
b. 向前扫描直到array[j]<=枢值,
c. 交换array[i]和array[j]的值,
循环执行a、b、c直到两个索引相等或者交叉;
将array[j]和枢值交换,并返回j作为枢值所在的最终位置。
下面是partition的实现:
int partition(int[] array, int start, int end) {
int i,j,pivot;
i = start; j = end - 1;
pivot = array[end];
while (i <= j) {
for(; array[i]<pivot; i++);
for(; j>=start && array[j]>pivot; j--);
if (j>=start) {
swap(array[i],array[j]);
} else {
swap(array[end], array[start]);
return start;
}
}
swap(array[i],array[j]); //当i>=j撤销最后一次交换
swap(array[end],array[i]);
return i;
}
在实现过程中,向后的扫描不需要加入数组越界判断,但是向前的扫描需要加入数组越界判断,并且需要考虑子数组中枢值如果是最小的情况,对应while循环中的else。
除此之外,退出while循环时多做了一次交换,并且i指向的值一定大于等于pivot,j指向的值一定小于等于pivot,所以在while之后要撤销最后一次交换,并将pivot放置于i处。
该运动了,总结确实挺耗时的,还想看看spring struts的知识,哎,时不我待呀。。。运动完再继续写吧。
快速排序在平均情况下,仅比最优情况多执行38%的比较操作为(1.38nlogn)。此外,它的最内层循环效率非常高,使得在处理随机排列的数组时,速度要比归并排序、堆排序快。
另外,快速排序交换的键值相距经常比较远,所以它是不稳定的排序算法。
3. 选择排序
记得本科学习排序时,第一个自己能想到的算法就是选择排序,作为蛮力法的两个代表(选择排序+冒泡排序)之一,选择排序具有更加清晰的实现方法。
它的思想是:对长度为n的待排数组进行n-1次扫描,每次扫描找到剩余数组中最小的数,然后交换这个数和扫描起始位置上的数。
for(i=0;i<array.length-2;i++) {
min = array[i];
minindex = i;
for(j=i+1;j<array.length-1;j++) {
if (array[j] < min) {
min = array[j];
minindex = j;
}
}
swap(array[i],array[j]);
}
对于任何输入来说,选择排序时间复杂度都是O(n2),但是选择排序的键值交换次数仅为n-1次,这个特性使得选择排序超过了许多其他的排序算法。
4. 归并排序
归并排序是一个既适合内部排序又适合外部排序的算法,而且在众多排序中既稳定又有O(nlogn)的时间复杂度。
但是归并排序不是在位的排序方法,what a pitty!
借助分治法的思想:
(1)将问题的实例分为同一个问题的几个较小的实例,最好拥有同样的规模
(2)对这些较小的实例求解(一般采用递归的方法,但在问题规模小于一定阈值时,有时也会利用另一个算法)
(3)如果必要的话,合并这些较小问题的解,已得到原始问题的解
说明归并排序的思想:
(1)递归地将待排数组划分成前半个数组和后半个数组
(2)当划分后的数组都是一个元素时,这个数组是一个有序数组(因为只有一个元素)
(3)把这两个有序的子数组合并为一个有序的数组(1+1=2,2+2=4...)
算法由两个方法实现recursiveMerge和merge,其中recursiveMerge包含上述三步,如下所示:
void recursiveMerge(ArrayList<Integer> array, int start, int end) {
if (start < end) {
int m = (start+end)/2;
recursiveMerge(array, start, m);
recursiveMerge(array, m+1, end);
merge(array, start, m, end);
}
}
merge的工作包括array从start到middle拷贝到一个新的数组,从middle+1到end拷贝到一个新的数组
注意,这两个数组都是有序的,然后合并这个两个有序数组,代码如下:
i,j,k三个索引分别指示array、lowHalf、highHalf这三个数组
private void merge(ArrayList<Integer> array, int start, int middle, int end) {
int i,j,k;
ArrayList<Integer> lowHalf = new ArrayList<Integer>();
ArrayList<Integer> highHalf = new ArrayList<Integer>();
if ((middle-start)==0 && (end-middle)==0) {
return;
}
for (i=start;i<=middle;i++) {
lowHalf.add(array.get(i));
}
for (i=middle+1;i<=end;i++) {
highHalf.add(array.get(i));
}
j=0;
k=0;
for (i=start;i<=end;) {
while(j<=(middle-start) && k<=(end-middle-1) && lowHalf.get(j)<=highHalf.get(k)) {
array.set(i, lowHalf.get(j));
j++;
i++;
}
while(j<=(middle-start) && k<=(end-middle-1) && highHalf.get(k)<lowHalf.get(j)) {
array.set(i, highHalf.get(k));
k++;
i++;
}
if (j<=(middle-start) && k>(end-middle-1)) {
array.set(i, lowHalf.get(j));
j++;
i++;
}
if (j>(middle-start) && k<=(end-middle-1)) {
array.set(i, highHalf.get(k));
k++;
i++;
}
}
}
正如上面所说,归并排序的主要缺点就是该算法需要线性的额外空间。虽然归并排序也能做到在位,但会导致算法太过负载。而且因为它的增长次数具有一个很大
的常系数,所以在位的归并排序算法只具有理论上的意义。
5. 插入排序
借助减治法思想,使用2个索引 i和j分别指示待排序元素和当前有序列表的最后一位,然后从j向前遍历当前有序列表,直到将i指示的元素插入合适的位置为止,此为完成一趟排序;共需完成n-1趟这样的排序。代码如下:
public void insertSort(ArrayList<Integer> arrayList) {
int i, j;
int currentUnorderdElement;
for (i=1;i<arrayList.size();i++) {
currentUnorderdElement = arrayList.get(i);
for (j=i-1;j>=0;j--) {
if(arrayList.get(j)>currentUnorderdElement) {
arrayList.set(j+1, arrayList.get(j));
} else {
arrayList.set(j+1, currentUnorderdElement);
break;
}
}
}
}
算法的最坏时间复杂度对应倒序的待排数组,此时时间复杂度为O(pow(n,2));然而,对于基本有序的文件来说,插入排序可以达到良好的性能。一个可能的应用情形就是将插入排序与快速排序融合起来使用:当快速排序子数组的规模变得小于某些预定义的值时(比如,10个元素),可以用插入排序来完成10个元素的排序来替代快速排序中使用迭代来排序。有文献指出,对快速排序做了这种改动之后,一般会减少10%的运行时间。该算法的平均性能比最坏性能快2倍,以及遇到基本有序的数组时表现出的优异性能,使得插入排序领先于它在基本排序领域中的主要竞争对手----选择排序和冒泡排序,另外,它有一种扩展算法,是以发明者D. L. Shell的名字命名的----Shell排序,此排序方法提供了一种更好的算法来对较大的文件进行排序。Shell排序没有在这篇文章中出现。
6. 堆排序
现在看来,变治法很好的形容了堆排序的实质,本来一个排序问题,却通过引入一个大顶堆或小顶堆(本质上是一样的)的数据结构,然后通过每次将“顶”移出堆并将剩下的节点重新组成大顶堆或小顶堆的数据结构来逐个找到待排序元素中的最大元素。有点像高中时候的数学证明大题,让你证明一个东西,但是给你两问,第二问是基于第一问的结论来回答的。
先来说一下什么是大顶堆:大顶堆可以定义为一颗二叉树,输的节点中包含键(每个节点一个键),并且满足下面两个条件:
(1)树的形状要求----这棵二叉树是完全二叉树,输的每一层都是满的,除了最后一层最右边的元素有可能缺位。
(2)父母优势要求----每一个节点的键都要大于或等于它子女的键
在本例中,存储大顶堆的数据结构是数组,设父节点在数组中的索引为i,则左子节点索引 2*i+1,右子节点索引 2*i+2,i=0, ... , (n-2)/2
在介绍完大顶堆的概念后,堆排序的算法heapSort可以这样形容:
(1)将待排数组构建成大顶堆constructBigTopHeap;
(2)将待排序数组第一个元素(最大值)和最后一个元素交换,这时破坏了大顶堆,因此调整大顶堆,adjustBigTopHeap
(3)待排序数组规模减1,重复(2)(3)直到待排序数组规模为1
主程序heapSort:
public void sort(ArrayList<Integer> list) {
int tmp,size;
size = list.size();
constructBigTopHeap(list);
for (int i=size-1;i>0;i--) {
tmp = list.get(0);
list.set(0, list.get(i));
list.set(i, tmp);
adjustBigTopHeap(list, i-1);
}
}
子程序constructBigTopHeap:
private void constructBigTopHeap(ArrayList<Integer> list) {
int i,parent,tmp,j,k;
for (i=list.size()-1;i>0;i--) {
parent = (i-1)/2;
if (parent>=0 && list.get(i)>list.get(parent)) {
tmp = list.get(parent);
list.set(parent, list.get(i));
list.set(i, tmp);
j=i;
k=2*j+1;
while(k<list.size()) {
if (k+1<list.size()) {
if (list.get(k)>=list.get(k+1) && list.get(k)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else if (list.get(k+1)>list.get(k) && list.get(k+1)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k+1));
list.set(k+1, tmp);
j=k+1;
k=2*j+1;
} else {
break;
}
} else if (list.get(j)<list.get(k)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else {
break;
}
}
}
}
}
子程序adjustBigTopHeap:
private void adjustBigTopHeap(ArrayList<Integer> list, int end) {
int j,k,tmp;
if (end == 0) {
return;
}
j = 0;
k = 2*j+1;
while(k<=end) {
if (k+1<=end) {
if (list.get(k)>=list.get(k+1) && list.get(k)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else if (list.get(k+1)>list.get(k) && list.get(k+1)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k+1));
list.set(k+1, tmp);
j=k+1;
k=2*j+1;
} else {
break;
}
} else if (list.get(j)<list.get(k)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else {
break;
}
}
}
构建大顶堆的时间复杂度为O(n),循环调整堆的时间复杂度为O(nlogn),实际上,无论是最差情况还是平均情况,堆排序的时间效率都属于O(nlogn),因此,堆排序的时间效率和归并排序的时间效率属于同一类。而且,与后者不同,堆排序是在位的。针对随机文件的计时实验指出,堆排序比快速排序运行得慢,但和归并排序相比还是有竞争力的。
断断续续终于把这篇博客写完了,现在才知道写博客这么耗费精力,尤其是精心组织、插图、引用的博客,这三个本文都没很好做到,向无私奉献的博主们致敬!