出现最多的元素

117 阅读5分钟

题目

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)

  1. 统计频率:我们依然使用 collections.Counter 来统计数组中每个元素的频率。
  2. 排序:将频率排序,按频率从高到低排序。如果有多个元素具有相同的频率,可以选择按元素值排序(升序)。然后取出前 k 个元素。
  3. 按升序排列返回:最后,按升序排列这 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的

image.png

算法分析

但是往下看会发现,这个题目对算法复杂度是有要求的,我们来分析一下:

  • 统计频率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. 具体步骤解析

  1. 统计元素频率

    • 首先,我们统计数组中每个元素的频率,可以通过 collections.Counter 实现。这个操作的时间复杂度是 O(n),其中 n 是数组的大小。
  2. 用最小堆维护频率前 k 高的元素

    • 我们遍历所有的频率数据,对于每个元素频率 (freq, element)

      • 如果堆中的元素个数小于 k,直接将这个元素加入堆中。

      • 如果堆中的元素个数已经是 k,则将当前元素和堆顶(即频率最小的元素)进行比较:

        • 如果当前元素的频率大于堆顶元素的频率,说明当前元素是一个更高频的元素,我们应该将堆顶元素移除,替换成当前元素。这一操作的时间复杂度是 O(log k)。
  3. 为什么堆顶是最小的元素

    • 在每次插入元素时,堆的结构会自动调整。假设堆中已经有 k 个元素,其中堆顶的元素是频率最小的。如果我们插入一个新的频率更大的元素,堆会确保新的元素替换掉堆顶元素,从而保证堆中的元素始终是 频率最大的 k 个元素
  4. 返回结果

    • 最后,堆中的 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 较小的时候。