本文主要讲解几个最常用的排序算法:冒泡排序,插入排序,快速排序,归并排序。
1 冒泡排序
假设排序目标是将元素从小到大排列,则冒泡排序的做法是:
从数组最后一个元素起,不断向左比较相邻的元素,如果两个元素顺序不正确,则交换两个元素,直到数组头。此时数组头部的元素肯定是剩下的元素中最小的。不断进行这个过程直到没有元素需要交换。
例:有5个元素需要排序:4 2 1 3 5
- 第一次循环:
4 1 2 3 5 交换2 1
1 4 2 3 5 交换1 4 。 此时1排在数组头部- 第二次循环
1 2 4 3 5 交换2 4 此时1 2 有序- 第三次循环
1 2 3 4 5 交换3 4此时数组已有序,但是还没结束- 第四次循环
比较4 5不用交换,排序结束
冒泡排序可用以下代码实现:
void BubbleSort(int n[],int size){
for(int i = 0; i < size; i++){
int flag=0;
for(int j = size-1; j > i; j--){
flag=1;
if(n[j]<n[j-1]) { swap(n[j],n[j-1]);}
}
if(flag==0) break;
}
}
首先算法创建一个循环,从第1个元素开始对待排序数组的每个元素的一个遍历。接下来是第二个循环,从数组的最后一个元素开始,每个元素与前一个元素比较,如果顺序不对,则交换两个元素(交换算法没有写出),知道第i+1个元素与第i个元素比较完成,则1~i 这i个元素顺序已经排好。
算法还做了一点小改进,创建了flag变量,对于每一个i,初始化为0,当该趟排序有元素交换时,flag为1,否则flag保持为0。如果该趟排序结束后flag为0,则表明该趟搜索没有元素需要更改,也就是说数组排序已经完成,没有必要再继续比较下去,排序可以提前结束。
1.1 时间空间复杂度
对于冒泡排序,所需要的额外空间只是在移动元素的时候提供的该元素的储存位置,因此空间复杂度是O(1)
关于时间复杂度,如果数组已经有序,则对数组扫描一次即可完成排序,所以最好情况的时间复杂度是O(N),
如果数组刚好倒序,则要将其排序正确所需的元素交换次数是最多的,可以证明其交换次数为O(N
2
^{2}
2)。
可以证明,冒泡排序的平均时间复杂度是O(N
2
^{2}
2)。
可以看到,当数组中的某个元素比其前一个元素要小时,两者才会发生交换,如果相等时,两者位置是不变的,所以冒泡排序是稳定的。
2 插入排序
插入排序的思想,是将数组后方乱序的元素逐个插入到前方已经排好序的序列中,直到数组末尾。
下边是插入排序的代码:
void InsertSort(int n[],int size){
int temp;
for(int i = 1; i < size; i++){
temp = n[i];
for(int j = i-1;; j >= 0; j--){
if(temp<n[j]) {n[j+1] = n[j];}
else break;
}
n[j+1] = temp;
}
}
代码中首先创建了一个从数组的第二个元素到最后一个元素的遍历,将下标为i的元素放进缓存空间中。
之后下标为i-1的元素开始,将缓存元素与下标为j的元素比较,如果缓存元素较小,则表明缓存元素应该放在下标更小的地方,因此将下标为j的元素移动到j+1的地方。否则如果缓存元素大于等于下标为j的元素,则缓存元素应该放在紧靠在该元素的右边,因此退出循环,直接赋值即可。
可以看到,当缓存元素比待插入位置的元素小时,才需要将元素后移,如果等于则直接插入其后方,因此元素相等的数字其相对位置是不变的,也就是说插入排序是稳定的
2.1 时间空间复杂度
对于插入排序,所需额外空间是存放缓存元素用的,因此空间复杂度是O(1)。
若数组已经排好序,则无需移动数组中的任何元素,对其扫描一次即可判断已完成排序,因此时间复杂度最好为O(N)。
若数组逆序,则移动数组中的元素次数最多,其时间复杂度是O(N
2
^2
2)。
可以证明,插入排序的平均时间复杂度是O(N
2
^2
2)。
2.2 逆序数
对于冒泡、插入这种每次只交换相邻两个元素的排序方式,其交换次数与逆序数密切相关。
逆序数指在一组序列中,大小顺序不正确的组合数。
比如在 1、3、2、8、6,这几个数字中,(3,2),(8,6)这两个组合的大小顺序是不正确的,所以这个序列中有2个逆序对。
在简单排序中,每次交换两个元素都会消除数组中的1个逆序对,而数学上已经证明,平均情况下随机的数组序列逆序数为O(N
2
^2
2),因此对于简单排序,其平均时间复杂度不可能低于O(N
2
^2
2)。要想提高效率,只能够想办法一次交换消除多个逆序对。
3 快速排序
快速排序是日常使用中最常用的排序算法之一,其平均时间复杂度是O(NlogN),最坏时间平均复杂度是O(N
2
^2
2),但是经过优化后基本不可能会达到最坏的时间复杂度。
快速排序使用的是分而治之的思想,每一次的排序开始,随机从数组中选择一个元素,称为枢纽元,将比枢纽元小的元素放在数组左端,比枢纽元大的元素放在其右端。之后递归地对数组左端和右端递归使用快排算法,直到排序完成。
快排的重要步骤之一就是选取枢纽元的方法。最简单的方法就是每次都选取数组的最右端或者最左端的元素,如果数组是正序或者逆序,这种方法实际上并没有对数组进行划分,这样的话每次划分完都要对剩下没有排序的数组进行检索,因此时间复杂度就退化为O(N
2
^2
2)。
另一种选取枢纽元的方法是随机从数组中挑选一个元素作为枢纽元,这种方法能够避免上述情况,但是选择随机数的花销很大,这种方法并不划算。
另一种的选取方法,就是选择数组上的首、尾,中间的元素,选取这3者中间的元素作为枢纽元,这也是比较常用的方法,以下是选择的代码。
int SelectMid(int n[],int left,int right){
int mid = (left+right)/2;
if(n[left]>=n[right]){
if(n[left]<n[mid]) return left;
else if(n[right]>n[mid]) return right;
else return mid;
}
if(n[right]>n[left]){
if(n[mid]<n[left]) return left;
else if(n[mid]>n[right]) return right;
else return mid;
}
}
3.1 递归实现
快排的递归实现这里讲述两种方法。
方法1
int Partion1(int n[],int left, int right){
int pivot = SelectMid(n,left,right);
swap(n[pivot],n[right]);
int temp = n[right];
while(left<right){
while(n[left]<=temp&&left<right){left++;}
if(left<right) {n[right]=n[left]; right--;}
while(n[right]>=temp&&left<right){right--;}
if(left<right){n[left]=n[right]; left++;}
}
n[right] = temp;
return right;
}
算法一开始挑选出枢纽元,并且将其移动到数组末尾,然后存放到缓存空间。
声明了左右指针,一开始分别指向数组最左边和最右边。
判断左指针元素与枢纽元的大小,如果指针元素比枢纽元小,证明位置正确,指针指向下一个元素,否则将其放在右指针元素的位置。
判断右指针元素与枢纽元的大小,如果指针元素比枢纽元大,证明位置正确,指针指向下医院是,否则将其放在左指针元素位置。
当左右指针指向同一个元素,循环结束,此时该位置为枢纽元位置。
下边举例说明此法排序过程:
- 待排序数组为 4 5 1 3 2
- 选择枢纽元:根据首尾和中间元素,判断枢纽元可以设置为2。将其放进缓存空间。
- 初始化指针,左指针指向第一个元素4,右指针指向最后一个元素2。
- 左指针元素4比2大,放到右指针处,结果:4 5 1 3 4。
- 右指针依次经过,2,3,1。发现1比2小,将1放到左指针处,结果:1 5 1 3 4。
- 左指针元素5比2大,放到右指针处,结果1 5 5 3 4。
- 两指针已重合,其位置是枢纽元的位置,将枢纽元放回,结果: 1 2 5 3 4。
- 递归对序列左右继续进行快排。
方法2
int Partion2(int n[],int left, int right){
int pivot = SelectMid(n,left,right);
swap(n[pivot],n[right]);
int i = left;
int j = right;
while(i<j){
while(n[i]<=n[right]&&i<j){i++;}
while(n[j]>=n[right]&&i<j){j--;}
if(i<j)swap(n[i],n[j]);
}
swap(n[i],n[right]);
return i;
}
方法2同样一开始挑选了枢纽元,并将其放到最后。
声明了左右指针,指向数组首尾两个元素。
如果左指针元素比枢纽元小,则指向下一个元素,否则停止
如果右指针元素比枢纽元大,则指向下一个元素,否则停止
交换两个元素
左右指针相等时,该位置即为枢纽元位置,与枢纽元素交换。
下边举例说明该法。
- 待排数组为4 5 1 3 2
- 选取枢纽元为2,左指针指向4,右指针指向2。
- 左指针元素比2大,停止移动
- 右指针依次经过2,3,1,元素1比2小,停下,此时数组为4 5 1 3 2
- 交换两个元素,数组变为1 5 4 3 2
- 左指针指向5,比2大,停止移动
- 右指针指向5,此时两指针相等,此位置为枢纽元位置,与枢纽元交换,结果为 1 2 4 3 5
- 继续递归排序
以下是快排的主框架算法
void QuickSort(int n[],int left,int right){
if(left>=right) return;
int pivot = Partion2(n,left,right);
QuickSort(n,left,pivot-1);
QuickSort(n,pivot+1,right);
}
快排的总体框架已经实现,但是还有几个细节需要处理。
指针元素与枢纽元素相等
首先是考虑当指针所指元素等于枢纽元素是该如何处理。
第一种想法是当两者相等,不处理这个元素,继续往下继续。但是有一种情况会导致这种做法的时间复杂度变为O(N
2
^2
2),这就是当待排数组的所有元素相同时。这种情况下会有一个指针一直移动到数组的一端,结果就是每次都要遍历剩余的整个数组,一共要遍历数组元素个那么多次,因此时间复杂度会提示。
第二种做法是当两者相等,依然交换这两个元素。看起来算法是在做无用功,但是当数组所有元素相同时,时间复杂度依然保持在O(NLogN)。
空间复杂度
因为快速排序需要递归进行,因此需要用到栈空间,最坏的情况是每次都把所有元素分到枢纽的一边去,这种情况需要递归N次才能完成排序,需要的栈空间是O(N
2
^2
2)。
正常情况下,递归logN次就能完成排序,因此一般的空间复杂度是logN。
另外,由于每次递归调用是需要的时间的,所以如果数组已经被分割的很小的时候,再去递归调用快排就不划算了,所以当被分割的数组元素比较少的时候,一般都会用简单排序方法解决,如插入排序。
4 归并排序
归并排序是一种典型的分而治之的算法,其核心是将两个已经排好序的数组合并成一个。归并排序的最好、最坏以及平均时间复杂度都是O(NLogN),其排序速度有时候比快排还要快,但是有个缺点是归并排序需要一个额外的和数组一样大小的缓存空间来存放排序的数组,其空间复杂度是O(N),因此如果空间内存紧张,就比较不好使用了。
归并排序的代码如下:
void MergeSort2(int a[],int first, int last, int tmp[]){
if(first>=last) return;
int mid = (first+last)/2;
MergeSort2(a, first, mid, tmp);
MergeSort2(a, mid+1, last, tmp);
MergeArray(a,first,mid,last,tmp);
}
void MergeSort1(int a[],int len){
int *tmp = new int[len];
MergeSort2(a, 0, len-1, tmp);
delete[] tmp;
}
void MergeArray(int a[],int first, int mid, int last, int tmp[]){
/*
left_start: 左数组起点
left_end: 左数组末尾
right_start: 右数组起点
right_end: 右数组末尾
*/
int left_start = first;
int left_end = mid;
int right_start = mid+1;
int right_end = last;
int k= 0;//tmp下标
int left = left_start;
int right = right_start;
while(left <= left_end && right <= right_end){
if(a[left] <= a[right]) tmp[k++] = a[left++];
else tmp[k++] = a[right++];
}
while(left <= left_end) tmp[k++] = a[left++];
while(right <= right_end) tmp[k++] = a[right++];
//将tmp内容拷贝回a
for(int i = 0; i <k; i++){
a[first+i] = tmp[i];
}
}
代码中MergeSort1是供用户调用的接口,输入参数是数组名字以及数组长度。函数中创建一个与数组一样长的缓存空间后,将其一起作为参数创给MergeSort2。
MergeSort2是归并排序的递归接口,每次调用时都先将数组分成两个子数组,分别递归调用MergeSort2,完成后返回的两个子数组都是已经分布排好序的,之后调用MergeArray将两个子数组合并成1个排序完成的数组。
下边举例说明MergeArray的步骤
- 待排序数组分别为
A: 1、13、24、26
B: 2、15、27、38
C: 缓存数组- 初始化指针a、b,指向A、B的第一个元素
- 1比2小,1放进C中,a指向13。C: 1
- 2比13小,2放进C中,b指向15。C: 1、2
- 13比15小,13放进C中,a指向24。C: 1、2、13
- 15比24小,15放进C中,b指向27。C:1、2、13、15
- 24比27小,24放进C中,a指向26。C:1、2、13、15、24
- 26比27小,26放进C中,A已经没有元素。C:1、2、13、15、24、26
- 将B中剩下元素放进C中。C:1、2、13、15、24、26、27、38
- 将C的元素拷贝回A和B中。排序完成
总结
本文简单讲述了冒泡排序、插入排序、快速排序、归并排序的原理以及性能。
还有很多排序方法没来得及去讲述,比如选择排序、堆排序等。还有一些非典型的排序方法如桶排序、基数排序,时间有限,以后有机会再进行补充。