排序与堆¶
一句话:需要"全部有序"就用排序;只需要"前 K 个"或"动态维护当前最值",用堆——堆的本质是一棵只保证"父节点比子节点更优"、不要求兄弟节点之间有序的弱化二叉树,正是这个弱化换来了 O(log n) 的插入/取出,比维护整体有序便宜得多。
排序:先想清楚要不要真的排全序¶
912-排序数组 是各种排序算法的练习场(快排的分区、归并的合并、堆排序),但更重要的教训在别处:56-合并区间、252/253-会议室 都不是"排序题",而是先按某一维排序,把问题转化为一次线性扫描——56 按区间左端点排序后,重叠的区间必然相邻,一次遍历即可合并;253 按开始时间排序后,用一个小顶堆维护"当前正在使用、最早结束"的会议室,新会议开始时间 ≥ 堆顶结束时间就复用,否则新开一间。排序在这里只是预处理,真正的解法是排序之后的那一次扫描或者堆的运用。
75-颜色分类 是"不需要真正排序"的反例:只有三种取值,用双指针一次遍历原地分区即可,O(n) 而不是通用排序的 O(n log n)——取值范围有限时,专用的线性算法总是优于通用排序,这也是计数排序/桶排序的适用场景。
堆:Top-K 问题不需要排序¶
215-数组中的第K个最大元素、347-前K个高频元素、703-数据流中的第K大 的共性是只关心第 K 名或前 K 名,不关心其余元素的相对顺序——这正是堆比排序便宜的地方:维护一个大小为 K 的小顶堆,堆顶就是"当前已扫描元素里的第 K 大",新元素只有比堆顶大才有资格入堆(顶替堆顶),复杂度是 O(n log K) 而不是排序的 O(n log n),当 K 远小于 n 时优势明显。
295-数据流的中位数 是堆的进阶应用:一个大顶堆存较小的一半,一个小顶堆存较大的一半,两堆大小差不超过 1,中位数就是堆顶(或两堆顶的平均)——这是"用两个堆把中间那个位置固定在堆顶"的技巧,本质仍是"不需要整体有序,只需要能 O(log n) 维护某个特定位置的值"。
23-合并K个排序链表 用小顶堆维护"K个链表当前各自最小的头节点",每次弹出全局最小值、放入其所在链表的下一个节点——把"K路归并"从"每次线性扫描K个候选找最小"(O(K))降到了"堆弹出/插入"(O(log K))。
易错点:堆维护的是"候选集合"不是"最终答案"¶
用堆做 Top-K 时最容易搞反的方向:要找最大的 K 个,反而要维护一个小顶堆(堆顶是这 K 个候选里最小的一个,新元素只有比它大才有资格顶替它)——直觉上想用大顶堆,但大顶堆的堆顶是"已扫描元素里最大的一个",无法用来判断"要不要淘汰某个候选"。先想清楚"堆顶该扮演什么角色(候选集合里最弱的那个,用来判断谁该被淘汰)",再决定大顶堆还是小顶堆。
手写堆:完全二叉树用数组装¶
为什么是数组,不是节点¶
堆是一棵完全二叉树(除最后一层外每层都满,最后一层节点靠左连续)。这个"完全"性质意味着层序编号 0..n-1 连续无空洞,所以父子关系可以直接算,不需要存指针:
数组是树在内存里的紧凑投影。换成链式节点(left/right 引用)代价明显:
| 数组 | 链式节点 | |
|---|---|---|
| 每元素开销 | 4 字节(int) | 对象头 16B + 数据 + 2 个引用 ≈ 40B |
| 父子定位 | (i-1)/2、2i+1 算出来 |
逐层跳指针 |
| CPU 缓存 | 连续内存,一个 cache line 覆盖多个节点 | 节点分散,频繁 cache miss |
| 适用 | 完全二叉树(无空洞) | 任意形状二叉树 |
反例:非完全二叉树用数组会大量留空——深度 10 的链状树只有 10 个节点,数组却要 2^10 = 1024 槽。"完全"这两个字就是为数组索引公式而存在的。
push / pop 与 siftUp / siftDown¶
堆只保证父 ≤ 子(小顶堆),兄弟之间无序——所以数组内容看起来是乱的,但根永远最小。
| 操作 | 动作 | 为什么 |
|---|---|---|
push |
放数组末尾,再 siftUp 往上爬 |
末尾是"下一层最左边"的位置,保持完全二叉树形状 |
pop |
取走 heap[0],把末尾元素提到堆顶,再 siftDown 往下沉 |
直接删 heap[0] 会在数组开头留空洞破坏形状;末尾顶替后形状始终完整 |
siftUp(上浮):和一个父比较,比父小就交换,往上爬;父 ≤ 自己或到根就停。
siftDown(下沉):和两个孩子比较,比孩子大就交换——必须和「较小的那个孩子」交换。
为什么必须选较小的孩子:交换的目的是把更小的值顶上去当父。若和较大孩子(8)交换,留下的较小孩子(5)就会比新父(8)小,堆性质立刻崩掉。
poll [2,5,8,9,6]:
取 2 → 末尾 6 提到顶 [6,5,8,9]
→ 孩子 5、8,较小是 5,6 > 5 → 换 → [5,6,8,9]
→ i=1 孩子 9,6 ≤ 9 → 停
| siftUp | siftDown | |
|---|---|---|
| 用在 | push |
pop |
| 方向 | 往上爬 | 往下沉 |
| 和谁比 | 1 个父节点 | 2 个孩子中较小的 |
| 交换条件 | 比父小 | 比孩子大 |
| 停止 | 父 ≤ 自己 / 到根 | 自己 ≤ 两个孩子 / 无孩子 |
| 复杂度 | O(log heapSize) = 树高 | 同左 |
口诀:push 从底往上顶(新来的可能太小),pop 从顶往下沉(顶替者可能太大)。
两个版本:最小版 / 完整版¶
先明确"完整"指什么——手写堆不必一步到位,两种版本各自够用:
| 最小版 | 完整版 | |
|---|---|---|
| 构造 | 容量 MinHeap(capacity) |
容量 + 数组 heapify |
siftUp/siftDown |
无参(push 固定末尾、pop 固定根) | 带参(heapify/update/remove 需要任意起点) |
| 扩容 | ❌ 容量给足,否则越界 | ✅ ensureCapacity 翻倍 |
| 改值修复 | ❌ | ✅ update(i, v) |
| 删任意位置 | ❌ | ✅ removeAt(i) |
| 适用 | 刷题手写 Top-K(347) | 面试"手写优先队列"、需要动态增删的场景 |
最小版(347 够用)¶
/** 只服务 Top-K:容量由调用方给足,不扩容。 */
class MinHeapLite {
private final int[] heap;
private int size;
MinHeapLite(int capacity) { heap = new int[capacity]; }
int size() { return size; }
boolean isEmpty() { return size == 0; }
int peek() { return heap[0]; }
void push(int v) {
heap[size] = v;
siftUp(); // 无参:新元素总在 size
size++;
}
int pop() {
int top = heap[0];
size--;
heap[0] = heap[size];
siftDown(); // 无参:总从 0 开始
return top;
}
private void siftUp() {
int i = size;
while (i > 0) {
int p = (i - 1) / 2;
if (heap[p] <= heap[i]) break;
swap(p, i);
i = p;
}
}
private void siftDown() {
int i = 0;
while (true) {
int l = 2 * i + 1, r = l + 1, min = i;
if (l < size && heap[l] < heap[min]) min = l;
if (r < size && heap[r] < heap[min]) min = r;
if (min == i) break;
swap(i, min);
i = min;
}
}
private void swap(int i, int j) { int t = heap[i]; heap[i] = heap[j]; heap[j] = t; }
}
唯一约束:capacity 必须给够。347 里传 k + 1(push 后可能暂时有 k+1 个,再 pop 淘汰一个),就刚好不越界。
完整版(对标 PriorityQueue)¶
class MinHeapFull {
private int[] heap; // 非 final:扩容要换引用
private int size;
/** ① 空堆:逐次 push */
MinHeapFull(int capacity) { heap = new int[Math.max(1, capacity)]; }
/** ② heapify 建堆 O(n):从最后一个非叶子节点往前逐个下沉 */
MinHeapFull(int[] data) {
heap = data.clone();
size = data.length; // 必须先设 size
for (int i = size / 2 - 1; i >= 0; i--) siftDown(i);
}
int size() { return size; }
boolean isEmpty() { return size == 0; }
int peek() { return heap[0]; }
void push(int v) {
ensureCapacity();
heap[size] = v;
siftUp(size);
size++;
}
int pop() {
int top = heap[0];
size--;
heap[0] = heap[size];
siftDown(0);
return top;
}
/** 改下标 i 的值并恢复堆性质 */
void update(int i, int newValue) {
int old = heap[i];
heap[i] = newValue;
if (newValue < old) siftUp(i); // 变小 → 只需上浮
else if (newValue > old) siftDown(i); // 变大 → 只需下沉
}
/** 删除下标 i 的元素并返回它 */
int removeAt(int i) {
int removed = heap[i];
size--;
if (i == size) return removed; // 删的就是末尾,无需修复
heap[i] = heap[size]; // 末尾顶替
if (i > 0 && heap[i] < heap[(i - 1) / 2]) siftUp(i); // 比父小 → 上浮
else siftDown(i); // 否则下沉
return removed;
}
private void ensureCapacity() {
if (size == heap.length) heap = Arrays.copyOf(heap, heap.length * 2);
}
private void siftUp(int i) {
while (i > 0) {
int p = (i - 1) / 2;
if (heap[p] <= heap[i]) break;
swap(p, i);
i = p;
}
}
private void siftDown(int i) {
while (true) {
int l = 2 * i + 1, r = l + 1, min = i;
if (l < size && heap[l] < heap[min]) min = l;
if (r < size && heap[r] < heap[min]) min = r;
if (min == i) break;
swap(i, min);
i = min;
}
}
private void swap(int i, int j) { int t = heap[i]; heap[i] = heap[j]; heap[j] = t; }
}
完整版的四个关键点:
1. heap 不能是 final —— 扩容要换引用
2. heapify 里 size = data.length 必须在循环前,因为 siftDown 靠 size 判断孩子是否存在
3. heapify 起点 size/2 - 1 是最后一个非叶子节点;更大的下标都是叶子,天然是合法堆。整体 O(n)(不是 O(n log n))
4. removeAt 只修复一个方向:末尾顶替后,若比父小就上浮,否则下沉——两种情况互斥
最大堆只需把比较符号反过来:heap[p] >= heap[i]、heap[l] > heap[max]、heap[r] > heap[max]。
Java 实战默认用 PriorityQueue(内部就是这套二叉堆 + 数组 + 扩容):默认小顶堆,offer/poll/peek 对应 push/pop/peek。只有面试官明说"不许用 PriorityQueue"才手写;手写时至少要补上扩容,容量写死是真实缺陷。
变体:二元组压进一个 int(纯 int 堆 + 编码)¶
347 前 K 个高频元素 要按频率排序,但堆里想存 (值, 频率) 二元组——除了二维数组 / 传 map 进堆 / 比较器,还可以把二元组编码成一个 int,让标准 int 堆直接工作:
// 编码:频率放高位,值放低位(本题 值 ∈ [-1000,1000],+1000 消负数)
int code = 频率 * 10000 + (值 + 1000);
heap.push(code);
// 解码
int 值 = code % 10000 - 1000;
比较 code 就等于"先比频率、再比值",正是需要的顺序。前提是值域有限:要求 最大频率 × 值域宽度 < 2^31(本题 10^5 × 10^4 = 10^9,安全)。值域是 int 全域时必须改用 long 编码或比较器方案。
堆和 map 的职责分离:HashMap 只负责计数,堆只负责选择;堆是"排序/选择器",不是"数据仓库"——数据留在 map 里,需要权重时按身份去查。(用 PriorityQueue<Integer> + (a,b) -> freq.get(a) - freq.get(b) 就是这个形态。)
易错点¶
swap三步写反会变成"复制":heap[j] = heap[i]会把heap[j]的原值覆盖掉,结果两个位置都等于原heap[i]。症状:结果里出现重复值(如[1,1,1,2,2,3] k=2输出[1,1])。正确:int t = heap[i]; heap[i] = heap[j]; heap[j] = t;- 求前 K「大」用小顶堆(堆顶是这 K 个里最小的,用来判断谁该被淘汰);求前 K「小」用大顶堆。方向反了逻辑就崩
siftDown必须和较小的孩子交换,否则堆性质被破坏pop用末尾元素顶替堆顶,不要直接删heap[0]——形状会破- 出堆顺序是小顶堆的升序(即堆排序),题目要求逆序时注意是否需要额外翻转
- 容量写死是真实缺陷:
MinHeap(capacity)不扩容时,push到第capacity+1个就ArrayIndexOutOfBounds。347 侥幸没炸是因为传了k+1且元素数不会更多;通用实现必须ensureCapacity翻倍扩容(此时heap字段不能是final) - heapify 里
size必须先赋值再进siftDown循环,否则siftDown判断"孩子是否存在"时用的是旧的size=0 removeAt(i)删除末尾时直接返回,不要走"末尾顶替 + 修复",否则会把同一个元素又放回去