主题
核心思路
第
- 在
中随机选择一个基准元素 。关于为什么要随机,见文末答疑。 - 划分
。通过交换,把 的元素放在 的左侧,把 的元素放在 的右侧。如此划分可以让我们粗略地排序 。划分后, 此刻的位置就等于 在升序数组中的位置。 - 设
在 中的下标为 。 - 如果
,那么答案就是 。 - 如果
,说明答案在 左侧,我们在其中寻找,回到第一步。 - 如果
,说明答案在 右侧,我们在其中寻找,回到第一步。 - 这类似 二分查找,只要我们每次能把问题的规模缩小一半,就可以用
时间解决(见复杂度分析)。 - 问题规模缩小后,相当于在
的一个子数组中,继续划分子数组,寻找答案。
- 如果
然而,如果按照
解决办法:修改第二步,把
具体要如何交换元素?实现细节见代码注释。
python
class Solution:
def partition(self, nums: List[int], left: int, right: int) -> int:
"""
在子数组 [left, right] 中随机选择一个基准元素 pivot
根据 pivot 重新排列子数组 [left, right]
重新排列后,<= pivot 的元素都在 pivot 的左侧,>= pivot 的元素都在 pivot 的右侧
返回 pivot 在重新排列后的 nums 中的下标
特别地,如果子数组的所有元素都等于 pivot,我们会返回子数组的中心下标,避免退化
"""
# 1. 在子数组 [left, right] 中随机选择一个基准元素 pivot
i = randint(left, right)
pivot = nums[i]
# 把 pivot 与子数组第一个元素交换,避免 pivot 干扰后续划分,从而简化实现逻辑
nums[i], nums[left] = nums[left], nums[i]
# 2. 相向双指针遍历子数组 [left + 1, right]
# 循环不变量:在循环过程中,子数组的数据分布始终如下图
# [ pivot | <=pivot | 尚未遍历 | >=pivot ]
# ^ ^ ^ ^
# left i j right
i, j = left + 1, right
while True:
while i <= j and nums[i] < pivot:
i += 1
# 此时 nums[i] >= pivot
while i <= j and nums[j] > pivot:
j -= 1
# 此时 nums[j] <= pivot
if i >= j:
break
# 维持循环不变量
nums[i], nums[j] = nums[j], nums[i]
i += 1
j -= 1
# 循环结束后
# [ pivot | <=pivot | >=pivot ]
# ^ ^ ^ ^
# left j i right
# 3. 把 pivot 与 nums[j] 交换,完成划分(partition)
# 为什么与 j 交换?
# 如果与 i 交换,可能会出现 i = right + 1 的情况,已经下标越界了,无法交换
# 另一个原因是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分
# 与 j 交换,即使 j = left,交换也不会出错
nums[left], nums[j] = nums[j], nums[left]
# 交换后
# [ <=pivot | pivot | >=pivot ]
# ^
# j
# 返回 pivot 的下标
return j
def findKthLargest(self, nums: list[int], k: int) -> int:
n = len(nums)
target_index = n - k # 第 k 大元素在升序数组中的下标是 n - k
left, right = 0, n - 1 # 闭区间
while True:
i = self.partition(nums, left, right)
if i == target_index:
# 找到第 k 大元素
return nums[i]
if i > target_index:
# 第 k 大元素在 [left, i - 1] 中
right = i - 1
else:
# 第 k 大元素在 [i + 1, right] 中
left = i + 1cpp
// C++ 版待补充cpp
class Solution {
// 在子数组 [left, right] 中随机选择一个基准元素 pivot
// 根据 pivot 重新排列子数组 [left, right]
// 重新排列后,<= pivot 的元素都在 pivot 的左侧,>= pivot 的元素都在 pivot 的右侧
// 返回 pivot 在重新排列后的 nums 中的下标
// 特别地,如果子数组的所有元素都等于 pivot,我们会返回子数组的中心下标,避免退化
int partition(vector<int>& nums, int left, int right) {
// 1. 在子数组 [left, right] 中随机选择一个基准元素 pivot
int i = left + rand() % (right - left + 1);
int pivot = nums[i];
// 把 pivot 与子数组第一个元素交换,避免 pivot 干扰后续划分,从而简化实现逻辑
swap(nums[i], nums[left]);
// 2. 相向双指针遍历子数组 [left + 1, right]
// 循环不变量:在循环过程中,子数组的数据分布始终如下图
// [ pivot | <=pivot | 尚未遍历 | >=pivot ]
// ^ ^ ^ ^
// left i j right
i = left + 1;
int j = right;
while (true) {
while (i <= j && nums[i] < pivot) {
i++;
}
// 此时 nums[i] >= pivot
while (i <= j && nums[j] > pivot) {
j--;
}
// 此时 nums[j] <= pivot
if (i >= j) {
break;
}
// 维持循环不变量
swap(nums[i], nums[j]);
i++;
j--;
}
// 循环结束后
// [ pivot | <=pivot | >=pivot ]
// ^ ^ ^ ^
// left j i right
// 3. 把 pivot 与 nums[j] 交换,完成划分(partition)
// 为什么与 j 交换?
// 如果与 i 交换,可能会出现 i = right + 1 的情况,已经下标越界了,无法交换
// 另一个原因是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分
// 与 j 交换,即使 j = left,交换也不会出错
swap(nums[left], nums[j]);
// 交换后
// [ <=pivot | pivot | >=pivot ]
// ^
// j
// 返回 pivot 的下标
return j;
}
public:
int findKthLargest(vector<int>& nums, int k) {
srand(time(NULL));
int n = nums.size();
int target_index = n - k; // 第 k 大元素在升序数组中的下标是 n - k
int left = 0, right = n - 1; // 闭区间
while (true) {
int i = partition(nums, left, right);
if (i == target_index) {
// 找到第 k 大元素
return nums[i];
}
if (i > target_index) {
// 第 k 大元素在 [left, i - 1] 中
right = i - 1;
} else {
// 第 k 大元素在 [i + 1, right] 中
left = i + 1;
}
}
}
};复杂度分析
- 时间复杂度:期望
,其中 是 的长度。在平均情况下,第一次划分(partition)需要处理 个元素,第二次平均 ,第三次平均 ,依此类推。所以期望时间复杂度为 。 - 空间复杂度:
。
答疑
问:如果不随机选择基准元素
答:比如子数组是有序的,且我们每次都选子数组的第一个(或者最后一个)元素作为
问:代码中的 nums[i] < pivot 和 nums[i] > pivot 能否改成 nums[i] <= pivot 和 nums[i] >= pivot?
答:这个做法会在子数组所有元素相同时,划分后的
问:代码中的 i <= j 能否改成 i < j?
答:这会算错。来看一个例子 i < j 的条件,无法移动。此时我们交换
如果写成 i <= j,那么最终
附:库函数写法
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
ranges::nth_element(nums, nums.end() - k);
return nums[nums.size() - k];
}
};关联题目
如果你理解了划分的过程,那么快速排序算法最难的内容也就理解了。读者可以趁热打铁,完成如下题目:
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府