ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

二叉树与堆:从完全二叉树存储到堆排序与优先队列

2026/9/7 19:46:04 拓冰建站 浏览量
二叉树与堆:从完全二叉树存储到堆排序与优先队列 二叉树和堆是数据结构教材里雷打不动的两个重点也是我在带项目和面试候选人时最常拿出来考的基础模块。先说个观察很多同学学二叉树时觉得“无非就是递归遍历”学到堆的时候又觉得“不过是数组里比较大小”可一旦面试官追问“堆排序为什么不稳定”“建堆复杂度为什么是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右孩子下标是2i1父节点下标是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是根在中序里找到AA左边是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; queueBiTree 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的左孩子下标是2i1右孩子下标是2i2父节点下标是floor((i-1)/2)。下标从1开始的版本更简洁左孩子2i右孩子2i1父节点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(vectorint 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(vectorint 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(vectorint a, int val) { a.push_back(val); heapifyUp(a, a.size() - 1); } int heapPop(vectorint 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(vectorint 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((VE)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%以上都能通过手动画树找到症结画着画着就通了。写在最后的一点体会我自己带过不少实习生发现一个规律能把堆讲明白的人通常对“复杂度的常数项”也有更敏锐的感知。堆的代码不长但每一个细节都在和时间复杂度、空间复杂度较劲。从这一点说学堆不只是学一个数据结构也是在练习一种“设计取舍”的思维方式。如果你也是自学建议先不看答案自己手动实现一遍建堆、入堆、出堆。写完后用一组随机数测试再用暴力排序的结果去验证堆排序输出是否正确。一个小技巧是打印每一次调整后的数组对照完全二叉树的概念从树上找问题这样既能稳住基础又是应对期末、考研、面试最扎实的路线。最后分享一个我常用的复习小技巧把“先序定根、中序分左右”“建堆自底向上、入堆自底向上但方向相反”这些短句写在便利贴上贴到显示器边框。碎片时间反复看几遍经过若干次重复之后再复杂的树结构也能变成一种直觉反应。数据结构从来不是死记硬背的学科真正理解“为什么”之后代码怎么写都顺。