☰
堆的双面人生:从数据结构到内存管理
2026/10/3 17:56:44 网站建设 项目流程

提到“堆”,很多开发者的第一反应是同一个词劈成两个概念:数据结构课本里那棵完全二叉树,和程序运行时到处 new 对象的内存区域。这两个概念都叫 heap,含义却完全不同。面试时被问“用数组实现一个堆”,考察的是数据结构层面的存储设计;而服务器上看到 java.lang.OutOfMemoryError: Java heap space,调 -Xms/-Xmx,又是内存管理层面的事。这篇文章我把这两个维度放在一起讲透。第一,堆作为数据结构,为什么天生适合用数组存储,下标关系怎么推导;第二,堆在工程运行时到底占哪块内存,编译器报堆空间不足、进程堆调到 8000 还是炸,应该从哪下手排查。你把这两条线串起来之后,“堆的基本存储”就不再是个模糊的问题了。

1. 堆:逻辑上是树,存储上是数组

1.1 完全二叉树的“紧凑布局”决定了存储方式

先明确一个基础认知:堆本质上是一棵完全二叉树。所谓完全二叉树,指的是除最后一层外每一层都填满,最后一层的节点全部从左向右排列,不允许中间空位。打个比方,就像往墙角堆放正方体小木块,规则是必须先把上一层放满,再往下一层的左边依次堆,堆到一半就停,这就是完全二叉树的形状;如果你在第二层中间留个坑,把木块放到第三层,那就成了普通二叉树,完全二叉树那种“可以按序号连续编号”的性质就没了。

这个“完整且靠左”的约束看着不起眼,实际上等于给存储方案画好了边界:树的结构足够规则,可以使用数组按层序遍历编号存放,不需要额外保存左右孩子指针。指针不是不可以,但既然父子关系能够靠下标唯一推导出来,再额外存指针就是在浪费内存。对需要处理百万级节点、甚至上 GB 数据的堆来说,这种浪费相当可观。底层数据结构的设计通常就是在空间和可推导性之间做取舍,堆选择数组,本质原因就是“结构太规整,不需要指针”。

1.2 数组映射背后的唯一性

很多人第一次接触堆的数组实现时,最不适应的就是“树节点去哪了?指针呢?”这里有个关键概念:一棵完全二叉树可以被层序遍历编号,每个节点对应一个确定的下标;反过来,任何一个合法的下标也一定能映射回树中唯一的一个节点。这种一一对应关系,就是数组存储能够成立的基础。

我打个比方,就像电影院的座位从门口开始按排编号。票上写“7 号座”,你不需要看地图,走过去数到 7 就能坐下。下标就是座位号,数组就是那一排排座位。你问“7 号座的父母坐哪”,只需要一个固定公式,不需要问工作人员。堆的父子关系也是用公式推导的,后面我会把推导过程完整写出来。

也必须承认,这种映射能成立是有前提的。除了完全二叉树约束外,还要规定同一层节点从左到右顺序严格固定,不能调换。一旦调换,编号体系就乱了,数组存储也随之失效。堆在逻辑上是“半有序”的:只保证父子之间有顺序约束,不保证兄弟之间有序。这个“半有序”恰恰是它能在 O(log n) 时间内完成插入和删除的关键。

2. 数组存储的核心细节:下标推导公式全解析

2.1 从 0 开始的下标约定

这是 C、C++、Java、Python 等主流语言最常用的约定。假设数组从下标 0 存放堆顶元素,那么对任意下标 i 的节点,一共需要三个公式:

  • 左孩子:left = 2 * i + 1
  • 右孩子:right = 2 * i + 2
  • 父节点:parent = (i - 1) / 2

代入验证一下。堆顶下标 0,左孩子是 1,右孩子是 2;下标 1 的父节点是 (1-1)/2=0,下标 2 的父节点也是 (2-1)/2=0。下标为 3 的节点,父节点是 (3-1)/2=1;下标为 4 的节点,父节点是 (4-1)/2=1。这样每一条父子连线都有唯一的公式支撑。

我在笔试里经常看到有人把公式记错,尤其右孩子写成 2*i+1。最不容易错的记忆方法,是把两个孩子看作“从 i 往后延伸的两个相邻位置”:左孩子是 i 后面的第一个,右孩子紧挨着它,所以是 2i+1 和 2i+2。这不是死记硬背,而是完全二叉树的层序编号规律决定的。与之对立的另一种常见约定是从 1 开始编号,下面单独讲。

2.2 从 1 开始的下标约定

有些教材和竞赛题实现堆时,会故意把数组下标 0 空出来,让真正的堆元素从下标 1 开始。这时三个公式变成:

  • 左孩子:left = 2 * i
  • 右孩子:right = 2 * i + 1
  • 父节点:parent = i / 2

直观程度完全不同。堆顶下标 1,左孩子 2,右孩子 3;下标 2 的父节点是 2/2=1,下标 3 的父节点是 3/2=1。整数除法直接把父节点算出来,不用带任何减号。这就是为什么 C++ 的 priority_queue 底层默认大根堆、很多线段树风格的模板也喜欢用 1 下标数组。先申请一个长度为 n+1 的数组,index 0 空着或放哨兵,后面所有操作都清爽。

从 1 开始还有一个额外好处:left=2i 写成位运算就是 i << 1,right=2i+1 可以写成 (i << 1) | 1,parent=i/2 可以写成 i >> 1。在追求极致性能的代码里,这组位运算很常见,而且不容易写错。对于刷题选手来说,手写堆用 1 下标版本,边界判断会少一些。

2.3 为什么两种约定并存

两种约定并存不是谁对谁错,而是习惯和取舍的混合产物。从 0 出发是大部分语言的天然数组语义,直接遍历、扩容时不用特殊处理首元素;从 1 出发则公式更干净,手写堆时心智负担更小。

我自己写代码有个习惯:手写轮子时默认用 1 下标版本,工程里直接用现成容器则用 0 下标封一层辅助函数。没有任何强制要求,但核心原则只有一个:选定一种约定后,全篇保持一致,不要在同一个文件里一半用 0、一半用 1。曾有同事混合用两种下标推导,写出来的堆在特定数据规模下表现诡异,定位了半天才发现是父节点找到了错误的位置。下标约定这种基础形式一旦混乱,排查成本极高。

3. 配合数组存储的堆操作:上滤与下滤的完整过程

3.1 插入元素:尾部追加 + 向上调整

堆元素存进数组后,插入操作可以概括为“先放到最后,再往上爬”。具体过程是:把新元素追加到数组末尾,此时它暂时处于完全二叉树的最右位置,很可能违反堆序;然后比较新元素和它的父节点,如果新元素更大(以大根堆为例,下同)就交换,接着继续向上和新的父节点比较,直到它不大于父节点,或者走到根节点为止。

向上调整因为方向是从下往上,通常叫上滤。每次交换只涉及父子两代,每一步比较都能确定新元素往上走一层,最坏情况是从叶子走到根,层数是 log n,所以插入时间复杂度是 O(log n)。这个过程不需要申请新节点,也不需要整体搬移数组元素,就地把最后一位当作新叶子。这也是数组存储的第二个好处:追加元素和交换元素都是 O(1) 操作,循环比较才是唯一开销。

实际编码时,我建议把“比较大小”抽象成函数,不要在主循环里到处写大于号、小于号。你永远不知道三个月后的自己会不会需要把大根堆改成小根堆,一个 comparator 能解决的事,别拆成十处维护。同理,交换元素也可以抽成一个私有方法,避免下滤循环里反复写三行 swap,代码可读性会高很多。

3.2 删除堆顶:尾部补位 + 向下调整

堆最常用的操作是取最值,删除堆顶是核心场景。标准流程是:先用数组最后一个元素覆盖根节点,再让数组逻辑长度减一;然后从根节点开始向下调整,比较当前节点和左右孩子,选出“更大的孩子”(大根堆),如果孩子更大就交换,交换后在新位置继续向下比较。

向下调整也叫下滤,和插入的上滤形成对称:插入是往上爬,删除是往下沉。时间复杂度同样是 O(log n),因为每次只下沉一层,路径受树高限制。

这里有个细节容易踩坑:根节点被最后一个元素覆盖后,旧堆顶在数组中已经不存在了,但物理数组尾部还留着最后的旧值。如果之后遍历数组不区分 size 和 capacity,可能把“已删除元素”当有效数据带出来。用动态数组实现时,循环边界和实际数据量一定要以 size 为准,而不是以数组长度为准。

3.3 原地建堆:从最后一个非叶子节点开始

给定一组无序数组,要在 O(n) 时间内原地把它调整成堆,标准的做法是自底向上的逐节点下滤。先要找到最后一个非叶子节点:对 0 下标数组,它是 n/2 - 1;对 1 下标数组,它是 n/2。从这个节点开始,递减到根节点,对每个节点执行一次向下调整。

为什么起点是中间而不是末尾?因为从 n/2 往后的节点全是叶子节点。单独的叶子天然满足堆序,不需要调整。从最后一个非叶子节点开始,能够保证每次处理节点时,它的左右子树已经是合法的堆,下滤一次就能让整棵子树满足堆序。这个顺序是自底向下的,和递归里“先处理子树再处理本身”的思路一致。

很多人觉得原地建堆是 O(n log n),因为它看起来对 n 个节点各做了 O(log n) 的下滤。实际算下来是 O(n),原因也不复杂:越靠近树底的节点数量越多,但它们能下沉的深度越浅;越靠近根部的节点数量少,却拥有更深的下降路径,但数量又很少。把每一层的工作量按等比数列求和,结果收敛到线性。这是堆里最经典的复杂度结论之一,面试也常考。

3.4 复杂度分析:为什么堆操作都围绕 O(log n)

可以牢记一个思维模型:无论上滤还是下滤,一次循环处理一层。完全二叉树的高度是 O(log n),所以插入、删除堆顶、调整单个节点都是 O(log n)。而建堆因为每个节点最多被处理一次,工作量沿深度分层求和,才是 O(n)。

数组存储没有改变这些复杂度,但让复杂度保持得很干净,这得益于数组的 O(1) 随机访问。链式二叉树要做同样的上滤,得从叶子往父节点跳,父指针本身就是额外内存储开销;更麻烦的是缓存不友好,访问一个节点后,它的孩子大概率不在相邻地址,每次都要打一次内存。数组实现里父子节点间距分别是 1、2、3 这样的小数字,完全可以把一个连续区域的访问命中在 CPU 缓存里。数据量一大,这个差距会放大得非常明显,这也是我倾向于在工程里用数组堆而不是链式堆的核心原因。

4. 工程中的“堆存储”:内存堆、编译器报错与栈溢出

4.1 运行时数据区里的堆长什么样

工程里的“堆”和数据结构里的“堆”差着一层。程序运行时的堆属于内存管理范畴。拿 Java 虚拟机举例,运行时数据区里有一块被所有线程共享的堆区,new 出来的对象、数组绝大多数都在这里分配,由垃圾收集器自动回收。启动参数 -Xms 指定初始大小,-Xmx 指定最大大小。当堆空间耗尽且 GC 无法回收出足够空间时,就会抛出 java.lang.OutOfMemoryError: Java heap space。

很多初学者在这里被绕晕:代码里写了一个 PriorityQueue,底层用数组实现,这个数组在 JVM 里也是对象,也分配在内存堆区。所以“数据结构堆”和“内存管理堆”其实是两层概念。外层是 JVM 给所有动态对象分配的共享区域,内层是我们的堆结构自己管理的元素数组。结构堆决定数据用什么形式组织,内存堆决定数据放在哪块空间,两者各司其职,别混为一谈。

4.2 编译/构建时“堆空间不足”的排查思路

提到 OOM 报错,很多人第一反应是运行时服务器内存不够。但编译阶段也会看到类似错误。比如在 IDEA 里跑一个大型项目,控制台输出 java.lang.OutOfMemoryError: GC overhead limit exceeded,这时通常不是业务代码的问题,而是编译器进程自身的堆空间不够。

IDEA 的编译器进程堆大小默认并不大,几百 MB 是很常见的。遇到大型 Android 项目、Kotlin 项目或多模块 Maven 项目时确实不够用。常规解法是在 Settings 里找到 Compiler,把 Shared build process heap size 调大,比如 1500MB。命令行方式则是在启动脚本里设置 MAVEN_OPTS 或 JAVA_OPTS,给 Maven 使用的 JVM 扩堆。Kotlin 编译慢或内存不足时,还要单独调 kotlin.daemon.jvmargs 参数。

有一种反直觉的情况:你把进程堆大小调整到 8000MB,甚至更多,还是不断报 OOM。这往往说明问题不在堆的总量上。最常见的原因是内存泄漏,对象被某些容器长期引用无法回收,堆被一点一点填满;其次是某个超大对象或多个大量重复对象把空间瞬间撑爆;还有一种可能是 MetaSpace 存不下加载进来的类定义。调整 -Xmx 是治标,找到根因才是治本。我之前处理过一个案例,问题出在缓存 key 无限增长,把整块堆消耗殆尽,单纯加大堆上限只是晚一点崩。

4.3 堆外内存:堆里存不下还能存哪

JVM 里还有一块内存不归堆管理,叫堆外内存。最典型的是 NIO 的 DirectByteBuffer,通过 ByteBuffer.allocateDirect 申请的内存由操作系统直接分配,不经过垃圾回收器管理。它的优势是能绕开 JVM 堆与内核之间的一次拷贝,在 IO 密集场景明显提升吞吐;代价是回收时机不受 GC 控制,用完后必须显式释放,否则会持续占用操作系统内存。

-XX:MaxDirectMemorySize 参数限制堆外内存总量,默认情况下等于 -Xmx 的大小。很多 Netty 应用出现“总内存看着涨、堆却一直正常”的谜案,最后基本都落在 Direct Memory 没有及时释放上。排查时可以借助 native memory tracking,命令是 jcmd VM.native_memory summary,它会按 Java Heap、Class、Thread、GC、Compiler、Native 等分类统计内存占用。先看哪一块异常增长,再对症下药,比自己瞎猜高效得多。

4.4 别把栈溢出和堆溢出搞混

堆溢出讲完,栈溢出是另一个高频报错。StackOverflowError 通常来自方法调用太深,最常见的是递归没有终止条件,或者递归深度超过默认栈容量。栈是每线程私有的,存放局部变量、方法调用帧和返回地址。Java 线程栈默认大小约 1MB,用 -Xss 可以调大,但调大线程栈会加重内存压力,毕竟线程数乘以栈大小就是不小的开销。

有人在 Windows 上遇到栈溢出,第一反应是去系统里找“扩大栈空间”的办法。如果问题是递归太深,单纯把栈调大只是把崩溃时间往后移,根本解决办法是改成迭代或限制递归深度。一句话区分:堆溢出是“对象太多,空间放不下”,栈溢出是“调用太深,调用帧被顶穿”。排查方向完全不同,千万别一看到 StackOverflow 就去调 JVM 堆参数,那一顿操作对栈问题完全无效。

5. 实战避坑:堆存储使用中的高频问题

5.1 数据结构堆实现里的几个致命细节

先列几个我自己踩过、也看过别人踩的坑:

  • 下滤时数组越界:只判断左孩子是否越界,忽略右孩子边界,导致访问到已释放或空位置。标准做法是在 left < size 的前提下,把 right 的比较限制为 right < size,或者把 left 不存在当作循环终止条件。
  • 比较器方向写反:Java 的 PriorityQueue 默认是最小堆,要实现最大堆需要传入反序比较器。刷题时在“前 K 个最大”和“前 K 个最小”之间来回套,最后取出的集合经常反了。
  • 扩容带来的内存开销:动态数组扩容通常是两倍扩容,堆在插大量元素时会频繁复制。如果事先能大致估算数据规模,直接预分配容量,省掉中间多次 resize。

这类问题在报错时往往不显眼,表现通常是排序结果偶尔错、越界偶发崩溃,非常难查。我的经验是:写完堆结构,先用一批随机数据做一次“先全部插入再依次弹出堆顶”的单调性验证。如果输出不是严格有序,说明实现里有方向性或边界问题,这种验证能立刻暴露大部分错误。

5.2 TopK 与“在一堆数据里凑出一个数”

很多算法题表面是“在一堆数据里找目标”,本质都能用堆做剪枝或加速。最典型的是 TopK:从海量数据里找最大的 K 个数,用一个小根堆维护当前最大的 K 个数。堆顶是这 K 个里的最小值,新数据比堆顶大就把堆顶替换掉,再做一次下滤。整个过程扫描一遍数据,时间复杂度 O(N log K),内存只需要 K 个元素的空间,比全排序的 O(N log N) 省很多。

“在一堆数据里凑出一个数”这种描述,如果数据是静态的,可以配合双指针或前缀和;如果数据会动态插入删除,又要求随时拿到当前最大值、最小值、中位数,那堆基本就是标准答案。竞赛里常出现的题,比如“在墙角堆放着一堆完全相同的正方体小木块”,如果限制内存只有 16MB、时间 1000ms,基于数组的堆就特别占优势:不需要存指针,不需要维护节点对象,所有数据在连续数组里,内存开销比平衡树小一半以上。这也是为什么很多竞赛选手明明会用 TreeMap,还是坚持手写堆。

5.3 内存参数调整的正确姿势

JVM 内存参数不是越大越好。我把常见报错整理成对应关系,方便排查时对照:

现象常见原因优先处理方向
Java heap space对象堆积过多、GC 后仍无法分配排查泄漏、确认大对象,再调 Xmx
GC overhead limit exceededGC 频繁且回收率极低排查引用链、考虑调整老年代占比
Metaspace 报错加载类过多或动态生成类调 -XX:MaxMetaspaceSize,重点排查动态类
StackOverflowError递归过深或调用栈过大优先改迭代,必要时才调 Xss
系统内存持续上涨堆正常但堆外内存增长用 NMT 定位 Direct Memory、Native 区域

调试建议分两层看。第一层看堆内,用 jstat -gc 观察 GC 次数和堆占用趋势;第二层看整个进程,用 jcmd 看 Native Memory Tracking。堆内堆外都正常但系统还是飘,再往操作系统一级排查,比如文件句柄、线程数、共享内存。把每个层面的数据量化出来,而不是凭感觉猜,是解决内存类问题最基本的态度。

6. 参考实现:一份可以直接抄作业的代码

6.1 Java 版最小堆实现

以 0 下标为例,这是我在面试里常用的 Java 版最小堆骨架:

class MinHeap { private int[] heap; private int size; public MinHeap(int capacity) { heap = new int[capacity]; size = 0; } private int left(int i) { return 2 * i + 1; } private int right(int i) { return 2 * i + 2; } private int parent(int i) { return (i - 1) / 2; } public void push(int val) { if (size == heap.length) grow(); heap[size] = val; int i = size++; while (i > 0 && heap[i] < heap[parent(i)]) { swap(i, parent(i)); i = parent(i); } } public int pop() { int top = heap[0]; heap[0] = heap[--size]; int i = 0; while (true) { int smallest = i; if (left(i) < size && heap[left(i)] < heap[smallest]) smallest = left(i); if (right(i) < size && heap[right(i)] < heap[smallest]) smallest = right(i); if (smallest == i) break; swap(i, smallest); i = smallest; } return top; } private void grow() { heap = Arrays.copyOf(heap, heap.length * 2); } private void swap(int i, int j) { int t = heap[i]; heap[i] = heap[j]; heap[j] = t; } }

关键点全在 pop 的下滤逻辑:先假设当前节点最小,再分别和左右孩子比较。孩子下标必须小于 size 才参与比较,最后若无交换就退出循环。最容易被忽略的边界是右孩子下标等于 size,此时右孩子不存在,访问它会越界。

6.2 Python 版堆操作

Python 自带 heapq,默认是最小堆,工程里通常不需要手写。面试要求手写时,思路和 Java 版本保持一致即可。真正常见的是把任意列表原地堆化,或者用它实现前 K 个最大:

import heapq data = [4, 10, 3, 5, 1] heapq.heapify(data) # 原地堆化,O(n) heapq.heappush(data, 2) # 插入 top = data[0] # 查看堆顶 min_val = heapq.heappop(data) # 弹出堆顶 # 前 K 个最大:用小根堆维护 def top_k(nums, k): heap = nums[:k] heapq.heapify(heap) for x in nums[k:]: if x > heap[0]: heapq.heapreplace(heap, x) return heap

这段 top_k 有个值得说明的细节:heapreplace 等价于先 pop 再 push,但只需要一次下滤,比分开调两个方法要快。前 K 个最大对应小根堆,前 K 个最小对应大根堆,方向千万别记反。如果数据量极端到内存放不下全部,这个写法照样有效,因为堆里始终只保留 K 个元素。

6.3 面试考察点与延伸

手写堆这个题目,面试官想确认的事情一般有三类:一是完全二叉树性质是否真正理解,二是下标转换能否无参考地推导出来,三是上滤和下滤的循环终止条件是否严格。再往深问就是堆排序、合并 K 个有序链表、数据流中位数。这些题目本质上都是围绕“堆的数组存储”做的变化。

数据流中位数的做法是维护一个大根堆和一个小根堆,让两堆元素数量差不超过 1,中位数就在两个堆顶附近;合并 K 个有序链表则是每次都从 K 个链头中取最小,取出后补上该链表的下一个节点,小根堆正好胜任。这些场景里的堆仍然是数组存储,真正变化的只是你想让堆“排出”什么样的序。

还有一些高级扩展,比如索引堆,支持在 O(log n) 时间内修改任意位置的值,因为它额外维护了节点位置与数组下标之间的反向映射;再比如斐波那契堆,能把插入摊还到 O(1),但常数大、实现复杂,工程上还是二叉堆更常用。理解数组存储是地基,后面所有变种都是在这层地基上加索引、加指针。

我自己的体会是,学堆的时候别只盯着代码看,拿一张纸把“数组下标转树节点”这个过程多画几遍,画到条件反射的程度;再把大根堆和小根堆各实现一遍,插入、删除、堆化全部手写。写错了也没关系,关键是知道错在哪一步。数据结构里很多问题并不高深,卡住你的往往就是对底层存储形式缺乏直觉。把数组和树之间的那一步想明白,堆的问题就解决了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询