https://blog.csdn.net/2601_95366422/article/details/159008129
上节课链接
一.题目
215. 数组中的第K个最大元素 - 力扣(LeetCode)

二.思路讲解
2.1 堆思路
对于TopK问题 ,通常我们会想到使用堆排序 :维护一个大小为 k 的小根堆,遍历数组,最后堆顶就是第 k 大的元素。这种方法的时间复杂度是 O(n log k) ,虽然也能通过,但题目要求 O(n) 的时间复杂度,而堆排序无法达到线性。因此,我们需要一种更高效的算法------快速选择算法 ,它基于快速排序的划分思想,平均时间复杂度可达 O(n)。
2.2 快速选择算法
要理解快速选择,必须先掌握三路划分 (即上一章的颜色排序思想)。快速选择的核心在于:每次选择一个基准值 ,将数组划分为三个区域------小于基准 、等于基准 、大于基准 。然后根据第 k 大的元素落在哪个区域,只递归处理那个区域,从而大幅减少计算量。
具体来说,假设当前区间为 [l, r],经过三路划分后,我们得到:
-
小于区域 :
[l, left] -
等于区域 :
[left+1, right-1] -
大于区域 :
[right, r]
假设我们想要第 k 大的元素,即降序排列后的第 k 个。那么我们可以比较 k 与各个区域的大小:
-
如果 k 落在大于区域(即大于区域的长度 ≥ k),那么答案就在大于区域中,递归处理大于区域。
-
如果 k 落在等于区域(即大于区域长度 < k ≤ 大于区域长度 + 等于区域长度),那么基准值就是答案。
-
否则k 落在小于区域 ,需要更新 k 的值(减去前面两个区域的长度)后递归处理小于区域。
三.代码演示
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k)
{
int n = nums.size();
srand(time(NULL));
return qsort(nums,0,n-1,k);
}
int qsort(vector<int>& nums,int l,int r,int k)
{
//划分三块思想,不过改成降序
int key = getRandom(nums,l,r);
int i = l,left = l - 1,right = r + 1;
while(i < right)
{
if(nums[i] > key)
{
swap(nums[++left],nums[i++]);
}
else if(nums[i] == key)
{
i++;
}
else
{
swap(nums[--right],nums[i]);
}
}
//判断k在那个区域,采取从左------中------右的方式,因为数组现在是降序
//[l,left]左
//[left+1,right-1]中
//[right,r]右
int leftSize = left - l + 1;//左区间长度
int midSize = right - left - 1;//右区间长度
//在左区间
if(leftSize >= k)
{
return qsort(nums,l,left,k);
}
//在左中区间,但是不存在左区间,那么就是在中区间
else if(leftSize + midSize >= k)
{
return key;
}
//把左中区间减去,就在右区间
else
{
return qsort(nums,right,r,k - leftSize - midSize);
}
}
int getRandom(vector<int>& nums,int l,int r)
{
int q = rand();
return nums[q % (r - l + 1) + l];
}
};
四.代码讲解
一、随机基准值的选择
为了避免快速选择算法在特定输入下退化为 O(n²) ,我们采用随机化策略 。在 getRandom 函数中,通过 rand() % (r - l + 1) + l 生成一个位于当前区间 [l, r] 内的随机下标,并将该位置的元素作为基准值 key。这样,每次划分的基准都是随机的,使得算法在期望时间复杂度上达到 O(n) 。在排序前调用 srand(time(NULL)) 设置随机种子,确保每次运行产生的随机数不同。
二、三路降序划分
为了高效处理重复元素,我们使用三路划分 ,但这里将数组划分为大于基准 、等于基准 、小于基准三个区域(即降序排列)。定义三个指针:
-
left:初始为l - 1,指向大于区域的最后一个元素(即大于区域为空)。 -
right:初始为r + 1,指向小于区域的第一个元素(即小于区域为空)。 -
i:初始为l,用于遍历当前元素。
循环条件为 i < right,遍历过程中根据 nums[i] 与 key 的关系分三种情况:
-
当
nums[i] > key时 :该元素应放入大于区域 。先将left右移一位(++left),然后交换nums[left]与nums[i],接着i++。此时left指向新放入的大于元素,而i继续向后。 -
当
nums[i] == key时 :该元素属于等于区域 ,无需移动,直接i++。 -
当
nums[i] < key时 :该元素应放入小于区域 。先将right左移一位(--right),然后交换nums[right]与nums[i]。注意,此时i不能增加 ,因为交换过来的元素来自right位置,尚未被处理,需要在下一次循环中重新判断。
循环结束后,数组被划分为:
-
[l, left] :全部大于
key的元素 -
[left+1, right-1] :全部等于
key的元素 -
[right, r] :全部小于
key的元素
三、根据区域大小确定第k大的元素所在区间
由于我们要找的是第 k 大的元素,而当前数组是降序划分,因此大于区域 在前,等于区域 居中,小于区域在后。我们需要计算各个区域的长度:
-
左区间(大于区域)长度 :
leftSize = left - l + 1 -
中间区间(等于区域)长度 :
midSize = right - left - 1
接下来,根据 k 与这些长度的关系,决定下一步操作:
-
如果
k <= leftSize:说明第 k 大的元素在大于区域 中,递归处理左区间[l, left],且 k 保持不变。 -
如果
leftSize < k <= leftSize + midSize:说明第 k 大的元素就是基准值key,直接返回key。 -
如果
k > leftSize + midSize:说明第 k 大的元素在小于区域 中,递归处理右区间[right, r],但此时需要更新 k ,减去前面两个区域的长度,即新的k = k - leftSize - midSize。
四、递归处理对应区间
根据上述判断,只对包含目标元素的区间进行递归,其他区间被舍弃。这样每次递归的规模平均减半,保证了线性期望时间复杂度。至于为什么时间复杂度是O(n)则需要去看书,还是很难的!
五、关键细节
-
随机化:随机选择基准值是避免最坏情况的关键,使得算法在概率上高效。
-
三路划分:将等于基准值的元素集中在一起,避免了重复元素带来的冗余递归,同时简化了区间判断。
-
降序划分:代码采用大于在左、小于在右的方式,直接对应第 k 大的概念,无需转换。
-
k 的更新:当递归进入小于区域时,需要减去前面区域的大小,因为第 k 大的位置在全局中发生了变化。