零、前言
这篇快速排序的文章其实之前就写了,但是当时有个疑问就一直没发,今天就把文章整理顺便补充一下(我当时写了两种快排的写法,想比较两者效率,然后那时测试数据达到一百万时,一种写法没问题,但有一种写法的快排会导致StackOverflowError
栈溢出,我当时以为是写法问题,整到凌晨1,2点还没解决 T-T,后来才发现两种都可能出现或者不出现问题 代码真是玄学啊:-) 原因就是快排采用的是递归,而递归层数过多就会栈溢出)
一、排序必学之快速排序
快速排序是一种基于分治的思想以及采取递归的方式来处理子问题。
快速排序通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,将这两个子数组排序也就将整个数组排序了。
我特意画了图方便理解。
这里我选了数组a第一个元素作为切分元素(我称之为 轴)用v记录,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于它的元素,交换这两个元素。不断进行这个过程,就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素 a[l] 和 a[j] 交换位置。
这里给出上图思路实现代码:
public void sort(int[] nums){
sort(nums, 0, nums.length - 1);
}
private void sort(int[] nums, int left, int right){
if (left >= right) {
return;
}
int mid = partition(nums, left, right);
sort(nums, left, mid - 1);
sort(nums, mid + 1, right);
}
/**
* 双向快排:最好情况时间复杂度 O(NlogN),最坏情况(原数组有序)时间复杂度 O(N^2)
* +----------------------------------------------------------+
* | pivot | < pivot | > pivot |
* +----------------------------------------------------------+
* ^ ^ ^
* | | |
* v j i
*/
private int partition(int[] nums, int left, int right){
int v = nums[left];
int i = left + 1, j = right;
while (true){
while (nums[i] < v && i != right){
++ i;
}
while (nums[j] > v && j != left){
-- j;
}
if(i >= j)
break;
swap(nums, i, j);
}
nums[left] = nums[j];
nums[j] = v;
return j;
}
但在调试的过程中,我发现这种实现有个细节可以优化,减少判断次数,如下:
// 优化,判断更少的次数
private int partition2(int[] nums, int left, int right){
int v = nums[left];
int i = left, j = right + 1; // 注意!不同点
while (true){
// 因为初始化 i, j 不同,可以用先 ++ / -- 即不用在判断已调换的元素
while (nums[++i] < v && i != right);
while (nums[--j] > v && j != left);
if(i >= j)
break;
swap(nums, i, j);
}
nums[left] = nums[j];
nums[j] = v;
return j;
}
另外,关于
swap()
函数的实现,我相信你肯定会,而且用Java一般都是借助一个临时变量存储。
比如这样:
// 交换元素
private void swap(int[] nums, int a, int b){
int temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}
这样写当然没问题,而且通俗易懂,但是万一哪天在面试时有面试官难为你,不允许你使用额外的临时变量来完成交换呢? (为了成功反杀,我们要多留一手)
于是,niubility 的位运算就来了!
// 位运算交换数值
private void swapbit(int[] nums, int a, int b){
nums[a] = nums[a] ^ nums[b];
nums[b] = nums[a] ^ nums[b];
nums[a] = nums[a] ^ nums[b];
}
就问你猛不猛!三个都是 nums[a] ^ nums[b]
,高效好记还装13。
这里解释一下吧,为了表述方便,将上述的三个等式等价换成:
x = x ^ y // (1)
y = x ^ y // (2)
x = x ^ y // (3)
我们知道,两个相同的数 异或(^) 之后结果会等于 0,即 n ^ n = 0。并且任何数与 0 异或等于它本身,即 n ^ 0 = n。并且,异或运算支持运算的交换律和结合律。所以,解释如下:
把(1)中的 x 带入 (2)中的 x,有
y = x ^ y = (x ^ y) ^ y = x ^ (y ^ y) = x ^ 0 = x ,所以 y = x
同理吧 (2) 中的 y 带入 (3)中的 y,有
x = x ^ y = x ^ (x ^ y) = (x ^ x) ^ y = 0 ^ y = y ,所以 x = y
二、性能分析及算法改进
快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。
快速排序最好的情况下是每次都正好将数组对半分,这样递归调用次数才是最少的。这种情况下时间复杂度为 O(NlogN)。
最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。
public void sort(int[] nums){
// 随机打乱,防止最坏情况
shuffle(nums);
sort(nums, 0, nums.length - 1);
}
至于随机打乱,我们可以直接使用
Collections.shuffle();
这里我自己实现了一下:
private Random rand = new Random();
// 随机打乱
private void shuffle(int[] nums){
int length = nums.length;
for (int i = length; i > 0; i--){
int randIndex = rand.nextInt(i);
swap(nums, randIndex, i - 1);
}
}
1、切换到插入排序
因为快速排序在小数组中也会递归调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。
2、三数取中
最好的情况下是每次都能取数组的中位数作为切分元素,但是计算中位数的代价很高。一种折中方法是取 3 个元素,并将大小居中的元素作为切分元素。
3、三向切分
对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。
三向切分快速排序对于有大量重复元素的随机数组可以在线性时间内完成排序。
public void threeWaySort(int[] nums){
threeWaySort(nums, 0, nums.length - 1);
}
/**
* 三向快排:对于有大量重复元素的随机数组可以在线性时间内完成排序。
* left part center part right part
* +----------------------------------------------------------+
* | < pivot | pivot | > pivot |
* +----------------------------------------------------------+
* ^ ^ ^
* | | |
* lt gt i
*/
private void threeWaySort(int[] nums, int left, int right){
if (left >= right){
return;
}
int v = nums[left];
int lt = left, i = left + 1, gt = right;
while (i <= gt) {
if (nums[i] < v){
swap(nums, i++, lt++);
}else if(nums[i] > v){
swap(nums, i, gt--);
}else {
i++;
}
}
threeWaySort(nums, left, lt - 1);
threeWaySort(nums, gt + 1, right);
}
三、总结
快排的一个要点是轴的选取,一般有如下几种:
- 选取第一个或最后一个
- 随机取轴法
- 三数取中
三向切分快排对于有大量重复元素的数组有着很好的效果。
另外,JDK中的DualPivotQuicksort
采用的是 双轴快排 ,感兴趣的朋友可以查看源码了解一下。源码较多,这里就不贴出来了,大致如下:
/*
* Partitioning:
*
* left part center part right part
* +--------------------------------------------------------------+
* | < pivot1 | pivot1 <= && <= pivot2 | ? | > pivot2 |
* +--------------------------------------------------------------+
* ^ ^ ^
* | | |
* less k great
*
* Invariants:
*
* all in (left, less) < pivot1
* pivot1 <= all in [less, k) <= pivot2
* all in (great, right) > pivot2
*
* Pointer k is the first index of ?-part.
*/