朝花夕拾

A Development Engineer, a Life Liver, a Hope Holder

思路

从数组中找一个基准元素,以基准元素将数组分成三部分:

然后再分别对左右子数组X,Y进行排序。
可以看到,快速排序的核心方法是分区,找到分区位置,之后通过递归的方式分别对其他子数组进行分区。最后完成排序。

框架

void solution(int[] arr, int lo, int hi){
// 递归退出条件
if(lo >= hi) {
return;
}

// 分区 核心部分
int pos = partition(arr, lo, hi);

// 递归排序子数组
solution(arr, lo, pos - 1);
solution(arr, pos + 1, hi);
}

说明:

递归必须要有合适的退出条件,否则就会出现堆栈溢出。这里当分区出现最小位置大于等于最大位置时退出递归。

分区

分区的核心思路是:分别从数组左右两边进行扫描,然后找到合适的基准位,将基准元素置换到基准位并返回基准位。

void patition(int[] arr, int lo, int hi) {

// 定义扫描指针
int l = lo;
int r = hi + 1;

// 确定基准位元素
int seed = arr[lo];

// 开始扫描
while(true) {

// 从左向右
while(arr[++l] <= seed) {
// 达到最大搜索区间,退出
if(l == hi) {
break;
}
}

// 从右向左
while(arr[--r] >= seed) {
// 达到最小搜索区间,退出
if(r == lo) {
break;
}
}

// 找到了基准位置r,退出循环
if(r <= l) {
break;
}

// 尚未找到基准位置,需要后续继续扫描,当前左右指针位置数据进行交换
switch(arr, r, l);
}

// 将基准位元素放置到争取的位置
switch(arr, lo, r);

// 返回基准位
return r;
}

说明:

这段逻辑中比较关键的点是:为啥最外层的扫描循环退出条件是 r <= l。
我们先看r < l的情况
通过基准位元素和分区规则我们可以知道,那就是左右扫描结束时必须满足下面的条件

根据上面公式,我们可以推导出下面结论:

### 遍历左指针可能情况
case l == lo
//这种情况不可能出现,因为左指针的起始扫描位置就是lo+1
case lo < l < hi
//左指针在中间位置 此时一定满足 a[r]>base && a[i{i < r}] <= base
case l > r
// 这时,说明数字排序比较乱,a[l]<base && a[i{i>l}] >= base
// 在r和l之间存在倒序的情况
// 这时直接对r,l两个元素进行交换
case l < r
// 这里出现了一个临界点j,临界点左边的元素都小于base 右边的元素都大于base
// 那么,这时只需要把临界点前面的元素与base交换即可。
case l == r
// 由上面公式可知,达到临界点j时,l还会再扫描一次,变更j+1
// r也会扫描一次,变成j-1 所以r和j不会在临界点相遇
case l==hi
case r == hi
// 当数组中原有元素都比基准位小时,左指针会达到最大搜索区间,同时,右指针也会停在最大搜索区间
case r < hi
// 右上可知,这种情况不存在

结论:当且仅当r <= l时,函数找到临界点r;
r < l时:临界点在数组中间
r = l时:临界点在数组最右端

复杂度

空间复杂度

应该没有使用额外空间,所有操作都在数组上进行,所以空间复杂度为:O(n)

时间复杂度

最好情况:
由于快排本质上是二叉树的前序遍历,理想情况下每一层的遍历次数如下
image.png
那么总共的遍历次数为:n*树高
由等比数列求和公式我们可以计算树高为:h(tree) ≈ log(n)

所以,在理想情况下,快排的时间复杂度为O(N*logN)

最差情况:
image.png
如上图所示,数组每次分区基准位都在子数组的第一个元素。这样左子树每次不遍历,右子数组每次遍历n-depth(tree)
这种极不平衡二叉树高度等于数组元素个数n。那么最差情况时间复杂度O(n)≈N*N