快速排序 Quick Sort
快速排序 首先会在序列中随机选择一个基准值 (pivot
),然后将除了基准值以外的数分为"比基准值小的数"和"比基准值大的数"这两个类别,再将其排列成以下形式:
[
比基准值小的数
]基准值
[比基准值大的数
]
接着,对两个"[ ]
"中的数据进行排序之后,整体的排序便完成了。对"[ ]
"里面的数据进行排序时同样也会使用快速排序。
怎么用递归理解快速排序呢?比如对数组 6,4,3,7,5,1,2
进行快速排序:
⨳ 要想对 6,4,3,7,5,1,2
进行排序,如以 6
为 pivot
进行分区, 必先对 6
右边的区间 7
进行排序,也要对 6
左边的 4,3,7,5,1,2
进行排序;
⨳ 要想对 4,3,7,5,1,2
进行排序,如以 4
为 pivot
,必先对 4
右边的 7,5
进行排序,也要对 4
左边的 3,1,2
进行排序;
⨳ 要想对 3,1,2
进行排序,如以 3
为 pivot
,必先对 3
右边的区间进行排序,也要对 3
左边的 1,2
进行排序;
⨳ 要想对 1,2
进行排序,如以 1
为 pivot
,必先对 1
右边的区间 2
进行排序;
⨳ 要想对 2
进行排序,因子数组序列只有一个元素不需要分区排序,为递归终止条件。
通过分析这个过程中,可以看到快排和归并排序的不同:
⨳ 归并排序是按 1/2
进行分区,分到子数组只有一个元素时进行合并,在合并的过程中会进行排序(将两个有序的子数组合并成更大的有序数组),合并完成即意味着排序完成。
⨳ 快速排序是选择随机 pivot
进行分区,分区的过程中会先简单排一下序(小于 pivot
在左边,大于pivot
的在右边),当分无可分的时候,排序也就跟着完成了。
也就是说快速排序在问题递出的过程中就已经逐步排好序了,至于归的过程(对应栈弹出栈帧的过程),并没有做额外的处理,注意看下述代码 quickSort
在 partition
之后执行。
java
// 对 数组 arr 的 [head,tail] 进行排序
private void quickSort(int[] arr, int head, int tail){
if(head >= tail) return; // 递归终止条件
/**
* 对 arr 的 [head,tail] 进行分区
* 其中返回值 pivot_index 为分区后指向基准点的指针
* 基准点左半部分的值都比基准点小,基准点右半部分的值都比基准点大
*/
int pivot_index = partition(arr, head, tail);
// 对 arr[head,pivot_index - 1] 进行排序
quickSort(arr, head, pivot_index - 1);
// 对 arr[pivot_index + 1,tail] 进行排序
quickSort(arr, pivot_index + 1, tail);
}
如果是归并排序的重点是如何归并,那快速排序的重点就是如何分区。
分区过程
分区要解决的问题,就是在 [head,tail]
区间选取一个基准值 (pivot
),让 pivot
左边的元素都比其小,pivot
右边的元素都比其大。
这道题完全可以使用双指针原地排序,假设 head
指向的元素就是选取的基准值:
⨳ 快指针检索,如发现小于 pivot
的值将其插入到慢指针指向的位置;
⨳ 慢指针记录,每当有元素插入到慢指针指向的位置,慢指针加一,从而保证慢指针左侧的元素都是小于 pivot
的值。
java
private int partition(int[] arr, int head, int tail) {
int pivot = arr[head]; // 假设 `head` 指向的元素就是选取的基准值
int slow_index = head+1; // 慢指针
int fast_index = head+1; // 快指针
for(;fast_index<=tail;fast_index++){ // 快指针检索
// 如果快指针检索到小于基准值的,将其插入到慢指针指向的位置
if(arr[fast_index]<pivot){
int tmp = arr[slow_index];
arr[slow_index] = arr[fast_index];
arr[fast_index] = tmp;
slow_index++;
}
}
// 循环后慢指针左侧都是小于pivot的值,将慢指针前一个指向的元素和基准值交换
slow_index--;
arr[head] = arr[slow_index];
arr[slow_index] = pivot;
return slow_index;
}
你可能会问,如果快指针检索到与 pivot
相同的元素应该怎么处理?
上述分区处理只能保证让 pivot
左边的元素都比其小,而与pivot
相同元素会被留在 pivot
右边,至于在右边什么位置,不好说,也可能紧靠着 pivot
,也可能在最右侧(tail
指向位置)。
这样有什么问题吗?事实上,并没有,即使多个与pivot
相同的元素分散在 pivot
两边,都是可以的。
因为分区 partition
函数,也是不断缩小规模进行递归调用的,比如 对数组 5,4,3,7,5,1,2
进行快速排序:
⨳ 第一次分区,pivot = 5
,分区后为 4,3,1,2
5
7,5
⨳ 先对 4,3,1,2
继续递归分区,最终结果为 1,2,3,4
5
7,5
⨳ 接着对 7,5
递归分区,pivot = 7
,分区后为 1,2,3,4
5
5
7
也就是说即使某次分区后,与 pivot
相同的值被分散在 pivot
两侧,后续递归分区都会将其移动到合适的位置。
这里就可以给分区下一个更精确的定义:在 [head,tail]
区间选取一个基准值 (pivot
),让 pivot
左边的元素都不大于 pivot
,pivot
右边的元素都不小于 pivot
。
分区优化
和归并排序一样,对于数据规模小的数据,使用插入排序也许会比递归分区快一些:
java
// 对 数组 arr 的 [head,tail] 进行排序
private void quickSort(int[] arr, int head, int tail){
// if(head>=tail){
// return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
// }
if(tail - head <= 20 ){
// 对数据规模小的序列,插入排序也许会更快一点
insertionSort(arr,head,tail);
return; // 递归终止条件
}
/**
* 对 arr 的 [head,tail] 进行分区
* 其中返回值 pivot_index 为分区后指向基准点的指针
* 基准点左半部分的值都比基准点小,基准点右半部分的值都比基准点大
*/
int pivot_index = partition(arr, head, tail);
// 对 arr[head,pivot_index - 1] 进行排序
quickSort(arr, head, pivot_index - 1);
// 对 arr[pivot_index + 1,tail] 进行排序
quickSort(arr, pivot_index + 1, tail);
}
思考一下,如果数组本身就是有序的,每次分区都在 [head,tail]
选取 head
指向的元素作为 pivot
,会有什么问题吗?
比如对数组 1,2,3,4,5,6
进行快速排序:
⨳ 第一次分区 pivot
选择为 1
,pivot
左侧的部分都是空的, 2,3,4,5,6
都在 pivot
右侧;
⨳ 第二次分区 pivot
选择为 2
,pivot
左侧的部分都是空的, 3,4,5,6
都在 pivot
右侧;
⨳ 第三次分区 pivot
选择为 3
,pivot
左侧的部分都是空的, 4,5,6
都在 pivot
右侧;
⨳ ...
看出问题了吧,因为每次分区只排序好了一个元素,如数组中有 n
个元素,那分区的递归深度也会为 n
,时间复杂度从 O(nlogn)
膨胀到 O(n^2)
。
时间复杂度拉胯还不是最重要的,最重要的是容易栈溢出!!!
归并排序并没有这个问题,因为归并排序每次 1/2
拆分是写死的(递归深度为 logn
),所以只有当快排每次分区都能完美的划分出两个数量相差不大的左右子区间时,才能充分发挥快排的性能。
那该怎么优化呢?引入随机即可。
随机从[head, tail]
之间选取一个pivot
,虽然不能保证每次分区都可以完美的将区间划分成相同长度的左右部分,但至少不会出现每次分区左半部分都没有元素的窘境。
是有多倒霉,才能每次分区随机到的 pivot
都是最小值,这个概率大概为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 n ! \frac{1}{n!} </math>n!1,除非数组中元素都是相同的值,无论怎么选都是最小值。
java
private int partition(int[] arr, int head, int tail) {
// 生成 [head, tail] 之间的随机索引
// Random#nextInt(int n):返回随机数的取值范围是从0(包含)到n(不包含)
int pivot_index = head + (new Random()).nextInt(tail-head+1);
// 将随机基准值交换到头部
int pivot = arr[pivot_index];
arr[pivot_index] = arr[head];
arr[head] = pivot;
// 后续逻辑不变
int slow_index = head+1; // 慢指针
int fast_index = head+1; // 快指针
for(;fast_index<=tail;fast_index++){ // 快指针检索
// 如果快指针检索到小于基准值的,将其插入到慢指针指向的位置
if(arr[fast_index]<pivot){
int tmp = arr[slow_index];
arr[slow_index] = arr[fast_index];
arr[fast_index] = tmp;
slow_index++;
}
}
// 循环后慢指针左侧都是小于pivot的值,将慢指针前一个指向的元素和基准值交换
slow_index--;
arr[head] = arr[slow_index];
arr[slow_index] = pivot;
return slow_index;
}
其实 Random
类也可以像归并排序使用临时数组一样,没必要每次分区都要创建一次,在快排前先创建好,然后作为参数传入即可。
引入随机后,可以避免每次选取 pivot
都选到分区的最小值,但如果数组中元素都是相同的值 ,无论怎么随机,pivot
都会选择同一个值,这同样会导致分区其他元素都在 pivot
右侧。
避不可避,怎么办?
双路快速排序
双路快速排序主要目的是解决在面对大量重复元素时分区分割不平衡的问题。使用头尾指针 代替快慢指针对数组进行分区,可以实现双路快速排序:
⨳ 头指针从头到尾遍历数组,检索与记录 <=pivot
的元素;如果遇到 >=pivot
的元素,就将其与尾部 <=pivot
的元素进行交换,从而保证头指针左边的元素都 <=pivot
;
虽然头指针要保证左边的元素都
<=pivot
,但头指针并不检索==pivot
的元素,当头指针遇到==pivot
的元素时要停止遍历,与尾部<=pivot
的元素进行交换。
⨳ 尾指针从尾到头遍历数组,检索与记录 >=pivot
的元素;如果<=pivot
的元素,就将其与头部 >=pivot
元素进行交换,从而保证尾指针右边的元素是 >=pivot
。
和头指针一样,虽然尾指针要保证右边的元素都
>=pivot
,但尾指针并不检索==pivot
的元素,当尾指针遇到==pivot
的元素时要停止遍历,与头部>=pivot
的元素进行交换。
这道题不就是前边讲的利用头尾指针解决移除目标元素的题目变形嘛。
java
private int partition2(int[] arr, int head, int tail) {
// 生成 [head, tail] 之间的随机索引
// Random#nextInt(int n):返回随机数的取值范围是从0(包含)到n(不包含)
int pivot_index = head + (new Random()).nextInt(tail-head+1);
// 将随机基准值交换到头部
int pivot = arr[pivot_index];
arr[pivot_index] = arr[head];
arr[head] = pivot;
// 后续使用头尾指针
int head_index = head+1;
int tail_index = tail;
while(head_index<=tail_index){
// 指针向尾部遍历,寻找 小于 pivot 的元素
while(head_index<=tail_index){
if(arr[head_index]<pivot){
head_index++;
continue;
}
break;
}
// 尾指针向头部遍历,寻找 大于 pivot 的元素
while(head_index<=tail_index){
if(arr[tail_index]>pivot){
tail_index--;
continue;
}
break;
}
// 交换元素
if (head_index<=tail_index){
int tail_val = arr[tail_index];
arr[tail_index] = arr[head_index];
arr[head_index] = tail_val;
head_index ++;
tail_index --;
}
}
// 循环后头指针左侧都是小于pivot的值,将头指针前一个指向的元素和基准值交换
head_index--;
arr[head] = arr[head_index];
arr[head_index] = pivot;
return head_index;
}
双路快速排序对元素都是相同值的数组来说:
⨳ 头指针从头到尾遍历数组,遇到的第一个元素就不符合 <pivot
的条件,于是与尾部元素进行交换;
⨳ 尾指针从尾到头遍历数组,遇到的第一个元素就不符合 >pivot
的条件,于是与头部元素进行交换;
也就是说,对于元素都是相同值的数组来说,双路快速排序可以完美将数组 1/2
分区,极大减少了栈溢出的风险,只是代价有点大,左右分区的元素要交换一遍,而且每次分区递归,左右的元素都要交换一遍。
那可不可以将与 pivot
相同的值都并拢在一起,从而只对 <pivot
和 >pivot
的部分进行分区递归。
三路快速排序
三路快速排序是对双路快速排序的改进,会将数组分成三部分,<pivot
部分、==pivot
和 >pivot
部分,然后只对 <pivot
和 >pivot
部分进行递归。
这个算法不就是前面双指针讲的颜色分类的题嘛:
⨳ 快指针,从头到尾遍历数组,检索元素,如发现小于 pivot
的值将其插入到头部慢指针指向的位置;如发现大于 pivot
的值将其插入到尾部慢指针指向的位置;
⨳ 头部慢指针:每当有元素插入到头部慢指针指向的位置,头部慢指针加一,从而保证头部慢指针左侧的元素([0,head_slow_index)
)都是小于 pivot
的值。
⨳ 尾部慢指针,每当有元素插入到尾部慢指针指向的位置,尾部慢指针加一,从而保证尾部慢指针右侧的元素((tail_slow_index,size-1]
)都是小于 pivot
的值。
⨳ 当快指针和尾部慢指针相遇,意味着所有元素都已经归位,[head_slow_index,tail_slow_index]
中的元素都是 ==pivot
的
java
private Pair partition3(int[] arr, int head, int tail) {
// 生成 [head, tail] 之间的随机索引
// Random#nextInt(int n):返回随机数的取值范围是从0(包含)到n(不包含)
int pivot_index = head + (new Random()).nextInt(tail-head+1);
// 将随机基准值交换到头部
int pivot = arr[pivot_index];
arr[pivot_index] = arr[head];
arr[head] = pivot;
int fast_index = head+1; // 快指针
int head_slow_index = head+1; // 头部慢指针
int tail_slow_index = tail;
while(fast_index<=tail_slow_index){ // 快指针检索
// 如果快指针检索到小于基准值的,将其插入到头部慢指针指向的位置
if(arr[fast_index]<pivot){
int tmp = arr[head_slow_index];
arr[head_slow_index] = arr[fast_index];
arr[fast_index] = tmp;
head_slow_index++;
fast_index++;
}
// 如果快指针检索到大于基准值的,将其插入到尾部慢指针指向的位置
else if(arr[fast_index]>pivot){
int tmp = arr[tail_slow_index];
arr[tail_slow_index] = arr[fast_index];
arr[fast_index] = tmp;
tail_slow_index--;
// 因为交换后 fast_index 位置的元素没有被处理过,所以 fast_index 不用 ++
}
// 如果 arr[fast_index]==pivot
else{
fast_index++;
}
}
// 循环后慢指针左侧都是小于pivot的值,将慢指针前一个指向的元素和基准值交换
head_slow_index--;
arr[head] = arr[head_slow_index];
arr[head_slow_index] = pivot;
// `[head_slow_index,tail_slow_index]` 中的元素都是 `==pivot` 的
return new Pair(head_slow_index,tail_slow_index);
}
因为分区返回的是头部慢指针和尾部慢指针两个值,所以 sort
函数也要对应的改变:
java
// 对 数组 arr 的 [head,tail] 进行排序
private void quickSort3(int[] arr, int head, int tail){
if(head >= tail) return; // 递归终止条件
Pair<Integer,Integer> slow_indexes = partition3(arr, head, tail);
int head_slow_index = slow_indexes.getKey();
int tail_slow_index = slow_indexes.getValue();
// 对 arr[head,head_slow_index - 1] 进行排序
quickSort3(arr, head, head_slow_index - 1);
// 对 arr[tail_slow_index + 1,tail] 进行排序
quickSort3(arr, tail_slow_index + 1, tail);
}
数组中的第K个最大元素
给定整数数组
nums
和整数k
,请返回数组中第k
个最大的元素。请注意,你需要找的是数组排序后的第
k
个最大的元素,而不是第k
个不同的元素。你必须设计并实现时间复杂度为
O(n)
的算法解决此问题。
按照题目描述,如果k
是1
,对应就是要找最大元素,如果k
是nums.length
,其实就是求最小元素,如果是排好序的数组,第 k
个最大的元素对应的索引就是 nums.length-k
。
最直观的解法就是先排序,再取数组下标为 nums.length-k
的元素。
这样做肯定不符合时间复杂度为 O(n)
的要求,如果想用快速排序的分区思想实现,分区的目的是选取一个基准值 pivot
,小于 pivot
在左边,大于 pivot
的在右边。
这意味着每一次分区后,pivot
所在的位置是正确的,即使整个数组排好序,pivot
所在的位置也不需要移动。
⨳ 如果 pivot
所在的索引等于 nums.length-k
,那该pivot
就是要找的元素;
⨳ 如果 pivot
所在的索引大于 nums.length-k
,那要找的元素在pivot
的左侧,继续对pivot
的左侧的元素进行分区即可;
⨳ 如果 pivot
所在的索引小于 nums.length-k
,那要找的元素在pivot
的右侧,继续对pivot
的右侧的元素进行分区即可;
有点二分查找的意思了。。。
java
class Solution {
public int findKthLargest(int[] nums, int k) {
int target_index = nums.length-k; // 要寻找的索引
int head = 0;
int tail = nums.length-1;
return findKthLargest(nums,target_index,head,tail);
}
// 递归调用
public int findKthLargest(int[] nums, int target_index,int head,int tail) {
// 递归终止条件
if(head>tail){
return -1;
}
// 分区
int pivot_index = partition(nums,head,tail);
if(pivot_index==target_index) // 找到了
return nums[pivot_index];
else if(pivot_index>target_index) // 在左边找
return findKthLargest(nums,target_index,head,pivot_index-1);
else // 在右边找
return findKthLargest(nums,target_index,pivot_index+1,tail);
}
private int partition(int[] arr, int head, int tail) { // ...}
}
最小K个数
设计一个算法,找出数组中最小的k个数。以任意顺序返回这k个数均可。
输入: arr =
[1,3,5,7,2,4,6,8]
, k =4
输出:
[1,2,3,4]
这道题不就是数组中的第K个最大元素的变形嘛。
只要找到索引为 k-1
的基准值 pivot
,那 pivot
及其左部分就是要输出的结果。
java
class Solution {
public int[] smallestK(int[] arr, int k) {
int head = 0;
int tail = arr.length-1;
smallestK(arr,k-1,head,tail);
return Arrays.copyOf(arr, k);
}
public int smallestK(int[] arr, int k,int head, int tail) {
// 递归终止条件
if(head>tail){
return -1;
}
// 分区
int pivot_index = partition(arr,head,tail);
if(pivot_index==k) // 找到了
return pivot_index;
else if(pivot_index>k) // 在左边找
return smallestK(arr,k,head,pivot_index-1);
else // 在右边找
return smallestK(arr,k,pivot_index+1,tail);
}
private int partition(int[] arr, int head, int tail) {//...}