概述
堆是一种“动态最值机器”——它不保证全序,只保证最值立即可取,这种弱化有序性的设计换来了 O(log n) 的极值动态维护与 O(1) 的快速获取。本文从完全二叉树的数组映射出发,透彻讲解
siftUp、siftDown与heapify的底层机制,展现堆在优先级调度、Top-K、流式计算等场景中的核心价值,并以 JavaPriorityQueue作为工程印证。
- 完全二叉树是骨架:堆的逻辑结构是完全二叉树,这保证了最深路径为 ⌊log₂n⌋,且数组存储无指针浪费。
- 偏序关系:大顶堆(父 ≥ 子)/ 小顶堆(父 ≤ 子),只保证垂直方向有序,不保证水平方向或全局有序。
- 核心操作:插入
siftUpO(log n)、删除堆顶siftDownO(log n)、取堆顶 O(1)、建堆heapifyO(n)。 - 工程首选优先级队列:Java
PriorityQueue基于二叉堆,是小顶堆的默认实现,可用比较器反转。 - 适用场景:Top-K 问题、任务调度、流式中位数、Dijkstra 最短路径、堆排序。
文章组织架构
flowchart TD
subgraph S1["① 概述与核心特性"]
direction TB
A1["定义 / ADT / 特性表"] --> A2["适用场景 & 反模式"]
end
subgraph S2["② 完全二叉树的数组存储"]
B1["完全二叉树定义"] --> B2["数组索引公式推导"] --> B3["逻辑与物理解耦"]
end
subgraph S3["③ 核心操作详解"]
C1["siftUp 插入"] --> C2["siftDown 删除"] --> C3["heapify 建堆 O(n)"]
end
subgraph S4["④ 堆排序与性能分析"]
D1["堆排序过程"] --> D2["复杂度与对比"]
end
subgraph S5["⑤ 堆的变体与高级应用"]
E1["d-ary堆 / 二项堆 / 斐波那契堆"] --> E2["双堆中位数 / Top-K 模板"]
end
subgraph S6["⑥ 工程实现与最佳实践"]
F1["Java PriorityQueue 剖析"] --> F2["坑点与并发替代"]
end
subgraph S7["⑦ 面试高频专题"]
G1["10 道高频题 + 系统设计"]
end
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
架构分层说明:
- 宏观路径:文章严格遵循“是什么 → 怎么用 → 为什么 → 如何用得好”的认知逻辑。
- ① 概述与核心特性:先建立对堆作为优先级调度核心工具的直觉,前置特性、场景与反模式,避免过早陷入细节。
- ② 完全二叉树的数组存储:揭示堆的逻辑骨架与物理实现之间的映射,强调数组索引公式是理解堆操作时间复杂度的关键数学基础。
- ③ 核心操作详解:深入
siftUp、siftDown、heapify的过程与证明,每步操作的时间复杂度都从树深严格推导。 - ④ 堆排序与性能分析:展示堆在排序算法中的完整角色,并与其他排序进行工程对比。
- ⑤ 堆的变体与高级应用:扩展知识广度,介绍工业与理论中的重要堆变体,给出 Top-K 与流式中位数的完整解题模板。
- ⑥ 工程实现与最佳实践:以 Java
PriorityQueue为具体例子,分析其内部如何运用堆原理,并列出避坑清单。 - ⑦ 面试高频专题:独立模块集中攻克面试难点,含追问与系统设计题。
模块 1:堆与完全二叉树概述
1.1 堆的定义
堆(Heap) 是一棵完全二叉树,且树中每个节点的值都满足与其子节点之间的偏序关系:
- 大顶堆(Max-Heap):每个节点的值 ≥ 其所有子节点的值。
- 小顶堆(Min-Heap):每个节点的值 ≤ 其所有子节点的值。
这个定义中有两个关键点:第一,堆必须是一棵完全二叉树,这决定了它的存储方式和深度上限;第二,堆是偏序而非全序,它只保证从根到任何叶子的路径是单调的,但不保证兄弟之间或同层之间的顺序。
1.2 堆的抽象数据类型(ADT)
一个典型的堆 ADT 包含以下接口:
| 操作 | 签名 | 描述 |
|---|---|---|
insert | insert(E e) | 向堆中插入一个元素,并维持堆性质 |
extractMax / extractMin | E extractMax() | 移除并返回堆顶(最值),并重新调整堆 |
peek | E peekMax() | 获取堆顶但不移除,O(1) |
heapify | buildHeap(Collection<E> c) | 从无序集合构建一个合法堆 |
size / isEmpty | int size() | 返回堆中元素个数 |
这些 ADT 操作直接定义了优先级队列的接口规范。完全二叉树是堆的逻辑骨架,数组存储是堆的物理实现,两者结合赋予了堆 O(1) 取最值与 O(log n) 动态维护的核心优势。
1.3 核心特性清单
| 特性 | 根本原因 | 工程影响 |
|---|---|---|
| O(1) 取最值 | 堆顶即为全局最值(大顶堆最大,小顶堆最小) | 可用于以极大效率消费最高优先级任务 |
| O(log n) 插入与删除堆顶 | 上浮 / 下沉路径长度等于完全二叉树深度 ⌊log₂n⌋ | 在持续动态数据流中维持极值性价比极高 |
| 不完全有序 | 只约束父子关系,不保证兄弟或同层顺序 | 无法高效进行有序遍历、范围查询或按值快速删除 |
| 紧凑数组存储 | 完全二叉树结构规则,可用数组无空洞存储 | 无链式指针开销,空间紧凑,缓存局部性优于链式树 |
| 无界动态扩容 | 数组存储支持类似 ArrayList 的扩容策略 | 可处理动态数据量,无需预先指定上限 |
1.4 适用场景详解
1. 优先级任务调度
操作系统内核的任务调度器、消息中间件的优先级队列(如 RabbitMQ 的优先级队列)都依赖堆。每个任务携带优先级数值,调度器每次只需调用 extractMin / extractMax 即可取出当前优先级最高的任务。原因:调度是“动态取最高优先”的模型,与堆的 O(log n) 插入和 O(1) 取最值高度吻合,无需维护全局有序。
2. Top-K 问题
从海量数据中获取最大(或最小)的 K 个元素是典型流式处理问题。最优策略:维护一个大小为 K 的小顶堆(求 Top K 大),新元素若大于堆顶则替换堆顶并 siftDown。时间复杂度 O(n log K),空间 O(K)。若 K 远小于 n,该方案效率远超全排序。
3. 流式数据中位数
使用双堆策略:一个大顶堆存储较小的一半数据(堆顶最大),一个小顶堆存储较大的一半数据(堆顶最小)。插入时维持两堆大小平衡(差 ≤ 1),中位数即两堆堆顶之一或两者均值。每次插入 O(log n),取中位数 O(1)。这种设计在实际流式统计系统中广泛使用。
4. 堆排序(Heap Sort)
堆排序是利用堆进行原地排序的经典算法:先 heapify 建堆 O(n),然后反复交换堆顶与未排序部分的末尾元素并 siftDown O(log n),总时间 O(n log n)。优势:严格 O(n log n) 最坏时间复杂度,无递归栈溢出风险。
5. Dijkstra 最短路径
在 Dijkstra 算法中,需要反复从未处理的节点中选取距离最小的节点。使用小顶堆(或用带 decrease-key 的二项堆 / 斐波那契堆)可将复杂度降为 O((V+E) log V)。二叉堆在此场景虽然 decrease-key 为 O(log n),但实现简单,工程中最常见。
6. 事件驱动模拟
离散事件模拟(如网络仿真、游戏事件系统)中,事件按触发时间存入小顶堆,调度循环每次从堆顶取出最近发生的事件处理,并可动态插入新事件。堆天然贴合这种“动态抓取最早事件”的模型。
1.5 反模式(不当使用)
1. 需要全局有序遍历
堆只保证父子偏序,若业务需要按顺序消费所有元素(如按优先级完整打印列表),直接遍历堆输出是无序的。此时应使用排序数组或平衡二叉搜索树(如 TreeSet / TreeMap)。
2. 需要快速按值删除或查找
堆的 remove(Object) 操作需先遍历定位元素 O(n),再执行 siftUp 或 siftDown O(log n)。若频繁按值删除,应使用 TreeSet(O(log n) 删除)或哈希表 + 索引辅助。
3. 数据量极小且频繁取最值
当 n < 100 时,堆的数组访问跳跃和常数因子可能比直接线性扫描更高。简单场景下维护堆反而不如用 ArrayList 定期排序或直接遍历寻找最值。
1.6 工业界使用现状概览
| 领域 | 应用 | 堆类型 |
|---|---|---|
| 消息中间件 | RabbitMQ / ActiveMQ 优先级队列 | 二叉堆(小顶堆) |
| 搜索引擎 | Elasticsearch 基于 TF-IDF 评分取 Top-K 文档 | 最小堆 |
| 日志系统 | 按严重级别过滤,优先处理 ERROR 级别 | 小顶堆(按级别数值) |
| 任务调度器 | Linux CFS、Hadoop Capacity Scheduler 优先级 | 二叉堆 / 红黑树 |
| 图数据库 | ArangoDB / Neo4j 最短路径计算 | 斐波那契堆 / 二叉堆 |
| 网络流量控制 | 加权公平队列 (WFQ) | 小顶堆按虚拟结束时间排序 |
| 排行榜系统 | 实时 Top-N 分数排名,支持分数更新 | 堆 + 哈希辅助索引 |
模块 2:完全二叉树与数组存储
2.1 完全二叉树定义与优势
完全二叉树(Complete Binary Tree) 是满足以下条件的二叉树:
- 除最后一层外,其余各层均被完全填满;
- 最后一层的节点必须尽可能靠左排列,不允许出现“跳跃空洞”。
完全二叉树是堆结构的天然优良载体:它的规则形态保证任意时刻树的高度为 ⌊log₂n⌋,且可以直接映射到数组而不会产生空间空洞。
2.2 链式存储 vs. 顺序存储
| 存储方式 | 空间开销 | 父子定位 | 缓存友好度 |
|---|---|---|---|
| 链式存储(Node 对象 + left/right 指针) | 每节点额外 2 个引用(8~16 字节) | 指针跟随,内存跳跃 | 差(节点分散在堆内存各处) |
| 顺序存储(数组) | 仅数据引用,无额外指针 | 索引公式 O(1) 计算 | 较好(连续内存,预取友好) |
堆选择数组存储,原因正是完全二叉树的规则形态让数组索引映射毫无歧义,完美利用缓存行,降低内存开销。
2.3 数组索引公式严格推导(0-based)
设完全二叉树的节点在数组中按层序遍历顺序存储,根节点下标为 0。
对于下标为 i 的节点:
- 其左孩子的下标为
2i + 1; - 其右孩子的下标为
2i + 2; - 其父节点的下标为
(i - 1) / 2(整数除法向下取整)。
推导:
假设根在第 0 层,第 k 层(k ≥ 0)最多有 2ᵏ 个节点。第 k 层起始下标为 2ᵏ - 1。
下标 i 的节点所在层 k = ⌊log₂(i + 1)⌋,该层起始偏移量 offset = i - (2ᵏ - 1)。
左孩子位于第 k+1 层,起始下标为 2ᵏ⁺¹ - 1,其偏移为 2×offset,故左孩下标 = 2ᵏ⁺¹ - 1 + 2×(i - (2ᵏ - 1)) = 2i + 1。
右孩子同理得 2i + 2。反推父节点,即若节点为左孩子 (2p+1),父 p = (i-1)/2;若为右孩子 (2p+2),同样 p = (i-2)/2,整数除法下统一为 (i - 1) / 2。
推论:任意节点的父子关系只需两条 CPU 指令即可算出,零指针追踪空间零开销,这是堆高性能的根本物理基础。
2.4 完全二叉树与数组下标映射图
flowchart TD
A["[0] 50"] --- B["[1] 30"]
A --- C["[2] 40"]
B --- D["[3] 10"]
B --- E["[4] 20"]
C --- F["[5] 35"]
C --- G["[6] 5"]
subgraph Array["数组表示"]
Arr["[50, 30, 40, 10, 20, 35, 5]"]
end
图表分层说明:
- 图表主旨:展示一个大顶堆的完全二叉树形态与其对应的数组存储,节点中标明值及数组索引(0-based)。
- 逐层分解:根节点
50位于索引 0;左孩子30位于索引 1 = 2×0+1;右孩子40位于索引 2 = 2×0+2。同理,30的左孩子10在 2×1+1=3,右孩子20在 2×1+2=4。 - 原理映射:完全二叉树的层序排列使得数组完全没有空槽,每个索引都对应一个有效节点,这正是逻辑结构与物理结构完美解耦的体现。
- 场景关联:这张映射表是整个堆操作的核心记忆:所有
siftUp、siftDown操作都依赖索引公式在父子间快速跳转。 - 工程对应:Java
PriorityQueue内部正是用一个Object[] queue数组维护此映射关系,queue[0]永远是堆顶。 - 关键结论:完全二叉树的数组存储赋予了堆 O(1) 定位父子、零指针代价、良好缓存局部性的三大物理优势。
模块 3:核心操作详解:siftUp、siftDown、heapify
3.1 插入与 siftUp(上浮)
操作步骤:
- 将新元素
e放置在数组的尾部(索引size,即堆的下一个逻辑末尾)。 - 比较
e与其父节点:- 小顶堆:若
e < parent,则交换两者位置; - 大顶堆:若
e > parent,则交换。
- 小顶堆:若
- 将
e向上继续与其新父节点比较,直到满足堆性质或到达根节点。
时间复杂度:每步上浮一层,最大比较次数为树的高度 ⌊log₂n⌋,故 O(log n)。
伪代码(小顶堆):
siftUp(k, e):
while k > 0:
parent = (k - 1) / 2
if e >= queue[parent]:
break
queue[k] = queue[parent]
k = parent
queue[k] = e
3.2 删除堆顶与 siftDown(下沉)
操作步骤:
- 记录堆顶元素
root = queue[0]。 - 将数组最后一个元素
last = queue[size-1]移动到根位置(索引 0)。 - 从根开始,比较
last与两个子节点中较小(小顶堆)或较大(大顶堆)的那个:- 若不满足堆序,与对应的子节点交换;
- 继续向下比较,直到满足堆性质或无子节点。
- 返回记录的堆顶元素。
时间复杂度:最多下沉至叶子,路径长度为树高 ⌊log₂n⌋,故 O(log n)。
伪代码(小顶堆):
siftDown(k, e):
half = size >>> 1
while k < half: // 非叶子节点才继续
child = 2*k + 1 // 左孩子
right = child + 1
if right < size and queue[right] < queue[child]:
child = right // 选择更小的孩子
if e <= queue[child]:
break
queue[k] = queue[child]
k = child
queue[k] = e
3.3 建堆 heapify 与 O(n) 证明
操作步骤:
从最后一个非叶子节点开始(索引为 (size/2 - 1)),自右向左、自下而上地对每个节点执行 siftDown,使以该节点为根的子树满足堆性质。
关键观察:叶子节点已经是合法的堆(单节点堆),故只需处理非叶子节点。自下而上的顺序保证在处理节点 i 时,其左右子树已经是合法堆,只需一次 siftDown 即可将节点 i 融入堆中。
时间复杂度严格证明:
设完全二叉树有 n 个节点,高度 h = ⌊log₂n⌋。
- 第 0 层(根)有 1 个节点,下沉代价最多 h;
- 第 1 层有 2 个节点,下沉代价最多 h-1;
- …
- 第 i 层有 2ⁱ 个节点,下沉代价最多 h-i;
- 第 h 层(叶子)有 ≤ 2ʰ 个节点,下沉代价为 0。
总操作代价 T(n) = ∑_{i=0}^{h-1} 2ⁱ × (h - i)
令 j = h - i,则 T(n) = ∑_{j=1}^{h} j × 2^{h - j} = 2ʰ × ∑_{j=1}^{h} j / 2ʲ。
已知 ∑_{j=1}^{∞} j / 2ʲ = 2,故 T(n) ≤ 2ʰ × 2 = 2ʰ⁺¹。
因 2ʰ ≤ n,故 T(n) ≤ 2n,即 O(n)。
工程启示:许多人直觉上以为建堆是 O(n log n),因为涉及 n 次 O(log n) 操作。但大多数节点位于树的较低层,其下沉距离极短;仅有少数上层节点需要较长下沉。自底向上的 heapify 正是利用了这种高度分布特性,达到线性时间。
3.4 siftUp 与 siftDown 过程图
siftUp 过程图(小顶堆插入元素 5)
flowchart TD
subgraph step1["初始堆"]
A1["[0] 10"]
A2["[1] 15"]
A3["[2] 20"]
A4["[3] 17"]
A5["[4] 25"]
A6["[5] 5 (new)"]
A1 --- A2
A1 --- A3
A2 --- A4
A2 --- A5
A3 --- A6
end
step1 --> step2["5 与父 20 比较,上浮"]
subgraph step2_detail["第一次交换后"]
B1["[0] 10"]
B2["[1] 15"]
B3["[2] 5"]
B4["[3] 17"]
B5["[4] 25"]
B6["[5] 20"]
B1 --- B2
B1 --- B3
B2 --- B4
B2 --- B5
B3 --- B6
end
step2 --> step3["5 与父 10 比较,再上浮"]
subgraph step3_detail["最终堆"]
C1["[0] 5"]
C2["[1] 15"]
C3["[2] 10"]
C4["[3] 17"]
C5["[4] 25"]
C6["[5] 20"]
C1 --- C2
C1 --- C3
C2 --- C4
C2 --- C5
C3 --- C6
end
图表分层说明:
- 主旨:演示在小顶堆中插入元素
5后两次上浮的过程。 - 过程分解:新元素
5被放置在索引 5,即完全二叉树的最后一个叶子位置。先与父节点20(索引 2)比较,因5 < 20,交换并上移到索引 2;再与新父节点10(索引 0)比较,因5 < 10,再次交换到根。 - 原理映射:每次交换仅沿祖先路径向上,路径长度 ≤ 树深。整个过程中其他子树结构不受影响,因此算法局部性好。
- 关键结论:上浮操作保证只有新插入元素所在路径被扰动,复杂度严格为 O(log n)。
siftDown 过程图(删除堆顶 5 后下沉)
flowchart TD
subgraph begin["移除堆顶 5,末元素 20 移至根"]
D1["[0] 20"]
D2["[1] 15"]
D3["[2] 10"]
D4["[3] 17"]
D5["[4] 25"]
D1 --- D2
D1 --- D3
D2 --- D4
D2 --- D5
end
begin --> after1["20 与子节点 10,15 中较小者 10 比较,下沉"]
subgraph after1_detail["第一次交换后"]
E1["[0] 10"]
E2["[1] 15"]
E3["[2] 20"]
E4["[3] 17"]
E5["[4] 25"]
E1 --- E2
E1 --- E3
E2 --- E4
E2 --- E5
end
after1 --> final["20 没有子节点,终止下沉"]
subgraph final_detail["最终堆"]
F1["[0] 10"]
F2["[1] 15"]
F3["[2] 20"]
F4["[3] 17"]
F5["[4] 25"]
F1 --- F2
F1 --- F3
F2 --- F4
F2 --- F5
end
分层说明:
- 主旨:展示删除堆顶
5后,将末尾元素20放入根,然后通过两次比较执行一次下沉。 - 过程分解:
20进入根后,与两个孩子15和10比较,因为10更小且20 > 10,20与10交换下沉到右孩子位置。此时20已无子节点,过程终止。 - 原理映射:下沉路径只沿着树的一条分支向下,每层只与两个孩子比较,复杂度仍是 O(log n)。
- 关键结论:siftDown 的正确性依赖于父节点的两个子树已是合法堆,因此删除堆顶后必须选择正确的子分支下沉。
模块 4:堆排序与性能分析
4.1 堆排序完整过程
堆排序是一种原地、不稳定的比较排序算法,利用堆的最值特性将数组排序。
步骤(以升序为例):
- 建堆:对输入数组从最后一个非叶子节点开始,自底向上
siftDown,构建大顶堆,此时array[0]为全局最大值,时间为 O(n)。 - 交换-下沉循环:进行 n-1 次迭代,i 从 n-1 递减至 1:
- 交换
array[0]与array[i](将当前最大值放置到最终排序位置); - 将堆大小减 1;
- 对新的根
array[0]执行一次siftDown,恢复堆性质。
- 交换
- 循环结束时,数组即为升序排列。总时间 O(n log n)。
4.2 堆排序的性质
| 性质 | 说明 |
|---|---|
| 时间复杂度 | 建堆 O(n) + 循环 n-1 次 O(log n) = O(n log n),最坏、最好、平均一致 |
| 空间复杂度 | O(1),仅使用常数级额外空间 |
| 稳定性 | 不稳定,交换过程中可能打乱相等元素的相对顺序 |
| 缓存行为 | 较差的缓存局部性,因为 siftDown 在数组中按 2i 跳跃访问,远不如归并或快排的顺序访问 |
4.3 与快速排序对比
| 维度 | 快速排序 | 堆排序 |
|---|---|---|
| 平均时间 | O(n log n),常数因子小 | O(n log n),常数因子较大 |
| 最坏时间 | O(n²)(可优化为 O(n log n)) | 严格 O(n log n) |
| 空间 | O(log n) 递归栈 | O(1) 原地 |
| 稳定性 | 不稳定 | 不稳定 |
| 缓存局部性 | 良好(顺序分区) | 较差(指数跳跃) |
在实际工程中,快速排序的常数因子和缓存优势使得它通常更快,但堆排序在最坏情况下没有退化风险,同时 O(1) 附加空间的特性在嵌入式等严格内存受限场景中具有不可替代性。
4.4 堆排序流程图
flowchart TD
A["原始数组"] --> B["buildHeap: 构建大顶堆 O(n)"]
B --> C["i = n-1"]
C --> D{"i > 0?"}
D -- 是 --> E["交换 array[0] 与 array[i]"]
E --> F["堆大小减 1,siftDown 下沉新根 O(log n)"]
F --> G["i = i - 1"]
G --> D
D -- 否 --> H["数组升序完成"]
分层说明:
- 主旨:展示堆排序的完整控制流。
- 过程分解:建堆阶段将数组转化为大顶堆;循环阶段每次将堆顶(当前最大值)交换到已排序区域末尾,并缩小堆重新下沉。
- 关键结论:堆排序利用大顶堆的堆顶始终为最大值这一特性,通过反复抽取最大值完成排序,整个过程严格保证最坏 O(n log n) 且无需额外数组。
模块 5:堆的变体与高级应用
5.1 堆的变体家族
派生关系:完全二叉树 →(增加偏序约束)→ 堆 →(限制访问接口)→ 优先级队列
| 变体 | 特点 | 适用场景 |
|---|---|---|
| 二叉堆 | 每个节点最多 2 个子节点,实现最简单 | 通用优先级队列(Java/Python/C++ 默认) |
| d-ary 堆 | 每个节点 d 个子节点,降低树高,但增加 siftDown 比较次数 | 外部排序、Dijkstra(decrease-key 更优) |
| 二项堆 | 由一组二项树组成,支持 O(log n) 合并 | 需要高效合并优先队列的场景 |
| 斐波那契堆 | 惰性合并策略,decrease-key 均摊 O(1) | 图算法(Prim 最小生成树、Dijkstra)理论性能最优 |
| 配对堆 | 简化版斐波那契堆,实用性能好 | 某些函数式语言的标准优先队列 |
d-ary 堆的工程案例:在 Dijkstra 算法中,二叉堆的 decrease-key 需要 O(log n),而适当增大 d(如 4-ary)可以减少树高,从而减少 decrease-key 时路径长度,虽然 siftDown 比较次数增加,但整体常数因子可能更优。
5.2 双堆求流式中位数
数据结构设计:
maxHeap(大顶堆):存储数据流中较小的一半,堆顶为该半的最大值;minHeap(小顶堆):存储较大的一半,堆顶为该半的最小值。
约束:始终保持 0 ≤ maxHeap.size() - minHeap.size() ≤ 1。
算法:
- 插入元素
x:若x ≤ maxHeap.peek(),插入 maxHeap,否则插入 minHeap; - 平衡调整:若任一堆超限,将该堆堆顶弹出并插入另一堆;
- 取中位数:若
maxHeap.size() > minHeap.size(),中位数为maxHeap.peek();否则为两堆顶平均值。
复杂度:插入 O(log n),取中位数 O(1)。
5.3 Top-K 问题解题模板
场景:找出数组(或数据流)中最大的 K 个元素。
解:维护一个容量为 K 的小顶堆。遍历所有元素,若堆未满则直接插入;若已满且当前元素大于堆顶,则替换堆顶并 siftDown。最终堆中 K 个元素即为 Top-K 大。
复杂度:每元素 O(log K),总 O(n log K);当 K << n 时优势巨大。
代码模板(见模块 6 代码区)。
5.4 双堆中位数类图
classDiagram
class MedianFinder {
+void addNum(int num)
+double findMedian()
-PriorityQueue~Integer~ maxHeap
-PriorityQueue~Integer~ minHeap
}
note for MedianFinder "maxHeap 存较小半(大顶堆)\nminHeap 存较大半(小顶堆)"
MedianFinder --> maxHeap : uses
MedianFinder --> minHeap : uses
分层说明:
- 主旨:展示流式中位数数据结构
MedianFinder的组成关系。 - 核心字段:通过两个堆分工存储数据的两半,利用各自堆顶快速得出中位数。
- 关键结论:双堆设计将全局中位数转化为两个堆顶的局部极值比较,是分而治之思想的典型应用。
模块 6:工程实现与最佳实践
6.1 Java PriorityQueue 堆实现剖析
Java 的 java.util.PriorityQueue 是基于二叉小顶堆的无界优先级队列实现。其内部骨架是一个 Object[] queue 数组,核心逻辑即上述 siftUp 与 siftDown。
关键设计:
- 默认小顶堆:
PriorityQueue<Integer> pq = new PriorityQueue<>();使用元素的自然顺序(Comparable),因此poll()返回最小元素。 - 通过 Comparator 控制偏序:
new PriorityQueue<>(Comparator.reverseOrder())得到大顶堆。比较器的返回符号直接决定siftUp/siftDown中的比较方向。 - 容量管理:初始容量 11,插入时若满则扩容:旧容量 < 64 时翻倍(
oldCap*2+2),否则增长 50%。与ArrayList类似,均摊 O(1) 插入。 offer和poll分别调用siftUp和siftDown,严格 O(log n)。remove(Object)的代价:必须先通过线性遍历找到对象位置(O(n)),然后通过removeAt(index)执行一次siftDown或siftUp。这体现了堆“弱化查找,强化极值”的特性。
6.2 大顶堆的几种写法
// 方式1:Comparator.reverseOrder()
PriorityQueue<Integer> maxPQ = new PriorityQueue<>(Comparator.reverseOrder());
// 方式2:显式 Lambda(注意整数溢出)
PriorityQueue<Integer> maxPQ2 = new PriorityQueue<>((a, b) -> b - a); // 需注意溢出
// 更安全写法:
PriorityQueue<Integer> maxPQ3 = new PriorityQueue<>(Comparator.comparingInt(Integer::intValue).reversed());
// 方式3:自定义 Comparator
PriorityQueue<Integer> maxPQ4 = new PriorityQueue<>((a, b) -> Integer.compare(b, a));
内部原理:当传入 Comparator 后,siftUp/siftDown 中的比较由 Comparable 转为 Comparator.compare(),由此灵活控制堆的偏序方向。
6.3 堆的遍历与修改约束
- 迭代器无序:
for (Integer x : pq)输出顺序没有任何保证,它只是queue数组的顺序遍历,不反映优先级顺序。 - 有序输出:必须通过反复
poll()获取有序序列,这会破坏原堆。 - 不支持高效按值删除:
remove(Object)O(n),切勿在循环中频繁调用。
6.4 堆与 TreeMap 的选型边界
| 需求 | 选择 | 理由 |
|---|---|---|
| 只需要维护当前最值 | PriorityQueue | O(1) 取最值,O(log n) 插入/删除,开销最低 |
| 需要全局有序遍历或范围查询 | TreeSet / TreeMap | 红黑树保证有序,支持 O(log n) 范围查找 |
| 需要高效按值删除 | TreeSet | O(log n) 删除,PriorityQueue 需 O(n) |
| 流式 Top-K | PriorityQueue(小顶堆容量 K) | TreeSet 维护有序性成本高且容量控制复杂 |
6.5 线程安全
PriorityQueue 非线程安全。并发环境下必须使用 PriorityBlockingQueue,其内部采用 ReentrantLock 保证线程安全,并在 take() 时支持阻塞等待。PriorityBlockingQueue 同样基于二叉堆,但入队/出队操作受锁保护。
6.6 工程避坑清单
| 陷阱 | 具体表现 | 根源 | 解决方案 |
|---|---|---|---|
| 遍历 PriorityQueue | 输出顺序错乱 | 堆是偏序非全序,数组遍历不反映优先级 | 通过连续 poll() 输出,或拷贝到有序集合 |
频繁调用 remove(Object) | 性能骤降至 O(n) | 堆设计不支持快速按值定位 | 改换 TreeSet,或采用辅助 HashMap 索引 |
| 小顶堆误作大顶堆 | 取出的最小值与预期相反 | 默认自然顺序为小顶堆 | 构造时传入 Comparator.reverseOrder() |
| 忽略初始容量 | 大量扩容拷贝 | 默认容量仅 11,扩容触发数组复制 | 预估数据量,构造时指定 new PriorityQueue<>(capacity) |
| 多线程无保护写入 | 数据竞争、数组越界 | 无同步机制 | 换用 PriorityBlockingQueue 或手动同步 |
| 对已有堆数组直接排序 | 破坏堆性质 | 排序后再操作堆导致 siftDown 失败 | 堆中使用 offer/poll,不直接操作底层数组 |
6.7 可运行示例代码
(代码块将在下一节统一展示,包含基本使用、Top-K、流式中位数、heapify 等)
模块 7:面试高频专题
以下内容完全与正文隔离,仅供面试复习用。每题包含标准回答、追问模拟与加分回答。
7.1 什么是堆?堆与普通二叉树的区别?堆为什么用数组存储?
标准回答:
堆是一种满足偏序关系的完全二叉树。大顶堆每个节点不小于子节点,小顶堆每个节点不大于子节点。与普通二叉树的区别在于:堆必须是完全二叉树,且要满足堆序性质。堆用数组存储是因为完全二叉树的层序紧凑性,可以按公式 left=2i+1, right=2i+2, parent=(i-1)/2 定位父子,无指针开销,缓存友好。
追问:
- “普通二叉树可以用数组存吗?” 可以,但若树不是完全二叉树,会有大量空洞,空间浪费严重,而完全二叉树保证数组每个槽都有数据。
- “数组存储后,堆的大小如何动态扩展?” 类似 ArrayList 的扩容机制,Java PriorityQueue 初始容量 11,扩容倍增,均摊 O(1)。
加分回答:
可以进一步比较链式堆与数组堆的内存效率与缓存行为,指出数组存储使得 siftDown 的父子访问在同一个连续内存块内,虽然访问步长为指数,但比指针跳转的缓存未命中率低得多。
7.2 大顶堆和小顶堆的定义?它们分别适合哪些场景?
标准回答:
大顶堆:父节点 ≥ 子节点,堆顶是全局最大;小顶堆:父节点 ≤ 子节点,堆顶全局最小。大顶堆适合需要反复取最大值的场景,如堆排序升序、Top-K 小元素(最大堆过滤);小顶堆适合取最小值,如 Dijkstra、任务调度(最小优先级)、Top-K 大元素。
追问:
- “如果 Java 默认小顶堆,如何实现大顶堆?” 传入
Comparator.reverseOrder(),或自定义比较器(a,b) -> b - a。 - “大顶堆和小顶堆在
siftUp中的判断条件有何差异?” 只有比较符号不同:小顶堆是child < parent才上浮,大顶堆则是child > parent才上浮。
加分回答:
提到比较器设计中的稳定性考量:堆本身不保证相等元素的插入顺序,如果在比较器中处理相等时返回 0,两个堆都不保证顺序。这是面试官可能深入的方向。
7.3 插入和删除堆顶操作如何实现?siftUp 和 siftDown 过程描述。
标准回答:
插入:元素放数组末尾,siftUp 与父节点逐层比较交换,O(log n)。删除堆顶:末尾元素移到根,siftDown 与较小(小顶堆)或较大(大顶堆)子节点逐层比较交换,O(log n)。两个操作都只影响由操作点至根或叶的单一路径。
追问:
- “为什么删除堆顶要拿最后一个元素补位?” 保持完全二叉树的结构完整性,不能留下空洞。
- “如果删除的不是堆顶而是中间元素怎么处理?” PriorityQueue 的
removeAt先把末尾元素补位,然后视情况 siftDown 或 siftUp,复杂度仍为 O(log n),但查找索引需要 O(n)。
加分回答:
可以画出 siftDown 中选孩子的细节:两个子节点可能只有一个存在,需要边界判断;选择较小/较大子节点后比较,避免父与两子反复交换。
7.4 建堆 heapify 的时间复杂度?如何证明是 O(n) 而非 O(n log n)?
标准回答:
自底向上 heapify 是 O(n) 时间。证明:对总节点数 n,第 i 层有 2ⁱ 节点最多下沉 (h-i) 次,总操作数求和 ≤ 2n,所以 O(n)。直觉误区在于认为每个节点下沉 O(log n),但多数节点在底层,下沉次数极少。
追问:
- “如果自上而下逐步插入建堆,复杂度是多少?” O(n log n),因为每次插入可能会走到根。
- “在数据已经基本有序时,自底向上 heapify 会更快吗?” 仍然 O(n),但常数更小,因为很多节点可能不需下沉到底。
加分回答:
展示求和过程并作渐进紧确界分析,并可提及该证明在算法导论中使用了积分近似和相关数学技巧。
7.5 堆排序原理和过程?时间复杂度?是否稳定?
标准回答:
堆排序分两步:① heapify 建大顶堆 O(n);② 反复将堆顶与末尾交换,缩小堆并 siftDown O(log n),共 n-1 次,总 O(n log n)。原地排序,空间 O(1)。不稳定,因为交换时可能打乱等值元素原有顺序。
追问:
- “为什么堆排序通常比快速排序慢?” 堆排序缓存局部性差,siftDown 在数组中以 2i 跳跃访问,频繁跨越缓存行。
- “堆排序能否用于链表?” 数组原地性质依赖索引跳转,链表实现效率极低。
加分回答:
讨论堆排序与选择排序的关系:堆排序可以看作是对选择排序的优化,用堆将选择最小/最大的开销从 O(n) 降至 O(log n)。
7.6 如何用堆找数组前 K 大元素?复杂度?
标准回答:
使用容量为 K 的小顶堆。遍历数组,堆未满直接插入;堆满时若元素大于堆顶,替换堆顶并 siftDown。最终堆中元素即为前 K 大。时间 O(n log K),空间 O(K)。当 K << n 时非常高效。
追问:
- “如果 K 很大,接近 n,堆方案还合适吗?” 不适用,此时 O(n log K) ≈ O(n log n),不如直接排序。也可以使用快速选择算法 O(n) 平均。
- “如果是数据流不断新增,如何维护?” 这正是堆的理想场景,每次来新数据 O(log K) 更新。
加分回答:
补充当 K 极大且内存有限时的外部排序方法,引出 d-ary 堆或归并排序的使用。
7.7 如何用两个堆实现数据流中实时获取中位数?
标准回答:
维护一个大顶堆 maxHeap(存较小半)和一个小顶堆 minHeap(存较大半)。插入规则:新元素与 maxHeap 堆顶比较,决定进入哪一堆;然后平衡两堆大小,使 maxHeap 要么多 1 个元素,要么相等。中位数即 maxHeap 堆顶(奇数)或两堆顶均值(偶数)。插入 O(log n),取中位数 O(1)。
追问:
- “为什么必须平衡两堆大小?” 只有大小相差不超过 1,中位数才会出现在堆顶,保证 O(1) 获取。
- “如果数据流包含大量重复元素如何处理?” 平衡策略不变,重复元素也进入对应的堆,堆的比较器相等时返回 0,不会破坏平衡。
加分回答:
给出代码骨架,讨论 corner case(如第一个元素、两堆都为空时),以及如何扩展为百分位数计算(滑窗分位数等)。
7.8 PriorityQueue 内部实现原理?如何保证小顶堆性质?
标准回答:
内部使用 Object[] queue 存储完全二叉树,通过 siftUp 和 siftDown 维持小顶堆性质。插入时 offer 调用 siftUp 从尾部向上调整;删除时 poll 把末尾元素放根并 siftDown 下沉。比较通过 Comparator 或 Comparable 决定。
追问:
- “扩容策略是什么?” 容量 < 64 时翻倍为
2*old+2,否则增加 50%。 - “remove(Object) 具体做了什么?” 先遍历找到索引 O(n),然后类似删除堆顶但需要判断上浮还是下沉。
加分回答:
解读 JDK 源码中的优化细节:如在 siftDown 中通过 half = size >>> 1 判断是否到达叶子,减少分支;siftUp 使用循环而非递归,防止调用栈溢出。
7.9 为什么 PriorityQueue 不能直接有序遍历?如何有序输出?
标准回答:
堆只保证根到叶子的偏序,不保证水平顺序,因此迭代器输出顺序无意义。要获得有序输出,必须通过连续 poll() 逐个取出,但会破坏堆。若不想毁坏原堆,可以拷贝到一个数组并排序,或使用 TreeSet。
追问:
- “直接对 PriorityQueue 调用 Arrays.sort() 会怎样?” 不推荐,这直接修改内部数组破坏了堆的有序性,后续堆操作会出错。
- “为什么 JDK 不提供有序迭代器?” 获得有序序列的代价太高,不符合队列的定位,设计者有意限制接口。
加分回答:
讨论 API 设计哲学:暴露迭代器顺序会误导用户认为堆是有序集合。这体现“接口隔离原则”——不提供能力即是约束。
7.10 【系统设计/算法题】实时排行榜 Top 100 且支持更新分数
标准回答:
要求:支持高并发写入用户分数,动态查询 Top 100。
数据结构选型:使用小顶堆维护 Top 100,并用一个哈希表 HashMap<UserId, Score> 记录每用户最新分数。更新分数时,先通过哈希表更新分数记录,然后判断该用户是否已在堆中(需维护一个反向索引 HashMap<UserId, Index> 或使用支持快速更新的结构)。更优的方式是使用跳表(Redis ZSET 实现)或红黑树储存所有用户分数,范围查询 O(log n)。但若只需 Top-K,优先队列+哈希是最轻量的。
追问:
- “如果用户分数可能下降,怎么处理?” 若用户已在 Top-K,下降后应调整位置。纯堆无法直接调整中间元素,可以惰性处理:标记当前 Top-K 过时,或使用支持 increase/decrease-key 的堆变体如斐波那契堆。
- “如果数据量极大且实时性要求高,你会选堆还是 Redis ZSET?” Redis ZSET 基于跳表+哈希,支持 O(log n) 更新排名,且原子操作,适合高并发分布式排行榜。
加分回答:
讨论 CAP 与最终一致性:排行榜可接受最终一致时,可用消息队列对更新异步批量处理;若必须强一致,推荐 Redis ZSET。也可以涉及使用定时器周期重建堆的折中方案。
延伸阅读
- 《算法导论》(CLRS)第 6 章 —— 堆排序
经典的堆阐述,含 heapify O(n) 的严格数学证明、堆排序及优先级队列论述。 - 《数据结构与算法分析(Java 语言描述)》第 6 章 —— 堆
以工程视角讲解二叉堆、d-ary 堆、二项堆,并附有 Java 实现细节。 - JDK 8 源码 PriorityQueue.java
直接阅读siftUp、siftDown、grow等方法的实现,理解生产级堆的优化技巧与容量管理。 - 论文:Fibonacci Heaps and Their Uses in Improved Network Optimization Algorithms
斐波那契堆的原始论文,理解 decrease-key 均摊 O(1) 的设计及其在图算法中的理论优势。 - LeetCode 堆专项专题
精练 Top-K、数据流中位数、合并 K 个有序链表等经典题目,强化堆的实战应用。
全文到此结束。我们从完全二叉树的数组骨架开始,逐层拨开堆的秘密,最终将理论映射到 Java PriorityQueue 这一工程杰作。堆正是数据结构设计中“弱化全序以换取极致动态极值性能”的典范,理解它,你会发现调度、排序、流式计算中无处不堆。