二叉树:从基础概念到 LeetCode 实战
如果你正在准备算法面试,或者刚学完链表、栈、队列这些线性结构,想往更复杂的数据结构迈进------二叉树几乎是绕不过去的一道坎。
说实话,二叉树这个东西,概念本身不难理解。但一旦到了递归遍历、堆调整、LeetCode 刷题的环节,很多人就开始头大了。我自己当初学的时候也是这样:看懂了定义,一到写代码就懵。
所以这篇文章,我打算把二叉树从头到尾捋一遍。从最基础的定义开始,到四种遍历方式,再到堆和堆排序,最后用几道 LeetCode 经典题收尾。由浅入深,尽量把每一步的"为什么"讲清楚,而不只是"怎么做"。
第一部分:二叉树的基础概念
1.1 树的定义
先说树。这里的"树"不是外面种的那棵,而是一种层次化的数据结构。
树是由 n n n( n ≥ 0 n \geq 0 n≥0)个节点组成的有限集合。当 n = 0 n = 0 n=0 时,称为空树 ;当 n > 0 n > 0 n>0 时,有一个特殊的节点称为根节点(root) ,其余节点可以分为 m m m( m > 0 m > 0 m>0)个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树。
这个定义是递归的------树由子树构成,子树又是一棵树。这个递归特性贯穿了二叉树的整个学习过程,后面写遍历代码的时候你会深有体会。
1.2 二叉树的定义
二叉树是树的一种特例。它的约束很简单:每个节点最多有两个子节点 ,分别称为左孩子 和右孩子。
A
/ \
B C
/ \
D E
上面就是一棵二叉树。A 是根节点,B 是 A 的左孩子,C 是 A 的右孩子。D 和 E 是 B 的孩子。

图片说明:一棵标准的二叉树,根节点标为 A,位于图的最上方。A 的左孩子是 B,右孩子是 C,用线段连接。B 的左孩子是 D,右孩子是 E。C 没有子节点(叶子节点)。图中标注了 root 指向 A,B 和 C 分别为"左孩子"和"右孩子",D 和 E 为"叶子节点"。
1.3 满二叉树与完全二叉树
这两个概念面试常考,也容易混淆。
满二叉树(Full Binary Tree) :每一层的节点数都达到最大值。也就是说,一棵深度为 h h h 的满二叉树有 2 h − 1 2^h - 1 2h−1 个节点,所有叶子都在同一层,每个非叶子节点都有两个孩子。
完全二叉树(Complete Binary Tree):除了最后一层,其他每一层都被完全填满,并且最后一层的节点从左到右连续排列,中间不能有空缺。
两者的区别在哪?满二叉树要求所有层都满,完全二叉树允许最后一层不满,但必须从左往右依次排。满二叉树一定是完全二叉树,但完全二叉树不一定是满二叉树。

图片说明:左右并排两棵树。左边标题"满二叉树":3 层 7 个节点,第 1 层 1 个(根),第 2 层 2 个,第 3 层 4 个,全部填满。右边标题"完全二叉树":3 层 6 个节点,第 1 层 1 个,第 2 层 2 个,第 3 层从左到右 3 个节点,最右侧缺一个。
1.4 关键术语
| 术语 | 含义 |
|---|---|
| 节点的度(Degree) | 节点拥有的子树数。二叉树中度的取值范围是 0、1、2 |
| 叶子节点(Leaf) | 度为 0 的节点,没有孩子 |
| 分支节点 | 度不为 0 的节点 |
| 节点的层次(Level) | 根为第 1 层,根的孩子为第 2 层,以此类推 |
| 树的深度(Depth) | 树中节点的最大层次 |
| 父节点 / 子节点 | 直接相连的上下两级节点 |
| 兄弟节点 | 同一个父节点的子节点 |
1.5 二叉树的性质
这几条性质看起来简单,但推导过程能帮你理解二叉树的内部结构。
性质 1 :第 i i i 层上最多有 2 i − 1 2^{i-1} 2i−1 个节点( i ≥ 1 i \geq 1 i≥1)。
第 1 层只有根节点,1 个。第 2 层最多 2 个,第 3 层最多 4 个......每层翻倍,所以第 i i i 层最多 2 i − 1 2^{i-1} 2i−1 个。
性质 2 :深度为 h h h 的二叉树最多有 2 h − 1 2^h - 1 2h−1 个节点。
把每层的最大节点数加起来: 2 0 + 2 1 + 2 2 + ⋯ + 2 h − 1 = 2 h − 1 2^0 + 2^1 + 2^2 + \cdots + 2^{h-1} = 2^h - 1 20+21+22+⋯+2h−1=2h−1。等比数列求和公式直接得出。
性质 3 :对任何一棵二叉树,如果叶子节点数为 n 0 n_0 n0,度为 2 的节点数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1。
这条性质面试经常考。推导过程:设 n 0 n_0 n0、 n 1 n_1 n1、 n 2 n_2 n2 分别是度为 0、1、2 的节点数,总节点数 n = n 0 + n 1 + n 2 n = n_0 + n_1 + n_2 n=n0+n1+n2。另一方面,树的边数等于 n − 1 n - 1 n−1(每个节点除了根都有一条边连向父节点),也等于 n 1 + 2 n 2 n_1 + 2n_2 n1+2n2(度为 1 的节点贡献 1 条边,度为 2 的贡献 2 条)。所以 n − 1 = n 1 + 2 n 2 n - 1 = n_1 + 2n_2 n−1=n1+2n2,代入 n = n 0 + n 1 + n 2 n = n_0 + n_1 + n_2 n=n0+n1+n2 得到 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1。
性质 4 :具有 n n n 个节点的完全二叉树的深度为 ⌊ log 2 n ⌋ + 1 \lfloor \log_2 n \rfloor + 1 ⌊log2n⌋+1。
完全二叉树深度为 h h h 时,节点数 n n n 满足 2 h − 1 ≤ n < 2 h 2^{h-1} \leq n < 2^h 2h−1≤n<2h,取对数即可得到。
1.6 存储方式
二叉树有两种主要的存储方式。
顺序存储(数组)
把二叉树的节点按层序遍历的顺序存入数组。对于完全二叉树来说,这种存储方式很自然,也不浪费空间。
假设节点存在数组 arr 中,下标从 0 开始:
- 节点
i的左孩子下标:2 * i + 1 - 节点
i的右孩子下标:2 * i + 2 - 节点
i的父节点下标:(i - 1) / 2
但如果树很不平衡(比如退化成链表),数组中间会有大量空位,浪费严重。所以顺序存储主要用于完全二叉树场景,比如后面要讲的堆。
链式存储(二叉链表)
更通用的方式。每个节点包含数据域和两个指针域:
cpp
typedef struct TreeNode {
int val; // 数据域
struct TreeNode *left; // 左孩子指针
struct TreeNode *right; // 右孩子指针
} TreeNode;
创建一个新节点:
cpp
TreeNode* createNode(int val) {
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
node->val = val;
node->left = NULL;
node->right = NULL;
return node;
}
链式存储灵活,不会因为树形不平衡而浪费空间,是实际编程中最常用的方式。
第二部分:二叉树的遍历
遍历是二叉树最核心的操作。所谓遍历,就是按照某种规则访问树的每个节点一次且仅一次。
二叉树的遍历有四种主要方式:前序遍历 、中序遍历 、后序遍历 和层序遍历。前三种属于深度优先(DFS),层序遍历属于广度优先(BFS)。

图片说明:一棵 3 层二叉树,根节点 1,左孩子 2,右孩子 3。2 的左右孩子为 4 和 5,3 的左右孩子为 6 和 7。图中用三种颜色的箭头分别标出三种遍历路径:红色表示前序遍历路径(1→2→4→5→3→6→7),绿色表示中序遍历路径(4→2→5→1→6→3→7),蓝色表示后序遍历路径(4→5→2→6→7→3→1)。
2.1 前序遍历
访问顺序:根 → 左 → 右。
先访问根节点,再递归遍历左子树,最后递归遍历右子树。
cpp
void preorderTraversal(TreeNode* root) {
if (root == NULL) return;
printf("%d ", root->val); // 先访问根节点
preorderTraversal(root->left); // 递归遍历左子树
preorderTraversal(root->right); // 递归遍历右子树
}
对于上面那棵树(1-2-3-4-5-6-7),前序遍历的结果是:1 2 4 5 3 6 7。
2.2 中序遍历
访问顺序:左 → 根 → 右。
先递归遍历左子树,再访问根节点,最后递归遍历右子树。对于二叉搜索树(BST),中序遍历的结果是有序的。
cpp
void inorderTraversal(TreeNode* root) {
if (root == NULL) return;
inorderTraversal(root->left); // 先遍历左子树
printf("%d ", root->val); // 再访问根节点
inorderTraversal(root->right); // 最后遍历右子树
}
结果是:4 2 5 1 6 3 7。
2.3 后序遍历
访问顺序:左 → 右 → 根。
先递归遍历左子树,再递归遍历右子树,最后访问根节点。后序遍历的一个实际用途是释放二叉树的内存------必须先释放子节点,再释放父节点。
cpp
void postorderTraversal(TreeNode* root) {
if (root == NULL) return;
postorderTraversal(root->left); // 先遍历左子树
postorderTraversal(root->right); // 再遍历右子树
printf("%d ", root->val); // 最后访问根节点
}
结果是:4 5 2 6 7 3 1。
2.4 层序遍历
从上到下、从左到右逐层访问。需要借助队列来实现。
cpp
void levelOrderTraversal(TreeNode* root) {
if (root == NULL) return;
// 用数组模拟队列
TreeNode* queue[1000];
int front = 0, rear = 0;
queue[rear++] = root; // 根节点入队
while (front < rear) {
TreeNode* node = queue[front++]; // 出队
printf("%d ", node->val);
if (node->left != NULL)
queue[rear++] = node->left; // 左孩子入队
if (node->right != NULL)
queue[rear++] = node->right; // 右孩子入队
}
}
结果是:1 2 3 4 5 6 7。
2.5 非递归实现(迭代法)
递归写法简洁,但面试官经常追问"能不能用迭代写"。其实,递归的本质是系统帮你维护了一个栈,我们手动用栈模拟就行了。
以前序遍历为例:
cpp
void preorderIterative(TreeNode* root) {
if (root == NULL) return;
TreeNode* stack[1000];
int top = -1;
stack[++top] = root; // 根节点入栈
while (top >= 0) {
TreeNode* node = stack[top--]; // 出栈并访问
printf("%d ", node->val);
// 注意:先右后左,因为栈是后进先出
if (node->right != NULL)
stack[++top] = node->right;
if (node->left != NULL)
stack[++top] = node->left;
}
}
中序遍历的迭代写法稍有不同,需要一路向左走到底:
cpp
void inorderIterative(TreeNode* root) {
TreeNode* stack[1000];
int top = -1;
TreeNode* cur = root;
while (cur != NULL || top >= 0) {
// 一路向左,全部入栈
while (cur != NULL) {
stack[++top] = cur;
cur = cur->left;
}
// 弹出栈顶并访问
cur = stack[top--];
printf("%d ", cur->val);
// 转向右子树
cur = cur->right;
}
}
图片说明(前序遍历迭代过程):展示一个栈的变化过程。初始状态栈中只有根节点 1。第一步:弹出 1,访问 1,将右孩子 3 和左孩子 2 依次入栈(栈中:2, 3)。第二步:弹出 2,访问 2,将右孩子 5 和左孩子 4 入栈(栈中:4, 5, 3)。第三步:弹出 4,访问 4,无子节点(栈中:5, 3)。以此类推,直到栈空。
第三部分:堆(优先队列)
堆是一种特殊的完全二叉树,常用于实现优先队列。
3.1 堆的定义
堆分为两种:
- 大堆(大根堆) :每个节点的值都大于等于其子节点的值。根节点是最大值。
- 小堆(小根堆) :每个节点的值都小于等于其子节点的值。根节点是最小值。
关键点:堆是一棵完全二叉树,所以用数组存储非常合适。

图片说明:左侧是一棵完全二叉树形式的堆,根节点值为 9,左右孩子为 8 和 7。8 的左右孩子为 3 和 5,7 的左右孩子为 6 和 1。每个父节点都大于其子节点。右侧是对应的数组表示,下标 0-6 分别对应值 9, 8, 7, 3, 5, 6, 1。
3.2 向下调整算法
当一个节点的值可能破坏堆的性质时(比如建堆时从最后一个非叶子节点往前调整),需要把它"下沉"到合适的位置。
cpp
// 假设构建大堆
// n 是数组大小,i 是需要向下调整的节点下标
void siftDown(int arr[], int n, int i) {
int parent = i;
int child = 2 * parent + 1; // 左孩子下标
while (child < n) {
// 找出左右孩子中较大的那个
if (child + 1 < n && arr[child + 1] > arr[child])
child = child + 1;
// 如果父节点已经大于等于最大的孩子,调整结束
if (arr[parent] >= arr[child])
break;
// 否则交换,继续向下调整
int temp = arr[parent];
arr[parent] = arr[child];
arr[child] = temp;
parent = child;
child = 2 * parent + 1;
}
}
3.3 向上调整算法
当在堆末尾插入一个新元素时,可能破坏堆的性质,需要把它"上浮"到合适位置。
cpp
// i 是新插入元素的下标
void siftUp(int arr[], int i) {
int child = i;
int parent = (child - 1) / 2;
while (child > 0) {
// 大堆:如果子节点大于父节点,交换
if (arr[child] > arr[parent]) {
int temp = arr[parent];
arr[parent] = arr[child];
arr[child] = temp;
child = parent;
parent = (child - 1) / 2;
} else {
break;
}
}
}
3.4 建堆
给定一个数组,怎么把它调整成堆?从最后一个非叶子节点开始,从后往前依次执行向下调整。
最后一个非叶子节点的下标是 n / 2 − 1 n/2 - 1 n/2−1( n n n 是数组长度)。
cpp
void buildHeap(int arr[], int n) {
// 从最后一个非叶子节点开始,从后往前向下调整
for (int i = n / 2 - 1; i >= 0; i--) {
siftDown(arr, n, i);
}
}
建堆时间复杂度为什么是 O(N)?
很多人第一反应觉得建堆是 O ( N log N ) O(N \log N) O(NlogN),因为每次 siftDown 最多走 log N \log N logN 层,一共 N N N 个节点。但实际上不是这样。
关键在于:不是每个节点都会调整 log N \log N logN 层。底层的节点(叶子节点附近)几乎不需要调整,只有靠近根的节点才需要调整较多层。
精确计算:
- 第 h h h 层(倒数第 1 层)有约 n / 2 n/2 n/2 个节点,每个最多调整 0 层(已经是叶子)
- 第 h − 1 h-1 h−1 层有约 n / 4 n/4 n/4 个节点,每个最多调整 1 层
- 第 h − 2 h-2 h−2 层有约 n / 8 n/8 n/8 个节点,每个最多调整 2 层
- ...
- 第 1 层有 1 个节点,最多调整 h − 1 h-1 h−1 层
总调整次数:
S = ∑ k = 1 h ( k − 1 ) ⋅ n 2 k = n ∑ k = 1 h k − 1 2 k S = \sum_{k=1}^{h} (k-1) \cdot \frac{n}{2^k} = n \sum_{k=1}^{h} \frac{k-1}{2^k} S=k=1∑h(k−1)⋅2kn=nk=1∑h2kk−1
这个级数收敛到一个常数(约等于 1),所以 S = O ( n ) S = O(n) S=O(n)。
3.5 堆排序
堆排序的思路分两步:
- 建堆:升序排列建大堆,降序排列建小堆
- 反复交换 + 调整 :把堆顶元素(最大/最小值)和末尾元素交换,然后对堆顶执行
siftDown,缩小堆的范围
cpp
void heapSort(int arr[], int n) {
// 第一步:建大堆
buildHeap(arr, n);
// 第二步:反复交换堆顶和末尾,然后调整
for (int i = n - 1; i > 0; i--) {
// 交换堆顶和当前末尾
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 对堆顶执行向下调整,范围缩小到 i
siftDown(arr, i, 0);
}
}
为什么升序用大堆?因为大堆的堆顶是最大值,把它放到数组末尾,然后缩小堆的范围继续找次大值,最终数组就是升序排列。
图片说明(堆排序过程):展示堆排序的几个关键步骤。步骤1:建好大堆 9,8,7,3,5,6,1。步骤2:交换堆顶 9 和末尾 1,数组变为 1,8,7,3,5,6,\|9,9 已到位。步骤3:对 1 执行 siftDown,恢复大堆 8,5,7,3,1,6,\|9。步骤4:交换 8 和 6,继续调整。
3.6 TOP-K 问题
TOP-K 问题:从 N N N 个数据中找出最大(或最小)的 K K K 个。
一种朴素思路是全部排序,取前 K K K 个,时间复杂度 O ( N log N ) O(N \log N) O(NlogN)。但如果 N N N 很大、 K K K 很小,这就太浪费了。
更高效的做法是用堆:
- 找最大的 K 个 :建一个大小为 K K K 的小堆 。遍历数据,比堆顶大的就替换堆顶并调整。遍历完后,堆里就是最大的 K K K 个。
- 找最小的 K 个 :建一个大小为 K K K 的大堆,道理一样。
cpp
// 找数组中最大的 K 个元素
// 使用小堆,大小为 K
void topK(int arr[], int n, int k) {
// 先建一个大小为 k 的小堆
// 小堆的 siftDown:父节点小于子节点时不动
// 这里省略小堆的 siftDown 实现,逻辑和大堆对称
int* heap = (int*)malloc(k * sizeof(int));
// 前 k 个元素先入堆
for (int i = 0; i < k; i++)
heap[i] = arr[i];
// 建小堆
for (int i = k / 2 - 1; i >= 0; i--)
siftDownMin(heap, k, i); // 小堆版本的 siftDown
// 遍历剩余元素
for (int i = k; i < n; i++) {
if (arr[i] > heap[0]) { // 比堆顶大,替换
heap[0] = arr[i];
siftDownMin(heap, k, 0); // 调整
}
}
// heap 中就是最大的 k 个元素
for (int i = 0; i < k; i++)
printf("%d ", heap[i]);
free(heap);
}
时间复杂度: O ( N log K ) O(N \log K) O(NlogK),空间复杂度: O ( K ) O(K) O(K)。当 K ≪ N K \ll N K≪N 时,比排序快得多。
第四部分:LeetCode 实战
理论讲完了,来刷几道题。选的题目从易到难,覆盖了前面讲的遍历、深度、翻转和堆。
题目一:144. 二叉树的前序遍历
题目链接 :LeetCode 144
题意 :给你二叉树的根节点
root,返回它节点值的前序遍历。
解题思路
前序遍历的顺序是根→左→右。递归写法最直观,但面试官可能要求迭代写法,所以两种都给出。
递归解法
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
void preorder(struct TreeNode* root, int* result, int* returnSize) {
if (root == NULL) return;
result[(*returnSize)++] = root->val; // 根
preorder(root->left, result, returnSize); // 左
preorder(root->right, result, returnSize); // 右
}
int* preorderTraversal(struct TreeNode* root, int* returnSize) {
int* result = (int*)malloc(sizeof(int) * 200); // 题目保证节点数 <= 100
*returnSize = 0;
preorder(root, result, returnSize);
return result;
}
迭代解法
cpp
int* preorderTraversal(struct TreeNode* root, int* returnSize) {
int* result = (int*)malloc(sizeof(int) * 200);
*returnSize = 0;
if (root == NULL) return result;
struct TreeNode* stack[200];
int top = -1;
stack[++top] = root;
while (top >= 0) {
struct TreeNode* node = stack[top--];
result[(*returnSize)++] = node->val; // 访问节点
// 先右后左,因为栈是后进先出
if (node->right != NULL)
stack[++top] = node->right;
if (node->left != NULL)
stack[++top] = node->left;
}
return result;
}
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
- 空间复杂度 : O ( n ) O(n) O(n),最坏情况(树退化为链表)栈深度为 n n n
题目二:104. 二叉树的最大深度
题目链接 :LeetCode 104
题意:给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
解题思路
最大深度 = max(左子树深度, 右子树深度) + 1。这是一个非常自然的递归结构。
递归解法
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
int maxDepth(struct TreeNode* root) {
if (root == NULL) return 0; // 空树深度为 0
int leftDepth = maxDepth(root->left); // 左子树深度
int rightDepth = maxDepth(root->right); // 右子树深度
// 当前节点深度 = 较深子树 + 1
return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1;
}
BFS 解法(层序遍历)
也可以用层序遍历,走完几层深度就是几:
cpp
int maxDepth(struct TreeNode* root) {
if (root == NULL) return 0;
struct TreeNode* queue[10000];
int front = 0, rear = 0;
queue[rear++] = root;
int depth = 0;
while (front < rear) {
int levelSize = rear - front; // 当前层节点数
depth++; // 每处理一层,深度 +1
for (int i = 0; i < levelSize; i++) {
struct TreeNode* node = queue[front++];
if (node->left) queue[rear++] = node->left;
if (node->right) queue[rear++] = node->right;
}
}
return depth;
}
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
- 空间复杂度 :递归 O ( h ) O(h) O(h)( h h h 为树高),BFS O ( w ) O(w) O(w)( w w w 为最大层宽)
题目三:226. 翻转二叉树
题目链接 :LeetCode 226
题意 :给你一棵二叉树的根节点
root,翻转这棵二叉树,并返回其根节点。翻转是指交换每个节点的左右子树。
解题思路
这道题的关键是想到:翻转一棵树 = 交换当前节点的左右子树 + 递归翻转左子树 + 递归翻转右子树。顺序无所谓,先交换再递归,或者先递归再交换都行。
有个趣事:Max Howell(Homebrew 的作者)曾在 Google 面试中被问到这道题,没写出来。所以别觉得简单题就掉以轻心。
递归解法
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
struct TreeNode* invertTree(struct TreeNode* root) {
if (root == NULL) return NULL;
// 交换左右子树
struct TreeNode* temp = root->left;
root->left = root->right;
root->right = temp;
// 递归翻转左右子树
invertTree(root->left);
invertTree(root->right);
return root;
}
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),每个节点访问一次
- 空间复杂度 : O ( h ) O(h) O(h),递归栈深度等于树高,最坏 O ( n ) O(n) O(n)
题目四:215. 数组中的第K个最大元素
题目链接 :LeetCode 215
题意 :给定整数数组
nums和整数k,请返回数组中第k个最大的元素。
解题思路
这道题正好用前面讲的堆。两种堆解法:
解法 A:建大堆,弹出 K-1 次,堆顶就是第 K 大
建大堆 O ( N ) O(N) O(N),每次弹出 O ( log N ) O(\log N) O(logN),一共弹 K − 1 K-1 K−1 次,总复杂度 O ( N + K log N ) O(N + K \log N) O(N+KlogN)。
解法 B:维护大小为 K 的小堆(TOP-K 思路)
遍历数组,维护一个大小为 K K K 的小堆。遍历完后堆顶就是第 K K K 大。时间复杂度 O ( N log K ) O(N \log K) O(NlogK),空间 O ( K ) O(K) O(K)。
当 K K K 很小时,解法 B 更优。这里给出解法 B:
cpp
// 小堆的向下调整
void siftDownMin(int* heap, int n, int i) {
int parent = i;
int child = 2 * parent + 1;
while (child < n) {
// 找左右孩子中较小的
if (child + 1 < n && heap[child + 1] < heap[child])
child = child + 1;
if (heap[parent] <= heap[child])
break;
int temp = heap[parent];
heap[parent] = heap[child];
heap[child] = temp;
parent = child;
child = 2 * parent + 1;
}
}
int findKthLargest(int* nums, int numsSize, int k) {
int* heap = (int*)malloc(sizeof(int) * k);
// 前 k 个元素建小堆
for (int i = 0; i < k; i++)
heap[i] = nums[i];
for (int i = k / 2 - 1; i >= 0; i--)
siftDownMin(heap, k, i);
// 遍历剩余元素
for (int i = k; i < numsSize; i++) {
if (nums[i] > heap[0]) { // 比堆顶大就替换
heap[0] = nums[i];
siftDownMin(heap, k, 0);
}
}
// 堆顶就是第 k 大的元素
int result = heap[0];
free(heap);
return result;
}
复杂度分析
- 时间复杂度 : O ( N log K ) O(N \log K) O(NlogK),遍历 N N N 个元素,每次调整最多 log K \log K logK
- 空间复杂度 : O ( K ) O(K) O(K),维护大小为 K K K 的堆
扩展题目:236. 二叉树的最近公共祖先
题目链接 :LeetCode 236
题意:给定一个二叉树,找到该树中两个指定节点的最近公共祖先(LCA)。
解题思路
这道题比前面几道难一些,需要理解递归的返回值含义。
对每个节点,递归问三个问题:
- 左子树里有没有 p 或 q?
- 右子树里有没有 p 或 q?
- 当前节点是不是 p 或 q?
如果其中两个为真,当前节点就是 LCA。具体来说:
- 当前节点等于 p 或 q → 当前节点可能就是答案
- 左子树和右子树各找到一个 → 当前节点是 LCA
- 只有一边找到 → 答案在那一边
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
struct TreeNode* lowestCommonAncestor(struct TreeNode* root,
struct TreeNode* p,
struct TreeNode* q) {
// 递归终止条件:走到空节点,或者找到 p 或 q
if (root == NULL || root == p || root == q)
return root;
// 在左右子树中分别查找
struct TreeNode* left = lowestCommonAncestor(root->left, p, q);
struct TreeNode* right = lowestCommonAncestor(root->right, p, q);
// 两侧都找到了:当前节点就是 LCA
if (left != NULL && right != NULL)
return root;
// 只有一侧找到:返回那一侧的结果
return left != NULL ? left : right;
}
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),最坏情况要遍历所有节点
- 空间复杂度 : O ( h ) O(h) O(h),递归栈深度等于树高
参考资料
- LeetCode 官网:https://leetcode.cn/
- 严蔚敏、吴伟民《数据结构(C语言版)》------二叉树与堆的经典教材
- 《算法导论》第 6 章(堆排序)、第 12 章(二叉搜索树)
- 《剑指 Offer》------二叉树相关面试题合集
- GeeksforGeeks Binary Tree: https://www.geeksforgeeks.org/binary-tree-data-structure/