算法编程题:堆、优先队列与 Top K(5 题)
061 使用大小为 K 的堆返回数组中的第 K 大元素。
难度: 基础
方法签名: int[] numbers, int k
输入约束: numbers 非 null,1 <= k <= numbers.Length
示例: [3,2,1,5,6,4], 2 → 5
目标复杂度: 时间 O(n log k),额外空间 O(k)
查看参考答案
解题思路: 维护包含当前最大 K 个值的小根堆,超出 K 时移除最小值。
不变量: 扫描后堆中保存已见元素最大的 K 个,堆顶是其中最小值。
public static class AlgCode061Solution
{
public static int Solve(int[] numbers, int k)
{
var heap = new PriorityQueue<int, int>();
foreach (int value in numbers)
{
heap.Enqueue(value, value);
if (heap.Count > k) heap.Dequeue();
}
return heap.Peek();
}
}复杂度: 时间 O(n log k),额外空间 O(k)。
边界用例: k=1;k=n;重复值;负数。
面试追问:
- 快速选择的平均复杂度和最坏复杂度分别是什么?
062 返回数组中出现频率最高的 K 个不同元素。
难度: 进阶
方法签名: int[] numbers, int k
输入约束: numbers 非 null,1 <= k <= 不同元素数量
示例: [1,1,1,2,2,3], 2 → [1,2]
目标复杂度: 平均时间 O(n + u log k),额外空间 O(u)
查看参考答案
解题思路: 先统计频次,再用大小为 K 的小根堆保留最高频元素。
不变量: 堆中保存已处理键中频次最高的 K 个,堆顶频次最低。
public static class AlgCode062Solution
{
public static int[] Solve(int[] numbers, int k)
{
var counts = new Dictionary<int, int>();
foreach (int value in numbers)
counts[value] = counts.GetValueOrDefault(value) + 1;
var heap = new PriorityQueue<int, int>();
foreach ((int value, int count) in counts)
{
heap.Enqueue(value, count);
if (heap.Count > k) heap.Dequeue();
}
var result = new int[k];
for (int index = k - 1; index >= 0; index--)
result[index] = heap.Dequeue();
return result;
}
}复杂度: 平均时间 O(n + u log k),额外空间 O(u+k)。
边界用例: k 等于不同值数量;频次相同的结果顺序不作要求。
面试追问:
- 值频次上限为 n 时如何使用桶把时间降到 O(n)?
063 复用原节点合并 K 条升序单链表。
难度: 进阶
方法签名: ListNode?[] lists
输入约束: 每条链表无环且升序
示例: [1→4→5, 1→3→4, 2→6] → 1→1→2→3→4→4→5→6
目标复杂度: 时间 O(N log k),额外空间 O(k)
查看参考答案
解题思路: 把每条非空链表头加入小根堆,弹出后加入其后继。
不变量: 堆中最多保存每条链表当前尚未合并的最小节点。
public static class AlgCode063Solution
{
public sealed class ListNode
{
public int Value;
public ListNode? Next;
public ListNode(int value, ListNode? next = null)
=> (Value, Next) = (value, next);
}
public static ListNode? Solve(ListNode?[] lists)
{
var heap = new PriorityQueue<ListNode, int>();
foreach (ListNode? node in lists)
if (node is not null) heap.Enqueue(node, node.Value);
var sentinel = new ListNode(0);
ListNode tail = sentinel;
while (heap.Count > 0)
{
ListNode node = heap.Dequeue();
tail.Next = node;
tail = node;
if (node.Next is not null)
heap.Enqueue(node.Next, node.Next.Value);
}
tail.Next = null;
return sentinel.Next;
}
}复杂度: N 个节点各入堆出堆一次,时间 O(N log k),额外空间 O(k)。
边界用例: 空数组;全部为空;重复值;只有一条链表。
面试追问:
- 两两分治合并的复杂度和堆方案相比如何?
064 实现支持流式插入和 O(1) 查询中位数的数据结构。
难度: 综合
方法签名: MedianFinder Create()
输入约束: 调用 FindMedian 前至少插入一个值
示例: Add(1), Add(2), Median() → 1.5;Add(3), Median() → 2
目标复杂度: 插入 O(log n),查询 O(1),空间 O(n)
查看参考答案
解题思路: 最大堆保存较小一半,最小堆保存较大一半,并在每次插入后再平衡。
不变量: 两堆大小差不超过一,且 lower 的所有值不大于 upper。
public static class AlgCode064Solution
{
public sealed class MedianFinder
{
private readonly PriorityQueue<int, long> _lower = new();
private readonly PriorityQueue<int, int> _upper = new();
public void Add(int value)
{
if (_lower.Count == 0 || value <= _lower.Peek())
_lower.Enqueue(value, -(long)value);
else
_upper.Enqueue(value, value);
if (_lower.Count > _upper.Count + 1)
{
int moved = _lower.Dequeue();
_upper.Enqueue(moved, moved);
}
else if (_upper.Count > _lower.Count)
{
int moved = _upper.Dequeue();
_lower.Enqueue(moved, -(long)moved);
}
}
public double FindMedian()
{
if (_lower.Count > _upper.Count) return _lower.Peek();
return ((long)_lower.Peek() + _upper.Peek()) / 2.0;
}
}
public static MedianFinder Solve() => new();
}复杂度: 插入 O(log n),查询 O(1),空间 O(n)。
边界用例: 负数;重复值;int.MinValue;偶数个元素求和溢出。
面试追问:
- 如果只需要最近 W 个值的中位数,还需要解决什么删除问题?
065 计算带冷却时间的任务列表最短执行时长。
难度: 进阶
方法签名: char[] tasks, int cooldown
输入约束: tasks 非 null,cooldown >= 0,相同任务间至少间隔 cooldown
示例: [“A”,“A”,“A”,“B”,“B”,“B”], 2 → 8
目标复杂度: 时间 O(n log u),额外空间 O(u)
查看参考答案
解题思路: 最大堆选择剩余次数最多的任务,队列保存尚未冷却完成的任务。
不变量: 堆中任务当前可执行,冷却队列按再次可用时间递增。
public static class AlgCode065Solution
{
public static int Solve(char[] tasks, int cooldown)
{
var counts = new Dictionary<char, int>();
foreach (char task in tasks)
counts[task] = counts.GetValueOrDefault(task) + 1;
var ready = new PriorityQueue<int, int>();
foreach (int count in counts.Values) ready.Enqueue(count, -count);
var cooling = new Queue<(int ReadyAt, int Remaining)>();
int time = 0;
while (ready.Count > 0 || cooling.Count > 0)
{
time++;
while (cooling.Count > 0 && cooling.Peek().ReadyAt <= time)
{
int remaining = cooling.Dequeue().Remaining;
ready.Enqueue(remaining, -remaining);
}
if (ready.Count == 0) continue;
int next = ready.Dequeue() - 1;
if (next > 0) cooling.Enqueue((time + cooldown + 1, next));
}
return time;
}
}复杂度: 时间 O(T log u),空间 O(u),T 为包含空闲时间的总时长。
边界用例: 空任务;cooldown=0;单一任务;多任务频次相同。
面试追问:
- 如何用最高频任务的计数公式直接计算答案?