题目:
69.查找热点问题
问题描述
给你一个整数数组 nums 和一个整数 k,请你用一个字符串返回其中出现频率前 k 高的元素。请按升序排列。
你所设计算法的时间复杂度必须优于 O(n log n),其中 n 是数组大小。
输入
- nums: 一个正整数数组
- k: 一个整数
返回
返回一个包含 k 个元素的字符串,数字元素之间用逗号分隔。数字元素按升序排列,表示出现频率最高的 k 个元素。
参数限制
- 1 <= nums[i] <= 10^4
- 1 <= nums.length <= 10^5
- k 的取值范围是 [1, 数组中不相同的元素的个数]
- 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的
简单思路
看到题目的时候DNA就动了,看到前面的问题描述我就直接有思路了:
直接使用排序来选出出现频率最高的 k 个元素,排序的算法是时间复杂度为 O(n log n)
- 统计频率:我们依然使用
collections.Counter来统计数组中每个元素的频率。 - 排序:将频率排序,按频率从高到低排序。如果有多个元素具有相同的频率,可以选择按元素值排序(升序)。然后取出前
k个元素。 - 按升序排列返回:最后,按升序排列这
k个最频繁的元素,返回一个字符串。
import collections
def solution(nums, k):
# 步骤 1: 统计每个元素的出现频率
count = collections.Counter(nums)
# 步骤 2: 按频率(降序)排序,如果频率相同,则按元素值(升序)排序
sorted_items = sorted(count.items(), key=lambda x: (-x[1], x[0]))
# 步骤 3: 获取频率前 k 高的元素
result = [str(item[0]) for item in sorted_items[:k]]
# 步骤 4: 将结果列表转换为逗号分隔的字符串并返回
return ",".join(result)
# 测试用例
if __name__ == "__main__":
print(solution([1,1,1,2,2,3], 2) == "1,2") # 期望输出: "1,2"
print(solution([1], 1) == "1") # 期望输出: "1"
结果是true的
算法分析
但是往下看会发现,这个题目对算法复杂度是有要求的,我们来分析一下:
- 统计频率:
collections.Counter(nums)的时间复杂度是 O(n),其中 n 是数组的大小。 - 排序:对
count.items()中的元素进行排序,时间复杂度是 O(m log m),其中 m 是数组中不同元素的个数。在最坏情况下,m 可能等于 n,因此时间复杂度为 O(n log n)。 - 获取前 k 个元素:获取前
k个元素的时间复杂度是 O(k),然后将其转换为字符串的时间复杂度是 O(k)。
总体时间复杂度为 O(n log n),不符合要求!!
降低复杂度
每当我看到这种看起来很简单,但是有特殊要求的题目,我就会想起来要使用数据结构的知识来解决问题。我想到了最小堆。
为什么要用最小堆
使用 最小堆(Min-Heap) 来解决 "找出前 k 高频元素" 问题的原因,主要是堆的特性能够帮助我们高效地维护 频率最高的前 k 个元素。
1. 堆的特性:
- 最小堆(Min-Heap) 是一种完全二叉树结构,满足每个父节点的值小于或等于其子节点的值。也就是说,堆的根节点是最小的元素。
- 插入一个新的元素时,堆会进行调整,使得堆仍然满足这个特性。插入操作的时间复杂度是 O(log k)(因为堆的大小最大为
k),而删除堆顶(最小值)也是 O(log k) 时间。
2. 为什么用最小堆?
- 我们需要找出频率前
k高的元素,而堆的作用是快速地找到最小的元素。 - 具体来说,当我们遍历
n个元素并统计频率时,使用最小堆维护堆中频率前k高的元素,可以在 O(log k) 的时间内进行插入操作,保证堆中的元素始终是 频率最大的k个元素。
3. 具体步骤解析:
-
统计元素频率:
- 首先,我们统计数组中每个元素的频率,可以通过
collections.Counter实现。这个操作的时间复杂度是 O(n),其中n是数组的大小。
- 首先,我们统计数组中每个元素的频率,可以通过
-
用最小堆维护频率前
k高的元素:-
我们遍历所有的频率数据,对于每个元素频率
(freq, element):-
如果堆中的元素个数小于
k,直接将这个元素加入堆中。 -
如果堆中的元素个数已经是
k,则将当前元素和堆顶(即频率最小的元素)进行比较:- 如果当前元素的频率大于堆顶元素的频率,说明当前元素是一个更高频的元素,我们应该将堆顶元素移除,替换成当前元素。这一操作的时间复杂度是 O(log k)。
-
-
-
为什么堆顶是最小的元素:
- 在每次插入元素时,堆的结构会自动调整。假设堆中已经有
k个元素,其中堆顶的元素是频率最小的。如果我们插入一个新的频率更大的元素,堆会确保新的元素替换掉堆顶元素,从而保证堆中的元素始终是 频率最大的k个元素。
- 在每次插入元素时,堆的结构会自动调整。假设堆中已经有
-
返回结果:
- 最后,堆中的
k个元素即为频率前k高的元素,我们可以将它们提取出来并按升序排序(因为题目要求升序排列)。对堆中k个元素进行排序的时间复杂度是 O(k log k)。
- 最后,堆中的
代码实现
import collections
import heapq
def solution(nums, k):
# 统计每个元素的频率
count = collections.Counter(nums)
# 使用最小堆存储前 k 个高频元素
min_heap = []
for num, freq in count.items():
# 插入元素和频率的元组
heapq.heappush(min_heap, (freq, num))
# 如果堆的大小超过了 k,移除堆顶元素(频率最小的元素)
if len(min_heap) > k:
heapq.heappop(min_heap)
# 提取堆中的元素,排序并返回
result = [num for freq, num in min_heap]
result.sort() # 排序(升序)
# 返回逗号分隔的字符串
return ",".join(map(str, result))
# 测试
print(solution([1, 1, 1, 2, 2, 3], 2)) # 输出 "1,2"
通过最小堆,我们能以 O(n log k) 的时间复杂度找出频率前 k 高的元素,相比直接排序(O(n log n))要高效很多,尤其是在 k 相对于 n 较小的时候。