【笔面试算法学习专栏】堆与优先队列专题:数组中的第K个最大元素与前K个高频元素

目录

  1. 引言与背景
  2. 堆与优先队列核心思想
    • 2.1 堆数据结构的基本概念
    • 2.2 堆的性质与存储
    • 2.3 建堆过程的时间复杂度分析
    • 2.4 堆的插入与删除操作
    • 2.5 优先队列的实现与应用场景
    • 2.6 堆排序算法
  3. 力扣hot100堆与优先队列题目深度解析
    • 3.1 数组中的第K个最大元素(215)
    • 3.2 前K个高频元素(347)
    • 3.3 数据流的中位数(295)
    • 3.4 合并K个排序链表(23)
    • 3.5 天际线问题(218)
  4. 总结与展望
  5. 参考文献

1. 引言与背景

堆(Heap)是一种特殊的完全二叉树数据结构,它满足堆性质:对于最大堆,每个节点的值都大于或等于其子节点的值;对于最小堆,每个节点的值都小于或等于其子节点的值。堆常用于实现优先队列(Priority Queue),这是一种能够高效获取最高(或最低)优先级元素的数据结构。

在算法面试中,堆与优先队列是高频考点。力扣(LeetCode)hot100中有多道题目涉及这一知识点,掌握堆的原理与应用能够显著提升解题效率。本专题将系统讲解堆与优先队列的核心思想,并选取5道经典题目进行深度解析,面向27届求职者提供面试准备指南。

2. 堆与优先队列核心思想

2.1 堆数据结构的基本概念

堆是一种完全二叉树,通常使用数组存储。给定节点索引 i i i(从0开始),其父节点索引为 ⌊ ( i − 1 ) / 2 ⌋ \lfloor (i-1)/2 \rfloor ⌊(i−1)/2⌋,左子节点索引为 2 i + 1 2i+1 2i+1,右子节点索引为 2 i + 2 2i+2 2i+2。

堆分为两种类型:

  • 最大堆 :父节点的值大于或等于子节点的值,即 ∀ i , A [ parent ( i ) ] ≥ A [ i ] \forall i, A[\text{parent}(i)] \ge A[i] ∀i,A[parent(i)]≥A[i]。
  • 最小堆 :父节点的值小于或等于子节点的值,即 ∀ i , A [ parent ( i ) ] ≤ A [ i ] \forall i, A[\text{parent}(i)] \le A[i] ∀i,A[parent(i)]≤A[i]。

堆的根节点(索引0)是最大(或最小)元素。

2.2 堆的性质与存储

由于堆是完全二叉树,可以用数组紧凑存储,无需指针。设堆的大小为 n n n,则数组下标 0 0 0 到 n − 1 n-1 n−1 对应树的层次遍历顺序。

存储示例

对于最大堆 [9, 5, 8, 2, 3, 7, 6],其树形结构为:

复制代码
        9
       / \
      5   8
     / \ / \
    2  3 7  6

注:数组存储节省内存且访问速度快,但需要确保数组大小足够,动态扩容可能涉及 O ( n ) O(n) O(n) 开销。

2.3 建堆过程的时间复杂度分析

将无序数组构建成堆有两种方法:自顶向下插入法和自底向上堆化法。

自顶向下插入法 :从空堆开始,依次插入每个元素,每次插入后向上调整(sift-up)。插入 n n n 个元素的总时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

自底向上堆化法(Floyd建堆算法) :从最后一个非叶子节点开始,向前遍历并对每个节点执行向下调整(sift-down)。时间复杂度为 O ( n ) O(n) O(n)。

Floyd建堆算法步骤

  1. 找到最后一个非叶子节点索引: ⌊ n / 2 ⌋ − 1 \lfloor n/2 \rfloor - 1 ⌊n/2⌋−1。
  2. 从该索引向前遍历到根节点(索引0)。
  3. 对每个节点执行向下调整,确保以该节点为根的子树满足堆性质。

时间复杂度推导:

设树高 h = ⌊ log ⁡ 2 n ⌋ h = \lfloor \log_2 n \rfloor h=⌊log2n⌋。第 i i i 层有最多 2 i 2^i 2i 个节点,每个节点向下调整最多需要 h − i h-i h−i 次交换。总操作次数:
∑ i = 0 h 2 i ( h − i ) = O ( n ) \sum_{i=0}^{h} 2^i (h-i) = O(n) i=0∑h2i(h−i)=O(n)

具体计算可得精确上界 2 n 2n 2n,因此建堆是线性时间复杂度。

注:面试中经常要求推导建堆时间复杂度,掌握上述推导过程有助于展现分析能力。

2.4 堆的插入与删除操作

插入操作

  1. 将新元素追加到数组末尾。
  2. 向上调整(sift-up):若新元素大于父节点(最大堆),则交换,重复直至满足堆性质。
  3. 时间复杂度: O ( log ⁡ n ) O(\log n) O(logn)。

删除堆顶元素

  1. 将堆顶元素与末尾元素交换。
  2. 删除末尾元素(原堆顶)。
  3. 向下调整(sift-down):从根开始,若当前节点小于子节点(最大堆),则与较大的子节点交换,重复直至满足堆性质。
  4. 时间复杂度: O ( log ⁡ n ) O(\log n) O(logn)。

向下调整(sift-down)伪代码(最大堆):

python 复制代码
def sift_down(arr, i, n):
    # arr: 堆数组,i: 当前节点索引,n: 堆大小
    while True:
        left = 2 * i + 1
        right = 2 * i + 2
        largest = i
        
        if left < n and arr[left] > arr[largest]:
            largest = left
        if right < n and arr[right] > arr[largest]:
            largest = right
            
        if largest == i:
            break
        arr[i], arr[largest] = arr[largest], arr[i]
        i = largest

2.5 优先队列的实现与应用场景

优先队列是一种抽象数据类型,支持插入元素和提取最高(或最低)优先级元素。堆是实现优先队列的高效数据结构。

操作

  • push(item): 插入元素, O ( log ⁡ n ) O(\log n) O(logn)。
  • pop(): 移除并返回优先级最高的元素, O ( log ⁡ n ) O(\log n) O(logn)。
  • peek(): 返回优先级最高的元素, O ( 1 ) O(1) O(1)。

应用场景

  1. 任务调度:操作系统进程调度(优先级高的先执行)。
  2. Dijkstra算法:优先队列用于选择当前最短路径节点。
  3. 哈夫曼编码:优先队列合并最小频率节点。
  4. 合并K个有序序列:每次取最小元素。
  5. 实时数据流统计:如中位数、Top K等。

Python中可使用 heapq 模块(最小堆)或自定义最大堆。

注:heapq 仅提供最小堆实现,若要最大堆,可将元素取负后插入。

2.6 堆排序算法

堆排序(Heapsort)是一种基于堆数据结构的比较排序算法。其核心思想是利用堆的性质,逐步将最大(或最小)元素移到数组末尾,最终得到有序序列。

算法步骤

  1. 建堆 :将无序数组构建成最大堆, O ( n ) O(n) O(n)。
  2. 排序
    • 将堆顶元素(最大值)与末尾元素交换。
    • 堆大小减1,对新的堆顶执行向下调整。
    • 重复直到堆大小为1。
  3. 时间复杂度: O ( n log ⁡ n ) O(n \log n) O(nlogn),空间复杂度: O ( 1 ) O(1) O(1)(原地排序)。

Python代码实现

python 复制代码
def heap_sort(arr):
    n = len(arr)
    
    # 建堆
    for i in range(n // 2 - 1, -1, -1):
        sift_down(arr, i, n)
    
    # 排序
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]
        sift_down(arr, 0, i)
    
    return arr

注:堆排序是不稳定排序(相同元素可能交换顺序),但时间复杂度稳定,适合对大规模数据排序。

3. 力扣hot100堆与优先队列题目深度解析

3.1 数组中的第K个最大元素(215)

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

题目描述 :给定整数数组 nums 和整数 k,返回数组中第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

示例

复制代码
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5

核心解题思路

  1. 最小堆维护Top K

    • 维护一个大小为 k 的最小堆。
    • 遍历数组,当堆大小小于 k 时直接插入;否则,若当前元素大于堆顶,则替换堆顶并调整。
    • 遍历完成后,堆顶即为第 k 个最大元素。
    • 时间复杂度: O ( n log ⁡ k ) O(n \log k) O(nlogk),空间复杂度: O ( k ) O(k) O(k)。
  2. 快速选择算法(Quickselect)

    • 基于快速排序的分区思想,每次选取一个枢轴,将数组分为左右两部分。
    • 根据枢轴位置与 k 的关系,递归在相应分区查找。
    • 平均时间复杂度: O ( n ) O(n) O(n),最坏 O ( n 2 ) O(n^2) O(n2),空间复杂度: O ( 1 ) O(1) O(1)。

Python代码实现(最小堆解法):

python 复制代码
import heapq

def findKthLargest(nums, k):
    """
    :type nums: List[int]
    :type k: int
    :rtype: int
    """
    min_heap = []
    for num in nums:
        if len(min_heap) < k:
            heapq.heappush(min_heap, num)
        else:
            if num > min_heap[0]:
                heapq.heapreplace(min_heap, num)
    return min_heap[0]

注:heapq.heapreplace 相当于先 poppush,但更高效。

时间复杂度分析

  • 建堆过程: O ( k ) O(k) O(k)(初始化大小为 k 的堆)。
  • 遍历 n − k n-k n−k 个元素,每次堆操作 O ( log ⁡ k ) O(\log k) O(logk),总 O ( n log ⁡ k ) O(n \log k) O(nlogk)。

面试考点与注意事项

  1. 与排序解法( O ( n log ⁡ n ) O(n \log n) O(nlogn))对比,堆解法在 k k k 远小于 n n n 时更优。
  2. 快速选择算法更优但实现稍复杂,需注意随机化枢轴以避免最坏情况。
  3. 若要求第 k 个最小元素,可使用最大堆或最小堆取负。

3.2 前K个高频元素(347)

题目链接347. Top K Frequent Elements

题目描述 :给定一个非空的整数数组,返回其中出现频率前 k 高的元素。

示例

复制代码
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]

核心解题思路

  1. 哈希表统计频率 :遍历数组,使用哈希表记录每个元素的出现次数, O ( n ) O(n) O(n)。

  2. 优先队列求Top K

    • 将(频率,元素)对放入最小堆(按频率排序)。
    • 维护堆大小为 k,当堆满且当前频率大于堆顶频率时,替换堆顶。
    • 最终堆中元素即为前 k 个高频元素。
    • 时间复杂度: O ( n log ⁡ k ) O(n \log k) O(nlogk)。
  3. 桶排序优化

    • 创建大小为 n+1 的桶数组,索引为频率,值为该频率的元素列表。
    • 将频率统计后放入对应桶中。
    • 从高频率向低频率遍历桶,收集前 k 个元素。
    • 时间复杂度: O ( n ) O(n) O(n)。

Python代码实现(最小堆解法):

python 复制代码
import heapq
from collections import Counter

def topKFrequent(nums, k):
    """
    :type nums: List[int]
    :type k: int
    :rtype: List[int]
    """
    # 统计频率
    freq_map = Counter(nums)
    
    # 最小堆维护Top K
    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))
    
    # 提取结果
    return [num for freq, num in min_heap]

注:Countercollections 提供的频率统计工具,简化代码。

时间复杂度分析

  • 统计频率: O ( n ) O(n) O(n)。
  • 堆操作:最坏 O ( n log ⁡ k ) O(n \log k) O(nlogk),但实际 k k k 较小,效率较高。

面试考点与注意事项

  1. 掌握 Counterheapq 的使用。
  2. 桶排序解法在频率分布均匀时更优,但需要额外 O ( n ) O(n) O(n) 空间。
  3. 若频率相同,返回任意顺序通常可接受。

3.3 数据流的中位数(295)

题目链接295. Find Median from Data Stream

题目描述:设计一个支持以下两种操作的数据结构:

  • addNum(num):从数据流中添加一个整数到数据结构中。
  • findMedian():返回目前所有元素的中位数。

示例

复制代码
addNum(1)
addNum(2)
findMedian() -> 1.5
addNum(3) 
findMedian() -> 2

核心解题思路

中位数定义:对于有序列表,若长度为奇数,中位数为中间元素;若长度为偶数,中位数为中间两个元素的平均值。

双堆法(Two Heaps)

  • 使用两个堆:最大堆 left 存储较小的一半,最小堆 right 存储较大的一半。
  • 维护性质:
    1. left 的大小等于 right 的大小,或比 right 多1。
    2. left 的最大值 <= right 的最小值。
  • 插入操作:
    • 若两个堆大小相等,先插入 right,再将 right 的最小值弹出插入 left
    • leftright 多1,先插入 left,再将 left 的最大值弹出插入 right
  • 查询中位数:
    • 若两个堆大小相等,中位数 = (left 最大值 + right 最小值) / 2。
    • leftright 多1,中位数 = left 最大值。

Python代码实现

python 复制代码
import heapq

class MedianFinder:
    def __init__(self):
        # 最大堆(使用负数实现)
        self.left = []
        # 最小堆
        self.right = []

    def addNum(self, num: int) -> None:
        if len(self.left) == len(self.right):
            # 插入right,然后弹出最小值插入left
            heapq.heappush(self.right, num)
            heapq.heappush(self.left, -heapq.heappop(self.right))
        else:
            # left比right多1,插入left,然后弹出最大值插入right
            heapq.heappush(self.left, -num)
            heapq.heappush(self.right, -heapq.heappop(self.left))

    def findMedian(self) -> float:
        if len(self.left) == len(self.right):
            return (-self.left[0] + self.right[0]) / 2
        else:
            return -self.left[0]

注:Python的 heapq 是最小堆,最大堆通过取负实现。

时间复杂度分析

  • addNum: O ( log ⁡ n ) O(\log n) O(logn)。
  • findMedian: O ( 1 ) O(1) O(1)。

面试考点与注意事项

  1. 双堆法的平衡维护是核心,需确保大小关系。
  2. 注意浮点数除法,Python 3 中 / 返回浮点数。
  3. 扩展:若数据流有删除操作,可使用更复杂的数据结构(如平衡二叉搜索树)。

3.4 合并K个排序链表(23)

题目链接23. Merge k Sorted Lists

题目描述:给定一个链表数组,每个链表都已经按升序排列。请将所有链表合并到一个排序链表中,返回其头节点。

示例

复制代码
输入: lists = [[1,4,5],[1,3,4],[2,6]]
输出: [1,1,2,3,4,4,5,6]

核心解题思路

  1. 逐一合并 :顺序合并两个链表,时间复杂度 O ( k n ) O(k n) O(kn),其中 n n n 为平均链表长度。
  2. 分治合并 :类似归并排序,两两合并,时间复杂度 O ( n log ⁡ k ) O(n \log k) O(nlogk)。
  3. 优先队列(最小堆)
    • 将每个链表的头节点加入最小堆(按节点值排序)。
    • 每次弹出堆顶节点,将其加入结果链表,并将该节点的下一个节点(若存在)加入堆。
    • 时间复杂度: O ( n log ⁡ k ) O(n \log k) O(nlogk)。

Python代码实现(优先队列解法):

python 复制代码
import heapq

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def mergeKLists(lists):
    """
    :type lists: List[ListNode]
    :rtype: ListNode
    """
    # 最小堆
    min_heap = []
    # 初始化堆
    for i, head in enumerate(lists):
        if head:
            heapq.heappush(min_heap, (head.val, i, head))
    
    dummy = ListNode(0)
    curr = dummy
    
    while min_heap:
        val, i, node = heapq.heappop(min_heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(min_heap, (node.next.val, i, node.next))
    
    return dummy.next

注:堆中存储 (val, i, node),其中 i 用于当 val 相同时避免比较 node 对象(Python 3 不支持比较 ListNode)。

时间复杂度分析

  • 每个节点入堆一次,出堆一次,每次堆操作 O ( log ⁡ k ) O(\log k) O(logk),总 O ( n log ⁡ k ) O(n \log k) O(nlogk)。

面试考点与注意事项

  1. 掌握优先队列合并多个有序序列的模式。
  2. 注意边界情况:空链表数组、空链表。
  3. 分治解法同样重要,需掌握递归实现。

3.5 天际线问题(218)

题目链接218. The Skyline Problem

题目描述 :给定城市中建筑物的位置和高度,返回这些建筑物形成的天际线。天际线是由关键点组成的列表,关键点格式为 [x, height],表示在 x 处高度变为 height

示例

复制代码
输入: buildings = [[2,9,10],[3,7,15],[5,12,12],[15,20,10],[19,24,8]]
输出: [[2,10],[3,15],[7,12],[12,0],[15,10],[20,8],[24,0]]

核心解题思路

扫描线算法(Sweep Line) + 优先队列(最大堆):

  1. 将每个建筑物拆分为两个事件:起始点 (left, -height) 和结束点 (right, height)。高度取负用于区分起始和结束,且起始点优先。
  2. x 坐标排序事件,若 x 相同,起始点优先(高度负值更小),结束点按高度升序。
  3. 遍历事件:
    • 起始点:将高度加入最大堆。
    • 结束点:将高度从堆中删除(延迟删除技巧)。
  4. 维护当前最大高度,若与之前不同,则记录关键点。

Python代码实现

python 复制代码
import heapq
from collections import defaultdict

def getSkyline(buildings):
    """
    :type buildings: List[List[int]]
    :rtype: List[List[int]]
    """
    events = []
    for left, right, height in buildings:
        events.append((left, -height))  # 起始点
        events.append((right, height))   # 结束点
    
    # 排序:x升序,起始点优先,结束点按高度升序
    events.sort(key=lambda x: (x[0], x[1]))
    
    # 最大堆(使用负数实现)
    heap = [0]  # 初始地平线高度0
    # 延迟删除映射
    removed = defaultdict(int)
    
    result = []
    prev_max = 0
    
    for x, h in events:
        if h < 0:  # 起始点
            heapq.heappush(heap, h)
        else:      # 结束点
            removed[-h] += 1  # 标记删除
        
        # 清理堆顶已删除元素
        while heap and removed.get(heap[0], 0) > 0:
            removed[heap[0]] -= 1
            if removed[heap[0]] == 0:
                del removed[heap[0]]
            heapq.heappop(heap)
        
        # 当前最大高度(取负)
        curr_max = -heap[0]
        if curr_max != prev_max:
            result.append([x, curr_max])
            prev_max = curr_max
    
    return result

注:延迟删除技巧避免直接从堆中删除非堆顶元素,通过哈希表记录待删除次数。

时间复杂度分析

  • 排序: O ( n log ⁡ n ) O(n \log n) O(nlogn)。
  • 堆操作:每个事件 O ( log ⁡ n ) O(\log n) O(logn),总 O ( n log ⁡ n ) O(n \log n) O(nlogn)。

4. 总结与展望

堆与优先队列是算法设计与面试中的核心工具。本专题系统讲解了堆的性质、建堆时间复杂度、优先队列的实现,并通过5道力扣hot100题目展示了其应用。

核心要点回顾

  1. 堆是完全二叉树,数组存储,支持 O ( log ⁡ n ) O(\log n) O(logn) 插入删除, O ( 1 ) O(1) O(1) 获取最值。
  2. 建堆时间复杂度为 O ( n ) O(n) O(n),可用Floyd算法。
  3. 优先队列适用于任务调度、最短路径、Top K、中位数等问题。
  4. 掌握最小堆/最大堆的转换技巧(Python中取负)。

面试策略

  • 识别问题是否涉及"动态求最值"、"维护有序性"等特征,考虑使用堆。
  • 注意时间复杂度和空间复杂度的权衡,例如Top K问题中 k k k 较小时用堆, k k k 接近 n n n 时用排序。
  • 熟练使用 heapq 模块,并理解其最小堆特性。

扩展学习方向

  1. 斐波那契堆:支持更高效的合并操作,适用于某些图算法。
  2. 左倾堆、斜堆:可合并堆的变种。
  3. 堆在操作系统中的应用:内存分配、进程调度。
  4. 堆排序 :基于堆的排序算法,时间复杂度 O ( n log ⁡ n ) O(n \log n) O(nlogn)。
相关推荐
AI-Ming2 小时前
程序员转行学习 AI 大模型: 踩坑记录,HuggingFace镜像设置未生效
人工智能·pytorch·python·gpt·深度学习·学习·agi
talen_hx2962 小时前
《零基础入门Spark》学习笔记 Day 07
笔记·学习·spark
雅俗共赏1002 小时前
医学图像重建中常用的正则化分类
算法
IronMurphy2 小时前
【算法三十二】
算法
贺小涛2 小时前
STM32学习
stm32·单片机·学习
Mr_Xuhhh2 小时前
LeetCode 热题 100 刷题笔记:高频面试题详解(215 & 347)
算法·leetcode·排序算法
sensen_kiss2 小时前
CAN302 电子商务技术 Pt.2 深入了解HTML和CSS
前端·css·学习·html
野木香2 小时前
fnm在win10下安装配置
运维·学习
mmz12072 小时前
贪心算法3(c++)
c++·算法·贪心算法