【LeetCode 热题 100】215. 数组中的第K个最大元素——(解法一)快速选择
Problem: 215. 数组中的第K个最大元素
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:O(N) (平均情况), O(N^2) (最坏情况)
- 空间复杂度:O(1)
整体思路
这段代码旨在解决一个经典的 Top-K 问题:数组中的第K个最大元素 (Kth Largest Element in an Array)。问题要求在一个未排序的数组中,找到第 k
大的元素。
该算法采用了一种非常高效的、基于 快速排序 (QuickSort) 思想的 快速选择 (Quickselect) 算法。与完整排序不同,快速选择算法只关心目标元素所在的分区,从而避免了对整个数组进行完全排序,大大降低了平均时间复杂度。
-
问题转化:
- 算法首先将“找到第
k
大的元素”这个问题,转化为“找到排序后索引为n-k
的元素”。 - 例如,在一个长度为 5 的数组中找第 2 大的元素,等价于找排序后索引为
5-2=3
的元素(假设索引从0开始)。这个target = n-k
就是我们的目标索引。
- 算法首先将“找到第
-
核心:
partition
分区函数partition
函数是快速选择算法的心脏。它的作用是,在数组的[left, right]
区间内,随机选择一个元素作为 枢轴 (pivot),然后将数组重新排列,使得:- 所有小于
pivot
的元素都在pivot
的左边。 - 所有大于
pivot
的元素都在pivot
的右边。
- 所有小于
partition
函数最终会返回pivot
元素在分区后的最终索引p
。- 随机化:代码通过
RANDOM.nextInt
随机选择一个pivot
,然后将其与区间的第一个元素交换。这是一个至关重要的优化,它可以极大地避免在遇到有序或接近有序的数组时,算法退化到最坏的 O(N^2) 情况。
-
迭代选择与缩小范围:
findKthLargest
方法的主体是一个while(true)
循环,它不断地调用partition
函数并缩小搜索范围,直到找到目标。- 在每次分区后,我们得到枢轴的最终索引
p
。然后比较p
和我们的目标索引target
:- 如果
p == target
:这说明我们选中的pivot
正好就是我们要找的第k
大的元素。我们非常幸运,直接返回nums[p]
即可。 - 如果
p < target
:这说明目标元素在pivot
的右侧。我们就可以完全忽略左半部分,只需在[p+1, right]
这个更小的区间内继续搜索。 - 如果
p > target
:这说明目标元素在pivot
的左侧。我们就可以完全忽略右半部分,只需在[left, p-1]
这个更小的区间内继续搜索。
- 如果
- 这个过程不断重复,每次都将搜索范围缩小,直到找到目标。
完整代码
class Solution {// 使用一个静态的 Random 实例,以当前时间为种子,用于随机化选择private final static Random RANDOM = new Random(System.currentTimeMillis());/*** 在未排序的数组中找到第 k 大的元素。* @param nums 整数数组* @param k 第 k 大* @return 第 k 大的元素值*/public int findKthLargest(int[] nums, int k) {int n = nums.length;int left = 0;int right = n - 1; // 核心转化:第 k 大的元素,等价于排序后索引为 n-k 的元素。int target = n - k;// 使用循环不断缩小搜索范围,直到找到目标索引while (true) {// 对当前 [left, right] 区间进行分区,返回枢轴的最终位置 pint p = partition(nums, left, right);if (p == target) {// 如果枢轴的位置就是目标位置,则找到了答案return nums[p];} else if (p < target) {// 如果枢轴位置在目标左侧,说明目标在右半部分,更新左边界left = p + 1;} else { // p > target// 如果枢轴位置在目标右侧,说明目标在左半部分,更新右边界right = p - 1;}}}/*** 对 nums 数组的 [left, right] 区间进行分区操作(三路快排的变体)。* @param nums 数组* @param left 区间左边界* @param right 区间右边界* @return 枢轴元素分区后的最终索引*/private int partition(int[] nums, int left, int right) {// 随机化:随机选择一个索引作为枢轴,避免最坏情况int randomIndex = left + RANDOM.nextInt(right - left + 1);swap(nums, left, randomIndex);// 选择区间的第一个元素作为枢轴 pivotint pivot = nums[left];// le: 小于 pivot 的区域的右边界// ge: 大于 pivot 的区域的左边界int le = left + 1;int ge = right;while (true) {// 从左向右找到第一个 >= pivot 的元素while (le <= ge && nums[le] < pivot) {le++;}// 从右向左找到第一个 <= pivot 的元素while (le <= ge && nums[ge] > pivot) {ge--;}// 如果两个指针交错,分区完成if (le >= ge) {break;}// 交换找到的两个元素,使数组更有序swap(nums, le, ge);// 移动指针继续下一轮查找le++;ge--;}// 将枢轴元素放到其最终位置(ge 指向的位置)swap(nums, left, ge);return ge;}/*** 辅助函数:交换数组中两个索引的元素。*/private void swap(int[] nums, int i, int j) {int t = nums[i];nums[i] = nums[j];nums[j] = t;}
}
时空复杂度
时间复杂度:O(N) (平均情况), O(N^2) (最坏情况)
- 平均情况:
- 由于我们每次都随机选择
pivot
,可以期望partition
操作每次都能将搜索区间大约减半。 - 第一次分区,处理
N
个元素。第二次,处理大约N/2
个元素。第三次,处理大约N/4
个元素,以此类推。 - 总的操作次数是一个等比数列求和:
N + N/2 + N/4 + ... + 1
。这个级数的和收敛于2N
。 - 因此,平均情况下的时间复杂度为 O(N)。
- 由于我们每次都随机选择
- 最坏情况:
- 尽管随机化大大降低了最坏情况的概率,但理论上仍然存在。
- 最坏情况发生在我们每次选择的
pivot
都是当前区间的最大或最小值。这会导致分区后,搜索范围只减少了 1。 - 此时,总操作次数为
N + (N-1) + (N-2) + ... + 1
,这是一个 O(N^2) 的过程。 - 然而,在实际应用中,由于随机化的存在,这种最坏情况几乎不可能发生。
空间复杂度:O(1)
- 主要存储开销:该算法是原地的,它直接在输入的
nums
数组上进行修改,没有使用任何与输入规模N
成比例的额外数据结构。 - 辅助变量:算法只使用了
n
,left
,right
,target
,p
,le
,ge
,pivot
等固定数量的变量。 - 递归深度:代码使用的是迭代(
while
循环)而非递归,因此没有因递归调用而产生的栈空间开销。
综合分析:
算法所需的额外辅助空间是常数级别的。因此,其空间复杂度为 O(1)。
参考:liweiwei1419