快速排序
快速排序是最重要的排序方法之一,在操作系统和各种类库中都被广泛使用。快排其实涉及到了很多知识点,仅仅了解快排是怎么做的还远远不够。这篇总结一下跟快排相关的各种衍生知识点。
跟归并排序一样,快速排序的基本思想也是递归,他们主要区别是:归并排序是先递归,后排序,而快速排序是先排序,后递归。
快速排序的核心思路是:
- 打乱数组
- 从数组中选择一个元素
a[j]- 不大于
a[j]的数放到j的左边 - 不小于
a[j]的数放到j的右边。
- 不大于
- 递归处理左右两数组
核心思路和归并排序的对比
/**
* 归并排序
*
* @param a 待排序数组
* @param lo 起始位置
* @param hi 结束位置
*/
public void sort(T[] a, int lo, int hi) {
if (lo >= hi) {
return;
}
//辅助数组
T[] aux = (T[]) new Comparable[a.length];
int mid = lo + (hi - lo) / 2;
//先递归
sort(a, lo, mid);
sort(a, mid + 1, hi);
//后排序
merge(a, aux, lo, mid, hi);
}
/**
* 快速排序
* @param a 待排序数组
*/
public void sort(T[] a) {
//打乱
StdRandom.shuffle(a);
doSort(a, 0, a.length - 1);
}
private void doSort(T[] a, int lo, int hi) {
if (lo >= hi) {
return;
}
//先排序
int mid = partition(a, lo, hi);
//后递归
doSort(a, lo, mid - 1);
doSort(a, mid + 1, hi);
}
完整代码
public void sort(T[] a) {
//打乱
StdRandom.shuffle(a);
doSort(a, 0, a.length - 1);
}
private void doSort(T[] a, int lo, int hi) {
if (lo >= hi) {
return;
}
//切分
int mid = partition(a, lo, hi);
//切分完毕,对mid左右数组继续切分
doSort(a, lo, mid - 1);
doSort(a, mid + 1, hi);
}
/**
* 快速排序的切分
*
* @param a 数组
* @param lo 开始idx
* @param hi 结束idx
* @return 被选中的数字v
*/
private int partition(T[] a, int lo, int hi) {
int i = lo;
int j = hi + 1;
T v = a[lo];
while (true) {
//当心v是数组中最大或者最小的元素
//找到第一个>=v的数(v不用判断)
while (less(a[++i], v)) {
if (i == hi) {
break;
}
}
//找到第一个<=v的数(如果没有就停留在start)
//这里可以去掉条件j>lo 因为当j == lo时,a[lo]不可能比自己小
while (less(v, a[--j])) {
}
//说明第一个比v小的数在第一个比v大的数的左边,数组已经有序
if (i >= j) {
break;
}
//否则交换
swap(a, i, j);
}
//退出循环的条件只有i>=j,此时a[j]是比v小的数
//把v放到合适的位置,v和a[j]交换
swap(a, lo, j);
return j;
}
private void swap(T[] a, int i, int j) {
T tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
这里注意几个点:
- 在查找第一个<=v的数的循环中可以不用判断边界条件,因为数组中第一个数就是v,v不可能比自己小。
- 我们在扫描数组时,指针会停留在小于等于和大于等于v的元素上,也就是说相同元素也会停留并交换(为什么要这样做后面会讲)。所以用上面这种算法实现的快速排序是不稳定的。
- 打乱数组是很重要的步骤。
时间复杂度
最好情况和最坏情况
快速排序的最好情况是每次都能将数组对半分。这种情况下,快速排序所用的比较次数正好满足分治递归的公式。表示将两个子数组排序的成本,N表示用切分元素和所有元素进行比较的成本。那跟归并排序一样,这个公式的解是~,所以时间复杂度是。
那最坏的情况是什么?
如果数组已经有序,那每次扫描结束时,i会停留在lo+1,j从结尾一直扫描到lo。下一次切分时,就以a[lo]为切分元素,左边子数组为空。每一次切分后左子数组都为空。那比较次数就是
N+(N-1)+(N-2)+...+2+1 = (N+1)N/2 ~
这个算法的时间复杂度是,明显是平方级别的。同样,如果输入数组是倒序的,那每次切分后右子数组为空,也是平方级别。所以说,在排序之前打乱数组很重要,能有效预防这种情况。
重复元素
为什么扫描时遇到相等元素要停止?
我们假设partition函数中遇到相同元素不停留,当输入中含有大量相同元素的时候,算法的表现会怎样呢?首先把算法改为:
//遇到相同元素不停止
while (lessAndEquals(a[++i], v)) {
if (i == hi) {
break;
}
}
while (lessAndEquals(v, a[j])) {
//这里边界条件不能去掉,因为v本身已不能够作为哨兵
if (j == lo) {
break;
}
}
这里注意下,之前v本身可以作为哨兵,j最多只会停留在lo,但现在v不能作为哨兵了,所以j可能会越界。
假设一种极端情况,数组为:
a = [1, 1, 1, 1, 1, 1, 1, 1]
v = a[0] = 1
初始:i = 1, j = 6
第一次切分扫描后:i = 7, j = -1
跳出while循环后,执行swap(a, lo, j),也就是把v和自己交换。于是a[lo]为切分元素,左子数组为空,右子数组为a[lo+1]~a[hi],每一次切分后都是这样,这不就上面说的最差情况一样了吗。时间复杂度可以达到平方级。
那遇到相同元素停止的情况下是怎么样的呢?
a = [1, 1, 1, 1, 1, 1, 1, 1]
v = a[0] = 1
初始:i = 1, j = 6
第一次切分扫描后:i = 4, j = 3
这样就能够从中间切分数组。虽然说可能会把相同元素进行不必要的交换,但是为了出现避免的情况,还是值得的。
快速选择
给出一个长度为N的数组,找到数组中从小到大排序的第k(为了方便,k从0开始)个元素。
- 第一种方法,把数组排序,然后扫描一遍出结果。这种方法时间复杂度是
O(NlogN)的。 - 第二种方法,采用快排的思想:
随机选择一个元素
a[j],对数组切分,<=a[j]的数放到左边,>=a[j]的数放到右边。如果a[j]左边的元素个数>k,就对左子数组递归查询,否则就对右子数组递归查询,直到a[j]左边的元素个数=k,那么a[j]就是第k个元素。这就是快速选择,时间复杂度是线性的,不过证明太麻烦了,有兴趣可以自己搜。
/**
* 快速选择
* 这里假设输入合法,即第k个元素一定存在
*
* @param nums 目标数组
* @param k 从小到大第k个元素
* @return
*/
public int select(int[] nums, int k) {
k = k - 1;
shuffle(nums);
int lo = 0, hi = nums.length - 1;
while (hi > lo) {
int j = partition(nums, lo, hi);
if (j < k) {
lo = j + 1;
} else if (j > k) {
hi = j - 1;
} else {
return nums[k];
}
}
//当lo == hi时,假设输入一定合法,那lo即k
return nums[k];
}
private int partition(int[] a, int lo, int hi) {
int i = lo;
int j = hi + 1;
int v = a[lo];
while (true) {
//找到 >= v的
while (a[++i] < v) {
if(i == hi){
break;
}
}
//找到 <= v的
while (a[--j] > v) {
}
if (i >= j) {
break;
}
swap(a, i, j);
}
swap(a, lo, j);
return j;
}