一、引言
网上有很多关于对排序算法的讲解了,只是别人的终究是别人的,没有经过自己的思考与探究,知识很快就会遗忘,这篇博客主要从排序算法的空间复杂度、时间复杂度,稳定性以及简答易懂的图示讲解来描述常用的排序算法。
二、冒泡排序
冒泡排序可以说是广大编程者第一次接触的排序,因为其简单易懂,所以经常出现在语言基础教科书上用来给新手入门。
2.1 思想
冒泡排序的思想是:通过一轮比较找到最大或者最小的值,并将其置于最后或者最前。
从出发点依次选取两个值进行比较,如果是升序,那么大的值则位于后面,再接着选取后续两个值,再次比较,一轮交换完成后,那么继续下一轮的交换,直到没有交换进行。因为n轮交换后,最后的n个数字一定是最小或者最大的且是有序的,所以第n+1轮排序不必将末尾n个数字算进去。
从图示来看,假如我们是升序排列,那么每一轮交换,最大的值总会被排列到最后一个,就像“泡泡”一样,“浮“了上去,这就是冒泡排序这一名字的由来。
2.2 复杂度
- 空间复杂度:从消耗的空间来看,冒泡排序没有使用辅助数组来实现排序,所以其空间复杂度是
O(1)
。 - 时间复杂度:假如该数组是排好序的,那么冒泡排序只需要遍历一次数组便可完成排序,所以冒泡排序最好的时间复杂度为
O(n)
。而假如数组是逆序的,那么数组中第n个数字需要交换Array.length-n
次,总计需要交换(n-1)+(n-2)+(n-3).....+1
次。显而易见的,它是O(n^2^)
的,所以最坏时间复杂度为·O(n^2^)
的。而经数学证明其平均时间复杂度为O(n^2^)
。 - 稳定性:
冒泡排序是稳定性的
,因为一趟冒泡只是相对于移动了最大的或者最小的元素,其它元素都是相对之前的序列是相同的,比如:3,4,6,4
,第一趟排序后变成:3,4,4,6
,两个4的位置都相对于之前的排列是没变的。
2.3 代码实现
public class BubbleSort{
public static void sort(int []array){
if(array == null && array.length == 0){
return;
}
for(int i=0;i<array.length;i++){
//需要注意已排好序的不需要再排,所以需要减去i,另外减去1的意义在于防止数组越界,因为j-1为最后倒数两个元素时,就可以比较了
for(int j=0;j<array.length-i-1;j++){
if(array[j] > array[j+1]){
swap(array, j, j+1);
}
}
}
}
}
三、选择排序
冒泡排序令人诟病的一点在于,里面出现了太多次的交换
,导致性能的下降,那么我们能不能找到一个排序算法来避免冒泡排序带来的问题的呢?选择排序就应这个呼声出现了。
3.1 思想
选择排序的思想是:通过一轮比较,找到最大值或者最小值,并通过标记手法,保证每次查找只交换一次,并将最大值或最小值置于最前。
通俗点来讲,是选取第n个数,通过查找剩余的Array.length-n
个数来找到最小或者最大值,并在查找的时候通过标记来不断更新最大或最小值,来保证每轮查找只会交换一次元素。
3.2 复杂度
从图示可以看到,选择排序相较于冒泡排序交换的次数被控制在n-1
以内,但是其查找的效率是和冒泡排序是一样的,所以得出其复杂度:
- 空间复杂度:因为没有使用辅助数组,所以选择排序的空间复杂度为
O(1)
。 - 时间复杂度:选择排序只是在冒泡排序的基础上将交换的次数控制在了
n-1
以内,其时间复杂度与冒泡排序是一样的。 - 稳定性:选择排序是不稳定排序,比如:
0,5,5,1
,第一次交换后,序列为:0,1,5,5
,这两个5相对位置改变了。
选择排序相较于冒泡排序的性能是只高不低的,毕竟选择排序将交换次数控制在一个数量级以内。
3.3 代码实现
public class SelectSort {
public static void sort(int []array){
if(array == null && array.length == 0){
return;
}
for(int i=0;i<array.length;i++){
int min=i;
for(int j=i+1;j<array.length;j++){
if(array[min] > array[j]){
min=j;
}
}
if(min != i){
swap(array, i, min);
}
}
}
}
四、插入排序
前面说了选择排序解决了冒泡排序多次重复交换元素的问题,而且采用的是不断标记最小值或者最大值的思想。插入排序其实也解决了冒泡排序的重复交换元素的问题,只是采取的另外一种思想:维持一个有序列,将未排序的元素插入到合适的位置
。
4.1 思想
插入排序的名称其实已经揭示了它的思想精髓所在:在数组中维持一个有序的列,待排序的元素从后往前查找,直到找到合适的位置插入进去。
之所以采用从后往前遍历的顺序,是为了尽量减少元素的移动。举一个很形象的例子,假如你手上有A,2,3,4,5
,这一序列的扑克牌,这时候新来了一张4的牌,那么你会从后往前审视这个序列牌,并将4这张牌插入到5与4之间。
从后往前遍历插入不仅减少了移动的次数,还可以通过将待排序的元素与已排序的元素比较进行移位来保证只交换一次元素
4.2 复杂度
- 空间复杂度:因为没有使用辅助数组,所以同样插入排序的空间复杂度为
O(1)
。 - 时间复杂度:假如数组是逆序排列的,那么其遍历并比较的次数在
1+2+3....+(n-1)
,很明显,最坏时间复杂度是O(n^2^)
,而最好时间复杂度为O(n)
,平均时间复杂度为O(n^2^)
。 - 稳定性:在插入排序中,从后往前遍历插入保证了相同的数字会保持相对于未排序之前的排列,所以插入排序是稳定性的。
4.3 代码实现
public class InsertSort {
public static void sort(int []array){
if(array == null || array.length == 1){
return;
}
for(int i=1;i<array.length;i++){
int target=array[i];
int j=i;
while(j >0 && target < array[j-1]){
array[j]=array[j-1];
j--;
}
array[j]=target;
}
}
}
那么插入排序比选择排序以及冒泡排序的优势在哪呢?假如序列是逆序的,那么插排与冒泡的时间复杂度都为O(n^2)
,但是每次冒泡会用到三个赋值操作,而插排只需要一个赋值操作。而且插入排序、冒泡排序以及选择排序都适合于整体元素相对于有序的情况,但是插排是其中最优的,这三者在序列越有序,那么其性能越接近O(n)
。
五、希尔排序
希尔排序是在插入排序上做了改进,本来插入排序只是按部就班的一个个元素进行比较并插入,而且插入排序适合于整体相对有序的排序,那么自然我们会想到,我们能不能采取某种措施使整个序列尽量有序,从而使得插入排序的时间复杂度尽可能的小。出于这个想法,希尔排序被提了出来。
5.1 思想
希尔排序的核心思想是:每次分割不同的区间,不同的区间之间实行插入排序。
基于这样的思想,可以让整个待排序的序列每次在不同区间之间跳跃查找,从而逐渐让整体相对有序。
假如有5,7,1,3,6,9
,这一待排序数组,我们以每隔3个元素划分为成3个区间:5,3
,7,6
,1,9
,实行第一轮插入排序后的结果变为了:3,6,1,5,7,9
,再以每隔一个元素划分区间,那么这时就会对整个数组插入排序。其实每隔一个元素划区间,这时候希尔排序就变成了插入排序了,但是在这一步的时候数组在整体上偏向有序的状态。
这里每隔n个元素来划分区间,这里的n实质上指的是希尔排序的增量,在希尔排序中,每一轮区间之间排序完成后,增量会减少一部分,增量减少代表着每个区间的元素增加,当增量减至1的时候,数组里就只有一个区间,再次实行一轮插入排序,那么数组就是有序的状态,这也是希尔排序又叫做缩小增量排序的原因。这里增量的取值一般取Array.length/2
,每次增量减少,就直接将增量缩小为原来的一半,这样增量的选择是较常用的做法,但不是最优的。
5.2 复杂度
- 空间复杂度:同样,希尔排序只是在插入排序上做了改进,其空间复杂度是
O(1)
。 - 时间复杂度:当增量为1的时候,希尔排序就退化为了插入排序,此时时间复杂度为
O(n^2^)
,但是某些同步增量的取值不同,会造成希尔排序的时间复杂度的不同,比如Hibbard序列
就可以使得希尔排序的时间复杂度达到O(^5/4^)
。 - 稳定性:希尔排序是不稳定性的排序算法,比如增量为2时,有序列:
2,2,2,4
,第一次增量交换后,三个2之间的相对位置已经改变了,所以是不稳定的排序算法。
5.3 代码实现
public class ShellSort {
public static void sort(int []array){
if(array == null || array.length == 1){
return;
}
for(int gap=array.length/2;gap>=1;gap=gap/2){
for(int j=gap;j<array.length;j++){
int target=array[j];
while( j -gap >= 0 && target < array[j-gap]){
array[j]=array[j-gap];
j=j-gap;
}
array[j]=target;
}
}
}
}
我们应当思考的问题是,有些特定增值的希尔排序为什么会突破时间复杂度O(n^2^)
的限制,而达到接近O(n)
的时间复杂度。其中的缘由是,每次交换查找的时候,改变的不止一个元素的逆序
,简单的来说,要想突破n方的时间复杂度,那么必须在每次查找交换的时候,要保证多个元素同时朝着有序的方向前进,而不仅是一个。
六、归并排序
希尔排序是利用不同分组之间来跳着比较插入的,其实质是每次查找都不止改变了一个元素的逆序,那么还有没有其它的方式来实现每次交换查找会改变多个元素的逆序呢?我们仔细思考希尔排序的步骤,它将序列分成不同的组,在不同的组里面进行插入排序,那么我们为何不能将这个分组彻底的分下去,直到分组里面的数据为1,每个分组数据量为1的时候,那么它自身就是有序的,我们只需要的是把这些有序的数据给组装起来,基于这样的大致思想,归并排序被提了出来。
6.1 思想
归并排序的核心思想:分治。
将序列分而治之,把数据分割成一个个不可再分的数据量,自然,这些数据变成了有序的,然后按照某种排列组合方式将这些有序的数据组合起来。
从图中可以看出,整体分治的步骤就是两颗完全二叉树,其中每颗树的深度是log2n
,而其中合并的策略是新创一个序列,两个要合并的有序子序列中的元素比较,谁小就谁先插入新的序列,直到所有子序列插入完毕(其实就是按顺序合并的两个有序子数列而已)。
6.2 复杂度
- 空间复杂度:使用了辅助数组,辅助数组的大小取决于元素的个数,所以空间复杂度为
O(n)
。 - 时间复杂度:每次合并操作的时间复杂度是
O(n)
,而合并多少次取决于完全二叉树的深度,为lon2n
,所以总的时间复杂度为nlog2n
,简化为nlogn
。 - 稳定性:归并排序是稳定的排序算法,因为在合并的时候,相同的元素总是保持着相对的位置没有改变。
6.3 代码实现
public class MergeSort {
public static void main(String []args){
int []array={5,7,1,3,6,9,0,2};
int []temp=new int[array.length];
sort(array,temp,0,array.length-1);
System.out.println(Arrays.toString(array));
}
public static void sort(int []array,int []temp,int left,int right){
if(array == null && array.length == 1){
return;
}
if(left >= right){
return;
}
int mid=(left+right)/2;
sort(array, temp, left, mid);
sort(array, temp, mid+1, right);
merge(array, left, mid, right,temp);
}
private static void merge(int []array,int left,int mid,int right,int []temp){
int i=left;
int j=mid+1;
int count=0;
while(i <= mid && j <= right){
if(array[i] <= array[j]){
temp[count++]=array[i++];
}else{
temp[count++]=array[j++];
}
}
while(j <= right){
temp[count++]=array[j++];
}
while(i <= mid){
temp[count++]=array[i++];
}
j=left;
for(i=0;i<count;i++){
array[j++]=temp[i];
}
}
}
七、快速排序
快速排序是冒泡排序的改进,在冒泡排序中我们每一轮查找都是交换一个元素,也就是改变了一个元素的逆序,而在快速排序中,它的每一轮查找可以改变接近n/2
的元素的逆序。因为应用这个思想的排序在排序算法相对于其它排序算法较快,所以被称作快速排序。
7.1 思想
快速排序的思想:通过一轮排序,将序列分成以某个值为分界点的两个序列,一侧小于这个值,另一侧大于这个值。
重复这个操作,将两侧的序列再分,直到元素序列的元素数量为1,不可再分即排序结束。从它的思想来看,其实快速排序的思想也属于分治的思想,不同于归并排序的是,快速排序在分
这一步骤保证了整体序列的有序,小的在一侧,大的在另外一侧,而其相对于归并排序,快速排序没有使用辅助的数组,是原地排序算法,当分割的元素组里的元素数量达到1时,序列整体就有序了。
快速排序的一个经典的用法叫做三位取中快速排序
,即取序列的中间那位的值为分界点,并保证序列首位、中间位以及末尾是有序的,比如3,5,6,2,1
,我们取6为分界点,处理后的序列为:1,5,3,2,6
。快速排序的步骤如下:
- 第一步:三数取中处理,然后将中间位(pivot)与倒数第二位交换。
- 第二步:从首位后一位开始遍历查找,直到找到第一个大于中间位值的存在结束(i),从中间位(交换后)前一位开始往前查找,找到小于中间位的存在结束(j),交换i与j。
- 第三步:重复第二步,直到i的位置大于j,交换i与pivot的值,将序列以i为分割点分割序列。
- 第四步:分别对两个序列重复第一步到第三步,直到序列元素为1结束。
7.2 复杂度
- 空间复杂度:快速排序没有使用辅助数组,但是进行了递归遍历,递归栈的平均深度为
log2n
,所以它的空间复杂度为O(logn)
。 - 时间复杂度:经过复杂的推导,快速排序的平均时间复杂度为
O(nlogn)
,但是最坏时间复杂度会降至O(n^2^)
,这种情况出现在每次选中间值时,这个中间值恰好为最大值或者最小值。这时候快速排序就退化为冒泡排序了,但是这种可能性很小。 - 稳定性:快速排序不是稳定性的算法,比如
3,4,2(A),2(B),1
,经过快速排序会变成1,2(B),2(A),3,4
。
7.3 代码实现
public class QuickSort{
public static void main(String []args){
int []array={5,7,1,3,6,8,0,0};
sort(array, 0, array.length-1);
System.out.println(Arrays.toString(array));
}
public static void sort(int []array,int left,int right){
if(array == null && array.length == 1){
return;
}
if(left >= right){
return;
}
int pix=delMid(array, left, right);
int i=left;
int j=pix;
while(true){
while(array[++i] < array[pix]){}
while( j > i && array[--j] > array[pix]){}
if(i < j){
swap(array, i, j);
}else{
break;
}
}
if(i < right){
swap(array, i, right-1);
}
sort(array, left, i-1);
sort(array, i+1, right);
}
private static int delMid(int []array,int left,int right){
int mid=(left+right)/2;
if(array[left] > array[mid]){
swap(array, left, right);
}
if(array[mid] > array[right]){
swap(array, mid, right);
}
if(array[left] > array[mid]){
swap(array, left, mid);
}
swap(array, mid, right-1);
return right-1;
}
private static void swap(int []array,int i,int j){
if(i == j){
return;
}
array[i]=array[i]^array[j];
array[j]=array[i]^array[j];
array[i]=array[i]^array[j];
}
}
八、堆排序
如果说归并排序是隐含的在二叉树上进行操作排序的话,那么堆排序就是直接在二叉树上操作了,从名字可以看出,堆排序是基于堆这一数据结构来完成的。
8.1 思想
堆是一种特别的数据结构,本质是一个数组,但是以逻辑关系构建了一颗子节点都小于或者大于根节点的完全二叉树,都大于根节点被称作最小堆,反之被称为最大堆。堆特别适合于优先队列的实现,Java中的PriorityQueue
就是通过建立最小堆来实现的。
在队列中,调度程序反复提取队列中第一个作业并运行,因为实际情况中某些时间较短的任务将等待很长时间才能结束,或者某些不短小,但具有重要性的作业,同样应当具有优先权。堆即为解决此类问题设计的一种数据结构。—维基百科
堆排序是基于堆的,如果说希尔排序是分组进行插入排序来达到整体相对有序的,那么堆这一数据结构因为其性质则天生相对有序,如图:
那么如何让它整体有序呢?其实很简单,只需要将堆的首节点与末尾节点交换一下,然后在调整堆的结构使其满足堆的性质就可以了,这个操作保证了每次交换元素的时间复杂度在log2n
之内,也就是堆的树的最大深度,这样堆排序的最坏时间复杂度也就为O(nlogn)
了,而且保证了数组尾部是每次元素中未排序的最大值:
8.2 复杂度
- 空间复杂度:在实现的时候如果需要用到递归,递归栈的深度为堆结构的树的深度,所以空间复杂度为
O(logn)
,如果不需要则为O(1)
。 - 时间复杂度:堆排序的最坏、平均、最好时间复杂度都为
O(nlogn)
。 - 稳定性:堆排序不是稳定性的排序,因为涉及到首元素与末元素的交换,会打破相同元素的相对位置。
8.3 代码实现
public class HeadSort{
public static void main(String []args){
Head head=new Head();
int []array={9, 7, 6, 3, 5, 1, 0};
head.sort(array);
System.out.println(Arrays.toString(array));
}
static class Head{
private int caculateLeftChildIndex(int index){
return (index <<1 )+1;
}
private int caculateRightChildIndex(int index){
return (index <<1 )+2;
}
public void sort(int []array){
for(int i=array.length-1;i>0;i--){
swap(array, 0, i);
adjustHead(0, array,i);
}
}
public void adjustHead(int index,int []array,int length){
int temp=array[index];
for(int i=caculateLeftChildIndex(index);i < length; i=caculateLeftChildIndex(i)){
if(i+1 < length && array[i+1] > array[i]){
i++;
}
if(temp < array[i]){
array[index]=array[i];
index=i;
}else{
break;
}
}
array[index]=temp;
}
}
}
九、总结
不稳定的排序有:快速排序、希尔排序、选择排序、堆排序。如果记不住,借鉴网上一位老哥的说法:快(快速排序)些(希尔排序)选(选择排序)一堆(堆排序)美女
,哈哈。突破O(n^2^)
的时间性能的排序有:希尔排序、快速排序、堆排序、归并排序。