题目描述:
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
示例 2:
输入: nums = [1], k = 1
输出: [1]
提示:
1 <= nums.length <= 105k的取值范围是[1, 数组中不相同的元素的个数]- 题目数据保证答案唯一,换句话说,数组中前
k个高频元素的集合是唯一的
进阶: 你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n **是数组大小。
思路:
题目要求的时间复杂度是允许使用库排序算法的,所以第一个实现的思路是使用Map统计频率,然后用List排序频率,用Set装top k的频率,最后遍历map找到对应的key,返回int数组。
实现一:暴力解法
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> numMap = new HashMap<>();
for (int i : nums) {
numMap.put(i, numMap.getOrDefault(i, 0) + 1);
}
List<Integer> numFreq = new ArrayList<>(numMap.values());
numFreq.sort((a, b) -> b - a);
Set<Integer> s = new HashSet<>();
for (int i = 0; i < k; i++) {
s.add(numFreq.get(i));
}
int[] result = new int[k];
int i = 0;
// numMap.forEach((key, value) -> {
// if (s.contains(value)) {
// result[i] = key;
// i++;
// }
// });
for (int key : numMap.keySet()) {
if (s.contains(numMap.get(key))) {
result[i] = key;
i++;
}
}
return result;
}
}
时间复杂度就是O(nlogn)。这里面注意不能使用map.forEach()的方式遍历键值对,因为要改i的值的,除非使用原子Int,但是那个我记不住应该。所以最后还是用了传统的for each遍历keySet()了。另外Set可以作为ArrayList<>()的构造器参数创建List,另外map是有个values()方法的。我就记得keySet()了。
实现二:使用最小堆PriorityQueue
前K个高频元素的频率值,回到了之前的“第K大的数”的解法。使用Java内置的最小堆类实现。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> m = new HashMap<>();
for (int i: nums) {
m.put(i, m.getOrDefault(i, 0) + 1);
}
PriorityQueue<Entry> p = new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
for (int i: m.keySet()) {
if (p.size() < k) {
p.offer(new Entry(i, m.get(i)));
} else if (m.get(i) > p.peek().getValue()) {
p.poll();
p.offer(new Entry(i, m.get(i)));
}
}
int [] res = new int[k];
int i = 0;
for (Entry e: p) {
res[i] = e.getKey();
i++;
}
return res;
}
}
class Entry {
int key;
int value;
Entry(int k, int v) {
this.key = k;
this.value = v;
}
public int getKey() {
return this.key;
}
public int getValue() {
return this.value;
}
}
该算法的时间和空间复杂度分析如下:
时间复杂度:
- 统计频率:遍历数组
nums构建哈希表,时间复杂度为 O(n),其中n是数组长度。 - 维护堆:遍历哈希表中的
m个不同元素(最多为n),每次堆操作(插入或删除)的时间复杂度为 O(logk)。总时间复杂度为 O(m logk),最坏情况下m = n,即 O(n logk)。 - 结果转换:将堆中元素输出为数组,时间复杂度 O(k)。
综上,总时间复杂度为 O(n + n logk) = O(n logk)。
空间复杂度:
- 哈希表:存储元素频率,最坏情况需要 O(n) 空间。
- 优先队列:存储
k个元素,占用 O(k) 空间。 - 结果数组:占用 O(k) 空间。
总空间复杂度为 O(n + k)。
关键点:
- 使用最小堆优化,确保堆操作的时间复杂度仅与
k相关。 - 哈希表统计频率是线性时间和空间复杂度。
- 当
k远小于n时,堆方法的效率明显优于全局排序(O(n logn))。
实现三:手写最小堆
主要工作量体现在最小堆类的实现。需要实现四个核心方法:swim, sink, insert, removeHead,以及辅助方法swap,查看top的方法。其中sink的实现之前犯过错,但是现在已经熟练了。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> m = new HashMap<>();
for (int i : nums) {
m.put(i, m.getOrDefault(i, 0)+1);
}
MinHeap heap = new MinHeap(k);
for (int i:m.keySet()) {
if (heap.size < k) {
heap.insert(new Entry(i, m.get(i)));
} else {
if (m.get(i) > heap.top()) {
heap.removeHead();
heap.insert(new Entry(i, m.get(i)));
}
}
}
int[] res = new int[k];
int i = 0;
for (Entry en: heap.e) {
res[i] = en.key;
i++;
}
return res;
}
}
class MinHeap {
int size;
Entry[] e;
int top() {
return e[0].value;
}
MinHeap(int k) {
this.size = 0;
this.e = new Entry[k];
}
// swim, sink, removeHead, insert
void swim(int index) {
int i = index;
while(i > 0 && (i-1)/2 >=0 && e[i].value < e[(i-1)/2].value) {
swap(i, (i-1)/2);
i = (i-1)/2;
}
}
void sink(int index) {
int i = index;
int left = i*2 + 1;
while (left < size) {
int smaller = left;
if (left+1 < e.length && e[left].value >= e[left+1].value) {
smaller = left + 1;
}
if (e[i].value < e[smaller].value) break;
swap(i, smaller);
i = smaller;
left = smaller * 2 + 1;
}
}
void removeHead() {
e[0] = e[size-1];
size--;
sink(0);
}
void insert(Entry ee) {
e[size] = ee;
swim(size);
size++;
}
void swap(int i, int j) {
Entry temp = e[i];
e[i] = e[j];
e[j] = temp;
}
}
class Entry {
int key;
int value;
Entry(int k, int v) {
this.key = k;
this.value = v;
}
}
这段代码的时间复杂度和空间复杂度分析如下:
时间复杂度分析:
- 统计频率:遍历数组
nums,使用哈希表统计每个元素的频率。时间复杂度为O(n),其中n是输入数组的长度。 - 构建最小堆:
- 遍历哈希表的
m个不同元素(m ≤ n)。 - 每次插入或删除堆顶元素的时间复杂度为O(log k),其中
k是堆的大小。 - 最坏情况下,所有`m元素都需要插入堆,时间复杂度为O(m log k)。
- 由于
m在最坏情况下等于n,这部分的时间复杂度为O(n log k)。
- 遍历哈希表的
- 生成结果数组:将堆中的元素输出,时间复杂度为O(k),可忽略。
综上,总时间复杂度为 O(n + n log k) = O(n log k)。
空间复杂度分析:
- 哈希表:存储
m个不同元素的频率,占用**O(n)**空间(最坏情况所有元素不同)。 - 最小堆:存储
k个元素,占用**O(k)**空间。 - 结果数组:占用**O(k)**空间,通常不计入空间复杂度。
综上,总空间复杂度为 O(n)(哈希表占主导)。
总结:
• 时间复杂度:O(n log k) • 空间复杂度:O(n)
实现四:快速选择
快速选择的思想是用把数组分成三段,分别是大于pivot,等于pivot,以及小于pivot。然后根据各个区间的长度与k比较,决定下一步该怎么递归。
这里因为需要所有前k个值,所以快选返回的值应该是下标。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> m = new HashMap<>();
for (int i: nums) {
m.put(i, m.getOrDefault(i, 0) + 1);
}
Entry[] e = new Entry[m.size()];
int j = 0;
for (int i: m.keySet()) {
e[j] = new Entry(i, m.get(i));
j++;
}
int index = quickSelect(e, 0, e.length-1, k);
int[] res = new int[k];
for (int x = 0; x < k; x++) {
res[x] = e[x].key;
}
return res;
}
private int quickSelect(Entry[] e, int left, int right, int k) {
if (left == right) {
return left; // this time we return index.
}
Random r = new Random();
int pivot = r.nextInt(right - left + 1) + left;
int pivotVal = e[pivot].value;
int i = left;
int lt = left;
int gt = right;
while (i <= gt) {
if (e[i].value > pivotVal) {
swap(e, lt, i);
i++;
lt++;
} else if (e[i].value < pivotVal) {
swap(e, i, gt);
gt--;
} else {
i++;
}
}
// [left, x, x, lt, xxxxx, gt,x, x, x ,right]
int m = lt - left; // [left, lt)
int mid = gt - lt + 1; // [lt, gt]
if (k <= m) {
return quickSelect(e, left, lt-1, k); // [left, lt-1]0
} else if (k <= m + mid) {
return k-1; // different right? cause want k values.
} else {
return quickSelect(e, gt+1, right, k-(m+mid)); // [gt+1, right]
}
}
private void swap(Entry[] e, int i, int j) {
Entry temp = e[i];
e[i] = e[j];
e[j] = temp;
}
}
class Entry {
int key;
int value;
Entry(int k, int v) {
this.key = k;
this.value = v;
}
}
这段代码为平均情况O(n),最坏情况O(n²);空间复杂度为O(n)。
时间复杂度分析:
-
统计频率(哈希表构建):
遍历数组nums统计频率,时间复杂度为O(n),其中n为输入数组长度。 -
构建Entry数组:
将哈希表中的元素转换为Entry对象,O(m),m为不同元素的数量(m ≤ n)。 -
快速选择(QuickSelect):
- 平均情况: 每次随机选择pivot将数组大致分为两半,递归处理其中一半,时间复杂度为O(m)。
- 最坏情况: 若每次pivot都选到极值(如最小/最大值),导致每次仅排除一个元素,时间复杂度退化为O(m²)。由于使用随机化pivot,最坏情况概率极低。
-
提取结果:
将前k个元素复制到结果数组,时间复杂度为O(k)。
综上:
- 平均时间复杂度: O(n)(哈希统计O(n)主导)
- 最坏时间复杂度: O(n²)(当m ≈ n时,快速选择退化为O(n²))
空间复杂度分析:
- 哈希表: 存储最多m个键值对,空间为O(m)。
- Entry数组: 存储m个元素,空间为O(m)。
- 递归栈: 快速选择递归深度平均为O(log m),最坏为O(m)。
总空间复杂度: O(m)(哈希表+Entry数组) + O(m)(最坏递归栈) = O(m)。当所有元素不同时(m = n),空间复杂度为O(n)。
DS老师的批注:
为了高效解决LeetCode 347题,即找出数组中出现频率最高的前k个元素,可以采用两种主要方法:最小堆和桶排序。以下是这两种方法的详细说明及实现:
方法一:最小堆(优先队列) (实现二优化)
思路:
- 统计频率: 使用哈希表记录每个元素的出现次数。
- 维护最小堆: 使用大小为k的最小堆,确保堆顶元素是当前堆中频率最小的。遍历哈希表时,若堆未满则直接加入元素;若堆已满且当前元素频率更高,则替换堆顶。
- 提取结果: 将堆中元素依次弹出,由于堆顶始终是最小频率,最终结果需反转以获得频率降序(但题目不要求顺序,可直接返回)。
时间复杂度: O(n log k),其中n是数组长度。统计频率O(n),堆操作每次O(log k)。
空间复杂度: O(n),存储哈希表和堆。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
PriorityQueue<Integer> heap = new PriorityQueue<>(
(a, b) -> freqMap.get(a) - freqMap.get(b)
);
for (int num : freqMap.keySet()) {
if (heap.size() < k) {
heap.offer(num);
} else if (freqMap.get(num) > freqMap.get(heap.peek())) {
heap.poll();
heap.offer(num);
}
}
int[] res = new int[k];
int i = 0;
while (!heap.isEmpty()) {
res[i++] = heap.poll();
}
return res;
}
}
方法二:桶排序 (实现五)
思路:
- 统计频率: 使用哈希表记录各元素的频率。
- 创建频率桶: 构建一个桶数组,索引代表频率,存储对应频率的元素列表。
- 逆序收集元素: 从最高频率开始遍历桶数组,收集元素直到达到k个。
时间复杂度: O(n),统计频率、建桶、收集元素均为线性时间。
空间复杂度: O(n),用于哈希表和桶数组。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
List<Integer>[] buckets = new List[nums.length + 1];
for (int num : freqMap.keySet()) {
int freq = freqMap.get(num);
if (buckets[freq] == null) {
buckets[freq] = new ArrayList<>();
}
buckets[freq].add(num);
}
List<Integer> result = new ArrayList<>();
for (int i = buckets.length - 1; i >= 0 && result.size() < k; i--) {
if (buckets[i] != null) {
for (int num : buckets[i]) {
result.add(num);
if (result.size() == k) break;
}
}
}
int[] res = new int[k];
for (int i = 0; i < k; i++) {
res[i] = result.get }
return res;
}
}
总结
- 最小堆 适合处理数据流或动态数据,无需一次性知道所有元素的频率。
- 桶排序 在数据规模较大且k较大时效率更优,时间复杂度为线性。
- 根据具体场景选择合适的方法,前者节省空间,后者时间更优**。**