一 前置理论
Q1: 快排可以解决什么问题:
A: 排序问题
Q2: 快排的实现原理:
A1: 分治思想
A2 实现思路:
- 先排序一个元素x,让其放到正确的位置==> 即 获取到一个索引idx,使得 左子数组满足[l, idx]<=x 和 右子数组满足 [idx+1, r]>=x
- 再按照上述思想,递归 处理划分出的左右子数组,从而到最后会 正确排序数组里的每一个元素
Q3: 快排的特点:
A:
- 快排 平均时间复杂度是 O(nlogn),如果划分的左右子数组成员数量不平衡,极端情况下会 退化到O(n^2);
- 如果使用三路快排,则可以避免退化到 O(n^2)
- 快排 是不稳定的,即相同值的元素位置可能会发生变化
二 基础版实现
1 该版本实现虽然存在问题,但是能便于初步 理解快排最重要的Partition步骤,所有先介绍下
2 基础版-快速排序实现 步骤:
S1 对[left, right]区间内 某个元素A进行正确排序(partition):
- 假设其正确位置为i,则 [left, i-1] < A 且 [i, right] >= A
S2 A完成排序后,继续递归调用排序,范围缩减为[left, i-1] & [i+1, right]
S3 递归执行,直到left<=right,说明排序完成
partition实现步骤:
-
目标:对于任意一个元素A,找到一个索引值i,使得 [left, i-1] < A 且 [i, j] >= A
-
初始化A,默认为范围内的首个元素:willSort = arr[left]
-
初始化 i = left && j = left+1: 保证 [left+1, i]< A 为空 && [i+1, j] >=A 成员只有A
-
在[left+1, right] 范围内,逐次取值B 和 A 进行比较:
-
当B < A时,说明存在比A小的元素, 即 小于A的数组范围要扩大: i后移1位 + 交换i和j的元素 ==>
- i扩大1后,此时的i指向的元素值 和A的大小比较不确定 && 此时j指向的是必然是 <A的 ==>
- 交换i和j后,才依然满足 [left+1, i] < A, 至于[i+1, j] >= A的性质此时不一定能保证,但是会在后续 和A比较大小,从而处理它的位置
- i扩大1后,此时的i指向的元素值 和A的大小比较不确定 && 此时j指向的是必然是 <A的 ==>
-
当 B > A时,继续自然增加i即可,以保持[j+1,i] >= A 的性质
-
3 快速排序1 存在的问题:
- 问题1:对近乎有序的数组时,由于partition拆分后极度右倾,所以导致 时间复杂度为O(n*n), 空间复杂度为O(n)
- 问题2:对于有大量重复元素的数组排序,同样会造成partition拆分后极度左/右倾,导致时间复杂度退化
3.2 针对问题1,可以进行如下优化:
- 对于元素长度较小情况,直接使用插入排序
- 针对有序情况,随机取A值 而不是固定取left位置的值,这样右倾的概率就无限小
针对问题2,优化为 quickSortV2: - 其他步骤的目标和实现都不变,只是partiton实现进行优化 - partiton目标:保证 [left, i] <= base值 && [i+1, right] >= base值
3.3 进一步对于大量重复元素,可以使用三路快排进行优化-- quickSort3:
- 目标: 构建3个区间 [left, [left+1, lt], [lt+1, i-1], [gt, right] , i ] A 都小于A 等于A 大于A 当前循环值
// 基础版实现1 时间复杂度: 平均为 O(nlogn), 最差为O(n^2); 空间复杂度: O(n)
function quickSortV1(arr: number[]): number[]{
innerSort(arr, 0, arr.length - 1);
return arr
}
function innerSort(arr, left, right) {
if (left >= right) return;
let p = partition(arr, left, right);
innerSort(arr, left, p - 1);
innerSort(arr, p + 1, right);
}
// 把基准元素val 放到其正确排序位置i + 返回其位置索引i
// 即[left, i-1] < val && [i, right] >= val
function partition(arr, left, right) {
let willSort = arr[left];
let i = left;
// 始终保持[left+1, i] < willSort && [i+1, j] >= willSort
for (let j = left + 1; j <= right; j++) {
if (arr[j] < willSort) {
swap(arr, i, ++j);
}
}
// 交换left和i后,完成partition目标: [left, i-1] < val && [i, right] >= val
swap(arr, left, i);
return i;
}
function swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
三 双路快排实现
1 虽然可以通过随机选取基准分割点,来解决基础版本「处理近乎有序的数组时」的极度右倾问题,但是仍然无法 解决基础版在处理「有大量重复元素的数组」的极度倾斜问题(会导致 时间复杂度退化为O(n^2))
2 所以引入双路快排的实现,从而让多个相等元素 平均分到左右子数组内,从而避免左右子数组过度倾斜到某一侧的情况
3 双路快排的实现步骤:
- 总体思路同V1: partition + 递归左右子数组
- partition过程: 从同方向的双指针(i,j),优化为头尾相碰指针,有很多边界问题需要注意
// 双路快排版 实现2.1 时间复杂度: 极大概率为 O(nlogn); 空间复杂度: O(n)
function quickSortV2_1(arr: number[]): number[]{
innerSort(arr, 0, arr.length - 1);
return arr
}
// 排序arr里 [l, r]范围内的元素
function innerSort(arr, l, r) {
// 递归中止条件: 表示只有<=1个成员的数组,已经是有序的直接返回即可
if (l >= r) return;
let p = partition(arr, l, r)
innerSort(arr, l, p-1)
innerSort(arr, p+1, r)
}
// 排序arr里的某个元素X, 返回A在arr内的 排序后的正确下标p
function partition(arr, l, r) {
//S1 随机选取元素X,作为分割基准值,而不是V1版 固定的取最左侧元素
// [0,1) ==> 向下取整[0, r-l+1) ==> [l, r+1),且l和r一定是整数
let rdx = Math.floor(Math.random() * (r - l + 1)) + l;
swap(arr, l, rdx);
//S2 初始化x、相碰指针i和j,并保证循环不变量性质: arr[l+1, i-1] <=x && arr[j+1, r] >=x
let x = arr[l], i = l + 1, j = r
// x i&j
// S3 要包含处理i===j的情况,假设置换后位置的 arr = [2, 5, 6, 1]
// 因为在符合调换条件 交换后,i和j会继续相对缩进,就有可能出现在下一轮循环出现 i===j的情况
// 此时只要 i/j对应位置的元素大于或者小于x, 就会遗漏更新i/j的值,导致i或者j的位置不正确
// 而(i) j的值不正确时,后续划分左右子数组 分组就会不正确,导致排序结果错误
while (i <= j) {
// S3.1 不会等于x,从而保证等于x的也会被互换到不同子分组,避免分组过分倾斜
while (arr[i] < x) i++;
while (arr[j] > x) j--;
// S3.2:注意和S3.3的写法进行对比,两者是不等价的
// 由于在while循环内部 还会有while循环更新i/j的值,所以运行到此处时,i是有可能超出j/r的(如x取到了最大值),这时候就不能再错误更新i/j的值了,所以当i遇到j时, 直接退出外层循环
if (i >= j) break;
// S4 更换元素后 更新左右指针
swap(arr, i, j)
i++; j--;
// S3.3 注意这种写法会有概率导致死循环,当i===j时,如过此时i/j指向的值正好=x,就永远无法结束外层循环,所以要用S3.2的写法处理i===j时跳出循环
// if (i < j) {
// swap(arr, i, j);
// i++; j--;
// }
}
// S5 运行到此处时,x的正确位置就是 i-1/j, 返回任意一个指针即可
swap(arr, l, j)
return j
}
function swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]]
}
4 再介绍一种do...while实现的双路快排,优点是边界情况处理起来较简单,缺点是代码适用性和理解性较差
// 双路快排版 实现2.2 时间复杂度: 极大概率为 O(nlogn); 空间复杂度: O(n)
function quickSortV2_2(nums: number[]): number[] {
innerSort(nums, 0, nums.length - 1);
return nums
};
// 对[l, r]范围内的数组, 其中最左边的元素进行正确排序==> 使其放到正确位置
function innerSort(arr, l, r) {
//S1 递归中止条件: 表示只有<=1个成员的数组,已经是有序的直接返回即可
if (l >= r) return;
//S2 随机选取元素 + 前后相碰指针==> [l, i]都<=x; [j,r]都>=x
let rdx = Math.floor(Math.random() * (r - l + 1)) + l;
[arr[l], arr[rdx]] = [arr[rdx], arr[l]];
// 随机选取元素+交换过位置后,再从l-1, r+1位置 开始partition流程
let x = arr[l], i = l - 1, j = r + 1;
// 可以 以arr = [2, 3, 2, 1, 1]辅助理解S3partiton的过程
//S3 进行partiton: 当指针没有相遇时进行分割定位,从而保证 [l, i]都<=x; [j,r]都>=x
// 注意这种实现方法是从l-1开始,所以一开始就会处理l位置的元素,把它放到正确位置;
// 而 V2_1的版本是从i=l+1处理元素的,直到最后才会处理l位置的元素;
// 不需要包含 i===j的情况,因为在这种实现方法下,i===j只有一种可能,就是值和x相等
while (i < j) {
// 注意点1: do while保证了进入循环,就一定会更新i/j值,确保最外层的while 不会死循环
// 所以不需要 i===j break的写法
do i++; while(arr[i] < x);
do j--; while(arr[j] > x);
// 注意点2: 不用处理 i===j的情况,因为这时i和j指向同一个元素,没必要让i和j自己交换自己
// 而且因为不会有死循环出现,所以不需要用 break强制跳出循环
if (i < j) [arr[i], arr[j]] = [arr[j], arr[i]];
}
//S4 递归处理 正确位置分隔点2边的数组,最终实现递归对每个成员都进行正确排序
// 取[l, j] 和 [j+1, r]的原因,是因为是使用了 do...while,导致了j本身的位置就是切割点位置
innerSort(arr, l, j);
innerSort(arr, j + 1, r);
}
5 从上面2种实现,可以知道partition的关键是: 明确定义并维持「循环不变量」
function quickSortV2_3(nums: number[]): number[] {
innerSort(nums, 0, nums.length - 1);
return nums
};
function innerSort(arr, l, r) {
if (l >= r) return;
const p = partition(arr, l, r)
innerSort(arr, l, p);
innerSort(arr, p + 1, r)
}
// 获取x的正确位置p,使 arr[l, p]都<=x, arr[p+1, r]都>=x
function partition(arr, l, r) {
let rdx = Math.floor(Math.random() * (r-l+1)) + l
swap(arr, l, rdx)
let x = arr[l], i = l, j = r
// 循环不变量:保证 [l, i-1]都<=x, [j+1,r]都>=x
while (1) {
while (arr[i] < x) i++;
while (arr[j] > x) j--;
// 指针相遇/交错后,就说明处理完所有成员了,不需要再继续循环
if (i >= j) break;
// 隐含了i<j的前提条件,才会交换成员+缩进窗口
swap(arr, i, j);
i++; j--;
}
// 这里返回 i-1/j都可以
// 因为p的性质是使 arr[l, p]都<=x, arr[p+1, r]都>=x
// 而定义的循环不变量的性质是 [l, i-1]都<=x, [j+1,r]都>=x
// 可以看出,p就等价于i-1 或者 j
return j
}
function swap(arr, l, r) {
[arr[l], arr[r]] = [arr[r], arr[l]]
}
四 三路快排实现
1 三路快排实现思路:
三路快排,就是把等于x的元素单独划分为一个区间,即 把一个数组分成了下面3个区间
[ [l, lt], [lt + 1, gt - 1], [gt, r] ]
区间A: 值都小于x的区间; 区间B: 值都等于x的区间; 区间C: 值都大于x的区间;
然后只需要再对 区间A 和 区间C 进行递归处理即可
2 三路快排的优点:
由于单独划分了等于X的区间B,所以在下轮递归 可以避免再次处理大量的重复元素,同事也能避免 子区间划分过于倾斜
3 代码实现
function quickSortV3(nums: number[]): number[] {
innerSort(nums, 0, nums.length - 1);
return nums
};
function innerSort(arr, l, r) {
if (l >= r) return;
const [lq, gt] = partition(arr, l, r);
innerSort(arr, l, lq-1);
innerSort(arr, gt, r);
}
// 划分3个子区间,使 arr[l, lq)都<x, arr[lq, gt-1]都==x, arr[gt, r]都>x
// 返回lq和gt的 数组索引
function partition(arr, l, r) {
let rdx = Math.floor(Math.random() * (r - l + 1)) + l;
swap(arr, l, rdx);
// 获取基准值
let x = arr[l]
// 从l+1开始处理,在循环中保证 [l+1, lt]都<x, [lt+1, i)都===x, [gt, r]都>x
let lt = l, gt = r + 1, i = l + 1
// 易错点1: 当i和gt相遇时就表示数组所有成员都被处理划分了,此时就该停止
while (i < gt) {
if (arr[i] === x) {
// 默认开始时i为l+1,相等时直接i++,依旧满足[lt+1, i)都===x
i++
} else if (arr[i] < x ) {
// 这里先把lt前移,从而保证[l+1, lt]都<x,注意此时[lt+1, i)都===x仍然成立
// lt+1和i交换位置后,由于lt+1一定是等于val的,所以交换位置后的arr[i]也是等于x的
// 已经是正确位置了,所以i直接后移处理下一个元素即可
swap(arr, i++, ++lt)
} else {
// 先把gt前移,从而保证[gt, r]都>x
// 由于此时gt-1位置的元素值和x的大小关系不能确定,所以gt-1和i交换位置后
// arr[i]的值需要再次和x比较进行处理,所以i不更新,进入下一次循环比较
swap(arr, i, --gt)
}
}
// 易错点2: 由于是从l+1开始划分数组的,所以在[l+1,r]范围内划分完毕后
// 要把x放到lt位置,从而更新为以下性质:
// [l, lt-1] < base && [lt, gt-1] === base && [gt, r] > base
// 也就是说,lt此时正确的含义变成了lq,并满足函数定义性质:
// arr[l, lq)都<x, arr[lq, gt-1]都==x, arr[gt, r]都>x
swap(arr, l, lt)
return [lt, gt]
}
function swap(arr, l, r) {
[arr[l], arr[r]] = [arr[r], arr[l]];
}