基本思想
快速排序也是一种基于分治的排序算法,它的主要思想是将一个数组切分成两部分,将这两部分独立的进行排序。和归并排序不同的是:归并排序首先对两部分子数组进行排序,在子数组各自有序之后将他们合并为一个完整的有序数组;而快速排序在两个子数组均有序的时候整个数组也已经有序了。
快速排序的关键在于对数组的切分,这个过程通过一个切分元素(或者叫基准)来实现的,切分将数组划分为两部分,满足前一部分的元素均不大于切分元素,后一部分元素均不小于切分元素,这样在前后两部分都有序时整个数组自然而然就是有序的。
算法流程
- 首先选取切分元素(随计选取或者取数组第一个元素);
- 从前向后遍历数组,找到一个大于切分元素的元素,接着从后向前遍历元素找到一个小于切分元素的元素,然后交换这两个元素的位置;
- 重复步骤2,直到遍历数组的两个指针相遇,交换此位置的元素和切分元素。这样整个数组就被切分元素分割成了两部分,满足前面的元素均不大于它,后面的元素均不小于它;
- 对两部分子数组递归地进行步骤1-3。
演示
代码实现
上面讲到其实切分部分时算法的核心,因此首先给出切分数组的代码:
private static int partition(int[] arr, int lo, int hi){
int i = lo, j = hi + 1;
int v = arr[lo]; //指定切分元素为第一个元素
while(true){
while(arr[++i] < v){ // 从第二个元素开始向后遍历,找到大于基准的元素
if(i == hi) break;
}
while(arr[--j] > v){
if(j == lo) break; // 从最后一个元素向前遍历,找到小于基准的元素
}
if(i >= j) break; // 前后便利的指针相遇,退出循环
swap(arr, i, j); // swap是交换数组元素的函数
}
swap(arr, lo, j);
return j;
}
切分函数中使用了第一个元素作为切分元素,因此从第二个元素向后遍历,从最后一个元素向前遍历,并交换满足条件的元素。最差的情况就是其余所有元素都小于切分元素 (i==hi
),或者其余所有元素都大于切分元素(j==lo
),此时造成的结果是两部分切分非常不均匀,因此随机选取切分元素往往是更好的选择, 或者在排序之前将数组随机打乱。
下面是排序函数,可以看到排序的最主要的操作就是切分,然后不断地递归切分子数组
public static void quick_sort(int[] arr){
// 数组为空或者长度为1不需要排序
if(arr == null || arr.length < 2){
return;
}
sort(arr, 0, arr.length-1);
}
private static void sort(int[] arr, int lo, int hi){
if(lo >= hi) return; // lo>=hi说明当前部分已经不需要排序了
int j = partition(arr, lo, hi); // 切分数组
sort(arr, lo, j-1); // 递归地对两部分子数组进行排序
sort(arr, j+1, hi);
}
分析
算法改进
1. 随机选择切分元素
前面提到,在一些极端情况下(例如初始数组是倒序的),选取第一个元素进行切分造成的结果就是每次切分的两部分非常不平衡。以倒序数组来说第一次切分后较长的一部分长度为 n − 1 n-1 n−1 (假设数组总长度为n),这样会造成没有很好的利用到分治带来的优势,降低算法性能。 因此一个改进措施就是随机选取切分元素,或者是选取切分元素前将数组随机打乱。
2. 切换到插入排序
对于小数组快速排序比插入排序慢,因此和归并排序一样,在子数组规模较小的时候切换为插入排序而不是递归地使用快速排序能够提高算法的效率。
3. 三路切分
实际排序中如果数组中包含大量重复元素,此时对于所有元素均相等的子数组,快速排序仍旧会不断地将其切分为更小的数组,这时候可以通过将数组划分为三部分来改进快速排序。
① 思想
三路划分的思想是利用切分函数将待排序数组列划分为三部分:第一部分小于切分元素,第二部分等于切分元素,第三部分大于切分元素,接下来递归地对除了中间部分的其余两部份进行排序。这样如果数组中包含了大量重复元素,就可以避免对于重复部分进行切分排序的时间消耗。
② 代码
private static void quick3way_sort(int[] arr, int lo, int hi){
if(hi <= lo) return; // lo>=hi说明当前部分已经不需要切分排序了
int lt = lo, i = lo+1, gt = hi;
int v = arr[lo]; // 切分元素
while(i <= gt){
if(arr[i] < v) swap(arr, i++, lt++);
else if(arr[i] > v) swap(arr, i, gt--);
else i++;
}
// while循环执行完后,arr[lo...lt-1] < a[lt...gt] < a[gt+1...hi]
quick3way_sort(arr, lo, lt-1); // 然后对除了切分部分外的两部分递归排序
quick3way_sort(arr, gt+1, hi);
}
说明:这里三路切分函数依旧使用第一个元素作为切分的基准,标记为v,lt用来存储切分部分的左边界,初始为数组首位置,gt存放切分部分的右边界,初始为数组的末尾。从第二个元素开始向后遍历并且和v比较:如果当前元素小于v,将其切分到左边并且切分左边界右移(lt++
);如果当前元素大于v,将其切分到右边并且切分部分右边界左移(gt--
),此时不执行i++的原因是不知道当前右边界元素和切分元素的大小关系,需要下一次循环中进行比较;如果当前元素等于切分元素,继续向后遍历。
算法性能分析
-
时间复杂度
快速排序的平均时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。最好情况下,如果每次划分得当,递归树的深度就是 l o g n logn logn,时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn);最差情况下,每次划分都取到了数组中最大的(或最小的)元素作为切分元素,此时快速排序就退化为了冒泡排序,时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
-
空间复杂度
快速排序主要的空间消耗是递归调用的空间占用。最好情况下,每次都能平均划分数组,空间复杂度为 O ( l o g n ) O(logn) O(logn),最差情况下就是退化为冒泡排序,此时空间复杂度为 O ( n ) O(n) O(n)。
-
稳定性
在交换切分元素和遍历相遇点元素的时候,快速排序有可能打乱数组重复元素原有的顺序,因此快速排序是不稳定的。
参考资料
- 一文搞定十大经典排序算法
- 《算法(第四版)》