1. 插入排序
对于一个待排序的序列,如果它的前半部分是有序的(假设有 M 个元素),后半部分是无序的(假设有 N-M 个元素),那么最直接的排序方法就是从无序的部分中取出一个元素,并将之插入到有序部分的合适位置上,这样,有序部分就包含了M+1个元素,无序部分剩下了N-M-1个元素,接着再从无序部分中取出一个元素,并将之插入到有序部分的合适位置上,那么有序部分就包含了M+2个元素,无序部分剩下了N-M-2个元素,这样,经过 N-M 次这样的操作之后,整个序列就变成有序的了,这就是插入排序的基本思想。
对于任意一个待排序序列,总是可以把它分成有序部分和无序部分的,只是开始的时候,有序部分只包含一个元素(M=1),而无序部分包含了 N-1 个元素,举个简单的例子,假设待排序序列为{5,3,2,6},我们需要将它进行从小到大的排序,开始的时候有序部分为{5},无序部分为{3,2,6}:
第一趟排序操作:从{3,2,6}中取出3,将之插入有序部分中,构成新的有序部分为{3,5},此时无序部分变成了{2,6};
第二趟排序操作:从{2,6}中取出2,将之插入到有序部分,构成新的有序部分为{2,3,5},此时无序部分变成了{6};
第三趟排序操作:从{6}中取出6,插入到有序部分中,有序部分变成了{2,3,5,6},排序完成。
对于插入排序而言,影响性能的操作主要是“插入”,因为我们要把一个元素插入到一个有序序列中,并保持该序列继续有序。根据“插入”方法的不同,又有了“直接插入排序”、“折半插入排序”等。所谓的直接插入就是将待插入的元素与有序序列中的元素一个个进行比较,找到合适的位置进行插入,所谓的折半插入就是将待排序的元素与有序序列的中间元素进行比较,若大于该中间元素,就在有序序列的后半段继续寻找合适的位置,否则就在有序序列的前半段继续寻找合适的位置。显然,折半插入的效率更高。不过需要注意的是,折半插入只是能更有效率地找到合适的插入位置,如果序列存放在数组中,那么插入会引起数组元素的向后移动,而折半插入并不会改变移动的次数,所以其时间复杂度仍为O(n2)。当然,如果用链表来存储序列,就不用移动元素,只是改一下节点指针即可,但在链表下就不太容易实现“折半查找”了,因为没有所谓的下标。
直接插入排序在什么情况下效率最高呢?当原序列已经是有序序列了,此时时间复杂度为O(n),当原序列已经有序时,合适的插入位置就是当前的位置,不需要移动任何元素,倒是折半插入的效率略有下降,因为它还得“机械地”找合适的位置。那啥时候效率最低呢?当原序列逆序时!此时时间复杂度为O(n2)。
直接插入排序和折半插入排序都是稳定的排序算法,所谓稳定,就是当两个元素相等时,排序后的先后顺序与排序前的先后顺序一致。不过,不是所有的插入排序都是稳定的,如希尔排序,希尔排序的基本思想是将原序列看成几个子序列,比如原序列为{x1,x2,x3,x4,x5,x6},希尔排序有个“增量”的概念,如果增量为2,那么第一个子序列就是第1、3、5个元素,第二个子序列就是2、4、6个元素,然后分别对这两个子序列进行排序(注意,排序后,原序列的第1、3、5个元素是有序的,第2、4、6个元素是有序的,其他的就不保证了),这样做的目的是使得原序列比之前稍微有序一点,然后我们就可以对这个“稍微有序一点”的序列进行普通的插入排序,因为插入排序对有序序列更有效率一点(这里的有序可千万不能是逆序,否则就直接掉到最差情况了)。那希尔排序为什么是不稳定的呢?举例如下,原序列{1,5,31,32,6,7},其中下标表示相同元素在原序列中的先后顺序。假设希尔排序的增量为2,那么子序列排序后的结果为{1,32,31,5,6,7},接着对这个序列进行直接插入排序,由于直接插入排序是稳定的,不会改变相同元素的先后顺序,所以最终的结果是相同的元素的先后顺序被改变了!所以希尔排序是不稳定的!
2. 交换排序
与插入排序不同,交换排序的主要操作是“交换”,所以不需要大规模的移动元素。举个简单的例子,比如要把序列 {3,1,2} 按从小到大的顺序排列,我们先比较3与1,因为 1<3,所以将两者交换得到 {1,3,2},再比较3与2,因为2<3,所以将两者进行交换,得到{1,2,3},这样就完成了排序。
最简单的交换排序是冒泡排序,举个简单例子,比如要把数组 x[6] (下标从0~5)按照从小到大的顺序排列,第一趟排序的结果就是把最大的元素放在x[5]处,具体是这么操作的:
1、比较x[0]和x[1],如果x[0]>x[1],则将两者交换(tmp = x[1]; x[1]=x[0]; x[0]=tmp),否则不交换;
2、比较x[1]和x[2],如果x[1]>x[2],则将两者交换(tmp = x[2]; x[2]=x[1]; x[1]=tmp),否则不交换;
……
5、比较x[4]和x[5],如果x[4]>x[5],则将两者交换(tmp = x[5]; x[5]=x[4]; x[4]=tmp),否则不交换;
这样经过5次比较后,最大的元素就被交换到了x[5]处,接着进行第二趟排序,结果就是将第二大的元素放在了x[4]处(这个过程需要4次比较:x[0]与x[1],x[1]与x[2],x[2]与x[3],x[3]与x[4],x[5]就不需要参与比较了)。接着进行第三趟排序…… 五趟排序后,序列就变成有序的了。
伪代码如下(设序列为x[N],N为序列的长度):
for(int i=N-1; i>0; i--){
for(int j=0;j<i;j++){
if(x[j]>x[j+1]) swap(x[j],x[j+1]);
}
}
总结一下:冒泡排序要进行N-1趟排序,平均每趟排序需要进行N/2次比较操作,所以时间复杂度为O(N2)。冒泡排序是一种稳定的排序算法(大家试试就知道了:))。
改善性能的基本思路是尽量减少重复的操作,比如减少“比较”操作,对于冒泡排序而言,经过一趟排序后,无序部分仍然像之前那样“无序”,所以上一趟排序对下一趟排序带来的好处太少,导致下一趟排序仍需要进行很多操作。下面将要讨论的快速排序估计就是受到了类似的启发。
快速排序的第一趟排序不是把最大的元素放在序列的末端,而是把第一个元素放在序列的合适位置,同时保证该位置前边的元素均小于第一个元素,该位置后边的元素均不小于第一个元素。第二趟排序呢,思路一样,只是分别就处理刚刚生成的两个子序列,递归算法很容易就实现了。以第一趟排序为例,假设输入的序列为 x[N]
tmp = x[0];
i = 0;
j = N-1;
while(i<j){
for(; j>i && x[j]>=tmp; j--);
if(j>i) {x[i]=x[j]; i++; }
for(; i<j && x[i]<tmp; i++);
if(i<j) {x[j]=x[i]; j--; }
}
x[i]=x[0];
改成函数的形式如下:
int partition(int x[], int i,int j) {
tmp = x[i];
while(i<j){
for(; j>i && x[j]>=tmp; j--);
if(j>i) {x[i]=x[j]; i++; }
for(; i<j && x[i]<tmp; i++);
if(i<j) {x[j]=x[i]; j--; }
}
x[i]=x[0];
return i;
}
快速排序算法如下:
void quickSort(int x[],int left,int right){
int tmp;
while(left<right){
tmp = partition(x,left,right);
quickSort(x,left,tmp-1);
quickSort(x,tmp+1,right);
}
}
一般情况下,每趟排序会将原序列分成两个子序列,所以需要进行log2(n)趟排序(当子序列的长度为1时就不用继续递归了),每趟排序还是需要处理那n个元素,所以需要进行 O(n) 次操作,所以平均意义下的时间复杂度为O(nlog2(n))。最差的情况是什么呢?当原序列已经有序时,程序会“机械”地执行n-1趟排序,所以时间复杂度变成了O(n2)。因为采用了递归,每次函数调用会发生压栈操作,就会占用一定数量的栈空间,又因为递归深度为log2(n),所以会发生O(log2(n))次压栈,所以算法的空间复杂度为O(log2(n))。快速排序是一种不稳定的排序算法,举个简单例子,原序列为{5,31,4,6,7,32},第一趟排序的结果是{32,31,4,5,6,7,},显然,相同元素的先后顺序被打乱了。
3. 选择排序
选择排序的基本思想是: 第一趟排序选择一个最大的元素放在序列的末端,第二趟排序选择一个次大的元素放在倒数第二的位置……所以在选择排序中,每趟排序的效果跟冒泡排序是一样的,简单选择排序是这样操作的,给定一个序列 x[N],第一趟排序是从 1~N-1 个元素中选一个最小的,假如下标为k,则将 x[0] 与 x[k] 交换,第二趟排序是从 2~N-1 中选择一个最小的,假如下标为p,则将 x[1] 与 x[p] 交换……不难发现,对于一个长度为N的序列,需要进行 N-1 趟排序,平均每趟排序要进行 N/2 次比较,所以时间复杂度为 O(N2),又每趟排序仅需要一个临时变量来存放最小元素的下标,以及一个临时变量用于交换元素,所以空间复杂度为O(1)。代码如下:
void selectSort(int x[],int N){
for(int i=0;i<N-1;i++){
int min = i;
for(int j=i+1;j<N;j++){
if(x[j])<x[min]) min=j;
}
swap(x[i],x[min]);
}
}
简单选择排序是一种不稳定的排序算法,举个简单例子,原序列为{31,32,2,1,6,7,8,9},第一趟排序的结果是{1,32,2,31,6,7,8,9},第二趟排序的结果是{1,2,32,31,6,7,8,9},显然是不稳定的。
简单选择排序的上一趟排序仍然不能给下一趟排序带来更多的好处,堆排序的第 i 趟排序也会选出序列中的第 i 小的元素,但是堆排序的上一趟排序会给下一趟排序省下不少操作。
堆排序把序列看成了一颗完全二叉树,比如有序列{16,14,16,8,7,9,3,2,4,1},对应到完全二叉树如下,x[i]的子节点分别是x[2i+1]和x[2i+2]:
对于有n个节点的完全二叉树,树的高度为?log2(n+1)?。最后一个有子节点的节点为 x[?n/2??1]。对于上图而言,就是 x[4](5号节点)。
堆排序的基本思想就是保证每个节点都要比它的子节点大(这就是所谓的最大堆),那么根节点就是序列中最大的元素,所以建立一个最大堆的过程,就是第一趟排序,选出了最大的元素,将该元素与序列末端的元素交换后,再调整 x[0~n-2] 对应的堆,使之仍然为一个最大堆,这样就选出了第二大的元素,以此类推…..尽管这样仍然需要进行 n-1 趟排序,但是每趟排序(第一趟排序除外)只需要执行的比较次数与树深成正比,即O(log2(n)),所以时间复杂度为O(nlog2(n))。
堆排序的基本操作是“调整”,给定了一个完全二叉树,假设它的子树已经满足了最大堆的性质,但是由于根节点可能小于它的子节点,所以必须通过调整才能使得这个二叉树也满足最大堆的性质,这样我们就需要比较根节点与它的两个子节点,如果较大的子节点为左子节点,且左子节点大于根节点,则将这两个节点交换。交换后可能破坏了左子树的最大堆性质,所以需要继续调整左子树…… “堆调整”的代码如下:
void adjustHeap(int x[],int i,int n){
int l = 2*i+1, r = 2*i+2;
int max = i;
if(l<n && x[l]>x[max]) max = l;
if(r<n ** x[r]>x[max]) max = r;
if(max==i) return;
if(max==l) {swap(x[i],x[l]); adustHeap(x,l,n); }
if(max==r) {swap(x[i],x[r]); adustHeap(x,r,n); }
}
刚才只是介绍了如何调整堆,但是给出了待排序序列,我们首先需要建立一个堆。基本的思想就是先建立最小的子堆,然后调整得到一个稍大一点的堆,然后调整得到更大一点的堆…… (最小的堆当然就是叶子节点了:))代码如下:
void createHeap(int x[],int n){
for(int i=n/2-1;i>=0;i--) adustHeap(x,i,n);
}
把堆排序的细节介绍完了,现在介绍当给了一个待排序序列后,如何用堆排序的方式对它排序。第一步是建立一个最大堆,则堆顶元素(根节点)就是序列的最大元素,将该元素与序列末端的元素进行交换,现在堆的性质可能被破坏了,所以调整堆,使之重新成为最大堆(现在只考虑x[0~n-2]),然后将新的堆顶元素与序列的倒数第二个元素交换,以此类推…… 代码如下:
void heapSort(int x[],int n){
heapCreate(x,0,n);
for(int i=n-1; i>0; i--){
swap(x[0],x[i]);
adustHeap(x,0,n);
}
}
在堆排序中,建堆是比较耗时的操作,但也是O(n)的量级,之后的调整的时间复杂度仅为O(log2(n))。堆排序是一种不稳定的排序算法,举例如下:原序列为{2,1,6,3,0,71,5,72},建堆的示意图如下:
第一趟排序完成,选出的最大的元素72,第二趟排序如下:
第二趟排序完成,选出次大的元素71,以此类推,这个例子倒是没有体现出不稳定性,大家可以自己修改修改例子:)。
4. 归并排序
归并排序的思想是把序列分成两个子序列,分别对两个子序列排序后,再把它们合并在一起,大家或许注意到之前的快速排序也会将原序列分为两个子序列(一般而言),所以归并排序的性能与快速排序差不多,但是归并排序一定会把原序列分成两个子序列,而快速排序不一定,所以归并排序的最差性能比快速排序好,付出的代价就是增加了空间复杂度。
归并排序的核心在于“归并”,假设子序列x1[n1]、x2[n2]已经分别有序,那么归并的方法如下(需要一些额外的空间,设为t[n1+n2]):
int p = 0;
for(int i=0,j=0;i<n1 && j<n2;){
if(x1[i]<=x2[j]){t[p]=x[i];i++;p++;}
else {t[p]=x[j];j++;p++;}
}
if(i==n1){
for(;j<n2;j++) t[p++] = x2[j];
}
if(j==n2){
for(;i<n2;i++) t[p++] = x1[i];
}
归并排序是稳定的排序方式,因为排序过程中不涉及“大跨度的元素交换”。
最后一类排序:基数排序就不多说了:)
版权声明:本文为博主原创文章,未经博主允许不得转载。