[TOC]
前言
这是《Java数据结构与算法》一书中关于排序算法部分的读书笔记。
最近想看看算法方面的东西,便先整理下以前的笔记,发现关于排序算法这块以前的笔记太潦草,这几日重新看书修整一番,现在应该可以看入眼了,如果还是写得不好,那是体育老师的锅。
不变性:在很多算法中,有些条件在算法执行时是不变的,这些条件被称为不变性。如冒泡中,out右边的所有数据项为有序,在算法的整个运行过程中这个条件始终为真。(一开始,out右边数为0)
初级
冒泡排序
执行非常慢,概念上最简单。
int out,in;
for(out = size- 1;out > 1;out--){ //outer loop(backward)
for(in = 0;in < out;in++){ //inter loop(forward)
if(a[in] > a[in + 1]){ //out of order?
swap(in,in + 1); //swap them
}
}
}
在内部循环中就是一个冒泡,从头到尾,比较左右两个,如果逆序就交换位置,最大的那一项会一直被交换,直到排最后。1,2比较,(交换),2,3比较,3和4比较,4和5比较。这里的数字指的是位置,假如2是5个数据中最大的一个,1和2比较,不变,2和3比较,二者交换位置,最大值到了3位置,3和4比较,最大值到了4位置,再到5位置。冒泡,冒上来。
在外层循环中,是给内层循环设置数据比较的最后一项,因为下标大于out的数据项都已经排好序了,是最大的放后面,不需要再比较了。变量out在每完成一个内层循环后,就左移一位。
冒泡的效率: 比较的次数,(N-1) + (N-2) + (N-3) + ...+ 1= N*(N-1)/2,交换的次数取平均NxN/4,这个算法0(N^2)
选择排序
我觉得名字起得不好,体现不出这个算法的思想(主要是冒泡算法太形象了)。
思想:比较所有的数据项,取出最小值,放左边。比较剩下的数据,取最小,放最左。。。。
内层循环中,每一个in的新位置,数据项a[in]和a[min]比较,如果a[in]更小,则min被赋值为in的值,这里只是下标,没交换。到一次内层循环结束,再交换数据项。这样,最小数据项就会一直被放在左边。
比较的次数和冒泡是一样的,但是交换的次数小。因为交换数据需要在内存中移动,时间上要多(java语言中,影响不大,只是改变引用位置而已)
int out,in,min;
for(out = 0;out<nElems - 1;out++){ //outer loop
min = out; //minimum
for(in = out + 1;in < nElems;in++){ //inner loop
if(a[in] < a[min]){ //if min greater
min = in; //we have a new min
}
swap(out,min); //swap them
}
}
插入排序
思想:假设左边部分已经排序好了,从某个位置(比如10)开始无序,将10赋值给一临时值,然后和前面的数据比较,如果9位置比10大,就9右移一位,继续和8比较。。。直到到数据的最左边或找到比10位置数据小的某数据,放在它的右边,10位置的数据就排好了。
在大多数情况下,插入算法仍然需要0(N^2)的时间,但比冒泡快一倍,比选择排序也还要快一点。经常被用在较复杂的排序算法的最后阶段,例如快速排序。
int in,out;
for(out = 1;out < nElems; out++){ //out is dividing line
long temp = a[out]; //remove marked item
in = out; //start shifts at out
while(in > 0 && a[in - 1] >= temp){ //until one is smaller,
a[in] = a[in -1] //shift item right
--in;
}
a[in] = temp; //insert marked item
}
外层的for循环中,out变量从1开始,向右移动,它标记了未排序部分的最左端的数据。
而在内层while循环中,in变量从out变量开始,向左移动,知道temp变量小于in所指的数组数据项,或者它已经不能再往左移动为止。while循环的每一次都向右移动了一个排序的数据项。
在每次结束时,在将temp位置的项插入后,比outer变量下标号小的数据项都是局部有序的。
比较次数,1+2+3+...+(N-1) = Nx(N-1)/2,而因为每一次排序发现插入点之前,平均只有全体数据项的一半真的进行了比较,所以是N*(N-1)/2 /2
复制的次数大致等于比较的次数。复制和交换的时间耗费不同。相对于随机数据,这个算法比冒泡快一倍,比选择排序略快。
对于已经有序或基本有序的数据来说,插入排序要好得多。对于逆序排列的数据,每次比较和移动都会执行,所以插入排序不比冒泡排序快。
中级
归并排序
只要O(N * logN),而冒泡排序,选择排序,插入排序都要用O(N * N);.
归并排序的思想是:将一个数组分成两半,然后分别排序,然后将数组的两半合并,合并的时候需要比较大小(合并的时候还要考虑两小数组还有没有数据,即有可能一边有,另一边没有)。至于如何排序1/2半的数组,当然是再分成两个1/4数组,再排序。。。直到分割的小数组只有1个数据项,不用排序了。这是用到递归的思想
归并排序的缺点,需要在存储器中有另一个大小等于被排序的数据项数目的空间,用来复制分割出来的小数组。
归并算法的效率由来:需要复制log2N层(分子数组),每一个层都是N个数据,所以是NxlogN.
int[] workSpace = new int[source.length];
recMergerSort(source,workSpace,0,length-1);
private static void recMergeSort(int[] source ,int[] workSpace,int lowerBound, int upperBound){
if (lowerBound == upperBound){
return; // if range is 1,no use sorting
} else {
int mid = (lowerBound + upperBound) / 2; //find midpoint
recMergeSort(source,workSpace,lowerBound,mid); // sort low half
recMergeSort(source,workSpace,mid + 1,upperBound); // sort high half
merge(source,workSpace,lowerBound,mid+1 ,upperBound); //merge them
}
}
private static void merge(int[] source,int[] workPlace, int lowPtr, int highPtr, int upperBound) {
int j = 0; // workspace index
int lowerBound = lowPtr;
int mid = highPtr - 1;
int n = upperBound - lowerBound + 1; // size of items
while (lowPtr <= mid && highPtr <= upperBound){
if (source[lowPtr] < source[highPtr]){
workPlace[j++] = source[lowPtr++];
} else {
workPlace[j++] = source[highPtr++]
}
}
while (lowPtr <= mid){
workPlace[j++] = source[lowPtr++];
}
while (highPtr <= upperBound){
workPlace[j++] = source[highPtr++];
}
for (j = 0;j < n; j++) {
source[lowerBound + j] = workPlace[j];
}
}
高级
希尔排序,快速排序
希尔排序大约需要O(Nx(logN)^2)时间,快速排序需要O(N*logN)时间。
而这两种算法都不需要大量的辅助存储空间,不同于归并算法。
快速排序是所有通用排序算法中最快的一个排序算法。
希尔排序
希尔排序基于插入排序,但增加了一个新的特性,大大地提高了插入排序的执行效率。(希尔是个人名。。。)
改进的地方:插入算法中,如果一个数据比较小而居于最右边,那么它需要一个一个地移动所有中间的数据项,效率比较低。
希尔排序通过加入插入排序中元素之间的间隔,并在这些有间隔的元素中进行插入排序,从而使数据项能大跨度地移动。当这些数据项排过一趟序后,减小数据项的间隔。再进行排序,依次进行下去。间隔被称为增量,用h表示.
进行过几次增量排序后,所有的元素离它再最终有序序列中的位置相差不大,数组达到"基本有序",这时再来插入排序,移动量非常小。
当h值很大的时候,数据项每一趟排序需要移动元素的个数很少,但数据项移动的距离很长,这是非常有效率的。当h减少时,每一趟排序需要移动的元素的个数增多,但是此时数据项已经接近于它们排序后最终的位置,这对于插入排序可以更有效率。
其中,h = h x 3 +1, h = (h -1) / 3,是经验值。
java代码实现:
class ArraySh{
private long[] theArray;
private int nElems;
public ArraySh(int max){
theArray = new long[max];
nElems = 0;
}
public void insert(long value){
theArray[nElems] = value;
nElems++;
}
public void shellSort(){
int inner,outer;
long temp;
int h = 1;
while(h <= nElems / 3){
h = h * 3 + 1;
}
while(h > 0){
for(outer=h;outer<nElems;outer++){
temp = theArray[outer];
inner = outer;
while(inner > h - 1 && theArray[inner - h] >= temp){
theArray[inner] = theArray[inner - h];
inner -= h;
}
theArray[inner] = temp;
}
h = (h-1)/3;
}
}
}
划分算法
划分是快速排序的根本机制,但是划分本身也是一个有用的操作,这里单讲划分的算法。
划分数据就是把数据分为两组,使所有关键字大于特定值的数据项在一组,使所有关键字小于特定值的数据项在另一组。
划分算法由两个指针开始工作,两个指针分别指向数组的两头。在左边的指针,leftPtr,向右移动,而在右边的指针,rightPtr,向左移动。
实际上,leftPtr初始化时是在第一个数据项的左边一位(-1),rightPtr是在最后一个数据项的右边一位(size),这是因为在工作之前,它们都要分别的加一和减一。
停止和交换:当leftPtr遇到比枢纽(特定值,划分值)小的数据项时,它继续右移,因为这个数据项的位置已经处在数组的正确一边了。但是,当遇到比枢纽大的数据项时,它就停下来。类似的,当rightPtr遇到小于枢纽的数据项时,它也停下来。两个内层的while循环,控制这个扫面过程,当两个都停下来时,要么指针到头要么遇到错误的数据(大小比较不对),做交换(更换位置,正确排列了)。
当两个指针最终相遇的时候,划分过程结束,并且推出这个外层的while循环。
java代码实现:
class ArrayPar{
private long[] theArray;
private int nElems;
public ArrayPar(int max){
theArray = new long[max];
nElems = 0;
}
public void insert(long value){
theArray[nElems] = value;
nElems++;
}
public int partitionIt(int left,int right,long pivot){
int leftPtr = left - 1;
int rightPtr = right + 1;
while(true){
//这里需要检查边界,povit是外界设定,对效率影响很大,在快速排序中有更巧妙的设定方法
while(leftPtr < right && theArray[++leftPtr] < povit); //find bigger item
while(rightPtr > left && theArray[--rightPtr] > povit); //find smaller item
if(leftPtr >= rightPtr){ //if pointers cross, partition done
break;
} else {
swap(leftPtr,rightPtr);
}
}
return leftPtr;
}
}
精巧的代码分析
这个while循环中的代码相当精巧。举例来说,想要从内部循环条件中除去加1操作符,并且用这个加1操作符代替空操作指令语句(空操作指令指只包括一个分号的语句,它表示不做任何操作)。
可以把如下代码
while(leftPtr < right && theArray[++leftPtr] < pivot);
改为
while(leftPtr < right && theArray[leftPtr] < pivot) {
++leftPtr
};
这些改变使指针的初始值分别设为left,right,比设为left-1,right+1要更为清晰。
但是,这些改变导致只有在满足条件的情况下指针才会加1.而指针在任何情况下都必须移动,所以空操作指令是最有效的解决办法。
快速排序
最流行的排序算法,时间复杂度为O(N*logN)。虽然不觉得这种行为好,但有的公司喜欢笔试时让人手写快排(一些开发者如是说)。
原理是:把一个数组划分为两个子数组,这里用到划分算法,左子数组的数据项都小于右子数组的数据项,然后递归地调用自身为每个子数组进行快速排序来实现,最后使用插入排序。
在这个算法中划分的关键值(枢纽)的选择非常重要。
最初思想,选用数组最右边的值为pivot,进行一次划分,划分的结果就是left->mid-1, mid->right-1, right(这个位置的值是pivot),三部分,然后交换mid和right的值(划分算法的leftPtr在停止时会停在mid位置),这样pivot就到中间,而小于pivot的值全在左边,大于的值全在右边,数组的排序不受影响。
下面的排序从left到pivot-1,pivot+到right。中间项不参与划分。
最初思想的java代码实现:
class ArrayIns{
private long[] theArray;
private int nElems; // elements number, or size
public ArrayIns(int max){
theArray = new long[max];
nElems = 0;
}
public void insert(long value){
theArray[nElems] = value;
nElems++;
}
public void quickSort(){
recQuickSort(0,nElems - 1);
}
private void recQuickSort(int left,int right){
if(right - left <= 0) { // if size <= 1, already sorted
return;
} else {
long pivot = theArray[right]; // rightmost item
int partition = partitionIt(left,right,pivot);
recQuickSort(left,partition - 1); //sort left side
recQuickSort(partition+1,right); //sort right side
}
} // end recQuickSort()
private int partitionIt(int left,int right,long pivot){
int leftPtr = left - 1;
int rightPtr = right; //这里设定最右为pivot,所以从right-1开始划分,下面代码会-1
while(true){
while(theArray[++leftPtr] < pivot);
while(rightPtr > 0 && theArray[--rightPtr] > pivot);
if(leftPtr >= rightPtr){
break;
} else {
swap(leftPtr,rightPtr);
}
} // end while(true)
swap(leftPtr,right); //restore pivot 当0->right-1划分好了之后,交换right和leftPtr的位置,将pivot移动到中间,
return leftPtr; //return pivot location
}
}
最初思想中,使用最右为pivot,如果数据本身有序,那么pivot会是最小or最大(数组逆序or正序),不能起划分作用,效率低下。
(为什么不扫描要排序的全部数据,取中间值,因为这个做法比排序本身还要费时间,不可行)
进一步优化:"三数据项取中(median-of-three)",找数组里第一个,最后一个,中间位置数据项的居中数据项值。
三数据取中的一个好处:可以在第二个内部while循环中取消rightPtr>left的判断(left是数组的最左,避免rightPtr跑出数组),因为三数据取中时,也对三个数据项进行了排序,已经有序。
还有一个好处:对左端,右端中间的数据项排序之后,划分过程就不需要再考虑这三个数据项了。划分可以从left+1,right-1开始。
再一步提升,使用三数据项取中划分方法,则必须要遵循快速排序算法不能执行三个或者少于三个数据项的划分的规则。数字3被称为切割点。
本例子中处理小划分的方法是manualSort(),代码很简单,只是排序3个数据项或以下的数据。
处理小划分的另一个选择是使用插入排序,数量小插入排序效率也很高。也可以把切割点设定为别的数,推荐使用9.最好的选择取决于计算机,操作系统...等。替换掉recQuickSort()中的if(size <= 3) manualSort(),改用if(size <= 9) insertSort();
class ArrayIns{
private long[] theArray;
private int nElems;
private ArrayIns(int max){
theArray = new long[max];
nElems = 0;
}
public void insert(long value){
theArray[nElems] = value;
nElems++;
}
public void quickSort(){
recQuickSort(0,nElems-1);
}
private void recQuickSort(int left,int right){
int size = right - left + 1;
if(size <= 3) {
manualSort(left,right);
} else {
long median = median0f3(left,right);
int partition = partitionIt(left,right,median);
recQuickSort(left,partition - 1);
recQuickSort(partition + 1,right);
}
}
private long medianOf3(int left,int right){
int center = (left + right) / 2;
if(theArray[left] > theArray[center]) swap(left,center);
if(theArray[left] > theArray[right]) swap(left,right);
if(theArray[center] > theArray[right]) swap(center,right);
swap(center,right - 1); // put pivot on right
return theArray[right -1];
}
private int partitionIt(int left,int right,long pivot){
int leftPtr = left; //right of first elem
int rightPtr = right -1; //left of pivot
while(true){
while(theArray[++leftPtr] < pivot);
while(theArray[--rightPtr] > pivot);
if(leftPtr >= rightPtr ){
break;
} else {
swap(leftPtr,rightPtr);
}
}
swap(leftPtr,right-1);
return leftPtr;
}
private void manualSort(int left,int right){
int size = right - left + 1;
if(size <= 1) return;
if(size == 2) {
if(theArray[left] > theArray[right]) swap(left,right);return;
} else { //size is 3
if(theArray[left] > theArray[right -1]) swap(left,right-1);
if(theArray[left] > theArray[right]) swap(left,right);
if(theArray[right-1]> theArray[right]) swap(right-1,right);
}
}
}
也可以选择,对数组整个使用快速排序,不去考虑小于界限的划分的排序。当快速排序结束时,数组已经是基本有序了,然后对整个数组应用插入排序,也是非常快,很多专家推荐这种方法。
如,把界限设为10,那么快速排序到10就结束,每一块10个数据里都是无序的,但每一块之间都有序了。再插入排序。
还有一个提升,消除递归。但这是以前计算机性能不好时好用,现在的提升已经不明显了。