1. 插入排序
1.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 )。
1.2 直接插入排序
void insertSort(int x[],int N){
for(int i=1; i<=N-1; i++){
//直接查找x[i]应该被插入的位置
int j = i-1;
while(j>=0 && x[j]>x[i]) j--;
j += 1; // 现在j就是合适的位置
for(int p=i-1;p>=j;p--) x[p+1] = x[p]; //向后移动元素
x[j] = x[i]; // 将x[i]插入到了合适的位置
}
}
1.3 折半插入排序:
void binarySort(int x[],int N){
for(int i=1;i<=N-1;i++){
//折半查找合适的位置
int low = 0,high = i-1;
int mid = (low+high)/2;
while(low<=high){
if(x[mid]<=x[i]) low = mid+1;
else high = mid-1;
}
// while结束后low就是合适的插入位置
//接下来的步骤同上
for(int p=i-1;p>=low;p--) x[p+1] = x[p]; //向后移动元素
x[low] = x[i]; // 将x[i]插入到了合适的位置
}
}
直接插入排序在什么情况下效率最高呢?当然是原序列已经从小到大排序好了,这时内层循环就不用执行了,此时时间复杂度为O(
N
)。倒是折半插入的效率略有下降,因为它还得“机械地”找合适的位置。那啥时候效率最低呢?当原序列从大到小排序好了,此时内层循环要完完整整地执行完,所以时间复杂度为O(
1.4 稳定性
直接插入排序和折半插入排序都是稳定的排序算法,所谓稳定,就是当两个元素相等时,排序后的先后顺序与排序前的先后顺序一致。不过,不是所有的插入排序都是稳定的,如希尔排序。
希尔排序的基本思想是将原序列看成几个子序列,比如原序列为原序列{ 1,5,31,32,6,7 },其中下标表示相同元素在原序列中的先后顺序:X表示我们在操作的时候不考虑该位置的元素。
第一个子序列就是第1、3、5个元素,即{
1,X,31,X,6,X
},
第二个子序列就是第2、4、6个元素,即{
X,5,X,32,X,7
},
对第一个子序列的排序结果为{
1,X,31,X,6,X
},正好没变
对第二个子序列的排序结果为{
X,32,X,5,X,7
}
所以整体看来,排序后的序列为{ 1,32,31,5,6,7 },显然,改变了相同元素的先后顺序,所以希尔排序是不稳定的。希尔排序有个“增量”的概念,上面的例子中,其增量为2。
希尔排序的目的是通过对子序列进行排序,使得原序列比之前稍微有序一点,然后我们就可以对这个“稍微有序一点”的序列进行普通的插入排序,因为插入排序对有序序列更有效率一点,而子序列的长度较短,也可以用比较有效率的方式进行排序。
希尔排序的时间复杂度比较复杂,取决于增量,比O( N2 )好,可以达到O(N^1.5 ) ),具体可以参考其他文献。
2. 交换排序
与插入排序不同,交换排序的主要操作是“交换”,所以不需要大规模的移动元素。举个简单的例子,比如要把序列 {3,1,2} 按从小到大的顺序排列,我们先比较3与1,因为 1<3,所以将两者交换得到 {1,3,2},再比较3与2,因为2<3,所以将两者进行交换,得到{1,2,3},这样就完成了排序。
2.1 冒泡排序
最简单的交换排序是冒泡排序,举个简单例子,比如要把数组 x[6] (下标从0~5)按照从小到大的顺序排列,第一趟排序的效果就是把最小的元素放在x[0]处,具体操作如下:
第一趟排序:
1、比较x[4]和x[5],如果x[4]>x[5],则将两者交换(tmp = x[4]; x[4]=x[5]; x[5]=tmp),否则不交换;
2、比较x[3]和x[4],如果x[3]>x[4],则将两者交换(tmp = x[3]; x[3]=x[4]; x[4]=tmp),否则不交换;
……
5、比较x[0]和x[1],如果x[0]>x[1],则将两者交换(tmp = x[0]; x[0]=x[1]; x[1]=tmp),否则不交换;
这样经过5次比较后,最小的元素就被交换到了x[0]处,接着进行第二趟排序,结果就是将次小的元素放在了x[1]处,接着进行第三趟排序…… 五趟排序后,序列就变成有序的了。
代码如下(设序列为x[N],N为序列的长度):
void bubbleSort(int x[],int N){
for(int i=0;i<=N-2;i++){
for(int j=N-1;j>i;j--){
if(x[j]<x[j-1]){
int temp = x[j];
x[j] = x[j-1];
x[j-1] = temp;
}
}
}
}
总结一下:冒泡排序要进行N-1趟排序,平均每趟排序需要进行N/2次比较操作,所以时间复杂度为O(
2.2 快速排序
改善性能的基本思路是尽量减少重复的操作,比如减少“比较”操作,对于冒泡排序而言,经过一趟排序后,无序部分仍然像之前那样“无序”,所以上一趟排序对下一趟排序带来的好处太少,导致下一趟排序仍需要进行很多操作。下面将要讨论的快速排序估计就是受到了类似的启发。
快速排序的第一趟排序不是把最大的元素放在序列的末端,而是把第一个元素放在序列的合适位置,同时保证该位置前边的元素均小于第一个元素,该位置后边的元素均不小于第一个元素。第二趟排序呢,思路一样,只是分别就处理刚刚生成的两个子序列,递归算法很容易就实现了。以第一趟排序为例,假设输入的序列为 x[N]
void quickSort(int x[],int low,int high){
if(low>=high) return ;
int temp = x[low]
//给temp寻找合适的位置
int i = low, j = high;
while(i<j){
while(i<j && x[j]>temp) j--;
if(i<j){x[i] = x[j]; i++;}
while(i<j && x[i]<=temp) i++;
if(i<j){x[j] = x[i]; j--;}
}
//while循环结束后,i=j就是合适的位置
x[i] = temp;
quickSort(x,low,i-1);
quickSort(x,i+1,high);
}
一般情况下,每趟排序会将原序列分成两个子序列,所以需要进行
log2(n)
趟排序(当子序列的长度为1时就不用继续递归了),每趟排序还是需要处理那n个元素,所以需要进行 O(
n
) 次操作,所以平均意义下的时间复杂度为O(
3. 选择排序
选择排序的基本思想是: 第一趟排序选择一个最小的元素放在序列的前端,第二趟排序选择一个次小的元素放在第二的位置……所以在选择排序中,每趟排序的效果跟冒泡排序是一样的,只是做法有点区别。
3.1 简单选择排序
简单选择排序是这样操作的,给定一个序列 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-2;i++){
int min = i;
for(int j=i+1;j<=N-1;j++){
if(x[j])<x[min]) min=j;
}
//交换x[i]与x[min]
int temp = x[i];
x[i] = x[min];
x[min] = temp;
}
}
简单选择排序是一种不稳定的排序算法,举个简单例子,原序列为{ 31,32,2,1,6,7,8,9 },第一趟排序的结果是{ 1,32,2,31,6,7,8,9 },第二趟排序的结果是{ 1,2,32,31,6,7,8,9 },显然是不稳定的。
3.2 堆排序
简单选择排序的上一趟排序仍然不能给下一趟排序带来更多的好处,堆排序的第
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) )。
堆排序的基本操作是下沉,假设上图中的最大堆是第一次排序的结果,然后我们就需要把最大元素(即根元素)放在序列的末端,所以将”16”与”1”做了交换,这样根元素变成了”1”,显然破坏的最大堆的性质,所以我们要将这个”1”向下沉,直到它沉到了合适的位置,代码如下:
void downAdjust(int x[],int N,int root){
int left_child = 2*root+1;
int right_child = left_child+1;
//找到较大的子节点
if(left_child>=N) return ; //没有子节点就直接返回
int larger_child = left_child;
if(right_child<=N-1 && x[right_child]>x[left_child]) larger_child = right_child;
if(x[larger_child]<=x[root]) return ;
else {//将跟节点与较大的子节点交换
int temp = x[root];
x[root] = x[larger_child];
x[larger_child] = temp;
//递归第调整子树
downAdjust(x,N,larger_child);
}
}
建堆的过程中,就是从下到上,一层一层的处理节点:
1、首先处理倒数第二层,将这一层的节点与它们的子节点进行比较,如果小于子节点,就与子节点交换位置;
2、然后处理倒数第三层,将这一层的节点与它们的子节点进行比较(倒数第二层),如果小于子节点,就与子节点交换位置,同时,”交换“改变了倒数第二层的节点,所以要保证以倒数第二层节点为根节点的子树依然具有最大堆的性质,需要进行一个下沉调整;
3、然后处理倒数第四层……
所以每处理一层,不仅仅要比较自己与子节点的大小,也要保证以子节点为根的子树也满足最大堆的性质,建堆的代码如下:
void createHeap(int x[],int N){
for(int i=N/2-1;i>=0;i--) downAdjust(x,N,i);
}
堆排序的代码如下:
void heapSort(int x[],int N){
heapCreate(x,0,n); //建堆
for(int i=N-1; i>0; i--){
swap(x[0],x[i]); //交换根元素与序列末端的元素
downAdjust(x,i-1,0);
}
}
在堆排序中,建堆是比较耗时的操作,但也是O(n)的量级,之后的调整的时间复杂度仅为O( log2(n) )。堆排序是一种不稳定的排序算法。
4. 归并排序
归并排序的思想是把序列分成两个子序列,分别对两个子序列排序后,再把它们合并在一起,大家或许注意到之前的快速排序也会将原序列分为两个子序列(一般而言),所以归并排序的性能与快速排序差不多,但是归并排序一定会把原序列分成两个子序列,而快速排序不一定,所以归并排序的最差性能比快速排序好,付出的代价就是增加了空间复杂度。
归并排序的代码如下:
void mergeSort(int x[],int low,int high){
if(low>=high) return ;
int mid = (low+high)/2;
//一分为二地处理两个子序列
mergeSort(x,low,mid);
mergeSort(x,mid+1,high);
//合并两个子序列,需要额外的空间
int *p = new int[high-low+1];
int i = low, j = mid+1, k = 0;
while(i<=mid && j<=high){
if(x[i]<=x[j]) {p[k] = x[i];i++;k++;}
else {p[k] = x[j];j++;k++;}
}
while(i<=mid) {p[k] = x[i]; i++;k++;}
while(j<=high) {p[k] = x[j];j++;k++}
//复制回原序列中
for(k=0;k<=high-low;k++) x[low+k] = p[k];
}
归并排序是稳定的排序方式,因为排序过程中不涉及“大跨度的元素交换”。