【笔面试算法学习专栏】堆与优先队列实战:力扣hot100之215.数组中的第K个最大元素、347.前K个高频元素

引言与堆数据结构基础

在算法设计与问题求解中,堆(Heap) 是一种极其高效的数据结构,它能够在动态数据流中快速维护最值,广泛应用于排序、调度、图算法等领域。堆的本质是一棵完全二叉树 ,并满足堆序性质 :对于最大堆,任意节点的值不小于其子节点值;对于最小堆,任意节点的值不大于其子节点值。这种性质使得堆的根节点始终是全局最大(或最小)元素,从而能够在 O ( 1 ) O(1) O(1) 时间内获取最值。

堆的典型操作及其时间复杂度如下:

  • 建堆(Heapify) :将无序数组调整为堆结构,时间复杂度 O ( n ) O(n) O(n),优于逐个插入的 O ( n log ⁡ n ) O(n \log n) O(nlogn)。
  • 插入(Push) :将新元素加入堆并保持堆序,时间复杂度 O ( log ⁡ n ) O(\log n) O(logn)。
  • 弹出(Pop) :移除堆顶元素并调整堆,时间复杂度 O ( log ⁡ n ) O(\log n) O(logn)。
  • 取堆顶(Top) :获取堆顶元素,时间复杂度 O ( 1 ) O(1) O(1)。

在 Python 中,堆通过内置模块 heapq 实现,该模块提供基于列表的最小堆操作。需要注意的是,heapq 仅提供最小堆,若需要最大堆,可通过取相反数或自定义比较函数实现。

本文将深入解析力扣hot100中两道经典堆应用题:215. 数组中的第K个最大元素347. 前K个高频元素。通过对比快速选择、哈希表、桶排序等多种解法,揭示堆在TopK问题中的核心作用,并提供完整可运行的代码实现与面试考点分析。

215. 数组中的第K个最大元素

题目概述与链接

题目链接215. Kth Largest Element in an Array

问题描述 :给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。要求时间复杂度优于 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

示例

复制代码
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5
解释: 排序后数组为 [1,2,3,4,5,6],第2个最大的元素是5。

核心解题思路

本题有两种主流解法:快速选择(QuickSelect)堆解法 。快速选择基于快速排序的划分思想,期望时间复杂度为 O ( n ) O(n) O(n),最坏情况 O ( n 2 ) O(n^2) O(n2);堆解法维护一个大小为 k k k 的最小堆,遍历数组不断更新堆,最终堆顶即为第K大元素,时间复杂度稳定在 O ( n log ⁡ k ) O(n \log k) O(nlogk)。

堆解法的核心步骤

  1. 建立大小为 k k k 的最小堆(堆顶为堆中最小元素)。
  2. 遍历数组前 k k k 个元素,直接加入堆。
  3. 遍历剩余元素,若当前元素大于堆顶,则弹出堆顶并插入当前元素。
  4. 遍历结束后,堆顶即为第 k k k 个最大元素。

为什么使用最小堆? 因为我们要找第 k k k 大元素,只需维护当前看到的 k k k 个最大元素,其中最小的那个就是目标。最小堆的堆顶正好是这 k k k 个元素中的最小值,当遇到更大的元素时,替换堆顶即可保证堆中始终是当前最大的 k k k 个元素。

复杂度分析

时间复杂度

  • 建堆(前k个元素): O ( k ) O(k) O(k)
  • 遍历剩余 n − k n-k n−k 个元素,每次插入与弹出最多 O ( log ⁡ k ) O(\log k) O(logk),因此总时间 O ( k + ( n − k ) log ⁡ k ) = O ( n log ⁡ k ) O(k + (n-k) \log k) = O(n \log k) O(k+(n−k)logk)=O(nlogk)。
  • 当 k k k 远小于 n n n 时,效率接近 O ( n ) O(n) O(n);当 k k k 接近 n n n 时,退化为 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

空间复杂度

  • 堆存储 k k k 个元素,因此为 O ( k ) O(k) O(k)。

代码实现

以下是使用 Python 内置 heapq 模块的完整实现,关键代码段控制在20行以内,注释详细说明每一步操作。

python 复制代码
import heapq
from typing import List

def findKthLargest(nums: List[int], k: int) -> int:
    """
    使用最小堆求解第K大元素
    时间复杂度: O(n log k)
    空间复杂度: O(k)
    """
    # 边界条件检查
    if not nums or k <= 0 or k > len(nums):
        raise ValueError("Invalid input: nums empty or k out of range")
    
    # 创建最小堆,存储前k个元素
    min_heap = nums[:k]
    heapq.heapify(min_heap)  # O(k) 建堆
    
    # 遍历剩余元素
    for num in nums[k:]:
        # 若当前元素大于堆顶(即当前第k大的候选),则替换
        if num > min_heap[0]:
            heapq.heapreplace(min_heap, num)  # 弹出最小并插入新元素,O(log k)
            # 等价于 heapq.heappushpop(min_heap, num)
    
    # 堆顶即为第k大元素
    return min_heap[0]


# 测试代码
if __name__ == "__main__":
    nums = [3, 2, 1, 5, 6, 4]
    k = 2
    print(f"第{k}大元素是: {findKthLargest(nums, k)}")  # 输出 5
    
    nums2 = [3, 2, 3, 1, 2, 4, 5, 5, 6]
    k2 = 4
    print(f"第{k2}大元素是: {findKthLargest(nums2, k2)}")  # 输出 4

代码解析

  • heapq.heapify(min_heap) 将列表原地转换为最小堆,时间复杂度 O ( k ) O(k) O(k)。
  • heapq.heapreplace(min_heap, num) 是高效替换操作:先弹出堆顶(最小),再插入新元素,保证堆大小不变。这比先 heappopheappush 少一次调整。
  • 边界条件处理确保输入合法性,避免 k 大于数组长度等情况。

优化讨论

手动建堆 vs heapq库

  • heapq 提供的是基于列表的轻量级实现,适合大多数场景。若需要自定义比较逻辑(如最大堆),可存储 (-num, num) 或实现类重载 __lt__
  • 手动建堆(如实现 _heapify_siftdown)有助于深入理解堆调整过程,但在工程中推荐使用标准库。

K值大小对算法选择的影响

  • 当 k k k 较小时(如 k ≤ n / 10 k \leq n/10 k≤n/10),堆解法 O ( n log ⁡ k ) O(n \log k) O(nlogk) 非常高效。
  • 当 k k k 接近 n n n 时(如 k = n / 2 k = n/2 k=n/2),堆解法退化为 O ( n log ⁡ n ) O(n \log n) O(nlogn),此时快速选择(期望 O ( n ) O(n) O(n))更具优势。
  • 快速选择的最坏情况 O ( n 2 ) O(n^2) O(n2) 可通过随机化划分或中位数法避免,但在面试中通常只需分析期望复杂度。

空间优化

  • 若允许修改原数组,可使用原地划分的快速选择,将空间降至 O ( 1 ) O(1) O(1)。
  • 堆解法空间 O ( k ) O(k) O(k) 不可省略,但通常可接受。

面试考点

  1. 堆的选择:为什么用最小堆而非最大堆?解释"维护当前最大的k个元素"这一思路。
  2. 时间复杂度推导:能够详细分析建堆、插入、弹出的复杂度,并对比快速选择。
  3. 边界条件 :处理 k > len(nums)k <= 0、空数组等情况,体现代码健壮性。
  4. 库函数使用 :是否知道 heapq.heapreplaceheapq.heappushpop 的区别?两者在本题中效果相同,但 heapreplace 稍快。
  5. 扩展问题:若数据流持续到来(即在线算法),如何调整?此时堆解法天然支持动态更新,而快速选择需要重算。

347. 前K个高频元素

题目概述与链接

题目链接347. Top K Frequent Elements

问题描述 :给定一个非空的整数数组 nums,返回其中出现频率前 k 高的元素。你可以按任意顺序返回答案。要求时间复杂度优于 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

示例

复制代码
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
解释: 元素1出现3次,元素2出现2次,前2高频元素是1和2。

核心解题思路

本题需要两个步骤:统计频率筛选TopK 。统计频率自然使用哈希表(Python 中 collections.Counter),时间复杂度 O ( n ) O(n) O(n)。筛选TopK则有两种主流方法:

  1. 最小堆法 :维护大小为 k k k 的最小堆,堆中存储 (频率, 元素) 对,按频率排序。遍历频率哈希表,不断更新堆,最终堆中元素即为前K高频。
  2. 桶排序法 :创建长度为 n + 1 n+1 n+1 的桶数组,下标表示频率,桶内存储该频率的所有元素。然后从高频向低频遍历桶,收集前 k k k 个元素。

堆解法时间复杂度 O ( n log ⁡ k ) O(n \log k) O(nlogk),桶排序 O ( n ) O(n) O(n) 但需要额外空间。本文将重点分析堆解法,因其更通用且易于扩展到数据流场景。

堆解法的核心步骤

  1. 使用 Counter 统计每个元素的出现次数,得到频率哈希表。
  2. 初始化大小为 k k k 的最小堆(按频率比较)。
  3. 遍历哈希表,若堆未满则直接插入;若已满则比较当前频率与堆顶频率,若更大则替换。
  4. 遍历结束后,堆中元素即为前K高频,提取元素返回。

复杂度分析

时间复杂度

  • 统计频率: O ( n ) O(n) O(n)
  • 建堆与更新:最多 n n n 个元素进入堆,每次操作 O ( log ⁡ k ) O(\log k) O(logk),因此总时间 O ( n log ⁡ k ) O(n \log k) O(nlogk)。
  • 整体 O ( n + n log ⁡ k ) = O ( n log ⁡ k ) O(n + n \log k) = O(n \log k) O(n+nlogk)=O(nlogk)。

空间复杂度

  • 哈希表存储 m m m 个不同元素,最坏 O ( n ) O(n) O(n)。
  • 堆存储 k k k 个元素, O ( k ) O(k) O(k)。
  • 总空间 O ( n ) O(n) O(n)。

代码实现

python 复制代码
import heapq
from collections import Counter
from typing import List

def topKFrequent(nums: List[int], k: int) -> List[int]:
    """
    使用最小堆求解前K高频元素
    时间复杂度: O(n log k)
    空间复杂度: O(n)
    """
    if not nums or k <= 0:
        return []
    
    # 1. 统计频率
    freq_map = Counter(nums)  # O(n)
    
    # 2. 构建最小堆,堆中元素为 (频率, 值),按频率排序
    min_heap = []
    
    for num, freq in freq_map.items():
        if len(min_heap) < k:
            heapq.heappush(min_heap, (freq, num))  # 堆未满,直接插入
        else:
            # 堆已满,若当前频率大于堆顶频率,则替换
            if freq > min_heap[0][0]:
                heapq.heapreplace(min_heap, (freq, num))
    
    # 3. 提取堆中的元素值
    return [num for freq, num in min_heap]


# 测试代码
if __name__ == "__main__":
    nums = [1, 1, 1, 2, 2, 3]
    k = 2
    print(f"前{k}高频元素: {topKFrequent(nums, k)}")  # 输出 [2, 1] 或 [1, 2] 顺序不限
    
    nums2 = [1]
    k2 = 1
    print(f"前{k2}高频元素: {topKFrequent(nums2, k2)}")  # 输出 [1]

代码解析

  • Counter(nums) 快速统计频率,返回字典 {元素: 频率}
  • 堆中存储元组 (频率, 元素),Python 比较元组时按第一个元素(频率)排序,因此堆顶是频率最小的元素。
  • heapq.heapreplace 同样用于高效替换,保证堆大小不超过 k k k。
  • 最终返回时只需提取元素值,频率信息不再需要。

优化讨论

桶排序作为替代

当元素频率范围明确且较小(如频率不超过 n n n)时,桶排序可将时间复杂度降至 O ( n ) O(n) O(n)。具体步骤:

  1. 统计频率哈希表。
  2. 创建长度为 n + 1 n+1 n+1 的桶数组,下标 i i i 存储所有频率为 i i i 的元素。
  3. 从下标 n n n 到 0 0 0 逆序遍历桶,收集元素直到达到 k k k 个。

桶排序代码更简洁,但需要额外 O ( n ) O(n) O(n) 空间存储桶,且当频率分布极端时(如所有元素频率为1),桶的利用率低。

相同频率元素的处理

题目允许按任意顺序返回,但若要求相同频率时按元素大小排序,则需在堆中增加二级比较。例如堆存储 (频率, -元素) 或自定义类。

当K接近n时的优化

若 k k k 与不同元素数量 m m m 接近(如 k ≥ m / 2 k \geq m/2 k≥m/2),可考虑使用最大堆:将全部 m m m 个元素加入最大堆,然后弹出前 k k k 个,时间复杂度 O ( m log ⁡ m ) O(m \log m) O(mlogm),在 m m m 较小时可能更优。但通常 m m m 与 n n n 同阶,因此最小堆法更稳健。

面试考点

  1. 数据结构组合:哈希表与堆的结合是经典模式,能否清晰说明各自作用?
  2. 堆中存储内容 :为什么存 (频率, 元素) 而非 (元素, 频率)?解释元组比较规则。
  3. 时间复杂度分析 :区分统计频率与筛选TopK两部分的复杂度,并说明为什么总时间是 O ( n log ⁡ k ) O(n \log k) O(nlogk)。
  4. 边界与异常 :处理 k > 不同元素数量 的情况(此时返回所有元素),以及空输入。
  5. 扩展场景 :若数据是流式到达(无法一次统计全量频率),如何设计算法?此时需使用 空间节省的计数器(如Count-Min Sketch) 配合堆。

总结与扩展

通过以上两道力扣hot100题目的深度解析,我们揭示了堆(优先队列)在TopK问题中的核心作用。堆能够以 O ( n log ⁡ k ) O(n \log k) O(nlogk) 的时间复杂度高效维护动态数据流中的最值,相比全排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) 在 k k k 较小时有明显优势。关键在于 选择适当的堆类型 (最小堆用于维护最大K个元素,最大堆用于维护最小K个元素)以及 合理控制堆大小

堆的其他经典应用场景

  1. 合并K个有序链表 (LeetCode 23):使用最小堆存储每个链表的当前头节点,每次弹出堆顶并插入该节点的下一个节点,时间复杂度 O ( n log ⁡ k ) O(n \log k) O(nlogk)。
  2. 数据流的中位数 (LeetCode 295):使用一个最大堆存储较小一半数,一个最小堆存储较大一半数,保证两堆大小平衡,可在 O ( log ⁡ n ) O(\log n) O(logn) 时间内插入并 O ( 1 ) O(1) O(1) 查询中位数。
  3. 任务调度:操作系统中的优先队列调度、定时任务管理等。
  4. 图算法:Dijkstra最短路径算法、Prim最小生成树算法中都需要堆来高效选取最小边。

进阶学习资源

  • 斐波那契堆(Fibonacci Heap):支持合并、减值等操作的理论最优数据结构,但实现复杂,常用于理论分析。
  • 二项堆(Binomial Heap):支持高效合并的另一种堆结构,是斐波那契堆的基础。
  • 左偏树(Leftist Tree):可合并堆的一种简单实现,适合函数式编程环境。
  • 实战练习:力扣上标签为"堆"的题目约80道,建议按难度递增顺序刷题,巩固堆的应用技巧。

面试高频总结

在算法面试中,堆相关题目通常考察以下几点:

  1. 能否识别出TopK模式:当问题涉及"前K个最大/最小"、"第K个最值"时,优先考虑堆解法。
  2. 复杂度估算:清晰分析建堆、插入、弹出的时间复杂度,并与排序、快速选择等方法对比。
  3. 代码实现细节 :熟练使用语言提供的堆库(如Python heapq),正确处理边界与异常。
  4. 扩展思维:能够讨论数据流、海量数据(外排序)等变种场景的解决方案。

堆作为一种基础而强大的数据结构,其思想贯穿于算法设计的许多领域。掌握堆不仅有助于通过技术面试,更能提升实际工程中处理大数据、实时流的能力。希望本文的深度解析与代码实现能为你的算法学习之路提供坚实助力。

相关推荐
6Hzlia2 小时前
【Hot 100 刷题计划】 LeetCode 45. 跳跃游戏 II | C++ 贪心算法最优解题解
c++·leetcode·游戏
北顾笙9802 小时前
day18-数据结构力扣
数据结构·算法·leetcode
&&Citrus2 小时前
【CPN 学习笔记(三)】—— Chap3 CPN ML 编程语言 上半部分 3.1 ~ 3.3
笔记·python·学习·cpn·petri网
阿Y加油吧2 小时前
LeetCode 中等难度 | 回溯法进阶题解:单词搜索 & 分割回文串
算法·leetcode·职场和发展
QH_ShareHub3 小时前
反正态分布算法
算法
航Hang*3 小时前
第3章:Linux系统安全管理——第1节:Linux 防火墙部署(firewalld)
linux·服务器·网络·学习·系统安全·vmware
float_com3 小时前
LeetCode 27. 移除元素
leetcode
宋小米的csdn3 小时前
网络知识学习路线(实用向)
网络·学习
王老师青少年编程3 小时前
csp信奥赛c++中的递归和递推研究
c++·算法·递归·递推·csp·信奥赛