问题简介
题解github地址: https://github.com/swf2020/LeetCode-Hot100-Solutions
题目描述
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例说明
示例 1:
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5
解释: 排序后数组为 [6,5,4,3,2,1],第2个最大元素是5
示例 2:
输入: nums = [3,2,3,1,2,4,5,5,6], k = 4
输出: 4
解释: 排序后数组为 [6,5,5,4,3,3,2,2,1],第4个最大元素是4
解题思路
方法一:快速选择算法(QuickSelect)✅
💡 核心思想:基于快速排序的分区思想,但只递归处理包含目标元素的那一部分。
步骤详解:
- 选择一个基准元素(pivot)
- 将数组分为两部分:大于等于pivot的在左边,小于pivot的在右边
- 根据pivot的位置与k的关系决定下一步:
- 如果pivot位置正好是k-1,返回pivot
- 如果pivot位置大于k-1,在左半部分继续查找
- 如果pivot位置小于k-1,在右半部分继续查找
方法二:堆排序(最小堆)✅
💡 核心思想:维护一个大小为k的最小堆,堆顶就是第k大的元素。
步骤详解:
- 创建一个容量为k的最小堆
- 遍历数组中的每个元素:
- 如果堆的大小小于k,直接加入
- 如果当前元素大于堆顶,弹出堆顶并加入当前元素
- 最终堆顶就是第k大的元素
方法三:排序后直接取值 ❌
💡 核心思想:直接对数组排序,然后取第k个最大元素。
步骤详解:
- 对数组进行降序排序
- 返回索引为k-1的元素
⚠️ 注意:虽然这种方法简单,但时间复杂度为O(n log n),不满足题目要求的O(n)时间复杂度。
代码实现
java
// 方法一:快速选择算法
class Solution {
public int findKthLargest(int[] nums, int k) {
return quickSelect(nums, 0, nums.length - 1, k - 1);
}
private int quickSelect(int[] nums, int left, int right, int k) {
if (left == right) {
return nums[left];
}
// 随机选择pivot以避免最坏情况
Random random = new Random();
int pivotIndex = left + random.nextInt(right - left + 1);
pivotIndex = partition(nums, left, right, pivotIndex);
if (k == pivotIndex) {
return nums[k];
} else if (k < pivotIndex) {
return quickSelect(nums, left, pivotIndex - 1, k);
} else {
return quickSelect(nums, pivotIndex + 1, right, k);
}
}
private int partition(int[] nums, int left, int right, int pivotIndex) {
int pivotValue = nums[pivotIndex];
// 将pivot移到末尾
swap(nums, pivotIndex, right);
int storeIndex = left;
// 将所有大于pivot的元素移到左边
for (int i = left; i < right; i++) {
if (nums[i] > pivotValue) {
swap(nums, storeIndex, i);
storeIndex++;
}
}
// 将pivot放到正确位置
swap(nums, storeIndex, right);
return storeIndex;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
// 方法二:最小堆
class Solution {
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
if (minHeap.size() < k) {
minHeap.offer(num);
} else if (num > minHeap.peek()) {
minHeap.poll();
minHeap.offer(num);
}
}
return minHeap.peek();
}
}
go
// 方法一:快速选择算法
import (
"math/rand"
"time"
)
func findKthLargest(nums []int, k int) int {
rand.Seed(time.Now().UnixNano())
return quickSelect(nums, 0, len(nums)-1, k-1)
}
func quickSelect(nums []int, left, right, k int) int {
if left == right {
return nums[left]
}
pivotIndex := left + rand.Intn(right-left+1)
pivotIndex = partition(nums, left, right, pivotIndex)
if k == pivotIndex {
return nums[k]
} else if k < pivotIndex {
return quickSelect(nums, left, pivotIndex-1, k)
} else {
return quickSelect(nums, pivotIndex+1, right, k)
}
}
func partition(nums []int, left, right, pivotIndex int) int {
pivotValue := nums[pivotIndex]
// 将pivot移到末尾
nums[pivotIndex], nums[right] = nums[right], nums[pivotIndex]
storeIndex := left
// 将所有大于pivot的元素移到左边
for i := left; i < right; i++ {
if nums[i] > pivotValue {
nums[storeIndex], nums[i] = nums[i], nums[storeIndex]
storeIndex++
}
}
// 将pivot放到正确位置
nums[storeIndex], nums[right] = nums[right], nums[storeIndex]
return storeIndex
}
// 方法二:最小堆
import "container/heap"
type MinHeap []int
func (h MinHeap) Len() int { return len(h) }
func (h MinHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MinHeap) Push(x interface{}) { *h = append(*h, x.(int)) }
func (h *MinHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
func findKthLargest(nums []int, k int) int {
minHeap := &MinHeap{}
heap.Init(minHeap)
for _, num := range nums {
if minHeap.Len() < k {
heap.Push(minHeap, num)
} else if num > (*minHeap)[0] {
heap.Pop(minHeap)
heap.Push(minHeap, num)
}
}
return (*minHeap)[0]
}
示例演示
📌 以示例1为例:nums = [3,2,1,5,6,4], k = 2
快速选择过程:
- 初始数组:[3,2,1,5,6,4],寻找第2大元素(索引1)
- 假设选择pivot=4,分区后:[6,5,4,3,2,1],pivot位置=2
- 由于k-1=1 < 2,所以在左半部分[6,5]中继续查找
- 在[6,5]中选择pivot=5,分区后:[6,5],pivot位置=1
- k-1=1 == pivot位置,返回5
最小堆过程:
- 初始化空堆
- 依次加入:3→[3], 2→[2,3], 1→[1,2,3](但堆大小限制为2,实际为[2,3])
- 加入5:5>2,弹出2,加入5→[3,5]
- 加入6:6>3,弹出3,加入6→[5,6]
- 加入4:4<5,不加入
- 最终堆顶为5
答案有效性证明
✅ 快速选择算法正确性:
- 基于快速排序的分区性质:每次分区后,pivot元素都在其最终排序位置
- 通过比较pivot位置与目标位置k-1的关系,可以确定目标元素在哪个子区间
- 递归缩小搜索范围,最终必然找到第k大元素
✅ 最小堆算法正确性:
- 维护大小为k的最小堆,保证堆中始终包含当前遍历过的k个最大元素
- 堆顶是这k个元素中的最小值,也就是第k大的元素
- 遍历完整个数组后,堆中包含全局最大的k个元素,堆顶即为答案
复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否满足O(n)要求 |
|---|---|---|---|
| 快速选择 | 平均O(n),最坏O(n²) | O(1) | ✅ 平均情况下满足 |
| 最小堆 | O(n log k) | O(k) | ❌ 不严格满足O(n) |
| 排序 | O(n log n) | O(1)或O(n) | ❌ 不满足 |
💡 关键点说明:
- 快速选择的平均时间复杂度为O(n),因为每次期望能排除一半的元素
- 通过随机选择pivot,可以避免最坏情况(已排序数组)的发生
- 当k较小时,堆方法的实际性能可能更好,因为log k很小
问题总结
📌 核心要点:
- 快速选择是最佳解法:平均时间复杂度O(n),空间复杂度O(1)
- 堆方法实用性强:虽然理论复杂度不是O(n),但在k较小时表现优秀
- 随机化很重要:避免快速选择在特殊输入下的最坏情况
💡 面试建议:
- 首先提出快速选择算法,展示对分治思想的理解
- 讨论堆方法作为备选方案,体现多角度思考能力
- 强调随机化的重要性,展示对算法鲁棒性的考虑
✅ 适用场景:
- 快速选择:需要严格O(n)时间复杂度的场景
- 最小堆:k相对较小,或者需要在线处理数据流的场景