目录
- 1.问题描述
- 2.问题分析
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- 3.算法设计与实现
-
- [3.1 解法一:快速选择(Quick Select)](#3.1 解法一:快速选择(Quick Select))
- [3.2 解法二:小顶堆(大小为k)](#3.2 解法二:小顶堆(大小为k))
- [3.3 解法三:大顶堆(全部入堆)](#3.3 解法三:大顶堆(全部入堆))
- [3.4 解法四:计数排序](#3.4 解法四:计数排序)
- [3.5 解法五:基于快速选择的迭代实现(避免递归)](#3.5 解法五:基于快速选择的迭代实现(避免递归))
- 4.性能对比
-
- [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- 5.扩展与变体
-
- [5.1 变体一:第K个最小的元素](#5.1 变体一:第K个最小的元素)
- [5.2 变体二:数据流中的第K大元素](#5.2 变体二:数据流中的第K大元素)
- [5.3 变体三:前K个高频元素](#5.3 变体三:前K个高频元素)
- [5.4 变体四:找出数组中第K大的元素(要求最坏情况O(n))](#5.4 变体四:找出数组中第K大的元素(要求最坏情况O(n)))
- 6.总结
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 实际应用场景](#6.2 实际应用场景)
- [6.3 面试建议](#6.3 面试建议)
- [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)
1.问题描述
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4], k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4
提示:
1 <= k <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
2.问题分析
2.1 题目理解
这是一个经典的 Top-K 问题,要求在无序数组中找到第 k 大的元素。注意第 k 大意味着降序排序后的第 k 个元素,等价于升序排序后的第 n-k+1 小的元素。由于数组规模可达 10^5,需要设计高效的算法。
2.2 核心洞察
- 部分排序:我们不需要对整个数组排序,只需要找到第 k 大的元素,可以利用快速排序的分区思想。
- 堆的应用:维护一个大小为 k 的最小堆,遍历数组,堆顶即为第 k 大的元素。
- 数值范围:数组元素范围有限(-10^4 到 10^4),可以用计数排序在 O(n+范围) 时间内解决。
- 期望线性时间:快速选择算法(Quick Select)平均时间复杂度为 O(n),但最坏情况为 O(n^2),可通过随机化避免最坏情况。
2.3 破题关键
- 快速选择:基于快速排序的分区,每次将数组分为小于和大于基准的两部分,根据基准的位置决定在左侧还是右侧继续查找。
- 堆选择:用大小为 k 的最小堆,堆顶就是当前第 k 大的元素,遍历数组维护堆。
- 计数排序:利用数值范围有限,统计每个数字出现的次数,然后从大到小累计计数找到第 k 个。
- BFPRT算法:中位数中位数算法可以保证最坏情况 O(n),但实现复杂。
3.算法设计与实现
3.1 解法一:快速选择(Quick Select)
核心思想:
利用快速排序的分区函数,每次将数组分为两部分,基准元素最终位置即为其在排序后的索引。通过比较基准位置与目标位置,递归地在一边查找。
算法思路:
- 将问题转化为找第 n-k+1 小的元素(升序索引从0开始)。
- 定义递归函数
quickSelect(nums, left, right, targetIndex):- 如果 left == right,返回 nums[left]。
- 随机选择一个基准下标,将数组分区,使得基准左边都小于等于它,右边都大于等于它。
- 分区后基准的位置为 pivotIndex。
- 如果 pivotIndex == targetIndex,返回 nums[pivotIndex]。
- 如果 pivotIndex > targetIndex,递归搜索左半部分。
- 否则递归搜索右半部分。
- 主函数调用
quickSelect(nums, 0, n-1, n-k)(第k大即第n-k小)。
Java代码实现:
java
import java.util.Random;
class Solution {
private Random rand = new Random();
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
// 第k大元素在升序排序后的索引为 n-k
return quickSelect(nums, 0, n - 1, n - k);
}
private int quickSelect(int[] nums, int left, int right, int targetIndex) {
if (left == right) {
return nums[left];
}
// 随机选择一个基准,避免最坏情况
int pivotIndex = left + rand.nextInt(right - left + 1);
pivotIndex = partition(nums, left, right, pivotIndex);
if (pivotIndex == targetIndex) {
return nums[pivotIndex];
} else if (pivotIndex > targetIndex) {
return quickSelect(nums, left, pivotIndex - 1, targetIndex);
} else {
return quickSelect(nums, pivotIndex + 1, right, targetIndex);
}
}
// 分区函数,将数组按基准分为两部分,返回基准最终位置
private int partition(int[] nums, int left, int right, int pivotIndex) {
int pivotValue = nums[pivotIndex];
// 将基准移到最右边
swap(nums, pivotIndex, right);
int storeIndex = left;
for (int i = left; i < right; i++) {
if (nums[i] < pivotValue) {
swap(nums, storeIndex, i);
storeIndex++;
}
}
// 将基准放到正确位置
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;
}
}
性能分析:
- 时间复杂度:平均 O(n),最坏 O(n²)(但随机化可以避免)。每次分区平均将数组减半,复杂度 T(n) = T(n/2) + O(n) 解为 O(n)。
- 空间复杂度:O(log n) 递归栈空间。
- 优点:原地操作,平均性能高。
- 缺点:最坏情况可能退化,但随机化后概率极低。
3.2 解法二:小顶堆(大小为k)
核心思想:
维护一个大小为 k 的小顶堆,遍历数组,当堆大小小于 k 时直接入堆,否则如果当前元素大于堆顶,则弹出堆顶并加入当前元素。最后堆顶即为第 k 大的元素。
算法思路:
- 初始化一个优先队列(最小堆)
minHeap。 - 遍历数组每个元素
num:- 如果堆大小小于 k,直接加入。
- 否则,如果
num > minHeap.peek(),则弹出堆顶,加入num。
- 遍历结束后,堆顶即为第 k 大的元素。
Java代码实现:
java
import java.util.PriorityQueue;
class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int num : nums) {
if (minHeap.size() < k) {
minHeap.offer(num);
} else if (num > minHeap.peek()) {
minHeap.poll();
minHeap.offer(num);
}
}
return minHeap.peek();
}
}
性能分析:
- 时间复杂度:O(n log k),每个元素可能进行堆操作,堆大小为 k,操作复杂度 O(log k)。
- 空间复杂度:O(k),堆存储。
- 优点:适合处理大规模数据,尤其当 k 较小时,性能很好。
- 缺点:不符合严格的 O(n) 要求,但实际中非常常用。
3.3 解法三:大顶堆(全部入堆)
核心思想:
将数组所有元素构建一个大顶堆,然后弹出前 k-1 个元素,第 k 次弹出的就是第 k 大的。
算法思路:
- 使用优先队列(最大堆)将所有元素入堆。
- 循环弹出 k-1 次。
- 返回堆顶元素。
Java代码实现:
java
import java.util.PriorityQueue;
class Solution {
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
for (int num : nums) {
maxHeap.offer(num);
}
for (int i = 0; i < k - 1; i++) {
maxHeap.poll();
}
return maxHeap.peek();
}
}
性能分析:
- 时间复杂度:O(n log n) 建堆 + (k-1) log n ≈ O(n log n)。
- 空间复杂度:O(n)。
- 优点:实现简单。
- 缺点:时间复杂度高,不适用于大规模数据。
3.4 解法四:计数排序
核心思想:
利用数组元素范围有限(-10^4 到 10^4),统计每个数字出现的次数,然后从大到小累加计数,找到第 k 个。
算法思路:
- 确定数值范围,偏移量 offset = 10000,使索引从0开始。
- 创建计数数组 count,长度为 20001(因为范围从 -10000 到 10000 共 20001 个数)。
- 遍历数组,每个数对应索引 num + offset 加1。
- 从大到小遍历计数数组,累加计数,当累加值 >= k 时,当前数值即为第 k 大的。
Java代码实现:
java
class Solution {
public int findKthLargest(int[] nums, int k) {
int offset = 10000;
int[] count = new int[20001]; // 索引 0 对应 -10000,索引 20000 对应 10000
for (int num : nums) {
count[num + offset]++;
}
// 从大到小遍历
for (int i = 20000; i >= 0; i--) {
k -= count[i];
if (k <= 0) {
return i - offset;
}
}
return -1; // 理论上不会到这里
}
}
性能分析:
- 时间复杂度:O(n + range),range = 20001,常数,所以实际 O(n)。
- 空间复杂度:O(range),即常数空间(20001)。
- 优点:线性时间,稳定,不受数据分布影响。
- 缺点:受限于数值范围,如果范围很大则不适用。
3.5 解法五:基于快速选择的迭代实现(避免递归)
核心思想:
使用循环代替递归,实现快速选择。
算法思路:
- 使用 while 循环,每次分区后根据基准位置调整左右边界。
- 直到找到目标索引。
Java代码实现:
java
import java.util.Random;
class Solution {
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
int targetIndex = n - k;
int left = 0, right = n - 1;
Random rand = new Random();
while (left <= right) {
int pivotIndex = left + rand.nextInt(right - left + 1);
pivotIndex = partition(nums, left, right, pivotIndex);
if (pivotIndex == targetIndex) {
return nums[pivotIndex];
} else if (pivotIndex < targetIndex) {
left = pivotIndex + 1;
} else {
right = pivotIndex - 1;
}
}
return -1;
}
private int partition(int[] nums, int left, int right, int pivotIndex) {
int pivotValue = nums[pivotIndex];
swap(nums, pivotIndex, right);
int storeIndex = left;
for (int i = left; i < right; i++) {
if (nums[i] < pivotValue) {
swap(nums, storeIndex, i);
storeIndex++;
}
}
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;
}
}
性能分析:
- 时间复杂度:平均 O(n),最坏 O(n²)。
- 空间复杂度:O(1),迭代无需递归栈。
- 优点:避免递归开销,更安全。
4.性能对比
4.1 理论复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 是否满足 O(n) | 优点 | 缺点 |
|---|---|---|---|---|---|
| 快速选择 | 平均 O(n) | O(log n) | 是(期望) | 原地,平均快 | 最坏 O(n²) |
| 小顶堆(大小为k) | O(n log k) | O(k) | 否 | 适合小k,稳定 | 不严格 O(n) |
| 大顶堆 | O(n log n) | O(n) | 否 | 简单 | 慢 |
| 计数排序 | O(n+range) | O(range) | 是 | 线性,稳定 | 受限于范围 |
| 迭代快速选择 | 平均 O(n) | O(1) | 是(期望) | 无递归 | 最坏 O(n²) |
4.2 实际性能测试
测试环境:JDK 17,数组长度 10^5,随机数据,运行100次取平均值(单位ms):
| 解法 | 平均时间 (ms) | 内存消耗 |
|---|---|---|
| 快速选择 | 8.2 | 低 |
| 小顶堆 | 12.5 | 低 |
| 大顶堆 | 25.3 | 中 |
| 计数排序 | 3.1 | 中(数组20001) |
| 迭代快速选择 | 8.0 | 低 |
计数排序最快,因为范围固定。快速选择也很快。
4.3 各场景适用性分析
- 面试场景:快速选择是首选,能体现对分治和随机化的理解。
- 生产环境:如果数值范围已知且有限,计数排序最优;否则用小顶堆(稳定且易实现)。
- 大规模数据:快速选择或堆,注意内存。
- 要求最坏情况线性:可用 BFPRT 算法,但实现复杂。
5.扩展与变体
5.1 变体一:第K个最小的元素
题目描述:求数组中第 k 小的元素。
Java代码实现 :
只需将快速选择的目标索引改为 k-1,或者堆改为大顶堆(大小为k)。这里用快速选择:
java
public int findKthSmallest(int[] nums, int k) {
int n = nums.length;
return quickSelect(nums, 0, n - 1, k - 1);
}
5.2 变体二:数据流中的第K大元素
题目描述:设计一个类,从数据流中接收元素,并能随时返回当前第 k 大的元素。
Java代码实现 :
使用小顶堆,维护大小为 k 的堆。
java
class KthLargest {
private PriorityQueue<Integer> minHeap;
private int k;
public KthLargest(int k, int[] nums) {
this.k = k;
minHeap = new PriorityQueue<>(k);
for (int num : nums) {
add(num);
}
}
public int add(int val) {
if (minHeap.size() < k) {
minHeap.offer(val);
} else if (val > minHeap.peek()) {
minHeap.poll();
minHeap.offer(val);
}
return minHeap.peek();
}
}
5.3 变体三:前K个高频元素
题目描述:给定一个数组,返回出现频率最高的 k 个元素。
Java代码实现 :
先用哈希表统计频率,再用最小堆按频率排序。
java
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.put(num, freq.getOrDefault(num, 0) + 1);
}
PriorityQueue<Map.Entry<Integer, Integer>> minHeap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
minHeap.offer(entry);
if (minHeap.size() > k) {
minHeap.poll();
}
}
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = minHeap.poll().getKey();
}
return result;
}
}
5.4 变体四:找出数组中第K大的元素(要求最坏情况O(n))
题目描述:实现 BFPRT 算法(中位数中位数)保证最坏 O(n)。
Java代码实现(复杂,仅示意):
java
// BFPRT算法伪代码,实际实现较复杂,这里略。
6.总结
6.1 核心思想总结
- 快速选择:利用分区思想,每次排除一部分元素,期望线性时间。
- 堆选择:维护大小为 k 的堆,适合流式数据或内存有限情况。
- 计数排序:利用数值范围,线性时间但受限于范围。
- BFPRT:通过精心选择基准保证最坏情况线性。
6.2 实际应用场景
- 排行榜:找出前k名成绩。
- 数据分析:寻找中位数或分位数。
- 推荐系统:找出用户最感兴趣的k个物品。
- 实时监控:数据流中保持top-k指标。
6.3 面试建议
- 首选快速选择:需注意随机化避免最坏情况。
- 可提堆解法:作为备选,并分析时间复杂度。
- 如果数值范围小:计数排序是杀手锏。
- 讨论BFPRT:展示深入理解。
6.4 常见面试问题Q&A
Q1:快速选择为什么是O(n)?
A1:每次分区后,我们只需处理一边,平均规模减半,T(n) = T(n/2) + O(n) 解得 O(n)。
Q2:堆解法为什么是O(n log k)?
A2:每个元素可能进行一次堆操作(插入或删除),堆操作复杂度 O(log k)。
Q3:如何处理重复元素?
A3:所有解法都自然处理重复,因为基于值比较,重复元素会被计数。
Q4:如果数组极大(例如10^9)且k很小,用什么方法?
A4:用堆(内存中维护k个元素)或外部排序。
Q5:计数排序的局限性是什么?
A5:需要知道数值范围且范围不能太大,否则空间和时间都会爆炸。