二叉树和堆是数据结构教材里雷打不动的两个重点,也是我在带项目和面试候选人时最常拿出来考的基础模块。先说个观察:很多同学学二叉树时觉得“无非就是递归遍历”,学到堆的时候又觉得“不过是数组里比较大小”,可一旦面试官追问“堆排序为什么不稳定”“建堆复杂度为什么是O(n)不是O(nlogn)”,立刻就露馅了。这篇内容打算把二叉树和堆放在一起讲,因为它们在底层逻辑上是贯通的:堆本质上是完全二叉树的一种数组化存储,而完全二叉树又是二叉树里最容易用连续内存表达的特例。搞懂这层关系,后面看优先队列、Top-K问题、定时器实现都会轻松很多。
这篇文章适合正在学数据结构的在校生、准备考研或软考的朋友,也适合工作几年后想回头把基础补扎实的开发人员。我会把“为什么这样设计”讲透,而不是只给结论。毕竟考试考的是结论,工程里遇到问题拼的却是理解。
1. 整体设计思路:为什么二叉树和堆总被放在同一章
1.1 从线性结构到层级结构:树解决的核心痛点
在讲二叉树之前,建议先想清楚一个问题:数组、链表这类线性结构到底哪里不够用?
数组按下标访问是O(1),但插入和删除涉及数据搬移,平均O(n)。链表插入删除方便,但查找只能从头遍历,O(n)跑不掉。当数据量涨到百万级,还想频繁做“插入+查找最大值/最小值”这类操作时,线性结构就显得力不从心。
树结构解决的核心痛点就是“层级关系”。它让每一步比较能排除掉一整个子树的数据,查找效率从O(n)降到O(logn)。二叉树是树结构里最简单的一种,每个节点最多两个分支,但别小看这个限制——正是“最多两个”这个限制,让后续的遍历、递归、平衡操作都有了清晰的实现路径。
真正有意思的地方在于,二叉树并不是一个具体应用,它是“形态约束”,而堆是在这个形态上加了一个“值约束”后的具体数据结构。这就好比二叉树定义了骨架,堆在骨架上规定了谁大谁小。考试和面试之所以把这两个概念放在一起,就是因为它们之间可以互相推导:堆用数组存,因为它是完全二叉树;如果不是完全二叉树,数组中间就可能出现空洞,空间就浪费了。
1.2 二叉树的常见形态:满二叉树、完全二叉树、普通二叉树怎么区分
很多教材上来就抛概念:满二叉树、完全二叉树、二叉排序树、平衡二叉树、线索二叉树。背起来很痛苦,但它们的核心区别其实是“限制条件”不同。
满二叉树要求每一层的节点数都达到最大值,也就是说第k层有2^(k-1)个节点,整棵树总共2^h - 1个节点(h是高度)。这个条件很苛刻,实际工程里直接构造满二叉树的机会不多,它更多是理论分析的基准。
完全二叉树的条件相对宽松:除最后一层外,每一层都必须是满的,最后一层的节点从左到右连续排列。这个“从左到右连续”的约束非常关键,因为它意味着我们可以把节点按照层序编号,然后直接存进数组,编号之间存在确定的数学关系。堆选择完全二叉树作为载体,根本原因就在于此。
普通二叉树不做任何额外约束,所以在存储时需要额外记录左右孩子指针,形成链式结构。如果一棵普通二叉树严重偏向一侧,它就会退化成链表,查找效率跌回O(n)。这也是为什么后面才有AVL树、红黑树这些“自平衡”方案——本质上都是不想让树退化。
1.3 顺序存储还是链式存储:从空间和定位方式谈差异
二叉树有两种主流存储方式,很多人知道怎么用,但不清楚怎么选。
顺序存储直接用一个一维数组,根节点放在下标1(或0),某个节点下标为i时,左孩子下标是2i,右孩子下标是2i+1,父节点下标是i/2(整除)。这套下标换算只对完全二叉树真正友好,因为完全二叉树的节点天然连续,数组中间不会出现没用的空洞。若把一棵普通二叉树硬塞进数组,那些缺失的分支位置必须用空占位,最坏情况下空间利用率极低。
链式存储就是教科书中经典的二叉链表结构,每个节点带两个指针分别指向左右孩子。它的优点是对树的形态完全免疫,不管多歪的树都能精准表示;缺点是每个节点要多存两个指针,存在额外内存开销,而且在寻找父节点时需要额外遍历或者加一个parent指针。
从实际教学角度看,顺序存储是理解堆的钥匙。堆的所有操作都是围绕数组下标展开的,一旦你在纸上写出那棵完全二叉树,再把数组下标标在节点旁边,父子关系的数学规律就变得一目了然。
2. 二叉树的遍历与深度:递归之外的实操门道
2.1 求二叉树深度:递归“分而治之”的真正含义
二叉树的深度(又叫高度)是一个非常经典的递归题,写法通常只有四行左右。但很多初学者背下了代码,却说不出为什么对。
int treeDepth(BiTree root) { if (root == NULL) return 0; int leftDepth = treeDepth(root->left); int rightDepth = treeDepth(root->right); return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1; }这段代码的核心思路是:一棵树的深度等于“左子树深度”和“右子树深度”中较大的那个再加1。空节点深度是0,这是递归的终止条件。
很多教材把这类问题称为“分治法”,听起来高大上,其实本质就是把大问题拆到不能再拆的小问题。你先问左子树有多深,再问右子树有多深,两边比较后返回更大的那个,当前节点这一层还要加上去。程序跑起来后,会一直沿着分支往下钻,直到遇到叶子节点再逐层返回数据。
实操中容易踩的坑有两个。第一个是忘记处理根节点为空的情况,直接访问空指针,程序崩溃;第二个是误把节点个数当成深度。请记住:节点个数是总结点数,深度是从根到最远叶子的路径长度。一棵只有根节点的树,节点数是1,深度也是1。
2.2 先序、中序、后序遍历:怎么根据遍历序列确定二叉树
“先序、中序、后序怎么确定”是搜索热词,说明这是很多人的坎。其实先中后说的都是根节点何时被访问:先序是先访问根,再遍历左子树,再遍历右子树;中序是先左、再根、再右;后序是先左、再右、再根。难点在于脑海中能动态想象递归展开的过程,而不是死记顺序。
对于“根据遍历序列还原二叉树”,有一个非常实用的套路:
- 先序序列的第一个节点一定是整棵树的根。
- 在后序序列中,最后一个节点一定是整棵树的根。
- 拿到根之后,去中序序列里找到这个根的位置,根的左边是左子树的所有节点,右边是右子树的所有节点。
- 数出左右子树的节点数量,回到先序或后序序列中把左右子树对应的子序列切分出来,再递归操作。
举个例子,先序序列是ABDEC,中序序列是DBEAC。先序第一个A是根,在中序里找到A,A左边是DBE,右边是C。所以左子树有3个节点(DBE),右子树1个节点(C)。回到先序序列,根A后面3个是左子树序列BDE,最后1个是右子树C。继续对BDE做同样操作:B是子根,中序中B左边D,右边E。于是还原出整棵树。这种题考研爱考,面试偶尔作为五分钟手写题出现,核心就是“先序或后序定根、中序分左右”这个十字口诀。
2.3 层序遍历与线索二叉树:两个容易被忽略但很实用的扩展
层序遍历按从上到下、从左到右的顺序逐层访问节点,和三种深度优先遍历不同,它属于广度优先遍历,实现时依赖队列。
void levelOrder(BiTree root) { if (root == NULL) return; queue<BiTree> q; q.push(root); while (!q.empty()) { BiTree cur = q.front(); q.pop(); visit(cur); if (cur->left) q.push(cur->left); if (cur->right) q.push(cur->right); } }这个过程像什么?像公司里逐层下发通知:先通知部门总监,再让他们通知各组组长,组长再通知组员。每一层的任务都待在队列里排队等待处理。
线索二叉树是在普通二叉链表基础上,把空指针利用起来,分别指向前驱和后继节点。它们最大的价值在于让中序遍历无需借助栈或递归就能线性完成。虽然现在很少手写线索树,但理解它有助于看清“空间换时间”的经典思路。
2.4 常见误区:递归遍历中“访问节点”的位置为什么不能乱放
遍历代码的常规写法中,visit(cur)放在递归左子树之前就是先序,放在递归左子树和右子树之间就是中序,放在两次递归之后就是后序。很多同学三个版本换着背,容易搞混。
这里分享一个我常用的记忆方法:想象每个节点都要经过三条边,从父节点进入算“路过一次”,去左子树回来算一次,去右子树回来算一次。先序在第一次到节点时就打印;中序从左子树回来后打印;后序从右子树回来后打印。把代码里的visit挪一挪位置,你就是在改变“打印的时机”,遍历路径本身并没有变。想明白这一点,任何一道遍历变种题你都不会怕。
3. 堆的结构与实现要点:藏在数组里的完全二叉树
3.1 堆的定义:大顶堆与小顶堆的约束条件
堆是一种特殊的完全二叉树,额外规定父节点与子节点之间的大小关系。如果每个父节点的值都大于或等于它的孩子节点,称之为大顶堆(也叫大根堆、最大堆),堆顶元素是最大值;反过来,如果每个父节点的值都小于或等于孩子节点,称之为小顶堆(小根堆、最小堆),堆顶元素是最小值。
这里请务必注意:堆只约束父子之间的大小关系,不约束兄弟节点之间的大小关系。所以堆并非完全有序的结构,它只保证“堆顶是极值”。很多人拿堆和二叉搜索树比,说堆效率低,这是拿错了参照物。堆的定位从来不是做全序查找,而是动态维护极值。
这个约束条件带来的直接结果是:插入一个数、删除堆顶,都只需要沿着从堆底或堆顶到根的一条路径做调整,路径长度就是树高,也就是O(logn)。如果换成有序数组,插入后可能要移动O(n)个元素,数据量一大差距就体现出来了。
3.2 为什么堆用数组存储:下标关系的数学原理
堆不会像普通二叉树那样额外用左右指针建链,而是直接装进一个一维数组。核心原因是它保证是完全二叉树,节点在层序上连续,不存在空洞,所以天然可以用连续内存装载。
下标从0开始的数组里,节点i的左孩子下标是2i+1,右孩子下标是2i+2,父节点下标是floor((i-1)/2)。下标从1开始的版本更简洁:左孩子2i,右孩子2i+1,父节点i/2。国内教材大多从1开始,因为公式更直观;工程语言里(比如Python的heapq)从0开始,面试时要说清自己的约定。
为什么数组能做到这一点?你可以把完全二叉树按层序编号,每一层占满后再进入下一层,这种编号天然跟数组下标一一对应。链式二叉树像一栋复杂的别墅,每个房间单独挂门牌;堆的结构则是标准宿舍楼,每层房间连成排,知道某一间房号就能算出左右隔壁。也正因如此,堆在时间局部性和缓存命中率上有优势,工程实现里堆经常比链式优先队列更快。
3.3 向下调整与向上调整:堆操作的两个核心动作
不管建堆、插入还是删除堆顶,归根到底都是两个动作的组合。
向下调整(sift down / heapify down)的场景是:某个节点不满足堆性质,但其左、右子树都已经满足堆性质。做法是把当前节点和它的左右孩子比较,找到三者中最大(大顶堆)的那个,若最大者不是当前节点,就交换当前节点与最大孩子,然后继续对交换后的子树重复这个过程,直到当前节点比左右孩子都大或到达叶子。
向上调整(sift up / heapify up)的场景是:向堆尾部插入新节点后,该节点可能比父节点大(大顶堆),此时只需要沿着父节点路径一路向上比较,遇到不满足条件就交换,直到抵达堆顶或者满足条件。
这两个操作画成最大堆动画会特别直观:向下调整时,大的值像气泡一样往上升,小的值像石子一样向下沉,很多人看三遍动画就懂了。如果没有动画资源,手动模拟两个数组元素交换的过程也是一样有效的。
3.4 建堆过程:为什么自底向上调整复杂度是O(n)
给定一个无序数组,怎么把它调整成一个堆?最直观的想法是逐个插入,每插入一个做一次向上调整,时间复杂度是O(nlogn)。但更优的办法是自底向上的向下调整,复杂度只有O(n)。
void buildHeap(int arr[], int n) { for (int i = n / 2 - 1; i >= 0; --i) { heapifyDown(arr, n, i); } }有人会奇怪:for循环内层还有while,怎么算出来是O(n)?关键在于内层调整的代价由节点所在高度决定。自底向上从最后一个非叶子节点开始调整,意味着处理的大多数节点位于树的底部,底部节点的高度很小。叶子节点根本不用参与调整,倒数第二层节点至多移动1次,倒数第三层至多移动2次……把各层总移动次数求和后,整体是收敛的O(n)。这与“每个元素都从顶部一路向下调整到叶子附近”的潜意识完全不同。
常有人误以为完全二叉树的建堆内部嵌套while必然导致O(nlogn),这正是因为没有把“移动距离”之和算清楚。搞清楚这点,你对堆的理解会明显高出周围同学一截。
3.5 入堆与出堆的操作实现:手写堆的完整代码
下面给出一份大顶堆的关键操作实现(用C++风格写核心逻辑,语言无关,考试和工程都通用)。
void heapifyDown(vector<int>& a, int n, int i) { while (true) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && a[left] > a[largest]) largest = left; if (right < n && a[right] > a[largest]) largest = right; if (largest == i) break; swap(a[i], a[largest]); i = largest; } } void heapifyUp(vector<int>& a, int i) { while (i > 0) { int parent = (i - 1) / 2; if (a[i] <= a[parent]) break; swap(a[i], a[parent]); i = parent; } } void heapPush(vector<int>& a, int val) { a.push_back(val); heapifyUp(a, a.size() - 1); } int heapPop(vector<int>& a) { int top = a[0]; a[0] = a.back(); a.pop_back(); if (!a.empty()) heapifyDown(a, a.size(), 0); return top; }仔细看这段代码的脉络:入堆时先把元素放到数组末尾,模拟“完全二叉树的最后一个节点”,然后一路向上调整;出堆时用堆底最后一个元素覆盖堆顶,再向下调整。为什么不能用删除中间节点的方式?因为堆的核心目标是维护极值的快速访问,中间节点没有优先权,极少需要删除。
4. 堆的经典应用场景:从排序、Top-K到优先队列
4.1 堆排序:原地排序的完整流程与复杂度
堆排序是利用堆这种数据结构设计的一种排序算法,属于选择排序的变体:每次从待排序区间中取出堆顶的极值,放到排序区间的末尾,再对剩余元素重新调整。以大顶堆升序排序为例:先建堆,然后不断把堆顶元素(当前最大值)与数组末尾元素交换,交换后堆的大小减1,对新的堆顶做向下调整。
void heapSort(vector<int>& a) { int n = a.size(); buildHeap(a, n); for (int i = n - 1; i > 0; --i) { swap(a[0], a[i]); heapifyDown(a, i, 0); } }堆排序的时间复杂度稳定为O(nlogn),无论原始数据有序还是无序,都逃不掉每次向下调整logn步的过程。它不像快速排序那样依赖基准值选取的运气,也不像归并排序那样需要额外O(n)空间,堆排序可以做到原地排序,空间复杂度O(1)。
但它有两个被人诟病的缺点:一是不稳定,相等的元素在排序过程中可能相对位置改变;二是局部性较差,虽然用了数组存储,但调整时经常跳跃访问数组不同区间的元素,缓存命中率不如插入排序等算法。因此工程里很少拿堆排序当通用排序器,它的真正价值更多体现在需要“动态维护极值”的场景。
4.2 Top-K问题:什么时候用大顶堆,什么时候用小顶堆
有一类高频面试题:从海量数据中找出最大的K个数。直接全部排序显然太浪费,因为只需要K个结果。如果数据规模大到无法全部载入内存,排序方案就直接不可行。
最优雅的方案是用一个容量为K的小顶堆,堆顶是当前K个候选数中最小的一个。扫描数据时,若堆未满就入堆;堆满后若新元素大于堆顶,则弹出堆顶,把新元素入堆;否则跳过。扫描结束后,堆内的K个元素就是全局最大K个数。
那为什么找最大K个数反而用“小顶堆”?因为小顶堆能保证堆顶是候选集合中最小的那个,每当出现更大的新元素,就淘汰当前候选集里的最小者。最后堆顶就是第K大的门槛,堆内全是最大K个数。
同理,找最小的K个数时用大顶堆。每次淘汰当前候选集的最大者,剩下的就是最小K个数。
如果只是“找出从大到小第K大的元素”,还可以用快速选择算法做到平均O(n),但快速选择对于动态插入的流式数据无能为力;堆方案天然支持“数据源源不断到来”,所以Java的PriorityQueue、Redis的有序集合场景里,堆都是很重要的基石。
4.3 定时器里的最小堆、Dijkstra里的优先队列:堆的工程外延
堆在系统和算法中的应用远比教材里的排序题更普遍。操作系统定时器中,一般维护一个按到期时间排序的最小堆,每次取出堆顶即最近要触发的定时器,到期时间一到就执行回调,然后重新调整堆,效率远高于遍历所有定时器。
Dijkstra最短路径算法的经典实现也依赖优先队列(通常由最小堆实现),每次从未确定最短路径的节点中取出“当前距离最小的节点”进行松弛。如果用普通数组找最小节点,复杂度会变成O(V^2);用堆优化后可以降到O((V+E)logV),这就是为什么在大规模图中堆优化版Dijkstra几乎是标准解法。
C++的priority_queue、Python的heapq、Java的PriorityQueue本质上都是堆。Python的heapq比较特殊,它只提供小顶堆,需要大顶堆时,通常有两个处理方法:一是把元素取负再入堆;二是包装成(key,value)并自定义比较器。这是很多新手写LeetCode时容易卡壳的细节。
5. 进阶内容与问题排查:从二叉搜索树到考试高频坑
5.1 二叉搜索树、平衡二叉树(AVL树)与堆是什么关系
经常有读者把二叉搜索树和堆放在一起比较,因为两者都涉及节点大小关系。但它们的约束方向不同:二叉搜索树要求左子树所有节点小于根、右子树所有节点大于根,这种约束可以支持快速的精确查找、范围查找;堆只要求父子之间满足大小关系,无法在O(logn)时间内查找任意一个特定元素。
二叉搜索树在极端情况下会退化成链表,于是工程上引入了平衡因子、旋转操作来约束树形。AVL树通过左右旋转让左右子树高度差不超过1,把树高严格控制在O(logn)。堆不需要平衡因子这个概念,因为完全二叉树本身就是“天然平衡”的——它的树高始终是floor(log2n)+1,不需要任何旋转机制。
这个差异带来的思考是:不存在万能的数据结构,每一个结构都在解决某类特定问题。二叉搜索树解决有序查找;AVL树、红黑树解决查找效率退化问题;堆解决频繁获取极值和动态插入的问题。面试时如果能主动点明各自适用场景,会比只会背诵定义给面试官留下更深的印象。
5.2 考试和面试中关于堆的高频易错点
堆是考研408和软考算法题里的常客,我整理几个非常容易丢分的点。
- 堆删除任意元素:如果被删节点不是堆顶,需要先把它与堆底元素交换,再根据交换后的情况决定向上调整还是向下调整。很多教材只讲删除堆顶,考试时一旦扩展到删除任意节点就懵了。
- 堆中某个节点数值增大时:只需要向上调整,因为增大的值只可能破坏“孩子不大于父节点”的约束。反之,如果节点数值减小,则需要向下调整。判断清楚方向是基本功。
- 建堆循环起点:下标从0开始时,最后一个非叶子节点下标是(n-2)/2;从1开始时是n/2。写错起点会导致建堆结果不对,但又不会立刻暴露错误,隐蔽性很高。
- 判断一个数组是否构成大顶堆:需要检查所有非叶子节点是否都比自己的孩子大,而不是只看根节点。
另外特别提醒一句,不要把数据结构里的“堆”和操作系统内存管理里的“堆区”混淆。堆区是程序中动态分配内存的区域的通用叫法,和本文讨论的二叉堆没有任何结构上的关系,纯粹是中文翻译撞车。当年我在学习的时候就曾被这两个概念绕晕过,一定要留意区分。
5.3 现场排查技巧:代码写对了但运行结果不对怎么办
很多读者在实现堆操作时遇到“看似正确但结果不对”的情况。我建议按照以下顺序排查。
第一步,检查数组下标是否越界。向下调整时,左右孩子下标可能超过当前堆大小n,必须保证left < n和right < n,否则会访问到堆外的元素。这是最常见的越界来源。
第二步,检查堆大小在交换过程中是否被错误维护。堆排序中每轮交换后堆大小减1,可很多人复用同一个数组长度变量,导致已经排好的最大值又参与下一轮调整,排序结果各种错乱。
第三步,检查递归或循环的终止条件。向下调整里正确的是“largest == i”时退出,而不是简单比较一次就结束;向上调整里则是当前节点小于等于父节点时退出。写漏了循环终止条件,代码会陷入死循环或中途退场。
第四步,检查比较符号方向。大顶堆用“当前子节点大于largest”做比较,小顶堆则相反。因为符号方向写反导致的逻辑错误,在纸面推演时非常难发现,建议写完后用小数组如[3,1,4,1,5]从头到尾手跑一遍。
5.4 日常刷题和复习时的推荐路径
如果这篇内容读完想继续加深,我建议按这个顺序刷题:先做二叉树深度、二叉树遍历的基础题,再做“从前序与中序遍历序列构造二叉树”,接着做“数组中的第K个最大元素”、前K个高频元素,然后是合并K个升序链表,最后挑战数据流中的中位数(需要同时维护大顶堆和小顶堆)。
刷题时我特别建议在一张纸上画出堆的树形图和数组下标图的对照。遇到每次堆变化时都自己在纸上画一遍交换过程,加深记忆。这个过程看起来慢,但对后续理解堆排序的“不稳定”、理解建堆O(n)复杂度之类的深水区题目帮助很大。很多人在LeetCode上遇到过不了编译、样例跑出错误数组的问题,90%以上都能通过手动画树找到症结,画着画着就通了。
写在最后的一点体会
我自己带过不少实习生,发现一个规律:能把堆讲明白的人,通常对“复杂度的常数项”也有更敏锐的感知。堆的代码不长,但每一个细节都在和时间复杂度、空间复杂度较劲。从这一点说,学堆不只是学一个数据结构,也是在练习一种“设计取舍”的思维方式。
如果你也是自学,建议先不看答案自己手动实现一遍建堆、入堆、出堆。写完后用一组随机数测试,再用暴力排序的结果去验证堆排序输出是否正确。一个小技巧是:打印每一次调整后的数组,对照完全二叉树的概念从树上找问题,这样既能稳住基础,又是应对期末、考研、面试最扎实的路线。
最后分享一个我常用的复习小技巧:把“先序定根、中序分左右”“建堆自底向上、入堆自底向上但方向相反”这些短句写在便利贴上贴到显示器边框。碎片时间反复看几遍,经过若干次重复之后,再复杂的树结构也能变成一种直觉反应。数据结构从来不是死记硬背的学科,真正理解“为什么”之后,代码怎么写都顺。