☰
二叉树与二叉堆:从递归遍历到数组存储的核心原理与实战
2026/10/8 15:00:52 网站建设 项目流程

我学二叉树那会儿,最深的误区就是把“二叉树”当成了“画图题”——老师在上面画一棵倒挂的树,下面一堆学生抄结构,背概念,然后考试画三种遍历。课上是懂了,真到写代码的时候全废了:递归函数一跑就崩,指针指来指去指向空气,二叉堆更不知道跟树有什么关系。后来我才想明白,二叉树这玩意儿,核心不是“长得像树”,而是“递归地定义自己”;二叉堆就更反直觉了,它明明是个树状结构,却不用指针链接,直接塞进数组里。这篇我就把这两个东西放在一起讲,从概念到代码、从原理到踩坑,一次说透。

1. 看懂二叉树的第一关:把“图”忘掉,抓住“递归”二字

很多人一上来就问:二叉树是图吗?从广义上说,树确实是图的一种特例。但如果你用图那套思维去理解树,一定会被绕晕。图的核心是“连接关系任意”,而树的核心是没有环的递归结构——每个节点向下看,又是一棵独立的树。

1.1 节点定义本身就是递归

先看最典型的结构体定义,用C语言写:

typedef struct TreeNode { int value; struct TreeNode *left; struct TreeNode *right; } TreeNode;

注意这个定义:struct TreeNode的成员里出现了struct TreeNode *。这就是递归定义——一棵二叉树,要么是空树,要么是“一个根节点 + 左子树 + 右子树”,而左子树和右子树各自又是一棵二叉树。这句话才是二叉树真正的本质,也是后面所有遍历、深度、堆操作的源头。

很多初学者画了一堆树形图,却写不出递归代码,问题就出在这里:脑子里没有“子树”这个概念。看到一棵完整的大树,要能立刻把它拆成根、左子树、右子树;左子树再拆成根、左右子树……直到拆到空。

1.2 五种基本形态与“退化”的树

严格来说,二叉树不是只有“根加两个孩子”这一种样子。它有五种基本形态:

  • 空树(NULL)
  • 只有一个根节点
  • 只有左子树
  • 只有右子树
  • 左右子树都有

我面试候选人的时候,喜欢先让对方写树的深度,因为这个题能把“漏掉单支情况”的人全筛出来。很多人处理完左子树和右子树,却忘了处理只有单边子树的情况——实际上递归代码天然统一了这几种情况,不需要特判,前提是你别把逻辑写死。

还有一种“退化”二叉树叫斜树——所有节点都只有左孩子或者都只有右孩子。这时候二叉树从形状上退化成了一条链表,深度就是节点数。理解斜树很重要,因为它是二叉搜索树性能坠崖的根源,等下第3部分会细说。

1.3 满二叉树和完全二叉树,命名最容易混

考试最爱抠这两个概念,实际项目里也很重要,因为完全二叉树是二叉堆的地基。

  • 满二叉树(Full Binary Tree):每个非叶节点都有两个子节点,也就是所有层的节点都填满。高度h的满二叉树,节点总数是2^(h+1) - 1。
  • 完全二叉树(Complete Binary Tree):除最后一层外,上面每一层都填满,最后一层的节点从左到右连续排列,中间不能有空缺。

一句话记忆:满的必然完全,完全的没满也能装。完全二叉树允许最后一层少几个节点,但必须从右往左连续地缺,不能左边缺一块右边多一块。

为什么强调“从左到右连续”?因为后面二叉堆用数组存储时,如果中间有空洞,数组就存不下了——这是完全二叉树和数组存储能合体的关键前提。

2. 遍历不是走迷宫:先序、中序、后序到底在“序”什么

“二叉树的遍历”是热搜词里最常出现的关键词。很多人把遍历当成“走迷宫路线”,其实遍历的本质很朴素:把树里的每个节点按某种顺序访问且仅访问一次。那为什么要分先序、中序、后序三种?不是因为好玩,而是因为根节点的位置不同,访问顺序就不同,而不同顺序决定了解答不同问题的效率。

2.1 三句话记住三种遍历

假设一个节点有左子树、右子树,把“访问节点本身”记为“根”:

  • 先序(前序):根 → 左 → 右
  • 中序:左 → 根 → 右
  • 后序:左 → 右 → 根

核心记忆法就是看根在哪:根在最前面就是先序,根在中间就是中序,根在最后就是后序。

递归写法长得一模一样,只是处理“根”的代码位置不同:

void preorder(TreeNode *root) { if (root == NULL) return; process(root); // 先序遍历:先处理根 preorder(root->left); preorder(root->right); } void inorder(TreeNode *root) { if (root == NULL) return; inorder(root->left); process(root); // 中序遍历:中间处理根 inorder(root->right); } void postorder(TreeNode *root) { if (root == NULL) return; postorder(root->left); postorder(root->right); process(root); // 后序遍历:最后处理根 }

三套代码唯一的区别就是process(root)那一行的位置。这三行代码的意义比绝大多数人以为的深得多——递归序展开后,每个节点会被经过三次,process在哪一次“经过”时执行,就决定你是先序、中序还是后序。

2.2 层序遍历和“超市货架”的联想

热搜词里有一条特别有意思:“超市货架 遍历二叉树”。仔细想想,这个类比其实非常精妙——超市理货员补货,就是从过道一头走到另一头,一层一层地扫描货架上的每件商品,扫完一层再去下一层。这跟二叉树的层序遍历一模一样:从上到下、从左到右逐层扫描所有节点。

层序遍历的代码思路与先序/中序/后序完全不同。它是用队列实现的:根节点先入队,然后循环——出队一个节点,处理它,再把它左右孩子依次入队。这样利用队列“先进先出”的特性,天然实现逐层推进。

void levelOrder(TreeNode *root) { if (root == NULL) return; Queue q; enqueue(&q, root); while (!isEmpty(&q)) { TreeNode *node = dequeue(&q); process(node); if (node->left != NULL) enqueue(&q, node->left); if (node->right != NULL) enqueue(&q, node->right); } }

“逐层推进”这四个字请刻在脑子里,后面二叉堆的下沉调整、按层构建堆,全用这个思路。

2.3 线索二叉树:把空闲指针用起来

“线索二叉树”也上了热搜,很多人看到“线索”两个字就发怵。其实它解决的是一个很实际的问题:二叉树的遍历总要递归或借助栈,能不能把遍历过程变成“顺着指针一路走”的线性过程?

线索化的思路是:二叉树的很多节点会有空指针(左空或右空),与其让这些指针空着,不如让它们指向自己的中序遍历前驱或后继节点。这样就出现了“线索”。

最考验理解力的点是区分左/右指针到底是孩子还是线索:如果用两个布尔标记leftIsThread和rightIsThread,就能知道指针指向的是真实子树还是中序前驱/后继。线索二叉树的主要价值在于中序遍历不需要递归和栈,也适合在需要反复中序遍历的场景里提速。

不过实际工程里我用得不多,属于“懂了思路就行”的知识点。真正天天用的还是普通二叉树遍历和二叉堆里的层序遍历思想。

3. 搜索二叉树与树的深度:看似平平无奇,实际是查找效率的分水岭

二叉搜索树(Binary Search Tree,经常简写为BST,热搜里也有人搜“搜索二叉树”)和树的深度这两个话题,我放一起讲——因为它们相互成就,也相互拆台。

3.1 搜索二叉树的黄金法则:左小右大

搜索二叉树在结构上和普通二叉树没有任何区别,它只是一条额外的规则:左子树所有节点的值 小于 根节点;右子树所有节点的值 大于 根节点;且左右子树也各自满足这个规则。

就是这么一条简单的规则,让查找算法从遍历全树的O(n),下降到了只沿着一条路径走。查找的过程是:

  1. 从根节点开始。
  2. 目标值等于当前节点值,命中。
  3. 目标值小于当前节点值,向左走。
  4. 目标值大于当前节点值,向右走。
  5. 走到空节点,说明不存在。

这个查找过程本质上还是在树上“走直线”,每次比较都能排除一侧子树,效率取决于树“有多瘦”——树越矮,路径越短。这就要看树的深度了。

3.2 树的深度:递归里的经典“话术题”

树的深度(高度)有两种约定口径:有人说深度是“从根到最远叶子经过的节点数”,也有人说是“经过的边数”。LeetCode那类OJ题一般都按节点数算,空树深度是0,只有一个根节点深度是1。但很多教科书按边数算,空树深度是-1。写代码前一定要先确认口径,不然边界条件全是错的。

用递归写深度,一句话:

int depth(TreeNode *root) { if (root == NULL) return 0; int leftDepth = depth(root->left); int rightDepth = depth(root->right); return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1; }

这个写法是“自底向上”的:先把左子树和右子树的深度都问出来,取较大者再加1。这就是递归的信任——不要试图从头往下跟踪每一层,相信“函数能算出子树的深度”,我们只需要处理“当前层怎么用子结果”。

3.3 平衡问题:为什么搜索树会从O(logn)退化到O(n)

搜索二叉树的查找复杂度其实是O(h),h是树的深度。树长得“匀称”时,h ≈ logn,查找飞快;树长得“偏”了,比如按递增顺序插入节点,每次新节点都成为当前根节点的右孩子,整棵树就变成了一条斜链,这时候h = n,查找退化成纯线性扫描。

这个问题催生了自平衡二叉搜索树(AVL树、红黑树)——它们通过旋转操作让树在插入、删除后自动回到一个可控的高度范围。工程里,std::map、TreeMap用的都是红黑树,而数据库索引用的B树/B+树,本质上也是“更宽扁的搜索树”。初级入门阶段不用手写红黑树,但一定要理解搜索树为什么需要平衡,否则以后看到旋转操作会觉得那是魔法。

另外说一句,搜索二叉树做中序遍历,会得到一个递增序列——这个性质太好用了,考试和面试都喜欢考“验证一棵树是不是BST”,正统解法就是中序遍历后检查序列是否严格递增。这也是把遍历和最实际的使用场景结合在一起的好例子。

4. 二叉堆:长得像树,本质却是“排队规则”

热搜词里“二叉堆”和“二叉树”虽然紧挨着,但它俩的关系很多人是蒙的。一句话:二叉堆就是一颗被强加了“堆序性”的完全二叉树。但它最迷人的地方在于,它不用指针存储,就用一个数组。为什么能这样?因为完全二叉树的“连续”特性,使得父子关系可以用下标直接算出来。

4.1 二叉堆的两条铁律

以“大顶堆”为例(小顶堆对称理解):

  1. 结构上:必须是完全二叉树。
  2. 序关系上:每个节点的值 不小于 它的左右孩子的值(如果有孩子的话)。

注意,堆只保证父节点比孩子大,不保证同一层节点之间的左右顺序,也不保证左子树比右子树的某个节点大。堆只弱弱地保证了“根节点是最大值”,这个“弱保证”恰恰让它能高效地支持动态插入和弹出最大元素。

4.2 数组存储的秘密:下标的算术

既然是完全二叉树,我就从上到下、从左到右给每个节点编号(从0开始,跟数组下标对齐)。编号为i的节点:

  • 左孩子下标:2 * i + 1
  • 右孩子下标:2 * i + 2
  • 父节点下标:(i - 1) / 2(整数除法向下取整)

我用一个小例子演示,数组为[50, 30, 20, 10, 15, 5, 8],下标0的50是根;下标1的30的左孩子是下标2*1+1 = 3(值为10),右孩子是下标4(值为15);下标2的20的左孩子是下标5(值为5),右孩子是下标6(值为8)。所有父子关系只看下标就能定位,完全不需要指针。

数组存储的好处有三:缓存局部性好(连续内存访问快)、省去指针内存开销、随机访问任意下标是O(1)。

4.3 两个底层操作:上浮(bubble up)与下沉(bubble down)

堆的所有操作都建立在两个“微操作”上:

上浮:某个节点如果比它的父节点更大(大顶堆),就违反了堆序,得往上走。做法是:把该节点与父节点交换,然后继续跟新的父节点比较,直到满足堆序或到达根。

void bubbleUp(int *heap, int i) { while (i > 0) { int parent = (i - 1) / 2; if (heap[i] > heap[parent]) { swap(&heap[i], &heap[parent]); i = parent; } else { break; } } }

下沉:某个节点如果比它的孩子小(大顶堆),说明它应该往下走。做法是:找到左右孩子中较大的那个,如果自己比它小,就交换,然后继续对新位置做同样判断,直到叶节点或满足堆序。

void bubbleDown(int *heap, int n, int i) { while (true) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && heap[left] > heap[largest]) largest = left; if (right < n && heap[right] > heap[largest]) largest = right; if (largest == i) break; swap(&heap[i], &heap[largest]); i = largest; } }

对新手来说,下沉比上浮难写,因为要同时比较两个孩子、还要检查下标不越界。常见错误是访问数组越界——left和right可能超出当前堆大小n。

插播一个面试高频问题:**为什么入堆用上浮,出堆用下沉?**因为新节点总是加在数组末尾、离根最远,从底部往上放大概率比父节点小,但如果违反堆序,需要往上找位置;而弹出堆顶时,我是把末尾元素垫到根上,它从根出发大概率“名不副实”,所以要一路往下沉,同时让较大的孩子顶上来。

5. 手写二叉堆并应用到排序与优先队列

这里我把堆的完整操作链捋一遍。用C语言在一个整型数组上模拟动态堆。

5.1 插入元素

插入相当简单:把新元素放到数组尾部(相当于完全二叉树的最后一个节点),然后对它执行上浮操作。

void heapPush(int *heap, int *size, int val) { heap[*size] = val; (*size)++; bubbleUp(heap, *size - 1); }

不管堆里有几个元素,插入的时间复杂度都是O(logn)——上浮最多爬树的高度那么高。这里的底数是2,因为树是完全二叉树。

5.2 弹出最大元素(堆顶)

弹出堆顶元素(大顶堆里的最大值)不能直接把根拿走,否则数组中间出现空洞,完全二叉树的结构就破坏了。标准做法是:

  1. 把堆顶和最后一个元素交换。
  2. 堆大小减1。
  3. 对新的根节点做下沉操作。
int heapPop(int *heap, int *size) { int ret = heap[0]; heap[0] = heap[*size - 1]; (*size)--; bubbleDown(heap, *size, 0); return ret; }

这个“删头部、尾部补位、再调整”的思路,在堆排序里正好“顺手”完成了排序。

5.3 从数组建堆:不要天真地一个个插入

如果已知一群元素要建堆,每次都调用heapPush,总复杂度是O(n log n)。这没问题,也能用。但有个更巧妙、也更常考的方法:自底向上下沉调整。

给定一个数组,最后一个非叶节点的下标是n/2 - 1(整数除法)。从它开始往前,依次对每个节点做下沉操作:

void buildHeap(int *arr, int n) { for (int i = n / 2 - 1; i >= 0; i--) { bubbleDown(arr, n, i); } }

为什么从n/2 - 1开始?因为下标大于n/2 - 1的节点都是叶子,叶子没有孩子,下沉不会移动,没必要处理。从最后一个非叶节点开始,逐层往上,每个位置做一次下沉,就能保证以该节点为根的子树变成堆。这个建堆过程的均摊复杂度是O(n),不是O(n log n)——虽然每个下沉最坏走log n步,但越靠下层的节点数量越多、每个节点的下沉步数越少,总数算下来收敛在O(n)。

这个反直觉的结果,面试官百问不厌。我建议你亲手统计一下:对高度为h的堆,从最底层(大量节点)往上算,把“节点数量×下沉步数”加总,能直观感受到为什么是O(n)。

5.4 堆排序:建堆之后一路弹出

堆排序直接用上面的函数:

  1. buildHeap把原数组变成大顶堆。
  2. 反复执行“堆顶与末尾交换、堆减一、下沉”,每轮把当前最大元素放到数组末尾。
  3. 结束后,数组从小到大排好序。
void heapSort(int *arr, int n) { buildHeap(arr, n); for (int i = n - 1; i > 0; i--) { swap(&arr[0], &arr[i]); bubbleDown(arr, i, 0); } }

这个写法不额外申请空间,属于原地排序。堆排序时间稳定在O(n log n),但实际工程里不一定比快排快——因为缓存命中率不如快排。不过它在优先队列和Top K问题里是不可替代的。

5.5 优先队列:为什么操蛋的调度都靠它

优先队列本质就是一个堆结构的外壳:插入任意元素,弹出“最大/最小”元素。操作系统进程调度里的高优先级先执行、网络报文按优先级路由、A*搜索的开放列表,都是优先队列的典型应用。

用二叉堆实现优先队列的好处是,插入和弹出最大(最小)元素都能控制在O(logn),这比“每次插入后全排序”的O(n log n)或者“链表扫描最大值”的O(n)都香得多。它可以一边有元素不断进来、又不断有最高优先级出去,这是动态数据流场景的刚需。

6. 写二叉树程序时为什么总是报运行时错误?真正的坑在指针思维

热搜词里有句大实话:“写二叉树程序时为什么总是报运行时错误”。这个我太有共鸣了,教数据结构的这几年,几乎每次上机课都有人举着屏幕喊“老师我这个一跑就崩”。

首先要明白一个概念:运行时错误(runtime error)不是你语法写错了,而是程序跑起来之后,访问了不该访问的内存。最常见的无非这么几类,我一个个拆开说。

6.1 对NULL解引用:最大的坑没有之一

TreeNode *node = findNode(root, 5); process(node->left); // 如果node是NULL,这行直接崩

这是新手第一周必撞的墙。根因是你调用函数返回了NULL,但你没检查就直接用。解法是养成习惯:任何函数返回指针,在使用前先判断是否为NULL。

6.2 递归忘记终止条件

写递归树操作时,最容易忘的就是“空树”那一分支:

void traverse(TreeNode *root) { process(root); // 当root为NULL时,这里已崩 traverse(root->left); // 甚至不会执行到这里 }

正确的递归结构必须有“最小问题”的出口——也就是处理NULL的分支。我给学生改代码时最常说的一句话:递归三要素——终止条件、本层逻辑、递归调用,一个都不能少。

6.3 插入新节点时指针没接到树上

再看这个经典错误:

void insert(TreeNode *root, int val) { if (root == NULL) { root = createNode(val); return; // 这个新节点根本没接到树上! } if (val < root->value) insert(root->left, val); else insert(root->right, val); }

你以为你把新节点接上去了,实际上只是让函数的局部参数root指向了新节点,调用结束后啥都没留下。问题出在C语言函数参数默认传值。要真的修改外部的指针,得用二级指针,或者让函数返回新节点指针、由调用方赋值:

TreeNode *insert(TreeNode *root, int val) { if (root == NULL) return createNode(val); if (val < root->value) root->left = insert(root->left, val); else root->right = insert(root->right, val); return root; }

这样的写法在逻辑上更自然:“把新节点插入到以root为根的子树,返回这棵新子树的根”。

6.4 排查运行时错误的调试流程

我一般建议按下面这套路子来,别瞎改瞎试:

  1. 先给每个函数入口加空指针检查,甭管有没有问题,先排除最便宜的崩溃原因。
  2. 打印遍历序列:写一个中序遍历函数,把值打印出来,看看结构是否符合预期。树的调试不是人脑模拟递归,而是把树“压平”成序列看。
  3. 画递归树:找一个小规模用例(3到5个节点),在纸上按递归调用的顺序展开,检查终止条件和传参传址是否符合预期。80%的问题都能在纸上暴露。
  4. 用断言(assert)捕捉中间状态:比如在下沉函数里断言i >= 0 && i < n,防止数组越界。
  5. 小数据集先跑,大数据别直接上:树的bug通常规模越小越容易定位。

7. 学了二叉树到底能干嘛?别让数据结构只活在卷子上

写到这,得聊聊“二叉树的应用”,这也是热搜词里的一条。我特别烦“这个学了有用吗”这种问题,但反过来也承认:如果不知道一个东西能解决什么问题,学一百遍也记不住。所以这部分我讲几个特别经典、又离现实很近的场景。

7.1 目录和DOM:天生就是树

你的电脑文件系统就是个多叉树,文件夹是中间节点,文件是叶子。ls -R命令的递归输出、编译器的符号表层级、HTML文档的DOM树,全是树的遍历。前端框架的虚拟DOM diff、浏览器渲染的DOM解析,核心操作都是“遍历一棵树,找出变化节点”。学好了二叉树的递归遍历,这些大树的遍历方式都是一回事——左孩子右兄弟之类只是表示方式的差异。

7.2 表达式树:编译器和计算器的底层

(a + b) * (c - d)可以表示成一棵二叉树:根是*,左子树是(a + b),右子树是(c - d)。对这个树做后序遍历,就是在计算它的值——先算左子树,再算右子树,最后算根。计算器、解释器、编译器的语法分析树就是这么干的。

7.3 哈夫曼树:压缩算法的基石

哈夫曼编码(Huffman Coding)是一种常用的无损压缩算法,它本质上就是构造一棵二叉树:出现频率越高的字符,离根越近,编码越短;频率越低的,叶子越深。文件压缩软件、ZIP格式里的核心算法都跟它有关。“构造最优二叉树”的过程,就是反复从集合里取两个最小权重的节点、合并、放回的过程——这听起来像不像最小堆的每次弹出两个最小值?这就是堆和树协同工作的经典例子。

7.4 数据库索引:为什么不是链表而是B+树

数据库索引不直接用二叉搜索树,是因为磁盘IO是大头,二叉树太高了,每次走一层可能就要一次磁盘IO。B+树把二叉变成“多叉宽带”,把树高压到3到4层,极大减少磁盘寻道。理解搜索引擎二叉树为什么需要平衡、为什么树高直接决定效率,是理解B树的钥匙。学B树之前,我强烈建议先把二叉树这一整套递归思维练熟,不然满脑子都是“多路查找+分裂合并”,很容易绕进去。

7.5 堆在工程中无处不在

最后再补一点二叉堆的落地场景:操作系统的进程调度、任务队列、带权图的最短路径算法(Dijkstra优先队列优化)、Spark和Flink这类流式计算里的窗口TopN聚合、游戏服务器里的定时器轮询,全都是堆或优先队列在扛着。掌握堆的两个微操作,等于掌握了一大票工程组件的最小公共单元。

我个人感受是,二叉树和二叉堆看似简单,却把“递归思想”“抽象数据结构”“复杂度分析”三座大山都串起来了。学的时候别赶进度,动手把三种遍历、深度、插入/删除堆元素、堆排序各写一遍,跑通后再看看每个操作背后“为什么这么设计”。真把这几个程序吃透了,后面学图和树的高级变体,会轻松得多。

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

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

立即咨询