快速排序是东尼·霍尔在1962提出的划分交换排序,并采用一种分治的策略。在这,我们先总结一下:快速排序 = 划分交换排序 + 分治。然后我们在一一介绍他。
划分交换排序
在讨论它时,感觉有种看了崔颢《黄鹤楼》之后,再去写黄鹤楼的感觉,因为MoreWindows写 得白话经典算法系列之六 快速排序 快速搞定已经足够出色了。我在这只是进行简单的复述,你需要了解更多请看他的博文。
先来看看划分交换排序的具体算法描述:
1.从数列中选出一个数作为基准
2.从数组中选出比它大的数放右边,比它小的数放左边
3.重复以上操作,直至只剩最后一个数
看了算法描述呢,感觉还是有点晦涩,我们可以换过一种思考方式:挖坑填数。
我们先来看看具体实例:
给出一个数组,并将第一个数作为基准
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
72 | 6 | 57 | 88 | 60 | 42 | 83 | 73 | 48 | 85 |
由于array[0] 已经保存到X中,所以,可以理解已经在i = 0处,挖好了一个坑,可以将其他的数据填充到这个坑中。
接下来充由j位置从后往前进行查找小于等于基准的值,在j = 8处48符合条件,这时我们将它挖出,并填到上一个坑处,即array[0] = array[8];i++;此时,我们填好了一个坑,但是又重新生成了一个新坑,该怎么办呢。我们依旧需要找一个其他的值将它填上,接下来我们在i的位置从前往后找,直至找到大于等于基准的值。当i = 3;时,符合条件,将array[8] = array[3];j--;数组便变成了:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
48 | 6 | 57 | 88 | 60 | 42 | 83 | 73 | 88 | 85 |
重复以上操作,先从后往前找,在从前往后找。
从j开始往前找,在j = 5处的42小于基准值,将它填到i = 3处,array[3] = array[5]; i++;
从i = 4处开始从前往后找,当i = 5时依旧没有符合条件的值,且(i<j)不满足,所以应该跳出循环,退出。
此时 i=5,且j = 5,整个数组剩下一个坑就是i = 5时,因为第一次的划分已经完毕,所以讲基准值填回坑中,即array[i]=X;
数组变成:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
48 | 6 | 57 | 42 | 60 | 72 | 83 | 73 | 88 | 85 |
可以看出,i=5之前的值全部小于或等于array[5],之后的值大于等于array[5]。
请注意,不是每次的划分都会是这么理想,当所以怎么选择基准会成为影响效率的一大原因,其次因为每次是找出大于等于或小于等于的值进行填坑,所以该算法是不稳定的。
接下来,我们将数组分为两个集合p1 = array[0...4], p2 = array[6...9],那么我们先来看看这段思想化成的代码吧。
public static void sort(int[] array, int left, int right){
int i = left;
int j = right;
if (i >= j){
return;
}
int pivot = array[i];
while (i < j){
while ((i < j) && (array[j] > pivot)){
j--;
}
if (i < j){
array[i] = array[j];
}
while ((i < j) && (array[i] < pivot)){
i++;
}
if (i < j){
array[j] = array[i];
}
}
array[i] = pivot;
}
然后再划分好之后继续如此,直至集合中元素小于等于1个,好好想想,这个有没有像什么,没错,树,小的放左边,大的放右边,依照方法想,我们可以想到递归。好了下来,就是我们的分治了。
分治法
在这,不做详细的介绍,假如有兴趣,推荐博客 五大常用算法之一:分治算法。
分治算法最简单的解释就是:将一个问题分解成多个小问题,再递归解决所有的问题,最后将解合并,生成最终解。
他的算法模式:
Divide-and-Conquer(P)
1. if |P|≤n0
2. then return(ADHOC(P))
3. 将P分解为较小的子问题 P1 ,P2 ,...,Pk
4. for i←1 to k
5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi
6. T ← MERGE(y1,y2,...,yk) △ 合并子问题
7. return(T)
分治在快排中的具体思想:
在一个给定的一个数组R[left....right]中,先从数组中选取一个元素作为基准pivort,根据基准,可以将数组分为两个子数组R[left....pivortpos-1]和R[pivortpost+1....right]。根据定义,可以知道,左边的元素全部小于等于基准pivort,右边的元素全部大于等于pivort,所以pivort已经在正确的位置上了,在接下来的递归中,不需要在对它进行划分。
划分的关键是求出基准所在的位置pivortpos,划分的结果可以简单表示为pivort = R[pivortpos];换分的结果可以简单表示为:
R[left...pivortpos-1] <= pivort <= R[pivortpos+1....right]
left <= pivortpos <= right
所以在我们可以写出分治的部分代码
public static void sort1(int[] array, int left, int right){
if (left >= right){
return;
}
int i = left;
int j = right;
int pivort = array[i];
while(i < j){
//划分交换
int pivortpos = Partition(R, left, right);
sort(array, left, pivortpos-1);
sort(array,pivortpos+1, right);
}
}
所以,我们可以将两个代码结合,得到完整的代码:
public static void sort(int[] array, int left, int right){
int i = left;
int j = right;
if (i >= j){
return;
}
int pivot = array[i];
while (i < j){
while ((i < j) && (array[j] > pivot)){
j--;
}
if (i < j){
array[i] = array[j];
}
while ((i < j) && (array[i] < pivot)){
i++;
}
if (i < j){
array[j] = array[i];
}
}
array[i] = pivot;
sort(array, left, i-1);
sort(array, i+1, right);
}
说完了,但是可能还是感觉有点不清楚,那我们继续来看一下他的执行过程:
首先可以大家可以先看个动画演示,是关于划分交换的,当然假如你觉得自己足够清楚,可以跳过;
假如我们存在以下集合:
在这我们可以看到,他并不是稳定的排序算法,并且他的效率和基准的选择有很大的关系。
总结:
快速排序的最坏时间复杂度是O(n^2),最好时间复杂度为O(nlogn),平均时间复杂度为O(nlogn),他是基于关键字比较的内部排序算法中速度最快的。
空间复杂度:因为是递归树的高度O(logn),所以需要栈空间O(logn),最差时是O(n)。