打开你电脑的文件管理器,左边是一棵展开的目录树:D: 下有 Documents,Documents 下有 prj,prj 下有你的代码仓库……这个嵌套的层级关系,和我们家族的族谱、公司的组织架构图如出一辙。一个上级可以管着多个下级,但每个下级只属于一个上级。这种"一对多"的层级结构,用我们之前学过的线性表(数组、链表)是表达不好的——你没法用一条"拉直的线"把分叉的层次关系画出来。这正是数据结构中树登场的时刻,而二叉树又是树里应用最广的一支。
这篇文章会沿着一条很自然的路线走下去:先认识树这种非线性结构,再收敛到二叉树,然后分别攻下二叉树的两种存储实现——顺序存储(数组)对应的堆,以及链式存储对应的递归遍历。堆的部分会带着你亲手实现大堆,并推导两个最重要的复杂度结论;链式部分会画出递归的调用过程,让"递归遍历"不再是一团迷雾。最后用一批算法题和选择题收尾。过程中会用到递归、结构体、队列这几个老朋友——它们出现的时候我会当场提一嘴,不用专门复习。
树的概念与结构
树是由 n(n ≥ 0)个有限结点组成的一个具有层次关系的集合,n = 0 时叫空树。为什么叫"树"?因为它长得很像一棵倒挂的树——根朝上,叶朝下。
A ← 根结点(第 1 层)
┌────┬────┬────┬────┬────┐
B C D E F G ← 第 2 层
│ ││ ││
H I J K L ← 第 3 层
│
Q ← 第 4 层
树有两个重要的基本特征:
- 有一个特殊的结点叫根结点,它没有前驱(上图中 A 就是根)。
- 除根结点外,其余结点被分成 M(M > 0)个互不相交的集合 T1、T2、……、Tm,其中每一个集合本身又是一棵子树,且每棵子树的根有且只有一个前驱。因此树是递归定义的——树的子树还是树,这个"自己包含自己"的定义,正是后面所有递归代码的根源。
细品"互不相交"这四个字,它实际上就是判断一个结构是不是树的标准。顺着这个标准,我们可以总结出树的三个硬性特征:
- 子树是不相交的。一旦两棵子树有交集(比如某个结点同时被两个父结点指向),那它就不是树形结构了——那是图,图以后单独有一门课讲。
- 除了根结点外,每个结点有且仅有一个父结点。
- 一棵有 N 个结点的树有 N - 1 条边。每一条边连接一个孩子,除了根以外的 N - 1 个结点各占一条边,这条性质虽然简单,但稍后证明二叉树性质时会派上大用场。
注意这里用的是"结点"(jié diǎn)这个词,它和"节点"是同一个概念,教材里两种写法都有。再体会一遍递归定义:"判断一棵树是否合法"这个动作本身也是递归的——先看根,再看它的每一棵子树。递归的思想在这里第一次出现,后面会反复用到。
树的术语
树有一套约定俗成的"家谱"式术语,我们把课件里那一组全都在同一棵树上认一遍,先看下图例再对照表格,比死记硬背轻松得多:
A ← A 是所有人的祖先
┌────┬────┬────┬────┬────┐
B C D E F G ← B、C、D、E、F、G 都是 A 的孩子
│ ││ ││
H I J K L ← D 的孩子是 H,E 的孩子是 I、J
│
Q ← J 的孩子是 Q
| 术语 | 含义 | 上图举例 |
|---|---|---|
| 父结点 / 双亲结点 | 含有子结点的结点 | A 是 B 的父结点 |
| 子结点 / 孩子结点 | 父结点的下一层结点 | B 是 A 的孩子结点 |
| 结点的度 | 一个结点有几个孩子,度就是几 | A 的度为 6,F 的度为 2,K 的度为 0 |
| 树的度 | 所有结点中最大的度 | 树的度为 6(A 贡献) |
| 叶子结点 / 终端结点 | 度为 0 的结点 | B、C、H、I、K、L |
| 分支结点 / 非终端结点 | 度不为 0 的结点 | D、E、F、G、J |
| 兄弟结点 | 具有相同父结点的结点(亲兄弟) | B、C 是兄弟结点 |
| 结点的层次 | 根为第 1 层,往下递增 | A 在第 1 层,Q 在第 4 层 |
| 树的高度 / 深度 | 树中结点的最大层次 | 这棵树的高度为 4 |
| 结点的祖先 | 从根到该结点路径上的所有结点 | A 是所有结点的祖先 |
| 路径 | 沿父-子连接从任意结点到另一结点的序列 | A 到 Q 的路径:A-E-J-Q |
| 子孙 | 以某结点为根的子树中的所有结点 | 所有结点都是 A 的子孙 |
| 森林 | m(m > 0)棵互不相交的树的集合 | 把根 A 去掉,剩下 B~Q 就是一片森林 |
两个容易混的点:一是"路径"是沿着父子关系走的,H 到 Q 的路径是 H-D-A-E-J-Q,要绕到根再下来;二是"森林"就是"砍掉根之后的树",一棵树砍掉根,它的子树们就自然组成森林。
树的表示
线性表用数组或链表就能存,树的结构要复杂得多——不仅要存结点的值,还要存结点与结点之间的父子关系。实际中树有多种表示方式:双亲表示法(每个结点只存父结点的下标)、孩子表示法(每个结点存它所有孩子的链表)、孩子双亲表示法(两者结合)等,其中最常用的是孩子兄弟表示法:
struct TreeNode
{
struct TreeNode* child; // 指向左边开始的第一个孩子结点
struct TreeNode* brother; // 指向其右边的下一个兄弟结点
int data; // 结点中的数据域
};孩子兄弟表示法的核心思想是"把多叉树强行变成二叉树":每个结点只记两个指针——第一个孩子和下一个兄弟。想找任意一个结点的所有孩子?从 child 出发,沿着 brother 一路扫过去就行。代价是查询"父是谁"比较麻烦(要往上回溯),所以它适合需要频繁遍历孩子的场景,也是"树转二叉树"的理论基础。别的表示法各有权衡:双亲表示法好找父亲、难找孩子;孩子表示法相反;孩子双亲表示法两全但每个结点要多存一个指针域。工程上按"哪种操作最频繁"来选,这正是数据结构设计的通用思路。
树形结构的实际运用
树在现实世界里最常见的应用就是文件系统。文件系统利用父结点和子结点之间的关系来表示不同层级的文件和文件夹:根目录是根结点,文件夹是分支结点,普通文件是叶子结点。你在资源管理器里一级一级点开的路径,就是树上的路径;把某个文件夹剪切到别处,就是"移动一棵子树"。C 盘、D 盘、回收站、控制面板共享一棵树,靠的就是这套层级结构。可以说,没有树,就没有我们现在用的操作系统界面。
二叉树的概念与结构
在树形结构中,最常用的就是二叉树。一棵二叉树是结点的有限集合,该集合由一个根结点加上两棵分别称为左子树和右子树的二叉树组成,或者为空。注意这个定义和树的定义一脉相承——它同样是递归的,而且明确允许空树。
二叉树有两个鲜明特点:
- 不存在度大于 2 的结点——每个结点最多两个孩子。
- 子树有左右之分,次序不能颠倒——所以二叉树是有序树。同样是两个孩子,左孩子是 1、右孩子是 2 和反过来是两棵不同的树。
任何一个二叉树,要么是空树,要么是下面五种基本形态之一复合而成:空结点、只有根、根加左子树、根加右子树、根加左右两棵子树。这个"由根 + 左右子树复合"的认识,是后面所有递归算法的立足点——你处理一棵二叉树,永远是"先处理根,再递归处理左子树和右子树"。
满二叉树与完全二叉树
二叉树里有两个特殊品种,堆的学习离不开它们。
满二叉树:每一层的结点数都达到最大值。如果一个满二叉树的层数为 K,它的结点总数是 2 的 K 次方减 1。比如 3 层的满二叉树,第 1 层 1 个、第 2 层 2 个、第 3 层 4 个,总共 7 个 = 2^3 - 1:
① 满二叉树(3 层,共 7 个结点)
/ \
② ③
/ \ / \
④ ⑤ ⑥ ⑦
完全二叉树:它是从满二叉树引出来的,效率很高的数据结构。对于深度为 K、有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应时,称之为完全二叉树。通俗地说:完全二叉树只允许最后一层不满,且最后一层的结点必须从左往右连续排列,中间不能有空缺:
A A A
/ \ / \ / \
B C B C B C
/ \ / \ / \ / \ \
D E F G D E D E F
满二叉树 完全二叉树 ✗ 不是完全二叉树
(也是完全二叉) (最后一层连续) (右孩子 F 前空缺,不连续)
满二叉树是特殊的完全二叉树——满的一定完全,完全的不一定满。这个包含关系后面判断"是不是完全二叉树"的算法和堆的存储都会用到。
二叉树的性质
二叉树有四条常用性质,我们一条条推导,不背结论:
性质 1:第 i 层最多有 2^(i-1) 个结点(根为第 1 层)。 第 1 层最多 1 个 = 2^0;每往下一层,结点数最多翻一倍(每个结点最多两个孩子),所以第 i 层最多 2^(i-1) 个。数学归纳法一句话就证完。
性质 2:深度为 h 的二叉树最大结点数是 2^h - 1。 各层都取最大值:2^0 + 2^1 + ... + 2^(h-1) = 2^h - 1(等比数列求和)。当且仅当它是满二叉树时取到。
性质 3:具有 n 个结点的满二叉树的深度为 h = log₂(n + 1)。 由性质 2,n = 2^h - 1,解得 h = log₂(n + 1)。这是"log 层数"的重要来源——后面堆的调整次数都跟它挂钩。
性质 4:对任何一棵二叉树,度为 0 的结点(叶子)个数 n0 = 度为 2 的结点个数 n2 + 1。 这是最常用也最值得亲自证一遍的性质。证明用"边数"做桥梁:
假设这棵树有 a 个度为 2 的结点、b 个度为 1 的结点、c 个叶子结点。那么结点总数是 a + b + c。从"度"的角度数边:每个度为 2 的结点贡献 2 条边,度为 1 的贡献 1 条,叶子贡献 0 条,所以边数 = 2a + b。从"结点"的角度数边:利用前面树的性质——N 个结点的树有 N - 1 条边,所以边数 = (a + b + c) - 1。两者是同一棵树的边数,必然相等:
2a + b = a + b + c - 1
a = c - 1 (两边消去 b,移项)
c = a + 1 (即 n0 = n2 + 1)这个性质在选择题里几乎是必考,后面我们会用它连做四道题。
二叉树的存储结构
二叉树一般用两种结构存储:顺序结构和链式结构。
顺序结构就是用数组存储。把树的结点按"从上至下、从左至右"的顺序放进数组后,你会发现:只有完全二叉树能实现"下标和树位置一一对应、零浪费"。看下面的对比——非完全二叉树如果硬塞进数组,中间会出现大量空位,白白浪费空间:
完全二叉树 → 数组 [A, B, C, D, E, F] 非完全二叉树 → 数组 [A, B, C, D, E, F, _, _]
A A
/ \ / \
B C B C
/ \ / / \ \
D E F D E F
下标 0 1 2 3 4 5 全部占用 下标 6、7 空着,浪费链式结构就是用链表表示二叉树:每个结点由数据域和左右指针域组成,左右指针分别指向左孩子和右孩子所在的链结点。链式结构又分二叉链和三叉链:二叉链只有 left / right 两个指针,我们当前阶段学的就是它;三叉链多一个指向父结点的指针,后面学到红黑树等高阶数据结构时会用到。一句话总结选型:完全二叉树用数组省空间且能随机访问,任意二叉树用链式最灵活。而接下来要实现的堆,正是"完全二叉树 + 顺序存储"这对黄金组合。
堆的概念与结构
现在进入本篇文章的第一个重点:堆。
堆本质上是"一棵用数组顺序存储的完全二叉树",但它还多一个额外的约束。设有一个关键码集合 K = {K0, K1, K2, ..., K(n-1)},把它的所有元素按完全二叉树的顺序存储方式存入一维数组,并且满足:Ki <= K(2i+1) 且 Ki <= K(2i+2)(i = 0、1、2...),则称为小堆(小根堆/最小堆);如果满足 Ki >= K(2i+1) 且 Ki >= K(2i+2),则称为大堆(大根堆/最大堆)。简单说:
- 小堆:根最小,每个结点的值都不大于它的孩子;
- 大堆:根最大,每个结点的值都不小于它的孩子。
堆具有两条性质:堆中某个结点的值总是不大于或不小于其父结点的值(对应小堆/大堆);堆总是一棵完全二叉树。注意堆只约束"父子之间"的大小关系,不约束"兄弟之间",所以堆不是有序的——这给了堆排序可乘之机,也给了它 O(1) 取最值的便利。
这里的堆和操作系统虚拟进程地址空间里的"堆"是两回事:我们说的是**数据结构**(用来组织数据的完全二叉树),操作系统里的堆是**内存管理**的一块区域分段(malloc/free 申请释放的那块)。同名不同物,面试被问到要能说清。
完全二叉树的顺序存储之所以好用,是因为下标之间藏着纯数学的亲子关系。对具有 n 个结点的完全二叉树,按从上至下、从左至右从 0 开始编号,对序号为 i 的结点有:
| 要找的结点 | 公式 | 条件 |
|---|---|---|
| 双亲 | (i - 1) / 2 | i > 0;i = 0 是根,无双亲 |
| 左孩子 | 2i + 1 | 2i + 1 < n,否则无左孩子 |
| 右孩子 | 2i + 2 | 2i + 2 < n,否则无右孩子 |
用一个大堆例子体会一下(数组下标写进结点里):
数组: 下标 0 1 2 3 4 5
[ 60, 42, 50, 30, 25, 35 ]
60 (0) i=1 的双亲 (1-1)/2 = 0,即 42 的父是 60
/ \ i=1 的左孩子 2*1+1=3,即 42 的孩子是 30
42(1) 50(2) i=5 的双亲 (5-1)/2 = 2,即 35 的父是 50
/ \ /
30(3) 25(4) 35(5)这套下标公式是堆所有算法的地基:向上调整靠"找双亲",向下调整靠"找孩子"。
堆的实现
我们以大堆为例,把堆的完整代码写出来。堆的底层就是动态数组,结构体定义和接口声明如下:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a; // 指向动态开辟的数组
int size; // 当前堆中的元素个数
int capacity; // 容量,方便扩容
} HP;
void HPInit(HP* php); // 默认初始化堆
void HPInitArray(HP* php, HPDataType* a, int n); // 利用给定数组建堆
void HPDestroy(HP* php); // 销毁堆
void HPPush(HP* php, HPDataType x); // 堆的插入
HPDataType HPTop(HP* php); // 取堆顶数据
void HPPop(HP* php); // 删除堆顶数据
bool HPEmpty(HP* php); // 判空
int HPSize(HP* php); // 元素个数先写一个通用的交换函数和堆的初始化/销毁等基础接口:
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
void HPDestroy(HP* php)
{
assert(php);
free(php->a); // 释放动态数组
php->a = NULL;
php->size = php->capacity = 0;
}
HPDataType HPTop(HP* php)
{
assert(php);
assert(php->size > 0); // 空堆取不了堆顶
return php->a[0];
}
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
int HPSize(HP* php)
{
assert(php);
return php->size;
}堆的插入:向上调整算法
往堆里插新元素分两步:先插到数组末尾(也就是完全二叉树最后一个孩子之后的位置),再从末尾顺着双亲往上调,直到重新满足堆的性质。这个"往上调"的过程就是向上调整算法 AdjustUp,它只和双亲比较,所以天然满足"从末尾往上"的路径:
// 向上调整:从下标 child 出发,不断与双亲比较,把大值往上"浮"
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2; // 先算出双亲下标
while (child > 0) // child 到根就停
{
if (a[child] > a[parent]) // 大堆:孩子比双亲大,违反堆性质
{
Swap(&a[child], &a[parent]); // 交换,把大值往上顶
child = parent; // 继续往上
parent = (parent - 1) / 2; // 找新的双亲
}
else
{
break; // 已经满足堆性质,提前结束
}
}
}
void HPPush(HP* php, HPDataType x)
{
assert(php);
// 扩容:容量不足时按 2 倍扩,与动态顺序表一致
if (php->size == php->capacity)
{
size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size] = x; // 新元素先放到数组末尾
php->size++;
AdjustUp(php->a, php->size - 1); // 再向上调整
}用图走一遍插入过程。初始大堆 [50, 42, 40, 30, 25, 35],插入 60:
50 50 60
/ \ / \ / \
42 40 --插入--> 42 40 --调整--> 42 50
/ \ / / \ / \ / \ / \
30 25 35 60 30 25 35 60 30 25 35 40
↑新元素 60 ↑ 60>40 交换 ↑ 60>50 再交换
(下标6) (到下标2) (到下标0,停)堆的删除:向下调整算法
删除堆顶(大堆里最大的元素)不能像删除数组元素那样直接搬——那会破坏完全二叉树的结构。标准做法三步走:堆顶与最后一个元素交换 → 删除最后一个元素 → 从堆顶向下调整。向下调整的前提是:左右子树必须已经是一个堆(堆顶被换进来一个"新成员",它的两个孩子子树还保持着堆结构,所以可以只调这一条路):
// 向下调整:从下标 parent 出发,选较大的孩子比较,把大值往下"沉"
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1; // 先默认左孩子是候选
while (child < n) // 孩子下标越界说明到叶子了
{
// 用"假设法":先假设左孩子大,若右孩子存在且更大,则改用右孩子
if (child + 1 < n && a[child + 1] > a[child])
{
++child; // 选出左右孩子中较大的那个
}
if (a[child] > a[parent]) // 大堆:孩子比双亲大,交换
{
Swap(&a[child], &a[parent]);
parent = child; // 继续往下
child = parent * 2 + 1;
}
else
{
break; // 双亲不小于两个孩子,调整结束
}
}
}
void HPPop(HP* php)
{
assert(php);
assert(php->size > 0); // 空堆不能删
Swap(&php->a[0], &php->a[php->size - 1]); // 堆顶与最后一个交换
php->size--; // 删除最后一个元素
AdjustDown(php->a, php->size, 0); // 从新堆顶开始向下调整
}还是用上面的堆走一遍删除:初始 [60, 42, 50, 30, 25, 35]:
60 40 50
/ \ / \ / \
42 50 --交换-- 42 50 --删除-- 42 40 --调整--
/ \ / / \ / \ / \
30 25 35 30 25 35 [60] 30 25 35
↑ 60 被换到末尾,删掉
堆顶 40 与两个孩子 42、50 比,选大的 50 交换;
50 的孩子 30、25 都比它小,停止。调整完成向下调整里"假设法选孩子"是个很秀的小技巧:先假设左孩子是大的,再用 if 判断右孩子是否更大,是就切换。这样只用一次比较就能选出两个孩子中的较大者,代码短且不会越界(child + 1 < n 保证了右孩子存在才访问)。
还有个常用接口 HPInitArray——给定一个乱序数组,直接原地建成堆。既然现在向下调整和向上调整都实现了,就有两种建堆策略,而它们的时间复杂度天差地别,这是堆最值得深挖的地方,下面单独用两节把推导完整走一遍。
向上调整建堆的时间复杂度:O(N·logN)
假设我们一个一个地把元素 HPPush 进堆,每次插入都要从末尾向上调整。总时间就是所有插入的向上调整次数之和。因为堆是完全二叉树,而满二叉树也是完全二叉树,为了简化证明,我们用满二叉树来近似(时间复杂度本来就是近似值,多几个结点不影响结果)。
设树的高度为 h。分析每一层:第 i 层有 2^(i-1) 个结点,最坏情况下每个结点要向上调整 i - 1 次(第 1 层在根上,调整 0 次):
| 层数 | 该层结点个数 | 每个结点向上调整次数 | 该层总移动步数 |
|---|---|---|---|
| 第 1 层 | 2^0 | 0 | 0 |
| 第 2 层 | 2^1 | 1 | 2^1 × 1 |
| 第 3 层 | 2^2 | 2 | 2^2 × 2 |
| ... | ... | ... | ... |
| 第 h 层 | 2^(h-1) | h - 1 | 2^(h-1) × (h - 1) |
总的移动步数 T(h) = 每层结点个数 × 该层调整次数之和:
T(h) = 2^1·1 + 2^2·2 + 2^3·3 + ... + 2^(h-1)·(h-1) ①这个"等差数列 × 等比数列"的求和叫"错位相减":先把等式两边都乘 2,让指数对齐,再用②式减①式,中间的项就会两两消掉:
2T(h) = 2^2·1 + 2^3·2 + ... + 2^(h-1)·(h-2) + 2^h·(h-1) ②
② - ① 得:
T(h) = 2^h·(h-1) - (2^1 + 2^2 + 2^3 + ... + 2^(h-1))
= 2^h·(h-1) - (2^h - 2) ← 等比数列求和
= 2^h·(h-2) + 2再利用二叉树性质:n = 2^h - 1,h = log₂(n + 1),代入:
T(n) = (n + 1)·(log₂(n + 1) - 2) + 2去掉低阶项和常数,结论就是:
向上调整建堆(逐个插入)的时间复杂度为 O(N·logN)。代价来自"结点越多,越深的层结点数量越大,每个还要爬 h - i 层",重灾区在底层。
向下调整建堆的时间复杂度:O(N)
换个思路建堆:直接把乱序数组当成完全二叉树,从最后一个非叶子结点开始,从下往上对每个结点执行一次 AdjustDown。注意向下调整是"上面的结点要往下沉",和向上调整正好反过来,所以它的代价分布也反过来——层数越深、结点越多的地方,向下调整的次数越少(叶子根本不用调)。这让总代价从 O(N·logN) 降到了 O(N)。
继续用满二叉树近似,高度 h。分析每一层:第 i 层有 2^(i-1) 个结点,最坏情况下要向下调整 h - i 次(叶子层调整 0 次,所以第 h 层不算):
| 层数 | 该层结点个数 | 每个结点向下调整次数 | 该层总移动步数 |
|---|---|---|---|
| 第 1 层 | 2^0 | h - 1 | 2^0 × (h - 1) |
| 第 2 层 | 2^1 | h - 2 | 2^1 × (h - 2) |
| 第 3 层 | 2^2 | h - 3 | 2^2 × (h - 3) |
| ... | ... | ... | ... |
| 第 h-1 层 | 2^(h-2) | 1 | 2^(h-2) × 1 |
| 第 h 层 | 2^(h-1) | 0 | 0(叶子不调整) |
总的移动步数:
T(h) = 2^0·(h-1) + 2^1·(h-2) + 2^2·(h-3) + ... + 2^(h-2)·1 ①同样用错位相减(两边乘 2 后,指数对齐再相减):
2T(h) = 2^1·(h-1) + 2^2·(h-2) + ... + 2^(h-2)·2 + 2^(h-1)·1 ②
② - ① 得:
T(h) = (2^1 + 2^2 + 2^3 + ... + 2^(h-2) + 2^(h-1)) - (h - 1)
= (2^h - 2) - (h - 1)
= 2^h - 1 - h代入 n = 2^h - 1,h = log₂(n + 1):
T(n) = n - log₂(n + 1) ≈ n向下调整建堆的时间复杂度为 O(N)。直觉:向下调整的"大头"在靠近根部的少量结点上(它们往下调得多但数量少),而数量巨大的底层结点几乎不用调——所以总代价被摊薄成了线性。
这两个结论是堆的"分水岭":逐个插入建堆 O(N·logN),自下而上向下调整建堆 O(N)。堆排序的时间复杂度正是建立在 O(N) 建堆之上,接下来就派上用场。
堆排序
堆排序有两个版本,对应两种思想层次。
版本一:借助现成的堆数据结构。 把所有数据依次 push 进堆,再不断取堆顶放到数组前面、同时 pop。注意我们前面实现的是大堆,它的堆顶是最大值,所以依次取出来得到的是降序结果;若想升序,把 AdjustUp / AdjustDown 里的大小比较反过来建小堆再取堆顶即可。这个版本的缺点是必须额外开辟一份堆的空间,空间复杂度 O(N):
// 版本一:借助堆数据结构完成排序
// 缺点:空间复杂度 O(N)
void HeapSort(int* a, int n)
{
HP hp;
HPInit(&hp);
// 1. 全部入堆,O(N*logN)
for (int i = 0; i < n; i++)
{
HPPush(&hp, a[i]);
}
// 2. 依次取堆顶:大堆堆顶是当前最大,取出来是降序结果
// 若想要升序,建小堆后依次取堆顶即可
int i = 0;
while (!HPEmpty(&hp))
{
a[i++] = HPTop(&hp); // 取堆顶
HPPop(&hp); // 删掉它,次大的浮上来
}
HPDestroy(&hp);
}版本二:原地排序,空间 O(1)。 关键思想两条:升序建大堆、降序建小堆(为什么升序要建大堆?因为我们要把最大的放到数组末尾,大堆的堆顶正好是最大的);然后首尾交换 + 向下调整,每轮把当前最大堆顶"扔"到数组尾部,并把它从堆的逻辑范围里剔除。注意建堆用的是前面证明过 O(N) 的向下调整法,从最后一个非叶子结点 (n-1-1)/2 开始倒着调:
// 版本二:原地建堆,空间 O(1)
// 升序 → 建大堆;降序 → 建小堆
void HeapSort(int* a, int n)
{
// 1. 原地建堆,O(N):从最后一个非叶子结点开始向下调整
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
// 2. 排序,O(N*logN):首尾交换 + 向下调整
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]); // 最大的堆顶换到末尾
AdjustDown(a, end, 0); // 新的堆顶下沉,end 缩小后堆逻辑范围减小
--end;
}
}堆排序的时间复杂度 = 建堆 O(N) + 排序阶段 O(N·logN)(每轮 AdjustDown 是 O(logN),共 N - 1 轮)= O(N·logN)。你可能会问:排序阶段的向下调整和向上调整建堆的代价分布一样吗?是的——每轮把不同层的结点换到堆顶再下沉,等价于"第 i 层结点下沉 i 层"的分析,和向上调整建堆同构,所以总代价是 O(N·logN)。这个版本的堆排序在最好、最坏、平均情况下都是 O(N·logN),而且原地完成,是真正的"稳定输出复杂度"的排序算法(注意是复杂度稳定,不是排序稳定性)。
TOP-K 问题
TOP-K 问题:求数据集合中前 K 个最大的元素或最小的元素,比如专业前 10 名、世界 500 强、富豪榜、游戏中前 100 的活跃玩家。数据量非常大时(可能都装不进内存),全量排序就不可取了——排序至少 O(N·logN),而且要求数据全部加载。用堆可以把时间压到 O(N·logK),内存只装 K 个数。思路三步:
- 用数据集合中前 K 个元素建堆:求前 K 个最大的元素,建小堆(小堆的堆顶是堆里最小的,方便"淘汰");求前 K 个最小的,建大堆。
- 用剩余的 N - K 个元素依次与堆顶比较:比堆顶大(在"找最大"场景)就替换堆顶并向下调整,让更大的数沉进堆里。
- 比完之后,堆中剩下的 K 个元素就是前 K 个最大(或最小)的元素。
为什么"找最大的 K 个"要建小堆?因为小堆堆顶是当前 K 个数里最小的——它是"守门员",新来的数只要比它大就能把它踢出去;如果你建大堆,堆顶是最大的,新来的数比不过堆顶就全被挡在外面,最后堆里反而是"前 K 个最大的"里混进了不该有的数。
先写一个造数据的函数,生成 10 万个随机数到文件里(需要 #include <stdlib.h> 和 #include <time.h> 支持 rand/srand/time),方便验证程序真的能从大数据中筛出最大的 K 个:
void CreateNDate()
{
// 造数据:向文件里写入 n 个随机数
int n = 100000;
srand((unsigned int)time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 1000000; // 保证数据在 0~999999
fprintf(fin, "%d\n", x);
}
fclose(fin);
}topk 里要维护的是一个小堆,而前面实现的 AdjustDown 是大堆版(选较大的孩子交换)。把大小比较反过来就是小堆版——选较小的孩子交换,其他逻辑完全一样:
// 小堆版向下调整:符号与前面的大堆版相反,选较小的孩子交换
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child]) // 选较小的孩子
{
++child;
}
if (a[child] < a[parent]) // 孩子比双亲小,交换(小堆)
{
int tmp = a[child];
a[child] = a[parent];
a[parent] = tmp;
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}然后是核心的 topk 函数,读取文件,建 K 个元素的小堆,用剩余数据滚动替换:
void topk()
{
printf("请输入k:>");
int k = 0;
scanf("%d", &k);
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 1. 先读前 k 个数进数组
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc error");
return;
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
}
// 2. 用上面小堆版的向下调整把这 k 个数建成小堆(O(K))
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(minheap, k, i);
}
// 3. 读取剩余 N-K 个数据,比堆顶大就替换并向下调整
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
if (x > minheap[0]) // 新数据比"守门员"大
{
minheap[0] = x; // 替换堆顶
AdjustDown(minheap, k, 0); // 重新调整为小堆
}
}
// 4. 输出堆中剩余 k 个数,即前 k 个最大
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
fclose(fout);
free(minheap);
}TOP-K 的时间复杂度:建堆 O(K) + 遍历剩余 N - K 个数据,每次替换后向下调整 O(logK),合计 O(K + (N - K)·logK) ≈ O(N·logK)。相比全排序的 O(N·logN),当 K 远小于 N 时(比如 K = 100,N = 10 亿),省下的时间非常可观,而且内存永远只有 K + 1 个整数。
课件里把 TOP-K 复杂度记为 O(n) 是取了"K 很小近似常数"的简化写法,严谨的表达式是 O(n·logK),面试时按这个说。
链式二叉树的实现
顺序存储解决了完全二叉树的存储问题,但面对任意形态的二叉树,链式才是通用方案。我们用链表来表示二叉树:每个结点由数据域和左右指针域组成。这就是二叉链,也是当前阶段的主角:
typedef int BTDataType;
// 二叉链
typedef struct BinaryTreeNode
{
struct BinaryTreeNode* left; // 指向当前结点的左孩子
struct BinaryTreeNode* right; // 指向当前结点的右孩子
BTDataType val; // 当前结点的值域
} BTNode;二叉树的创建方式比较复杂(要从遍历序列构建),为了先把遍历等核心内容讲透,我们先手动创建一棵链式二叉树。买结点 + 搭骨架:
BTNode* BuyBTNode(int val)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode == NULL)
{
perror("malloc fail");
return NULL;
}
newnode->val = val;
newnode->left = NULL;
newnode->right = NULL;
return newnode;
}
BTNode* CreateTree()
{
BTNode* n1 = BuyBTNode(1);
BTNode* n2 = BuyBTNode(2);
BTNode* n3 = BuyBTNode(3);
BTNode* n4 = BuyBTNode(4);
BTNode* n5 = BuyBTNode(5);
BTNode* n6 = BuyBTNode(6);
BTNode* n7 = BuyBTNode(7);
n1->left = n2; // 1 的左孩子是 2
n1->right = n4; // 1 的右孩子是 4
n2->left = n3; // 2 的左孩子是 3
n4->left = n5; // 4 的左孩子是 5
n4->right = n6; // 4 的右孩子是 6
n5->left = n7; // 5 的左孩子是 7
return n1; // 返回根结点
}这棵树长这样(先用一个不含 n7 的简化版本做讲解图,稍后讲遍历结果时再说 n7 的事):
1
/ \
2 4
/ / \
3 5 6回顾二叉树的定义:二叉树分为空树和非空二叉树,非空二叉树由根结点、根的左子树、根的右子树组成;而左子树和右子树又各自是"根 + 左 + 右"……二叉树的定义是递归的,所以后面链式二叉树的所有操作,几乎都是按这个递归结构实现的——这就是为什么你必须先把递归的思维磨利。
前中后序遍历
遍历是二叉树最核心的操作。按访问根结点的时机,递归遍历有三种:
- 前序遍历(Preorder):根 → 左子树 → 右子树;
- 中序遍历(Inorder):左子树 → 根 → 右子树;
- 后序遍历(Postorder):左子树 → 右子树 → 根。
代码惊人地相似,只有 printf 的位置不同——这正体现了递归遍历的本质:换一个"访问根"的位置,就换一种遍历:
// 前序遍历:根 左 右
void PreOrder(BTNode* root)
{
if (root == NULL) // 空树 / 递归出口
{
printf("N "); // 打印 N 代表空,便于观察递归过程
return;
}
printf("%d ", root->val); // 先访问根
PreOrder(root->left); // 再递归左子树
PreOrder(root->right); // 最后递归右子树
}
// 中序遍历:左 根 右
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left); // 先递归左子树
printf("%d ", root->val); // 再访问根
InOrder(root->right); // 最后递归右子树
}
// 后序遍历:左 右 根
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
PostOrder(root->left); // 先递归左子树
PostOrder(root->right); // 再递归右子树
printf("%d ", root->val); // 最后访问根
}课件 PPT 中 PostOrder 的实现误写成了两次调用 InOrder,请以这里为准:后序是 PostOrder(left) + PostOrder(right) + 打印根。
很多人背得下规则但看不懂"递归到底怎么走的"。我们以前序遍历为例,把 6 结点树(1-2-3 / 1-4-5-6)的递归调用过程画出来。每调用一次 PreOrder 就压一个栈帧,函数返回就弹栈,箭头表示调用关系,缩进表示栈的深度:
PreOrder(1) 打印 1 ┌─ 调用栈最底层
├─ PreOrder(2) 打印 2 │
│ ├─ PreOrder(3) 打印 3 │
│ │ ├─ PreOrder(NULL) 返回 │ N 表示空结点
│ │ └─ PreOrder(NULL) 返回 │
│ └─ PreOrder(NULL) 返回 │
└─ PreOrder(4) 打印 4 │
├─ PreOrder(5) 打印 5 │
│ ├─ PreOrder(NULL) 返回 │
│ └─ PreOrder(NULL) 返回 │
└─ PreOrder(6) 打印 6 │
├─ PreOrder(NULL) 返回 │
└─ PreOrder(NULL) 返回 └─ 逐层弹栈
前序遍历结果:1 2 3 4 5 6看到关键点了吗?前序的"打印"发生在递归左右子树之前,所以每个结点一被压栈就先打印自己;左子树整棵递归完,才轮到右子树。同样的套路,中序是"左子树递归完回来才打印自己",后序是"左右子树都递归完才打印自己":
中序遍历结果:3 2 1 5 4 6
后序遍历结果:3 2 5 6 4 1课件上后序遍历结果写的是"3 1 5 6 4 1",这是笔误,正确结果是 3 2 5 6 4 1。另外,上面 CreateTree 代码里 n5 还带着左孩子 n7(值为 7),如果按那份代码完整打印,三种遍历的结果分别是:前序 1 2 3 4 5 7 6、中序 3 2 1 7 5 4 6、后序 3 2 7 5 6 4 1。课件的讲解图没画 n7,所以课堂演示用的是不含 7 的 6 结点结果,两种都对得上,别被搞混。
非递归遍历:用栈模拟递归
递归遍历好懂,但有个现实问题:递归深度等于树高,树一深(比如退化成一棵斜树、有 10 万层),函数调用栈直接溢出。所以面试和工程里常常要求写出非递归版本——把系统栈换成我们自己控制的栈,逻辑完全等价。三种遍历的非递归写法各有各的套路,逐个看。
前序非递归:栈 + "先右后左"压栈。 递归前序是"根 → 左 → 右",改成栈模拟后,为了让左子树先被访问,压栈时要先压右孩子、再压左孩子(栈后进先出,后压的左孩子先弹出):
// 前序非递归:根先入栈;每轮弹出栈顶访问,再先压右、后压左
void PreOrderNonR(BTNode* root)
{
ST st;
STInit(&st);
if (root != NULL)
STPush(&st, root);
while (!STEmpty(&st))
{
BTNode* top = STTop(&st);
STPop(&st);
printf("%d ", top->val); // 访问根(栈顶)
if (top->right != NULL)
STPush(&st, top->right); // 先压右:右子树最后被弹出
if (top->left != NULL)
STPush(&st, top->left); // 再压左:左子树先被弹出访问
}
STDestroy(&st);
}用 6 结点树验证:入栈 1 → 弹 1 访问,压 4、压 2 → 弹 2 访问,压 3 → 弹 3 访问 → 弹 4 访问,压 6、压 5 → 弹 5、弹 6。输出 1 2 3 4 5 6,与递归版一致 ✓。
中序非递归:一路向左,弹一个转右。 中序的递归顺序是"左 → 根 → 右",非递归的经典写法是:从根出发,把整条左链全部压栈;然后弹出一个访问,立刻转向它的右子树,继续"一路向左":
// 中序非递归:cur 一路向左压栈;弹出访问后转向右子树,重复"一路向左"
void InOrderNonR(BTNode* root)
{
ST st;
STInit(&st);
BTNode* cur = root;
while (cur != NULL || !STEmpty(&st)) // 栈空且 cur 为空才结束
{
while (cur != NULL) // ① 沿着左链压栈
{
STPush(&st, cur);
cur = cur->left;
}
BTNode* top = STTop(&st); // ② 弹出栈顶访问(此时左子树已空/已访问完)
STPop(&st);
printf("%d ", top->val);
cur = top->right; // ③ 转向右子树,下一轮处理它
}
STDestroy(&st);
}验证:压 1、2、3 → 弹 3 访问(3 无右)→ 弹 2 访问(2 无右)→ 弹 1 访问,cur = 4 → 压 4、5 → 弹 5 → 弹 4 访问,cur = 6 → 压 6 → 弹 6。输出 3 2 1 5 4 6 ✓。外层的 cur != NULL || !STEmpty 这个条件很关键:cur 不为空说明还有左链要压,栈不空说明还有结点等着访问,两个条件一个都不能少。
后序非递归:双栈法。 后序是"左 → 右 → 根",直接模拟最绕(难点在于"根要等左右都处理完才能访问")。这里介绍最直观的双栈法:观察可知,后序"左右根"恰好是前序变体"根右左"的逆序。于是:用栈 1 按"根 → 右 → 左"的顺序遍历(把前序"先压右后压左"换成"先压左后压右"即可),每弹出一个结点就压入栈 2,最后把栈 2 依次弹出——得到的正好是"左 → 右 → 根":
// 后序非递归:双栈法。
// 栈1 按"根右左"出栈(入栈时先左后右),出栈结果依次压入栈2;
// 栈2 弹出的顺序就是"左右根"(后序)
void PostOrderNonR(BTNode* root)
{
ST st1, st2;
STInit(&st1);
STInit(&st2);
if (root != NULL)
STPush(&st1, root);
while (!STEmpty(&st1))
{
BTNode* top = STTop(&st1);
STPop(&st1);
STPush(&st2, top); // 出栈的结点先存入栈2
if (top->left != NULL)
STPush(&st1, top->left); // 先压左:栈1 先弹右
if (top->right != NULL)
STPush(&st1, top->right); // 再压右:栈1 后弹右 -> 即"根右左"
}
while (!STEmpty(&st2)) // 栈2 依次弹出 = 后序序列
{
printf("%d ", STTop(&st2)->val);
STPop(&st2);
}
STDestroy(&st1);
STDestroy(&st2);
}验证:栈1 出栈顺序是 1、4、6、5、2、3(根右左),压入栈2 后栈2 的栈顶到栈底是 1、4、6、5、2、3,依次弹出得到 3、2、5、6、4、1——正好是后序 ✓。双栈法的代价是多一个栈的空间,但代码逻辑干净,不需要任何标记位,是面试里最容易写对的后序非递归版本。
三种非递归遍历一句话各记一个口诀:前序——"弹栈访问,先右后左";中序——"左链压满,弹一个转右";后序——"根右左入一号栈,倒进二号栈输出"。其中前序和中序的非递归版本是面试高频手写题,务必练到能独立默写。
结点个数与高度等
掌握了"递归三板斧"(处理根 + 递归左 + 递归右),下面这一组函数就是机械套用:
// 二叉树结点个数:左子树个数 + 右子树个数 + 自己
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return BinaryTreeSize(root->left)
+ BinaryTreeSize(root->right) + 1;
}
// 叶子结点个数:没有孩子的结点才是叶子
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
return 0;
if (root->left == NULL && root->right == NULL)
return 1; // 自己是叶子
return BinaryTreeLeafSize(root->left)
+ BinaryTreeLeafSize(root->right);
}
// 第 k 层结点个数:把问题递归地"降层"
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
return 0;
if (k == 1)
return 1; // 第 1 层只有根自己
// 第 k 层的结点 = 左子树第 k-1 层 + 右子树第 k-1 层
return BinaryTreeLevelKSize(root->left, k - 1)
+ BinaryTreeLevelKSize(root->right, k - 1);
}
// 二叉树的深度:较高的一棵子树 + 自己这层
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
return 0;
int leftDepth = BinaryTreeDepth(root->left);
int rightDepth = BinaryTreeDepth(root->right);
return leftDepth > rightDepth ? leftDepth + 1 : rightDepth + 1;
}
// 查找值为 x 的结点:找到了立即返回,不再递归
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->val == x)
return root;
BTNode* ret = BinaryTreeFind(root->left, x);
if (ret != NULL)
return ret; // 左子树找到了直接返回
return BinaryTreeFind(root->right, x);
}
// 二叉树销毁:必须后序遍历!先释放孩子,再释放自己
void BinaryTreeDestory(BTNode** root)
{
if (*root == NULL)
return;
BinaryTreeDestory(&(*root)->left); // 先销毁左子树
BinaryTreeDestory(&(*root)->right); // 再销毁右子树
free(*root); // 最后释放根
*root = NULL; // 置空,防止野指针
}一是销毁必须用后序顺序:如果先 free 根,就再也找不到左右子树了,直接内存泄漏 + 访问野指针。二是销毁函数接收的是二级指针:只有通过 BTNode** 才能把调用者手里的 root 置为 NULL,否则函数外面还悬着一个野指针。
层序遍历
前中后序遍历是"先深入再回头"的深度优先,层序遍历则是广度优先:从根开始,自上而下、自左至右,逐层访问。层序遍历只靠递归不够,需要额外借助一个队列(队列是"先进先出"的线性表,前面课程实现的 Queue 结构在这里直接拿来用)。算法非常像"排队叫号":根入队 → 出队打印 → 它的左右孩子依次入队,循环往复:
// 层序遍历:借助队列实现,先入先出
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root); // 根结点先入队
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q); // 取出队头
printf("%d ", front->val); // 访问它
QueuePop(&q); // 出队
if (front->left) // 左孩子入队
QueuePush(&q, front->left);
if (front->right) // 右孩子入队
QueuePush(&q, front->right);
}
QueueDestroy(&q); // 记得释放队列
}用 6 结点树走一遍队列的状态变化(| 表示队头方向):
队列入队序列:[1] → 出1,入2、4 → [2,4] → 出2,入3 → [4,3]
→ 出4,入5、6 → [3,5,6] → 出3 → [5,6] → 出5 → [6] → 出6 → 空
输出:1 2 4 3 5 6判断完全二叉树
有了层序遍历,判断一棵树是不是完全二叉树就很简单了,思路一句话:层序遍历中,一旦遇到空结点,它后面就不允许再出现非空结点。因为完全二叉树最后一层的结点必须从左到右连续,空位只能出现在末尾。代码仍然是层序遍历的骨架,区别是空结点也入队:
// 判断是否是完全二叉树
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root); // 空树入队一个 NULL
// 第一阶段:层序遍历,遇到第一个空结点就停
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
{
break; // 遇到空结点,退出
}
QueuePush(&q, front->left); // 空孩子也入队!
QueuePush(&q, front->right);
}
// 第二阶段:检查队列里剩下的全必须是空结点
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front != NULL) // 空结点后面还有非空结点
{
QueueDestroy(&q);
return false; // 不是完全二叉树
}
}
QueueDestroy(&q);
return true; // 空位都在末尾,是完全二叉树
}关键在"空孩子也入队":非完全二叉树的空位不在末尾,层序扫过去必然出现"空结点之后还有非空结点"的破绽,被第二阶段抓到。这个"两阶段检查"的技巧在算法题里也常见(比如判断完全二叉树、序列化二叉树)。
二叉树算法题
掌握了上面的实现,算法题其实就是"递归三板斧"的迁移。这几道题都是 LeetCode / 牛客上的经典原题,务必亲手敲一遍。
单值二叉树
题目:判断一棵二叉树中所有结点的值是否都相同。思路:根和左孩子比、根和右孩子比,只要有一处不等就整体为假;相等则继续递归检查左右子树:
bool isUnivalTree(struct TreeNode* root)
{
if (root == NULL)
return true; // 空树认为单值
if (root->left && root->left->val != root->val) // 左孩子存在且不同值
return false;
if (root->right && root->right->val != root->val) // 右孩子存在且不同值
return false;
return isUnivalTree(root->left) && isUnivalTree(root->right);
}相同的树(含对称二叉树扩展)
题目:判断两棵二叉树是否结构相同且对应值相同。思路:两棵树同时递归,先处理"都空 / 一空一非空 / 值不等"三种提前返回的情况:
bool isSameTree(struct TreeNode* p, struct TreeNode* q)
{
if (p == NULL && q == NULL) return true; // 都空,相同
if (p == NULL || q == NULL) return false; // 一空一非空,不同
if (p->val != q->val) return false; // 值不同,不同
// 左对左、右对右同时递归
return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
}同一套模板换一下"配对"方式,就是对称二叉树:不再是"左对左、右对右",而是"左对右、右对左"——因为镜像对称的树,一棵树的左子树要对应另一棵的右子树:
bool _isSymmetric(struct TreeNode* left, struct TreeNode* right)
{
if (left == NULL && right == NULL) return true;
if (left == NULL || right == NULL) return false;
if (left->val != right->val) return false;
// 镜像配对:left 的左 vs right 的右;left 的右 vs right 的左
return _isSymmetric(left->left, right->right)
&& _isSymmetric(left->right, right->left);
}
bool isSymmetric(struct TreeNode* root)
{
return _isSymmetric(root, root); // 树和它自己比较
}另一棵树的子树
题目:判断 subRoot 是否是 root 的子树。思路:站在 root 的每个结点上"套用 isSameTree",只要有一个结点出发的树和 subRoot 相同就成了——这正好是"大树里找小树"的暴力枚举,配合 isSameTree 的递归:
bool isSubtree(struct TreeNode* root, struct TreeNode* subRoot)
{
if (root == NULL)
return false; // 找遍了都没有
if (isSameTree(root, subRoot))
return true; // 当前结点这棵子树相同
// 否则去左右子树里继续找
return isSubtree(root->left, subRoot)
|| isSubtree(root->right, subRoot);
}这道题的题眼是"复用":isSubtree 本质上就是把 isSameTree 当成"匹配函数",对 root 的每个结点调用一次。这种"一个函数递归枚举、另一个函数递归匹配"的组合模式,在树的双递归题目里反复出现。
二叉树遍历(前/中/后序)
LeetCode 的遍历题要求把结果装进数组返回,所以要多一个"收集"函数,用 returnSize 指针记录个数:
// 前序遍历:把访问改为"写入数组"
void _preorder(struct TreeNode* root, int* res, int* size)
{
if (root == NULL)
return;
res[(*size)++] = root->val; // 收集根
_preorder(root->left, res, size); // 左子树
_preorder(root->right, res, size);// 右子树
}
int* preorderTraversal(struct TreeNode* root, int* returnSize)
{
int* res = (int*)malloc(100 * sizeof(int));
*returnSize = 0;
_preorder(root, res, returnSize);
return res;
}中序、后序只需要把 _preorder 里那三行(收集根 / 左 / 右)按 LNR、LRN 的顺序调换,就是对应题目的答案——这就是"换 printf 的位置换遍历"的工程版。
由前序与中序重建二叉树(LeetCode 105)
光会"遍历"还不够,真实的二叉树往往不是手动搭出来的,而是由遍历序列反推出来的。经典题目:给定前序和中序遍历序列(不含重复值),重建这棵二叉树。比如前序 [3, 9, 20, 15, 7]、中序 [9, 3, 15, 20, 7]。
思路。 靠两个铁律:前序序列的第一个元素就是根;在中序序列里,根的左边是整棵左子树、右边是整棵右子树。于是:在前序里取出根 → 在中序里定位根 → 把问题切成"左子树的前序+中序"和"右子树的前序+中序"两个子问题 → 递归解决。这正是前面选择题第 6 题"前序定根、中序分左右"的程序化实现。
#include <stdlib.h>
// LeetCode 105. 从前序与中序遍历序列构造二叉树
// preIndex 是前序数组的读取游标(必须传指针,递归时共享)
// [inBegin, inEnd] 是当前子树在中序数组中的区间
struct TreeNode* _buildTree(int* preorder, int* preIndex,
int* inorder, int inBegin, int inEnd)
{
if (inBegin > inEnd) // 中序区间为空:没有结点可建,返回空
return NULL;
struct TreeNode* root = (struct TreeNode*)malloc(sizeof(struct TreeNode));
root->val = preorder[*preIndex]; // ① 前序游标处的值就是当前子树的根
root->left = root->right = NULL;
// ② 在中序区间里找到根的位置 rootIn
int rootIn = inBegin;
while (rootIn <= inEnd && inorder[rootIn] != root->val)
rootIn++;
(*preIndex)++; // ③ 游标前进:下一个位置是子树根的取值处
// ④ 递归建左子树(中序 rootIn 左边)和右子树(中序 rootIn 右边)
root->left = _buildTree(preorder, preIndex, inorder, inBegin, rootIn - 1);
root->right = _buildTree(preorder, preIndex, inorder, rootIn + 1, inEnd);
return root;
}
struct TreeNode* buildTree(int* preorder, int preorderSize,
int* inorder, int inorderSize)
{
(void)preorderSize; // 题目签名要求带上,实际由中序区间控制递归
int preIndex = 0; // 前序数组的读取游标,从 0 开始
return _buildTree(preorder, &preIndex, inorder, 0, inorderSize - 1);
}用 [3, 9, 20, 15, 7] 和 [9, 3, 15, 20, 7] 走一遍:第一次调用取前序 0 号元素 3 为根,中序里 3 在下标 1,左区间 [0, 0](值 9)、右区间 [2, 4](值 15 20 7)。递归建左子树:游标前进到 1,取 9,中序区间只有一个元素,根就是 9。递归建右子树:游标到 2,取 20 为右子树根,中序 20 在下标 3,它的左区间 [2, 2](值 15)、右区间 [4, 4](值 7)……最终还原出完整二叉树。
`preIndex` 必须用指针传递:每次递归都会消费前序数组的不同位置,左子树递归建完后游标已经推进,右子树要从"推进后的位置"继续取根。如果按值传递,左子树里推进的游标在返回时就丢了,右子树的根会取错。这和牛客"前序字符串建树"里 `pi` 指针是同一个坑。
平衡二叉树(LeetCode 110)
题目:判断一棵二叉树是否是高度平衡的——每个结点的左右子树高度差都不超过 1。最容易想到的写法是:对每个结点都调一次"求深度",再递归检查左右子树,即"先序遍历 + 每次求深度",但这样每个结点会被重复访问多次,复杂度 O(N²)(每层都要把下面的子树重新算一遍深度)。更优的是后序自底向上:在递归返回的过程中顺便把高度带出来,一旦发现某个子树不平衡,立刻返回 -1 标记,提前剪枝,整体 O(N):
#include <stdlib.h> // abs
#include <stdbool.h> // bool
// 返回树的高度;如果发现不平衡返回 -1(作为"坏信号"向上传递)
int _height(struct TreeNode* root)
{
if (root == NULL)
return 0; // 空树高度 0
int leftH = _height(root->left);
if (leftH == -1) // 左子树已经不平衡,整棵树必不平衡
return -1; // 剪枝:不再往下算
int rightH = _height(root->right);
if (rightH == -1) // 右子树已经不平衡
return -1;
if (abs(leftH - rightH) > 1) // 左右高度差超过 1,当前结点不平衡
return -1;
return (leftH > rightH ? leftH : rightH) + 1; // 返回以 root 为根的子树高度
}
bool isBalanced(struct TreeNode* root)
{
return _height(root) != -1; // 高度不是 -1 就说明整棵树平衡
}这个"后序遍历 + 返回特殊值当信号"的技巧非常经典:它把"既要结果(高度)、又要判断(是否平衡)"两件事揉进同一个返回值里。类似的变形还有"求二叉树直径(LeetCode 543)""判断二叉搜索树(LeetCode 98)"——都是同一套"递归返回时顺便携带信息"的思维。对比前序解法,后序解法的优势在于每个结点恰好访问一次,且一旦发现不平衡立刻终止,时间稳定 O(N)。
二叉树的构建及遍历(牛客)
前面所有操作都假设树已经建好,但真实题目往往只给一个前序遍历字符串,比如 ABC##DE#G##F###,其中 # 表示空结点,要你自己把树建出来再中序输出。构建同样递归:读一个字符,是 # 就返回空;否则创建结点,递归建左子树,再递归建右子树:
#include <stdio.h>
#include <stdlib.h>
typedef struct BTNode
{
char val;
struct BTNode* left;
struct BTNode* right;
} BTNode;
BTNode* BuyBTNode(char val)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
node->val = val;
node->left = node->right = NULL;
return node;
}
// 根据前序字符串建树,pi 是"读取位置"的指针
BTNode* BuildTree(char* str, int* pi)
{
if (str[*pi] == '#') // 遇到 #,代表空结点
{
(*pi)++;
return NULL;
}
BTNode* root = BuyBTNode(str[(*pi)++]); // 建根
root->left = BuildTree(str, pi); // 递归建左子树
root->right = BuildTree(str, pi); // 递归建右子树
return root;
}
// 中序遍历输出(顺序:左 根 右)
void InOrder(BTNode* root)
{
if (root == NULL)
return;
InOrder(root->left);
printf("%c ", root->val);
InOrder(root->right);
}
int main()
{
char str[100];
while (scanf("%s", str) != EOF) // 牛客多组输入
{
int i = 0;
BTNode* root = BuildTree(str, &i);
InOrder(root);
printf("\n");
}
return 0;
}BuildTree 的第二个参数必须是指向 int 的指针:递归调用时每个分支都要"消费"字符串的不同位置,如果传值,左子树读过的位置就丢掉了。这个"用指针共享读取游标"的手法,在一切"从字符串重建树"的题目里都是标配。
二叉树选择题精讲
理论学完,用一组经典选择题检验一下。前四道考性质 4(n0 = n2 + 1),后四道考遍历与层次。
1. 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( ) A. 不存在这样的二叉树 B. 200 C. 198 D. 199
解析:由 n0 = n2 + 1,n2 = 199,叶子 n0 = 200。此时度为 1 的结点数 = 399 - 199 - 200 = 0,完全自洽(边数 2×199 + 0 = 398 = 结点数 399 - 1 ✓),所以这棵树是存在的。答案 B。
2. 在具有 2n 个结点的完全二叉树中,叶子结点个数为( ) A. n B. n + 1 C. n - 1 D. n / 2
解析:设叶子 n0、度 1 结点 n1、度 2 结点 n2。n0 + n1 + n2 = 2n,且 n0 = n2 + 1。代入得 2n2 + n1 + 1 = 2n,所以 n1 必为奇数。完全二叉树里度 1 的结点只可能是 0 或 1(它只出现在最后一层,最多一个位置能只有左孩子),奇数只能是 n1 = 1。于是 2n2 + 1 + 1 = 2n,n2 = n - 1,叶子 n0 = n2 + 1 = n。答案 A。
3. 一棵完全二叉树的结点数为 531 个,那么这棵树的高度为( ) A. 11 B. 10 C. 8 D. 12
解析:满二叉树 9 层的结点数是 2^9 - 1 = 511,10 层满的是 2^10 - 1 = 1023。531 落在区间 511 < 531 ≤ 1023 内,所以高度是 10。答案 B。
4. 一个具有 767 个结点的完全二叉树,其叶子结点个数为( ) A. 383 B. 384 C. 385 D. 386
解析:这是综合题,需要一层层拆。767 在 511 与 1023 之间,高度 h = 10。前 9 层满,共 511 个结点,第 10 层有 767 - 511 = 256 个结点,它们全是叶子。第 9 层原本有 2^8 = 256 个位置,这 256 个第 10 层结点需要第 9 层有 256 ÷ 2 = 128 个结点当"父亲"(每个父亲贡献 2 个位置),所以第 9 层有 128 个分支结点、256 - 128 = 128 个叶子。叶子总数 = 第 10 层 256 + 第 9 层 128 = 384。答案 B。
5. 某完全二叉树按层次输出(同一层从左到右)的序列为 ABCDEFGH,该完全二叉树的前序序列为( ) A. ABDHECFG B. ABCDEFGH C. HDBEAFCG D. HDEBFGCA
解析:8 个结点的完全二叉树,按层次序列能直接还原形状:A 是根,B、C 是第 2 层,D、E、F、G 是第 3 层,H 是第 4 层(D 的左孩子)。前序遍历顺序:A → B → D → H → E → C → F → G,即 ABDHECFG。答案 A。
6. 二叉树的先序遍历和中序遍历如下:先序 EFHIGJK,中序 HFIEJKG,则二叉树根结点为( ) A. E B. F C. G D. H
解析:前序遍历的第一个结点就是根,EFHIGJK 的第一个是 E,所以根是 E。用中序复核:中序 HFIEJKG 中 E 把序列分成左子树 HFI 和右子树 JKG,完全自洽。答案 A。这个"前序定根、中序分左右"的组合,就是上面牛客建树题的原理。
7. 设一棵二叉树的中序遍历序列为 badce,后序遍历序列为 bdeca,则二叉树前序遍历序列为( ) A. adbce B. decab C. debac D. abcde
解析:后序的最后一个结点是根——bdeca 最后一个 a 是根。中序 badce 里 a 在最中间,左边 b 是左子树,右边 dce 是右子树。右子树部分:后序里右子树是 de c(a 之前的 bde c,注意 b 是左子树),最后一个 c 是右子树的根;中序 dce 里 c 在中间,左 d、右 e。整棵树:a 的左孩子 b,a 的右孩子 c,c 的左孩子 d,c 的右孩子 e。前序 = 根左右 = abcde。答案 D。
8. 某二叉树的后序遍历序列与中序遍历序列相同,均为 ABCDEF,则按层次输出的序列为( ) A. FEDCBA B. CBAFED C. DEFCBA D. ABCDEF
解析:后序是"左、右、根",中序是"左、根、右"。对任意一个结点,设它的左子树序列为 L、右子树序列为 R、根为 r:中序是 L + r + R,后序是 L + R + r。要让两者相等,R 必须为空(否则 r 在两个序列里的位置对不上),所以每个结点都没有右子树,只有左孩子,形成一条"左单链":F 是根,F 的左孩子 E,E 的左孩子 D……一直链到 A。此时中序与后序都是 ABCDEF,层次输出从根 F 开始每层一个:FEDCBA。答案 A。
四道性质题考的是 n0 = n2 + 1 与完全二叉树"度 1 结点最多一个"的组合运用;四道遍历题考的是"前序/后序定根 + 中序分左右"和"层次序列还原树形"两个基本功。把这八道题吃透,二叉树的经典选择题就基本通关了。
思考题与练习
二叉树是递归的天下,也是面试的题库。下面是六道从基础到进阶的练习,覆盖了本章最容易考到的点。
练习 1:遍历序列互推。 上面用"前序 + 中序"重建了二叉树(LeetCode 105)。请思考:只用"后序 + 中序"能重建吗?"前序 + 后序"能重建吗?为什么?
提示
"后序 + 中序"可以:后序的**最后一个**是根,其余逻辑和 105 完全对称(参考选择题第 7 题)。"前序 + 后序"**一般不能**唯一重建——前序和后序都只能确定根的位置,但无法区分左右子树:比如只有一个左孩子和只有一个右孩子,前序后序完全相同,却对应两棵不同的树。只有当二叉树本身是满二叉树时,"前序 + 后序"才可能唯一确定。练习 2:非递归层序遍历变体。 把层序遍历的结果按"一层一个数组"输出(LeetCode 102):[ [1], [2,4], [3,5,6] ]。提示:在现有层序遍历的循环里,每轮开始时先记下 QueueSize(&q),这一轮就只处理这么多结点——它们恰好是同一层。
提示
每次进入 while 循环时,队列里恰好装着同一层的全部结点。先用 `int levelSize = QueueSize(&q)` 记下个数,再循环 `levelSize` 次:出队、收集、把孩子入队。这样内层循环结束,队列里正好是下一层的全部结点。这个"按层定界"的技巧在"二叉树右视图(LeetCode 199)""N 叉树层序遍历"里都会复用。练习 3:堆的细节追问。 ① 向下调整建堆为什么必须从最后一个非叶子结点开始,从下标 0 开始行不行?② 用向上调整逐个插入建堆,为什么是 O(N·logN) 而不是 O(N)?③ 小堆的堆顶是什么?用它求"前 K 个最小"还是"前 K 个最大"?
提示
① 从 0 开始不行:AdjustDown 的前提是"左右子树已经是堆",只有从下往上调整才能保证这个前提,从顶部往下调时下面的子树还不是堆。② 逐个插入时,新元素总是先放在最底层,最底层结点最多(占一半),每个都要向上爬很多层,重灾区在底层;而向下调整建堆时底层叶子根本不用调,代价分布正好相反——所以一个是 O(N·logN)、一个是 O(N)。③ 小堆堆顶是当前最小;求"前 K 个最小"建大堆(堆顶是守门员,比堆顶小才替换),求"前 K 个最大"建小堆(堆顶是守门员,比堆顶大才替换),和 TOP-K 部分讲的一致。练习 4:最大深度与直径(LeetCode 104 / 543)。 二叉树的最大深度已经用 BinaryTreeDepth 实现过。现在做"直径":二叉树中任意两个结点路径长度的最大值(LeetCode 543)。提示:直径一定经过某个结点,等于"它左子树深度 + 右子树深度"的最大值——在求深度的后序递归里顺手更新一个全局最大值即可。
提示
对每个结点,经过它的最长路径 = leftH + rightH(左右子树深度之和)。在后序求深度的同时,用全局变量 `max` 记录所有结点 leftH + rightH 的最大值,最后返回 max。这个"递归求深度的副产品"思路,和平衡二叉树那道题同源——返回高度,顺手记录额外信息。练习 5:判断二叉搜索树(LeetCode 98)。 一棵二叉搜索树满足:左子树所有结点 < 根 < 右子树所有结点,且左右子树自身也是搜索树。注意"左子树所有结点"而不是"左孩子"——只比较相邻结点会漏判。提示:中序遍历二叉搜索树得到的是严格递增序列,用这个性质判断。
提示
方法一:中序遍历收集所有值,检查是否严格递增。方法二(更省空间):中序遍历时用一个指针记录"前一个被访问的结点",当前结点值必须大于前一个。后序/标记法也可以,但中序性质最直接。注意题目要求严格递增,相等值也不合法。练习 6(挑战):二叉树的所有路径(LeetCode 257)。 输出从根到每个叶子结点的完整路径,比如 ["1->2->3", "1->4->5", ...]。提示:前序遍历 + 路径栈,走到叶子时把路径输出,回溯时把当前结点弹出。
提示
前序遍历:把当前结点加入路径,如果是叶子就输出整条路径;否则递归左、右孩子。**关键在回溯**:递归返回后要把当前结点从路径里移除(弹出),否则路径会被错误地越加越长。这个"加入路径 → 递归 → 移除"的模式叫**回溯**,是树和图路径类题目的标准套路,也是将来学 DFS 的基石。学完本章,你应该能脱口而出:树的"互不相交"和"N 个结点 N-1 条边";二叉树性质 4(n0 = n2 + 1)的边数证明;完全二叉树 vs 满二叉树;堆的下标公式((i-1)/2、2i+1、2i+2);向上/向下调整的逻辑与各自建堆复杂度(O(N·logN) vs O(N))及原因;堆排序"升序建大堆"和 TOP-K"找最大建小堆"的反直觉结论;三种递归遍历"换 printf 位置"的本质;非递归前序"先右后左"、中序"左链压满"、后序双栈;"前序定根、中序分左右"的重建原理。这些都能答上,二叉树这一关你就过了大半,接下来可以去看八大排序如何把这张知识网收拢。
收尾
从文件夹系统里那棵"倒挂的树"出发,我们一路走到了这里:树的定义与术语、孩子兄弟表示法;二叉树的性质与两种存储;然后一头扎进顺序存储的堆——亲手实现大堆的插入删除,用错位相减证明了向上调整建堆 O(N·logN)、向下调整建堆 O(N) 的复杂度,再顺势拿下堆排序与 TOP-K;接着转战链式二叉树,用"递归三板斧"打穿三种遍历和一堆统计函数,用队列实现层序遍历与完全二叉树判断,再补上三种非递归遍历,最后用七道算法题和八道选择题完成闭环。
回头看,贯穿全文的其实是两条线:递归(二叉树的结构天然递归,所以遍历、统计、查找全用递归)和完全二叉树的下标数学(堆的一切都建立在 (i-1)/2、2i+1、2i+2 之上)。把这两条线抓在手里,二叉树的后续内容——搜索树、平衡树、红黑树——都只是在这棵大树上继续生长。现在,去把文中每一段代码亲手敲一遍,尤其是堆的调整和递归遍历:有些东西,只有手指记住了,才是真的会了。
还没有评论 — 第一条由你来留。