🔀 优先队列 — 合并K链表与调度问题

优先队列=堆+业务逻辑,是算法设计中最优雅的抽象之一

📖 优先队列的本质

优先队列(Priority Queue)是堆的抽象接口。普通队列 FIFO,优先队列按优先级出队。优先队列的本质是:在动态集合中,始终能高效获取最值。它在算法设计中无处不在——从操作系统进程调度到网络路由,从搜索引擎排序到数据流处理。

优先队列 vs 普通队列: 普通队列 (FIFO): 优先队列 (按优先级): ┌─┬─┬─┬─┬─┐ ┌─┬─┬─┬─┬─┐ │3│1│5│2│4│ │1│2│3│4│5│ └─┴─┴─┴─┴─┘ └─┴─┴─┴─┴─┘ 出队顺序: 3,1,5,2,4 出队顺序: 1,2,3,4,5 (先进先出) (优先级高的先出) 底层实现: 堆 (通常) Java: PriorityQueue C++: priority_queue Python: heapq 模块 Go: container/heap

1. 优先队列的典型应用场景

场景优先级定义经典题目时间优化
多路归并当前元素值合并K个排序链表O(N log K)
任务调度截止时间/优先级任务调度器O(N log N)
最短路径当前距离Dijkstra算法O((V+E) log V)
数据流元素值/频率中位数/TopKO(log N) 每次
哈夫曼编码字符频率最优前缀编码O(N log N)
A*搜索f=g+h估价迷宫最短路依赖启发函数

2. 多路归并:优先队列的经典范式

核心思想:K个有序序列合并时,每次只需比较各序列的当前头元素,取最小即可。用优先队列维护K个头元素,每次O(log K)取出最小并补充新头元素。

为什么比两两合并更好?两两合并需要合并K-1次,总时间O(N*K)。优先队列一次合并所有路,总时间O(N log K)。当K很大时差异显著。

3. Python中优先队列的三种实现

# 方式一: heapq (推荐,最快,线程不安全)
import heapq
heap = []
heapq.heappush(heap, (priority, item))
item = heapq.heappop(heap)

# 方式二: queue.PriorityQueue (线程安全,稍慢)
from queue import PriorityQueue
pq = PriorityQueue()
pq.put((priority, item))
item = pq.get()

# 方式三: 自定义比较 (需要__lt__方法)
class Node:
    def __init__(self, val, cost):
        self.val = val
        self.cost = cost
    def __lt__(self, other):
        return self.cost < other.cost

# 性能对比:
# heapq: 最快, 适合单线程算法题
# PriorityQueue: 线程安全, 适合多线程场景
# 自定义类: 适合复杂比较逻辑

4. Dijkstra算法中的优先队列

优先队列在图论中最著名的应用是Dijkstra最短路算法。每一步取出当前距离最小的未访问节点,松弛其邻居,更新距离后重新入堆。

def dijkstra(graph, start):
    n = len(graph)
    dist = [float('inf')] * n
    dist[start] = 0
    heap = [(0, start)]  # (距离, 节点)

    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:  # 延迟删除:过时记录跳过
            continue
        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(heap, (dist[v], v))
    return dist
Dijkstra中的"延迟删除"技巧很重要:堆中可能有同一个节点的多个记录(距离不同),弹出时检查是否已过时。这避免了堆中删除元素的困难操作。

🔬 题目一:合并K个升序链表(LC 23)

给定一个链表数组,每个链表都已按升序排列,将所有链表合并到一个升序链表中,返回合并后的链表。

思路分析

合并K个排序链表 — 优先队列法: 链表1: 1 → 4 → 5 链表2: 1 → 3 → 4 链表3: 2 → 6 步骤1: 堆=[(1,L1),(1,L2),(2,L3)] 弹出(1,L1) → 结果:1 → 补入4 步骤2: 堆=[(1,L2),(2,L3),(4,L1)] 弹出(1,L2) → 结果:1,1 → 补入3 步骤3: 堆=[(2,L3),(3,L2),(4,L1)] 弹出(2,L3) → 结果:1,1,2 → 补入6 步骤4: 堆=[(3,L2),(4,L1),(6,L3)] 弹出(3,L2) → 结果:1,1,2,3 → 补入4 步骤5: 堆=[(4,L1),(4,L2),(6,L3)] 弹出(4,L1) → 结果:1,1,2,3,4 → 补入5 步骤6: 堆=[(4,L2),(5,L1),(6,L3)] 弹出(4,L2) → 结果:1,1,2,3,4,4 步骤7: 堆=[(5,L1),(6,L3)] 弹出(5,L1) → 结果:1,1,2,3,4,4,5 步骤8: 堆=[(6,L3)] 弹出(6,L3) → 结果:1,1,2,3,4,4,5,6 最终: 1→1→2→3→4→4→5→6 关键: 每次O(log K)弹出最小 + O(log K)补入
算法流程:
① 将K个链表的头节点入堆(按val排序)
② 循环:弹出堆顶(最小节点)→ 接入结果链表 → 若该节点有next则入堆
③ 直到堆空为止
④ 使用dummy节点简化链表操作

代码实现

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        dummy = ListNode(0)
        curr = dummy
        heap = []

        # 将各链表头节点入堆
        for i, node in enumerate(lists):
            if node:
                # i作为第二关键字避免ListNode比较
                heapq.heappush(heap, (node.val, i, node))

        while heap:
            val, i, node = heapq.heappop(heap)
            curr.next = node
            curr = curr.next
            if node.next:
                heapq.heappush(heap, (node.next.val, i, node.next))

        return dummy.next

# 分治合并版本(空间更优 O(log K))
class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        if not lists:
            return None

        def merge2(a, b):
            dummy = ListNode(0)
            curr = dummy
            while a and b:
                if a.val <= b.val:
                    curr.next = a
                    a = a.next
                else:
                    curr.next = b
                    b = b.next
                curr = curr.next
            curr.next = a or b
            return dummy.next

        # 分治:每次合并相邻两个链表
        step = 1
        while step < len(lists):
            for i in range(0, len(lists) - step, step * 2):
                lists[i] = merge2(lists[i], lists[i + step])
            step *= 2
        return lists[0]

复杂度分析

方法时间空间特点
优先队列O(N log K)O(K)推荐
两两合并O(NK)O(1)简单但慢
分治合并O(N log K)O(log K)空间更优
Python的heapq在元素为tuple时,如果第一个元素相同会比较第二个元素。若第二个元素是ListNode(不可比较),会报TypeError。解决方案:加入索引i作为中间元素:(node.val, i, node)。这是Python特有的坑!

🔬 题目二:数据流中的第K大元素(LC 703)

设计一个类,初始时传入 knums,每次调用 add(val) 添加元素并返回当前第k大的元素。

思路分析

数据流第K大 — 小顶堆维护: k=3, nums=[4,5,8,2] 初始化: 入4 → 堆[4] 入5 → 堆[4,5] 入8 → 堆[4,5,8] (堆满,堆顶4) 入2 → 2<4,弹出2 → 堆[4,5,8] add(3): 入3→堆[3,5,8,4]→弹出3→堆[4,5,8]→返回4 add(5): 入5→堆[4,5,8,5]→弹出4→堆[5,5,8]→返回5 add(10):入10→堆[5,5,8,10]→弹出5→堆[5,10,8]→返回5 add(9): 入9→堆[5,9,8,10]→弹出5→堆[8,9,10]→返回8 add(4): 入4→堆[4,9,8,10]→弹出4→堆[8,9,10]→返回8 核心洞察: 堆始终维护前k大元素 堆顶 = 第k大 = 前k个中最小的那个
与LC 215的异同:
LC 215是一次性查找(静态数组),LC 703是持续维护(动态数据流)。核心策略相同:用小顶堆维护前k大元素。

① 初始化时建堆并裁剪到大小k
② add时:入堆 → 若超过k则弹出最小 → 返回堆顶
③ 堆始终维护前k大元素,堆顶即第k大

代码实现

class KthLargest:
    def __init__(self, k: int, nums: List[int]):
        self.k = k
        self.heap = []
        for n in nums:
            heapq.heappush(self.heap, n)
            if len(self.heap) > self.k:
                heapq.heappop(self.heap)

    def add(self, val: int) -> int:
        heapq.heappush(self.heap, val)
        if len(self.heap) > self.k:
            heapq.heappop(self.heap)
        return self.heap[0]

# 使用示例
k = KthLargest(3, [4, 5, 8, 2])
print(k.add(3))   # 4
print(k.add(5))   # 5
print(k.add(10))  # 5
print(k.add(9))   # 8
print(k.add(4))   # 8

复杂度分析

初始化: O(n log k) add: O(log k) 空间: O(k)

这类数据流题目的核心是:不是每次全量计算,而是用合适的数据结构维护中间结果,使得查询操作高效。堆维护TopK、平衡树维护排名、单调队列维护窗口最值——都是同一个思想的不同表现。面试中遇到"动态"+"查询"的组合,第一反应就是数据结构维护。

🔬 变体:任务调度器(LC 621)

给你一个用字符数组表示的CPU任务列表和一个冷却时间n。每个相同任务之间必须有n个冷却时间。计算完成所有任务的最短时间。

思路分析

贪心 + 优先队列:
① 统计每个任务频率,用最大堆维护(最频繁的先安排)
② 每轮执行n+1个任务(包括冷却),从堆中取频率最高的任务
③ 执行后频率-1,若仍>0则暂存,本轮结束后放回堆中
④ 最后一轮可能不满n+1个,需区分情况计算
class Solution:
    def leastInterval(self, tasks: List[str], n: int) -> int:
        # 统计频率
        freq = list(collections.Counter(tasks).values())
        max_freq = max(freq)
        # 出现最大频率的任务数
        max_count = freq.count(max_freq)
        # 公式: (max_freq-1)*(n+1) + max_count
        # 但不能少于任务总数
        return max(len(tasks), (max_freq - 1) * (n + 1) + max_count)
任务调度器有两种解法:①优先队列模拟(直观但代码长)②数学公式(简洁但需理解推导)。面试中推荐先说模拟思路,再写公式版本。公式推导:每轮长度n+1,共max_freq-1轮满轮,最后一轮执行max_count个任务。
成就解锁:队列大师 — 掌握优先队列在多路归并和调度问题中的核心应用
LeetCode AC验证:LC 23 Merge k Sorted Lists ✅ | LC 703 Kth Largest Element in a Stream ✅

📝 课后练习

  1. LC 373 查找和最小的K对数字(多路归并变形)
  2. LC 355 设计推特(多路归并+哈希,系统设计题)
  3. LC 743 网络延迟时间(Dijkstra + 优先队列)
  4. LC 787 K站中转内最便宜的航班(Dijkstra变体)
  5. LC 621 任务调度器(优先队列+贪心)
  6. LC 1439 有序矩阵中第k小的乘积和

🔑 本课要点回顾

📚 扩展阅读

思考题:合并K个排序链表,如果不使用优先队列,还有什么方法?分治合并的具体流程是什么?各方法的时间复杂度对比如何?