引子:
这次,二刷热题101过程中,意见到了看山不是山看水不是水的程度了,现在应该做i的反而是:系统根据每类题型科学总结,对每一道背后的思路系统分析:
出了这些之外,树作为堆栈队列代码段一个非常重要的基础数据结构,注定了这个知识点一定是一个面试高频考点!!!
1 基础模块:
链表、树、栈队列 、查找排序算法
树:
2 进阶模块:
递归回溯:
动态规划:
贪心算法:
字符串双指针:
1 c语言二叉树精讲(上):基础遍历与常见操作
二叉树作为最重要的数据结构之一,在各大公司的面试中占据着举足轻重的地位。它不仅考察你对数据结构本身的理解,更是对递归、迭代、队列、栈等基础算法能力的综合检验。本文将结合牛客网上的经典二叉树题目,通过分析实际代码案例,深入剖析二叉树的常见操作、易错点以及优化策略,帮助你系统掌握二叉树的精髓,轻松应对面试挑战。
1. 结构体定义与预备知识
在C语言中,二叉树节点通常定义如下:
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
这是一个标准的二叉树节点定义,包含一个整型值 val
,以及指向左右子节点的指针 left
和 right
。理解这种递归定义是理解二叉树一切操作的基础。
2. 二叉树的遍历
二叉树的遍历是其最基本也是最重要的操作。主要有三种深度优先遍历方式:前序遍历、中序遍历和后序遍历。它们的核心区别在于访问根节点的时机。
2.1 BM23 二叉树的前序遍历
题目要求: 给定一个二叉树的根节点 root
,返回其节点值的前序遍历。
前序遍历定义: 访问根节点 -> 遍历左子树 -> 遍历右子树。
你的代码分析:
// BM23二叉树的前序遍历.c
void doFunc(struct TreeNode *root, int *res, int *returnSize)
{
if(root==NULL){
return ;
}
res[(*returnSize)++] = root->val;
doFunc(root->left,res,returnSize);
doFunc(root->right,res,returnSize);
return ;
}
int *preorderTraversal(struct TreeNode *root, int *returnSize)
{
int* res = (int* )malloc(1000*sizeof(int)); // 问题1:硬编码数组大小
*returnSize =0 ;
doFunc(root,res,returnSize);
return res;
}
你的代码优点:
-
递归实现清晰:
doFunc
函数完美地实现了前序遍历的递归逻辑,思路非常清晰。 -
指针传递
returnSize
: 使用int *returnSize
来动态记录结果数组的实际大小是正确的做法,使得调用者可以知道返回数组的有效元素数量。
你的代码存在的问题与改进:
-
硬编码数组大小(常见误区):
-
问题:
malloc(1000 * sizeof(int))
硬编码了结果数组的最大容量为1000。这在实际面试或项目中是非常危险的。如果二叉树的节点数超过1000,程序就会发生越界访问,导致崩溃。如果节点数远小于1000,又会造成内存浪费。 -
误区: 很多初学者在遇到需要返回动态数组的题目时,会想当然地给一个“足够大”的固定大小。这种做法虽然在一些测试用例下可能通过,但在实际应用中是不可取的。
-
如何避免:
-
两次遍历法: 第一次遍历计算二叉树的节点总数,然后根据节点总数精确分配内存。第二次遍历填充结果。
-
动态扩容法: 初始分配一个较小的内存块,当需要存储更多元素时,使用
realloc
进行扩容。这是更通用的方法。
-
-
-
doFunc
函数名不够语义化:-
问题:
doFunc
这样的命名太泛泛,无法从函数名中直接看出其功能。 -
改进: 建议改为
preorderRecursive
或traverseAndCollect
等更能体现其功能的名字。
-
改进后的代码(动态扩容法):
为了避免硬编码数组大小的问题,我们可以采用动态扩容的方法。这种方法在遇到数组空间不足时,会自动增加数组的容量。
#include <stdlib.h> // For malloc, realloc, free
// 假设TreeNode结构体已在其他地方定义或在函数前定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 辅助函数:递归进行前序遍历并收集节点值
*
* @param root 当前遍历到的节点
* @param res 指向结果数组指针的指针,用于动态扩容
* @param capacity 指向当前结果数组容量的指针
* @param returnSize 指向已收集节点数量的指针
*/
void preorderCollect(struct TreeNode *root, int **res, int *capacity, int *returnSize) {
// 递归终止条件:节点为空
if (root == NULL) {
return;
}
// 检查容量是否足够,如果不足则进行扩容
if (*returnSize >= *capacity) {
// 如果容量为0,则初始化一个默认容量,避免realloc(NULL, 0)的未定义行为
*capacity = (*capacity == 0) ? 10 : (*capacity * 2); // 常用扩容策略:翻倍
int *new_res = (int *)realloc(*res, *capacity * sizeof(int));
if (new_res == NULL) {
// 内存分配失败处理,实际应用中可能需要更复杂的错误报告
fprintf(stderr, "Memory reallocation failed in preorderCollect.\n");
exit(EXIT_FAILURE); // 直接退出程序或返回错误码
}
*res = new_res;
}
// 访问根节点
(*res)[(*returnSize)++] = root->val;
// 递归遍历左子树
preorderCollect(root->left, res, capacity, returnSize);
// 递归遍历右子树
preorderCollect(root->right, res, capacity, returnSize);
}
/**
* @brief 二叉树的前序遍历主函数
*
* @param root TreeNode类,二叉树的根节点
* @param returnSize 返回数组的实际元素个数
* @return int* 返回一个包含前序遍历结果的整型一维数组
*/
int *preorderTraversal(struct TreeNode *root, int *returnSize) {
int *res = NULL; // 初始化结果数组为NULL
int capacity = 0; // 初始化容量为0
*returnSize = 0; // 初始化实际元素个数为0
// 调用辅助函数进行遍历和收集
preorderCollect(root, &res, &capacity, returnSize);
// 优化:如果实际使用的空间远小于分配的空间,可以再次realloc缩小
// 但通常在面试中,只要解决了越界问题即可,这步可以省略
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) { // 只有在需要空间但realloc失败时才报错
fprintf(stderr, "Final memory reallocation failed in preorderTraversal.\n");
// 此时res仍然指向旧的内存块,可以返回res或者根据策略处理
} else if (*returnSize == 0) { // 如果没有元素,释放可能存在的空数组
free(res);
res = NULL;
}
else {
res = final_res;
}
}
return res;
}
非递归实现(迭代法):
除了递归,前序遍历也可以使用栈来实现非递归版本。这对于某些场景(如避免栈溢出)是很有用的。
#include <stdlib.h>
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 定义一个简单的栈结构,用于存储TreeNode指针
typedef struct {
struct TreeNode **array;
int top;
int capacity;
} Stack;
// 初始化栈
Stack* createStack(int capacity) {
Stack *s = (Stack *)malloc(sizeof(Stack));
if (s == NULL) return NULL;
s->capacity = capacity;
s->array = (struct TreeNode **)malloc(s->capacity * sizeof(struct TreeNode *));
if (s->array == NULL) {
free(s);
return NULL;
}
s->top = -1;
return s;
}
// 检查栈是否为空
bool isStackEmpty(Stack *s) {
return s->top == -1;
}
// 检查栈是否已满
bool isStackFull(Stack *s) {
return s->top == s->capacity - 1;
}
// 扩容栈
bool expandStack(Stack *s) {
int new_capacity = s->capacity * 2;
if (new_capacity == 0) new_capacity = 1; // 避免0容量
struct TreeNode **new_array = (struct TreeNode **)realloc(s->array, new_capacity * sizeof(struct TreeNode *));
if (new_array == NULL) return false; // 扩容失败
s->array = new_array;
s->capacity = new_capacity;
return true;
}
// 压入元素
void pushStack(Stack *s, struct TreeNode *node) {
if (isStackFull(s)) {
if (!expandStack(s)) {
fprintf(stderr, "Stack expansion failed!\n");
exit(EXIT_FAILURE);
}
}
s->array[++s->top] = node;
}
// 弹出元素
struct TreeNode* popStack(Stack *s) {
if (isStackEmpty(s)) return NULL;
return s->array[s->top--];
}
// 销毁栈
void destroyStack(Stack *s) {
if (s == NULL) return;
free(s->array);
free(s);
}
/**
* @brief 二叉树的前序遍历 (迭代法)
*
* @param root TreeNode类,二叉树的根节点
* @param returnSize 返回数组的实际元素个数
* @return int* 返回一个包含前序遍历结果的整型一维数组
*/
int *preorderTraversal_Iterative(struct TreeNode *root, int *returnSize) {
int *res = NULL;
int capacity = 0;
*returnSize = 0;
if (root == NULL) {
return NULL;
}
Stack *s = createStack(10); // 初始化栈,给定一个初始容量
if (s == NULL) {
fprintf(stderr, "Failed to create stack.\n");
return NULL;
}
pushStack(s, root); // 将根节点压入栈
while (!isStackEmpty(s)) {
struct TreeNode *current = popStack(s);
// 访问当前节点
if (*returnSize >= capacity) {
capacity = (capacity == 0) ? 10 : (capacity * 2);
int *new_res = (int *)realloc(res, capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in preorderTraversal_Iterative.\n");
destroyStack(s);
free(res); // 释放已分配的部分结果数组
exit(EXIT_FAILURE);
}
res = new_res;
}
res[(*returnSize)++] = current->val;
// 先右子节点入栈,后左子节点入栈,确保左子节点先被弹出
if (current->right != NULL) {
pushStack(s, current->right);
}
if (current->left != NULL) {
pushStack(s, current->left);
}
}
destroyStack(s); // 销毁栈
// 最终调整结果数组大小
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in preorderTraversal_Iterative.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
2.2 BM2 二叉树的中序遍历 (略)
题目要求: 给定一个二叉树的根节点 root
,返回其节点值的中序遍历。
中序遍历定义: 遍历左子树 -> 访问根节点 -> 遍历右子树。
由于你没有提供中序遍历的代码,这里直接给出通用的递归和迭代实现。
递归实现:
#include <stdlib.h>
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
void inorderCollect(struct TreeNode *root, int **res, int *capacity, int *returnSize) {
if (root == NULL) {
return;
}
inorderCollect(root->left, res, capacity, returnSize); // 遍历左子树
// 访问根节点
if (*returnSize >= *capacity) {
*capacity = (*capacity == 0) ? 10 : (*capacity * 2);
int *new_res = (int *)realloc(*res, *capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in inorderCollect.\n");
exit(EXIT_FAILURE);
}
*res = new_res;
}
(*res)[(*returnSize)++] = root->val;
inorderCollect(root->right, res, capacity, returnSize); // 遍历右子树
}
int *inorderTraversal(struct TreeNode *root, int *returnSize) {
int *res = NULL;
int capacity = 0;
*returnSize = 0;
inorderCollect(root, &res, &capacity, returnSize);
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in inorderTraversal.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
迭代实现:
#include <stdlib.h>
#include <stdbool.h> // For bool type
// 假设TreeNode结构体和Stack结构体及相关函数已在其他地方定义或在此之前定义
int *inorderTraversal_Iterative(struct TreeNode *root, int *returnSize) {
int *res = NULL;
int capacity = 0;
*returnSize = 0;
Stack *s = createStack(10);
if (s == NULL) {
fprintf(stderr, "Failed to create stack for inorder traversal.\n");
return NULL;
}
struct TreeNode *current = root;
while (current != NULL || !isStackEmpty(s)) {
// 一直向左,并将沿途节点入栈
while (current != NULL) {
pushStack(s, current);
current = current->left;
}
// 左子树访问完毕,弹出栈顶节点(根节点),并访问
current = popStack(s);
if (current != NULL) {
if (*returnSize >= capacity) {
capacity = (capacity == 0) ? 10 : (capacity * 2);
int *new_res = (int *)realloc(res, capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in inorderTraversal_Iterative.\n");
destroyStack(s);
free(res);
exit(EXIT_FAILURE);
}
res = new_res;
}
res[(*returnSize)++] = current->val;
// 转向右子树
current = current->right;
}
}
destroyStack(s);
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in inorderTraversal_Iterative.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
2.3 BM3 二叉树的后序遍历 (略)
题目要求: 给定一个二叉树的根节点 root
,返回其节点值的后序遍历。
后序遍历定义: 遍历左子树 -> 遍历右子树 -> 访问根节点。
同样,没有你的代码,直接给出通用实现。
递归实现:
#include <stdlib.h>
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
void postorderCollect(struct TreeNode *root, int **res, int *capacity, int *returnSize) {
if (root == NULL) {
return;
}
postorderCollect(root->left, res, capacity, returnSize); // 遍历左子树
postorderCollect(root->right, res, capacity, returnSize); // 遍历右子树
// 访问根节点
if (*returnSize >= *capacity) {
*capacity = (*capacity == 0) ? 10 : (*capacity * 2);
int *new_res = (int *)realloc(*res, *capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in postorderCollect.\n");
exit(EXIT_FAILURE);
}
*res = new_res;
}
(*res)[(*returnSize)++] = root->val;
}
int *postorderTraversal(struct TreeNode *root, int *returnSize) {
int *res = NULL;
int capacity = 0;
*returnSize = 0;
postorderCollect(root, &res, &capacity, returnSize);
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in postorderTraversal.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
迭代实现(双栈法):
后序遍历的迭代实现相对复杂一些,通常需要两个栈。
#include <stdlib.h>
#include <stdbool.h>
// 假设TreeNode结构体和Stack结构体及相关函数已在其他地方定义或在此之前定义
int *postorderTraversal_Iterative(struct TreeNode *root, int *returnSize) {
int *res = NULL;
int capacity = 0;
*returnSize = 0;
if (root == NULL) {
return NULL;
}
Stack *s1 = createStack(10); // 主栈
Stack *s2 = createStack(10); // 辅助栈,用于反转结果
if (s1 == NULL || s2 == NULL) {
fprintf(stderr, "Failed to create stacks for postorder traversal.\n");
if (s1) destroyStack(s1);
if (s2) destroyStack(s2);
return NULL;
}
pushStack(s1, root);
// 将节点按照 "根 -> 右 -> 左" 的顺序压入辅助栈s2
while (!isStackEmpty(s1)) {
struct TreeNode *current = popStack(s1);
pushStack(s2, current);
if (current->left != NULL) {
pushStack(s1, current->left);
}
if (current->right != NULL) {
pushStack(s1, current->right);
}
}
// 从辅助栈s2中依次弹出,即为 "左 -> 右 -> 根" 的后序遍历顺序
while (!isStackEmpty(s2)) {
struct TreeNode *current = popStack(s2);
if (*returnSize >= capacity) {
capacity = (capacity == 0) ? 10 : (capacity * 2);
int *new_res = (int *)realloc(res, capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in postorderTraversal_Iterative.\n");
destroyStack(s1);
destroyStack(s2);
free(res);
exit(EXIT_FAILURE);
}
res = new_res;
}
res[(*returnSize)++] = current->val;
}
destroyStack(s1);
destroyStack(s2);
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in postorderTraversal_Iterative.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
总结: 无论是前序、中序还是后序遍历,递归实现通常更简洁直观,但可能面临栈溢出的风险(对于非常深的树)。迭代实现虽然代码量稍大,但可以避免栈溢出,并且对于某些场景(如层次遍历)是更自然的。在面试中,两种实现方式都应该掌握。
3. 二叉树的深度
二叉树的深度(或高度)是指从根节点到最远叶节点的最长路径上的节点数。
3.1 BM28 二叉树的最大深度
题目要求: 给定一个二叉树的根节点 root
,返回其最大深度。
最大深度定义:
-
空树的深度为0。
-
非空树的深度为
max(左子树深度, 右子树深度) + 1
。
你的代码分析:
// BM28二叉树最大深度.c
int maxDepth(struct TreeNode *root)
{
if (!root) return 0 ; // 递归终止条件
int lLen = maxDepth(root->left); // 递归计算左子树深度
int rLen = maxDepth(root->right); // 递归计算右子树深度
return lLen>rLen?lLen+1:rLen+1; // 返回左右子树深度的较大值加1
}
你的代码优点:
-
简洁高效: 你的
maxDepth
函数非常精炼且完全正确地实现了二叉树最大深度的计算。这是一个典型的递归解法,体现了分治的思想。 -
边界条件处理得当:
if (!root) return 0;
很好地处理了空树的深度为0的边界情况。
你的代码存在的问题与改进:
几乎没有问题,这是一个非常标准的优秀答案。
可以改进的地方(风格和可读性):
-
三元运算符与
fmax
: 虽然你的三元运算符lLen>rLen?lLen+1:rLen+1
完全正确,但使用fmax
(需要#include <math.h>
)或者简单的if-else
结构可能会让代码在某些情况下更具可读性,尤其是在表达式更复杂时。但对于这种简单的比较,三元运算符也很常见。#include <math.h> // For fmax int maxDepth(struct TreeNode *root) { if (root == NULL) { return 0; } int leftDepth = maxDepth(root->left); int rightDepth = maxDepth(root->right); return (int)fmax(leftDepth, rightDepth) + 1; // 显式类型转换 }
或者
int maxDepth(struct TreeNode *root) { if (root == NULL) { return 0; } int leftDepth = maxDepth(root->left); int rightDepth = maxDepth(root->right); if (leftDepth > rightDepth) { return leftDepth + 1; } else { return rightDepth + 1; } }
总结: 最大深度问题是二叉树递归思想的入门题,你的解法非常标准。理解其递归定义和边界条件是关键。
4. 合并二叉树
合并二叉树通常指将两棵二叉树合并成一棵新的二叉树,合并规则可能多种多样。
4.1 BM32 合并二叉树
题目要求: 给定两棵二叉树 t1
和 t2
,将它们合并成一个新的二叉树。合并的规则是:如果两个节点重叠,那么将它们的值相加作为新树的节点值;否则,不重叠的节点将直接作为新树的节点。
你的代码分析:
// BM32合并二叉树.c
struct TreeNode *mergeTrees(struct TreeNode *t1, struct TreeNode *t2)
{
if (t1 == NULL)
return t2; // 如果t1为空,直接返回t2(及其后续子树)
if (t2 == NULL)
return t1; // 如果t2为空,直接返回t1(及其后续子树)
t1->val +=t2->val; // 问题1:直接修改了t1的节点值,可能不符合题意“生成新树”
t1->left = mergeTrees(t1->left,t2->left); // 递归合并左子树
t1->right = mergeTrees(t1->right,t2->right); // 递归合并右子树
return t1;
}
你的代码优点:
-
递归逻辑正确: 核心的递归合并逻辑是正确的,即对左右子树进行递归合并。
-
边界条件处理得当:
if (t1 == NULL) return t2;
和if (t2 == NULL) return t1;
处理了当其中一棵树的某个节点为空时的正确合并行为。
你的代码存在的问题与改进:
-
原地修改问题(常见误区):
-
问题: 你的代码
t1->val += t2->val;
以及后续将合并结果直接赋给t1->left
和t1->right
,这意味着你是在 原地修改 了t1
这棵树,并将其作为结果返回。 -
误区: 题目通常会要求“合并成一棵 新的 二叉树”,这意味着你不能修改原有的输入树。如果你在面试中这样操作,面试官可能会追问你是否修改了原树。虽然某些题目允许原地修改以节省空间,但默认情况下,最好创建新节点。
-
如何避免: 明确题目要求是原地修改还是生成新树。如果题目没有明确说明,通常应生成新树。
-
改进后的代码(生成新树):
为了避免原地修改原树,我们应该在合并时创建新的节点。
#include <stdlib.h> // For malloc
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 合并两棵二叉树,生成一棵新的二叉树
*
* @param t1 TreeNode类,第一棵树的根节点
* @param t2 TreeNode类,第二棵树的根节点
* @return TreeNode类,返回合并后新树的根节点
*/
struct TreeNode *mergeTrees(struct TreeNode *t1, struct TreeNode *t2) {
// 如果t1和t2都为空,则合并结果为空
if (t1 == NULL && t2 == NULL) {
return NULL;
}
// 如果t1为空,t2不为空,则新树的节点直接是t2的节点
if (t1 == NULL) {
return t2; // 这里可以理解为直接挂载t2的子树,因为t2的子树也会被递归处理
}
// 如果t2为空,t1不为空,则新树的节点直接是t1的节点
if (t2 == NULL) {
return t1; // 同理
}
// 如果t1和t2都不为空,则创建新节点,并将其值设为t1和t2节点值之和
struct TreeNode *new_node = (struct TreeNode *)malloc(sizeof(struct TreeNode));
if (new_node == NULL) {
fprintf(stderr, "Memory allocation failed for new_node in mergeTrees.\n");
exit(EXIT_FAILURE);
}
new_node->val = t1->val + t2->val;
// 递归合并左右子树,并将结果赋给新节点的左右孩子
new_node->left = mergeTrees(t1->left, t2->left);
new_node->right = mergeTrees(t1->right, t2->right);
return new_node;
}
注意: 如果题目明确允许原地修改,你的原始代码是完全正确的,且能节省内存。但在不确定时,生成新树是更安全的做法。
5. 二叉树的镜像
二叉树的镜像操作是指将二叉树的左右子树进行交换,从而得到一个关于根节点对称的镜像树。
5.1 BM33 二叉树的镜像
题目要求: 给定一个二叉树的根节点 pRoot
,返回其镜像。
镜像操作定义: 递归地交换每个节点的左右子节点。
你的代码分析:
// BM33二叉树镜像.c
struct TreeNode *Mirror(struct TreeNode *pRoot)
{
if(pRoot==NULL) return ; // 问题1:返回类型不匹配
struct TreeNode* temp = pRoot->right;
pRoot->right = pRoot->left;
pRoot->left = temp;
Mirror(pRoot->left);
Mirror(pRoot->right);
return pRoot;
}
你的代码优点:
-
核心逻辑正确: 交换左右子树的逻辑
struct TreeNode* temp = pRoot->right; pRoot->right = pRoot->left; pRoot->left = temp;
是完全正确的。 -
递归调用正确: 对左右子树进行递归调用
Mirror(pRoot->left); Mirror(pRoot->right);
确保了所有子树都被镜像。 -
原地修改: 该操作通常是原地修改,你的代码也符合这一点。
你的代码存在的问题与改进:
-
void
返回类型误区:-
问题: 在
if(pRoot==NULL) return ;
这一行,当pRoot
为NULL
时,你使用了return;
。然而,函数的返回类型是struct TreeNode *
,这意味着当pRoot
为NULL
时,函数应该返回NULL
。这在C语言中是一个编译警告(或者某些编译器会报错),并且逻辑上不严谨。 -
误区: 习惯了
void
函数的递归终止条件,忘记了带返回值的递归函数在终止时也需要返回一个符合类型的值。 -
如何避免: 仔细检查函数签名,确保所有返回路径都返回正确的类型。
-
改进后的代码:
#include <stdlib.h>
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 对二叉树进行镜像操作
*
* @param pRoot TreeNode类,二叉树的根节点
* @return TreeNode类,返回镜像后的二叉树的根节点
*/
struct TreeNode *Mirror(struct TreeNode *pRoot) {
// 递归终止条件:如果节点为空,直接返回NULL
if (pRoot == NULL) {
return NULL; // 修正:当pRoot为NULL时,返回NULL
}
// 交换当前节点的左右子节点
struct TreeNode *temp = pRoot->left;
pRoot->left = pRoot->right;
pRoot->right = temp;
// 递归地对左右子树进行镜像操作
Mirror(pRoot->left);
Mirror(pRoot->right);
// 返回当前节点(根节点),因为它现在是镜像树的根
return pRoot;
}
总结: 二叉树镜像操作是二叉树递归应用的一个经典例子,关键在于理解每个节点的操作是独立的,且递归地作用于其子节点。
------------------------------------------------------------------------------------------------------------跟新于2025.4.1晚7.47
二叉树核心点精讲(2):高级特性与复杂问题
承接上文对二叉树基础遍历与操作的讲解,本部分我们将深入探讨二叉树的几种重要特性判断(二叉搜索树、完全二叉树、平衡二叉树),以及在面试中频繁出现的复杂问题,如查找最近公共祖先和二叉树的序列化与反序列化。我们将继续结合你提供的代码,剖析常见误区,提供优化建议,并给出详尽的C语言实现。
6. 二叉搜索树的判断
二叉搜索树(Binary Search Tree, BST)是一种特殊的二叉树,它具有以下性质:
-
若左子树不为空,则左子树上所有节点的值均小于根节点的值。
-
若右子树不为空,则右子树上所有节点的值均大于根节点的值。
-
左右子树本身也分别是二叉搜索树。
关键性质: 中序遍历二叉搜索树得到的结果是一个递增序列。
6.1 BM34 判断是不是二叉搜索树
题目要求: 给定一个二叉树的根节点 root
,判断它是否是一个有效的二叉搜索树。
你的代码分析:
// BM34是不是而搜索树.c
#include<limits.h> // For LONG_MIN, LONG_MAX
bool doFunc(struct TreeNode *root, long minV, long maxV)
{
if(root==NULL) return true; // 递归终止条件
if(root->val<minV ||root->val>maxV){ // 检查当前节点值是否在有效范围内
return false;
}
// 递归检查左右子树,并更新子树的有效范围
return doFunc(root->left,minV,root->val)&& doFunc(root->right ,root->val,maxV);
}
bool isValidBST(struct TreeNode *root)
{
// write code here
return doFunc(root,LONG_MIN,LONG_MAX); // 初始调用,根节点值的范围是所有可能的long型整数
}
你的代码优点:
-
核心思想正确: 你的代码采用了自顶向下、递归检查节点值范围的思路,这是判断二叉搜索树有效性的标准且高效的方法。
-
边界条件处理得当:
if(root==NULL) return true;
处理了空节点作为有效BST的情况。 -
初始范围正确: 使用
LONG_MIN
和LONG_MAX
作为根节点的初始上下界,确保了对整个树的正确性检查。
你的代码存在的问题与改进:
几乎没有问题,这是一个非常标准的正确解法!
可以改进的地方(细节和严谨性):
-
等值问题(易错点):
-
问题: 你的判断条件是
root->val < minV || root->val > maxV
。这表明你的BST不允许节点值重复。然而,有些BST的定义允许重复值,有些则不允许。通常情况下,严格的BST定义是“左子树所有节点值小于根节点,右子树所有节点值大于根节点”,即不允许重复。如果题目明确允许重复值,那么root->val <= maxV
和root->val >= minV
可能会是适当的调整,但这需要根据具体的题目要求来定。 -
误区: 对BST的“等于”情况考虑不足。在面试中,务必和面试官确认BST是否允许重复值。如果允许重复值,通常是左子树
val <= root->val
,右子树val >= root->val
(或者反过来,但要保持一致)。如果不允许,则val < root->val
和val > root->val
是正确的。你的代码符合不允许重复值的严格BST定义。 -
你的代码在此题中是正确的,因为牛客的BM系列题目通常采用严格的BST定义。
-
-
函数命名: 再次提及,
doFunc
仍不够语义化。可以考虑isValidBSTRecursive
或checkBSTRange
。
总结: 判断二叉搜索树的关键在于维护一个节点值的有效范围。在递归遍历时,左子树的有效范围是 (minV, root->val)
,右子树的有效范围是 (root->val, maxV)
。这是一个非常重要的知识点,你的实现非常出色。
7. 完全二叉树的判断
完全二叉树(Complete Binary Tree)定义:若设二叉树的深度为h,除第h层外,其它各层(1到h−1层)的节点数都达到最大个数,第h层所有的节点都连续集中在最左边。
判断思路: 通常使用**层次遍历(BFS)**来判断。
-
从根节点开始层次遍历。
-
在遍历过程中,如果遇到一个空节点,那么此后的所有节点都必须是空节点,否则就不是完全二叉树。
-
如果遇到非空节点,但其左子节点为空而右子节点不为空,则不是完全二叉树。
7.1 BM35 判断是不是完全二叉树
题目要求: 给定一个二叉树的根节点 root
,判断它是否是一个完全二叉树。
你的代码分析:
// BM35完全二叉树.c
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param root TreeNode类
* @return bool布尔型
*/
bool isCompleteTree(struct TreeNode* root ) {
// write code here
}
你的代码存在的问题与改进:
-
代码缺失: 你的
isCompleteTree
函数体为空。这表明你可能还没有来得及实现这部分。
实现思路(层次遍历/BFS):
-
使用一个队列(Queue)来进行层次遍历。
-
将根节点入队。
-
设置一个标志
has_null
,初始为false
。 -
当队列不为空时:
-
出队一个节点
current
。 -
如果
current
为空,将has_null
设置为true
。 -
如果
current
不为空:-
如果
has_null
已经为true
,但current
却不是空节点,说明在空节点之后又出现了非空节点,不符合完全二叉树的定义,返回false
。 -
将
current
的左子节点和右子节点依次入队。
-
-
改进后的代码:
需要先定义一个队列结构。
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// --- 队列结构定义 ---
typedef struct {
struct TreeNode **array;
int front;
int rear;
int size;
int capacity;
} Queue;
// 创建队列
Queue* createQueue(int capacity) {
Queue *q = (Queue *)malloc(sizeof(Queue));
if (q == NULL) return NULL;
q->capacity = capacity;
q->front = 0;
q->size = 0;
q->rear = capacity - 1; // rear 指向最后一个元素的前一个位置,方便循环队列的计算
q->array = (struct TreeNode **)malloc(q->capacity * sizeof(struct TreeNode *));
if (q->array == NULL) {
free(q);
return NULL;
}
return q;
}
// 队列是否为空
bool isQueueEmpty(Queue *q) {
return (q->size == 0);
}
// 队列是否已满
bool isQueueFull(Queue *q) {
return (q->size == q->capacity);
}
// 扩容队列
bool expandQueue(Queue *q) {
int old_capacity = q->capacity;
int new_capacity = old_capacity * 2;
if (new_capacity == 0) new_capacity = 10; // 避免0容量扩容
struct TreeNode **new_array = (struct TreeNode **)realloc(q->array, new_capacity * sizeof(struct TreeNode *));
if (new_array == NULL) return false;
// 如果数据不是连续的,需要重新排列
if (q->front > q->rear) { // 队列数据循环到了数组末尾又从头开始
// 将后半部分数据移动到新数组的末尾
// 例如:[..., D, E, F, A, B, C, ...] (old_capacity)
// 变成:[A, B, C, ..., D, E, F, ...] (new_capacity)
for (int i = 0; i < q->front; ++i) {
new_array[old_capacity + i] = new_array[i];
}
q->rear += old_capacity; // 更新rear
}
q->array = new_array;
q->capacity = new_capacity;
return true;
}
// 入队
void enqueue(Queue *q, struct TreeNode *item) {
if (isQueueFull(q)) {
if (!expandQueue(q)) {
fprintf(stderr, "Queue expansion failed!\n");
exit(EXIT_FAILURE);
}
}
q->rear = (q->rear + 1) % q->capacity;
q->array[q->rear] = item;
q->size++;
}
// 出队
struct TreeNode* dequeue(Queue *q) {
if (isQueueEmpty(q)) {
return NULL; // 或者报错
}
struct TreeNode *item = q->array[q->front];
q->front = (q->front + 1) % q->capacity;
q->size--;
return item;
}
// 销毁队列
void destroyQueue(Queue *q) {
if (q == NULL) return;
free(q->array);
free(q);
}
// --- 完全二叉树判断函数 ---
/**
* @brief 判断一棵二叉树是否是完全二叉树
*
* @param root TreeNode类,二叉树的根节点
* @return bool布尔型,如果是完全二叉树返回true,否则返回false
*/
bool isCompleteTree(struct TreeNode* root) {
if (root == NULL) {
return true; // 空树是完全二叉树
}
Queue *q = createQueue(10); // 初始队列容量
if (q == NULL) {
fprintf(stderr, "Failed to create queue for isCompleteTree.\n");
return false;
}
enqueue(q, root);
bool has_null = false; // 标志位:是否遇到了第一个空节点
while (!isQueueEmpty(q)) {
struct TreeNode *current = dequeue(q);
if (current == NULL) {
// 如果当前节点是空,设置has_null为true,并继续处理队列中剩余的节点
has_null = true;
} else {
// 如果has_null已经为true,但当前节点却不为空,则不是完全二叉树
if (has_null) {
destroyQueue(q);
return false;
}
// 如果当前节点不为空,将其左右孩子入队(即使为空也要入队,用于后续判断)
enqueue(q, current->left);
enqueue(q, current->right);
}
}
destroyQueue(q); // 释放队列内存
return true; // 遍历结束,符合完全二叉树定义
}
总结: 判断完全二叉树的核心在于BFS遍历过程中,一旦遇到空节点,之后的所有节点都必须是空节点。同时,任何非空节点的右子节点不能在左子节点为空的情况下出现。
8. 平衡二叉树的判断
平衡二叉树(Balanced Binary Tree)又称AVL树,是一种特殊的二叉搜索树,它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
8.1 BM36 判断是不是平衡二叉树
题目要求: 给定一个二叉树的根节点 pRoot
,判断它是否是一个平衡二叉树。
你的代码分析:
// BM36是不是平衡二叉树.c
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param pRoot TreeNode类
* @return bool布尔型
*/
int getD(struct TreeNode* root ){ // 计算深度函数
if(root==NULL) return 0 ;
int lLen = getD(root->left);
int rLen = getD(root->right);
return lLen >rLen?lLen+1:rLen+1;
}
bool IsBalanced_Solution(struct TreeNode* pRoot ) {
// write code here
int lLen = getD(pRoot->left); // 问题1:对空树根节点直接调用getD会导致错误
int rLen = getD(pRoot->right);
// 问题2:递归判断条件缺失对pRoot本身的空判断,且abs函数需要引入
return IsBalanced_Solution(pRoot->left ) && IsBalanced_Solution(pRoot->right) && (abs(lLen-rLen)<=1);
}
你的代码优点:
-
getD
函数正确:getD
函数与之前判断最大深度的函数一致,正确地计算了子树的高度。 -
分治思想: 你尝试用递归方式
IsBalanced_Solution(pRoot->left ) && IsBalanced_Solution(pRoot->right)
来检查子树的平衡性,这是正确的方向。 -
高度差判断:
(abs(lLen-rLen)<=1)
抓住了平衡二叉树的核心条件。
你的代码存在的问题与改进:
-
根节点为空的边界条件处理:
-
问题:
IsBalanced_Solution
函数缺少对pRoot
为NULL
的初始判断。如果传入的pRoot
是NULL
,直接调用getD(pRoot->left)
会导致空指针解引用错误。 -
误区: 认为函数的参数一定是非空的。在处理树的问题时,一定要最优先考虑空节点的情况。
-
如何避免: 在函数入口处添加
if (pRoot == NULL) return true;
。空树是平衡的。
-
-
重复计算高度:
-
问题: 你的
IsBalanced_Solution
在每次递归调用时,都会通过getD
重新计算左右子树的高度。这意味着对于一个节点,它的高度可能被重复计算多次(在其父节点和祖父节点的getD
调用中)。这导致了大量重复工作,时间复杂度较高。 -
误区: 将高度计算和平衡性判断分开进行,导致了效率低下。
-
如何避免: 将高度计算和平衡性判断结合在一个递归函数中,从底向上进行。在计算子树高度的同时,判断子树是否平衡。如果子树不平衡,可以直接返回一个特殊值(例如 -1)表示不平衡,从而剪枝。
-
-
缺少
abs
函数的头文件:-
问题:
abs
函数通常在<stdlib.h>
或<math.h>
中定义。你的代码没有包含这些头文件。 -
如何避免: 引入必要的头文件。
-
改进后的代码(优化:一次遍历判断高度和平衡性):
这种方法通过一个递归函数,同时返回子树的高度和子树是否平衡的信息。如果子树不平衡,返回一个负值(例如-1),否则返回其高度。
#include <stdlib.h> // For abs, exit, fprintf
#include <stdbool.h> // For bool
#include <math.h> // For fmax (可选,也可以用三元运算符)
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 辅助函数:递归计算二叉树的高度,并在过程中判断是否平衡
* 如果子树平衡,返回其高度;如果不平衡,返回-1。
*
* @param root 当前递归到的节点
* @return int 如果子树平衡,返回其高度;否则返回-1
*/
int getHeightAndCheckBalance(struct TreeNode* root) {
if (root == NULL) {
return 0; // 空节点高度为0,且是平衡的
}
// 递归获取左子树的高度和平衡性
int leftHeight = getHeightAndCheckBalance(root->left);
if (leftHeight == -1) {
return -1; // 左子树不平衡,则整个树也不平衡
}
// 递归获取右子树的高度和平衡性
int rightHeight = getHeightAndCheckBalance(root->right);
if (rightHeight == -1) {
return -1; // 右子树不平衡,则整个树也不平衡
}
// 检查当前节点的左右子树的高度差是否超过1
if (abs(leftHeight - rightHeight) > 1) {
return -1; // 当前子树不平衡
}
// 如果当前子树平衡,返回其高度(左右子树高度的较大值加1)
return (int)fmax(leftHeight, rightHeight) + 1;
}
/**
* @brief 判断一棵二叉树是否是平衡二叉树
*
* @param pRoot TreeNode类,二叉树的根节点
* @return bool布尔型,如果是平衡二叉树返回true,否则返回false
*/
bool IsBalanced_Solution(struct TreeNode* pRoot) {
// 调用辅助函数,如果返回-1则表示不平衡
return getHeightAndCheckBalance(pRoot) != -1;
}
总结: 平衡二叉树的判断是一个典型的需要“自底向上”进行递归的题目。将高度计算和平衡性判断结合在一次递归中,可以避免重复计算,大大提高效率。
9. 最近公共祖先
最近公共祖先(Lowest Common Ancestor, LCA)是指在二叉树中,对于两个给定的节点 p 和 q,它们的最近公共祖先是离这两个节点最近的,同时是它们共同祖先的节点。
9.1 BM37 最近公共祖先
题目要求: 给定一个二叉树的根节点 root
,以及树中的两个节点的值 p
和 q
,找到这两个节点的最近公共祖先。
你的代码分析:
// BM37最近公共祖先.c
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* };
*/
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param root TreeNode类
* @param p int整型
* @param q int整型
* @return int整型
*/
int lowestCommonAncestor(struct TreeNode *root, int p, int q)
{
// write code here
}
你的代码存在的问题与改进:
-
代码缺失: 你的
lowestCommonAncestor
函数体为空,需要实现。
实现思路(递归):
这道题有两种主要情况:
-
普通二叉树的LCA: 节点
p
和q
可能在树中的任何位置,没有大小顺序关系。 -
二叉搜索树(BST)的LCA: 节点
p
和q
具有大小顺序关系,可以利用BST的性质进行优化。
牛客的BM系列题目通常会区分普通二叉树和二叉搜索树的LCA。根据题目列表,你提到的是 BM15二叉搜索树的最近公共祖先
和 BM16在二叉树中找到两个节点的最近公共祖先
。由于你提供的是 BM37最近公共祖先.c
且其文件名未明确说明是BST,这里我们将假设是普通二叉树的LCA,因为它更具普遍性。如果是BST的LCA,解法会更简洁。
普通二叉树LCA的递归思路:
一个经典的后序遍历(或自底向上)的思路:
-
基本情况:
-
如果
root
为NULL
,或者root
的值等于p
或q
,则返回root
。
-
-
递归情况:
-
递归地在左子树中查找
p
和q
(left_lca
)。 -
递归地在右子树中查找
p
和q
(right_lca
)。
-
-
处理结果:
-
如果
left_lca
和right_lca
都不为空,说明p
和q
分别位于root
的左右子树中,那么root
就是它们的最近公共祖先,返回root
。 -
如果
left_lca
不为空而right_lca
为空,说明p
和q
都位于root
的左子树中(或者p
或q
本身就是left_lca
),那么left_lca
就是它们的最近公共祖先,返回left_lca
。 -
如果
right_lca
不为空而left_lca
为空,同理,返回right_lca
。 -
如果
left_lca
和right_lca
都为空,说明root
的子树中没有找到p
或q
,返回NULL
。
-
改进后的代码(普通二叉树LCA):
#include <stdlib.h> // For exit, fprintf
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 在普通二叉树中找到两个节点的最近公共祖先
*
* @param root TreeNode类,二叉树的根节点
* @param p int整型,第一个目标节点的值
* @param q int整型,第二个目标节点的值
* @return int整型,返回最近公共祖先的节点值 (注意:题目要求返回int型,而不是TreeNode*)
*/
int lowestCommonAncestor(struct TreeNode *root, int p, int q) {
// 基本情况:
// 1. 如果当前节点为空,返回NULL
// 2. 如果当前节点的值等于p或q,那么当前节点就是p或q本身,也可能是LCA
if (root == NULL || root->val == p || root->val == q) {
// 返回当前节点指针,后续需要取val
// 由于题目要求返回int整型,这里直接返回节点值
// 但为了递归逻辑的完整性,通常函数会返回TreeNode*,然后再在顶层取val
// 这里为了遵循原题接口,我们先返回一个特殊值,或者需要改变接口设计
// 鉴于C语言接口的限制,我们让递归函数返回TreeNode*,外部函数再取val
// 所以我们需要一个新的辅助函数。
// 或者,如果题目的意思是返回找到的LCA的val,且保证p和q一定在树中,
// 那么可以在找到LCA的TreeNode*后,直接返回其val。
return (root != NULL) ? root->val : -1; // 假设-1表示未找到或错误
}
// 递归在左子树中查找p和q的LCA
int left_lca_val = lowestCommonAncestor(root->left, p, q);
// 递归在右子树中查找p和q的LCA
int right_lca_val = lowestCommonAncestor(root->right, p, q);
// 情况1:p和q分别在左右子树中,那么当前root就是LCA
if (left_lca_val != -1 && right_lca_val != -1) {
return root->val;
}
// 情况2:p和q都在左子树中(或者left_lca_val就是p或q),LCA在左子树中
else if (left_lca_val != -1) {
return left_lca_val;
}
// 情况3:p和q都在右子树中(或者right_lca_val就是p或q),LCA在右子树中
else if (right_lca_val != -1) {
return right_lca_val;
}
// 情况4:左右子树都没有找到p或q
else {
return -1; // 表示未找到
}
}
// **更标准的LCA递归函数,返回TreeNode*,外部再取val**
// 这是面试中更常见的LCA函数设计,因为它返回了节点本身,更灵活
struct TreeNode* findLCA(struct TreeNode* root, int p_val, int q_val) {
// 递归终止条件:
// 1. 如果当前节点为空,则返回NULL
// 2. 如果当前节点的值等于p_val或q_val,说明找到了其中一个目标节点,
// 或者当前节点就是LCA(如果另一个节点在其子树中)
if (root == NULL || root->val == p_val || root->val == q_val) {
return root;
}
// 递归地在左子树中查找p和q的LCA
struct TreeNode* left_lca = findLCA(root->left, p_val, q_val);
// 递归地在右子树中查找p和q的LCA
struct TreeNode* right_lca = findLCA(root->right, p_val, q_val);
// 根据左右子树的查找结果进行判断
if (left_lca != NULL && right_lca != NULL) {
// 如果左右子树都找到了目标节点(或其LCA),说明p和q分别在root的左右子树中,
// 那么root就是它们的最近公共祖先。
return root;
} else if (left_lca != NULL) {
// 如果只有左子树找到了结果,说明p和q都在左子树中,或者left_lca就是p或q本身。
// 那么left_lca就是它们的最近公共祖先。
return left_lca;
} else {
// 如果只有右子树找到了结果(或right_lca就是p或q本身),
// 或者左右子树都没有找到(此时left_lca和right_lca都为NULL),
// 统一返回right_lca (无论是找到的LCA还是NULL)。
return right_lca;
}
}
// 针对牛客题目要求返回int整型的情况,可以这样封装:
int lowestCommonAncestor_Wrapper(struct TreeNode *root, int p, int q) {
struct TreeNode* lca_node = findLCA(root, p, q);
if (lca_node != NULL) {
return lca_node->val;
}
// 如果题目保证p和q一定存在于树中,这个分支通常不会执行到。
// 如果不保证,则需要根据题目定义返回一个“未找到”的特殊值,比如INT_MIN或抛出错误。
// 假设题目保证存在,这里为了避免返回不确定的值,可以添加错误处理
fprintf(stderr, "Error: p or q not found in the tree, or unexpected NULL LCA.\n");
exit(EXIT_FAILURE); // 或者返回一个表示错误的值
}
如果是二叉搜索树(BST)的LCA:
如果题目明确是二叉搜索树,那么利用BST的性质,LCA的查找会更简单:
// BST版 LCA
struct TreeNode* lowestCommonAncestor_BST(struct TreeNode* root, int p, int q) {
if (root == NULL) {
return NULL;
}
// 如果p和q都比root的值小,说明LCA在左子树
if (root->val > p && root->val > q) {
return lowestCommonAncestor_BST(root->left, p, q);
}
// 如果p和q都比root的值大,说明LCA在右子树
else if (root->val < p && root->val < q) {
return lowestCommonAncestor_BST(root->right, p, q);
}
// 否则,说明p和q一个在左子树、一个在右子树,或者root就是p或q之一,
// 那么当前root就是LCA
else {
return root;
}
}
// 针对牛客题目要求返回int整型的情况
int lowestCommonAncestor_BST_Wrapper(struct TreeNode *root, int p, int q) {
struct TreeNode* lca_node = lowestCommonAncestor_BST(root, p, q);
if (lca_node != NULL) {
return lca_node->val;
}
fprintf(stderr, "Error: p or q not found in BST, or unexpected NULL LCA.\n");
exit(EXIT_FAILURE);
}
总结: LCA问题是二叉树中一个重要的应用。普通二叉树的LCA需要考虑节点在左右子树的分布情况,通常采用“自底向上”的递归策略。二叉搜索树的LCA则可以利用其有序性,通过比较节点值进行“自顶向下”的判断,效率更高。
10. 二叉树的序列化与反序列化
二叉树的序列化(Serialization)是指将二叉树的结构和节点值转换为一个线性的字符串或数组,以便于存储或传输。反序列化(Deserialization)则是指将这个线性表示恢复为原来的二叉树结构。
10.1 BM17 序列化二叉树
题目要求: 请实现两个函数,分别用来序列化和反序列化二叉树。
你的代码分析:
你没有提供此题的代码,因此这里直接给出通用的实现。
实现思路:
序列化和反序列化通常采用以下两种策略:
-
前序遍历序列化(常见):
-
在遍历时,如果遇到空节点,用一个特殊标记(例如
#
或null
)表示。 -
节点值之间用分隔符(例如逗号
,
)隔开。 -
示例:
1,2,#,#,3,4,#,#,5,#,#
表示根节点为1,左孩子为2,右孩子为3。2是叶子节点,3的左孩子是4,右孩子是5。
-
-
层次遍历序列化:
-
使用队列进行层次遍历。
-
空节点也入队,并用特殊标记表示。
-
这里我们以前序遍历为例进行实现,因为它在反序列化时通常比较直接。
序列化函数 serialize
:
#include <stdio.h> // For sprintf, sscanf
#include <stdlib.h> // For malloc, realloc, free
#include <string.h> // For strcat, strcpy
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 辅助函数:将整数转换为字符串
char* intToString(int val) {
char* str = (char*)malloc(12 * sizeof(char)); // 足够存储INT_MIN/MAX和负号
if (str == NULL) {
fprintf(stderr, "Memory allocation failed for intToString.\n");
exit(EXIT_FAILURE);
}
sprintf(str, "%d", val);
return str;
}
/**
* @brief 递归辅助函数:将二叉树前序遍历并转换为字符串
*
* @param root 当前节点
* @param str_buffer 指向动态字符串缓冲区的指针的指针
* @param current_len 指向当前字符串长度的指针
* @param buffer_capacity 指向缓冲区容量的指针
*/
void serialize_recursive(struct TreeNode* root, char** str_buffer, int* current_len, int* buffer_capacity) {
char temp_str[20]; // 临时字符串,用于存放节点值或空标记
if (root == NULL) {
strcpy(temp_str, "#"); // 空节点用 '#' 表示
} else {
sprintf(temp_str, "%d", root->val); // 非空节点转换为字符串
}
// 检查缓冲区是否足够存储当前节点字符串和分隔符
int needed_len = strlen(temp_str);
if (*current_len + needed_len + 1 >= *buffer_capacity) { // +1 for ',' or null terminator
*buffer_capacity = (*buffer_capacity == 0) ? 100 : (*buffer_capacity * 2); // 扩容
char* new_buffer = (char*)realloc(*str_buffer, *buffer_capacity * sizeof(char));
if (new_buffer == NULL) {
fprintf(stderr, "Memory reallocation failed in serialize_recursive.\n");
exit(EXIT_FAILURE);
}
*str_buffer = new_buffer;
}
// 将当前节点字符串添加到缓冲区
if (*current_len > 0) { // 如果不是第一个节点,先添加分隔符
strcat(*str_buffer, ",");
(*current_len)++;
}
strcat(*str_buffer, temp_str);
*current_len += needed_len;
if (root != NULL) {
// 递归序列化左子树和右子树
serialize_recursive(root->left, str_buffer, current_len, buffer_capacity);
serialize_recursive(root->right, str_buffer, current_len, buffer_capacity);
}
}
/**
* @brief 序列化二叉树
* @param root TreeNode类 树的根节点
* @return char* 序列化后的字符串
*/
char* serialize(struct TreeNode* root) {
char* str_buffer = NULL;
int current_len = 0;
int buffer_capacity = 0; // 初始容量为0,会在第一次调用时扩容
// 调用递归函数进行序列化
serialize_recursive(root, &str_buffer, ¤t_len, &buffer_capacity);
// 返回最终的字符串
return str_buffer;
}
反序列化函数 deserialize
:
反序列化通常需要一个指向当前解析位置的指针或索引,因为递归解析会前进这个位置。
// 辅助函数:将字符串分割成数组,方便解析
// 这是一个简化的分割函数,实际可能需要更健壮的实现
char** splitString(char* str, const char* delimiter, int* count) {
char** result = NULL;
int idx = 0;
char* temp_str = strdup(str); // 复制一份字符串,因为strtok会修改原字符串
if (temp_str == NULL) {
fprintf(stderr, "Memory allocation failed for splitString temp_str.\n");
exit(EXIT_FAILURE);
}
char* token = strtok(temp_str, delimiter);
while (token != NULL) {
result = (char**)realloc(result, (idx + 1) * sizeof(char*));
if (result == NULL) {
fprintf(stderr, "Memory reallocation failed for splitString result.\n");
free(temp_str);
exit(EXIT_FAILURE);
}
result[idx] = strdup(token); // 复制token
if (result[idx] == NULL) {
fprintf(stderr, "Memory allocation failed for splitString result[idx].\n");
// 释放之前分配的内存
for(int i = 0; i < idx; ++i) free(result[i]);
free(result);
free(temp_str);
exit(EXIT_FAILURE);
}
idx++;
token = strtok(NULL, delimiter);
}
*count = idx;
free(temp_str);
return result;
}
/**
* @brief 递归辅助函数:从字符串数组中反序列化二叉树
*
* @param nodes 字符串数组,包含序列化后的节点信息
* @param index 指向当前解析位置的指针
* @param total_nodes 字符串数组的总节点数
* @return struct TreeNode* 反序列化出的当前子树的根节点
*/
struct TreeNode* deserialize_recursive(char** nodes, int* index, int total_nodes) {
// 如果索引越界,或者当前节点是空标记,则返回NULL
if (*index >= total_nodes || strcmp(nodes[*index], "#") == 0) {
(*index)++; // 移动到下一个节点
return NULL;
}
// 创建新节点
struct TreeNode* new_node = (struct TreeNode*)malloc(sizeof(struct TreeNode));
if (new_node == NULL) {
fprintf(stderr, "Memory allocation failed for new_node in deserialize_recursive.\n");
exit(EXIT_FAILURE);
}
new_node->val = atoi(nodes[*index]); // 将字符串转换为整数值
(*index)++; // 移动到下一个节点
// 递归反序列化左子树和右子树
new_node->left = deserialize_recursive(nodes, index, total_nodes);
new_node->right = deserialize_recursive(nodes, index, total_nodes);
return new_node;
}
/**
* @brief 反序列化二叉树
* @param str char* 序列化后的字符串
* @return TreeNode类 恢复后的二叉树根节点
*/
struct TreeNode* deserialize(char* str) {
if (str == NULL || strlen(str) == 0) {
return NULL;
}
int total_nodes = 0;
char** nodes_str = splitString(str, ",", &total_nodes); // 将字符串分割成数组
if (nodes_str == NULL || total_nodes == 0) {
return NULL;
}
int index = 0; // 用于跟踪当前解析到的位置
struct TreeNode* root = deserialize_recursive(nodes_str, &index, total_nodes);
// 释放splitString分配的内存
for (int i = 0; i < total_nodes; i++) {
free(nodes_str[i]);
}
free(nodes_str);
return root;
}
总结: 序列化与反序列化是考察对二叉树遍历和内存管理能力的重要题目。前序遍历配合特殊标记 #
是一种常见的序列化方式,反序列化时通过一个全局或传递的索引来跟踪解析进度是关键。需要特别注意内存的动态分配和释放。
11. 重建二叉树
重建二叉树是指根据二叉树的两种遍历序列(通常是前序和中序,或中序和后序)来唯一确定并重建出原二叉树。
11.1 BM18 重建二叉树
题目要求: 给定二叉树的前序遍历和中序遍历结果,重建二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
你的代码分析:
你没有提供此题的代码,因此这里直接给出通用的实现。
实现思路:
核心思想是分治。
-
前序遍历的特点: 第一个元素永远是当前子树的根节点。
-
中序遍历的特点: 根节点将中序遍历序列分割成两部分:左边的部分是左子树的中序遍历,右边的部分是右子树的中序遍历。
算法步骤:
-
确定根节点: 前序遍历的第一个元素就是根节点的值。
-
在中序遍历中找到根节点: 在中序遍历序列中找到这个根节点的值。
-
划分左右子树:
-
根节点在中序遍历中的位置,其左边的所有元素构成左子树的中序遍历序列。
-
根节点在中序遍历中的位置,其右边的所有元素构成右子树的中序遍历序列。
-
根据左子树中序遍历的长度,从前序遍历中截取相应的长度作为左子树的前序遍历序列。
-
剩余的前序遍历序列则作为右子树的前序遍历序列。
-
-
递归重建: 递归地对左子树和右子树进行相同的重建过程。
改进后的代码:
需要一个辅助函数来根据数组范围进行递归。
#include <stdlib.h> // For malloc, exit, fprintf
// 假设TreeNode结构体已在其他地方定义
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
/**
* @brief 辅助函数:在中序遍历数组中查找目标值的索引
*
* @param inorder 中序遍历数组
* @param in_start 中序遍历的起始索引
* @param in_end 中序遍历的结束索引
* @param target 目标值
* @return int 目标值在数组中的索引,如果未找到返回-1
*/
int find_index_in_inorder(int* inorder, int in_start, int in_end, int target) {
for (int i = in_start; i <= in_end; i++) {
if (inorder[i] == target) {
return i;
}
}
return -1; // 应该总是能找到,因为题目保证不含重复数字
}
/**
* @brief 递归辅助函数:根据前序遍历和中序遍历结果重建二叉树
*
* @param preorder 前序遍历数组
* @param pre_start 前序遍历的起始索引
* @param pre_end 前序遍历的结束索引
* @param inorder 中序遍历数组
* @param in_start 中序遍历的起始索引
* @param in_end 中序遍历的结束索引
* @return struct TreeNode* 重建出的当前子树的根节点
*/
struct TreeNode* reConstructBinaryTree_recursive(int* preorder, int pre_start, int pre_end,
int* inorder, int in_start, int in_end) {
// 递归终止条件:如果前序或中序遍历的范围无效,或者为空
if (pre_start > pre_end || in_start > in_end) {
return NULL;
}
// 前序遍历的第一个元素是当前子树的根节点
struct TreeNode* root = (struct TreeNode*)malloc(sizeof(struct TreeNode));
if (root == NULL) {
fprintf(stderr, "Memory allocation failed for new_node in reConstructBinaryTree_recursive.\n");
exit(EXIT_FAILURE);
}
root->val = preorder[pre_start];
// 在中序遍历中找到根节点的位置
int root_in_inorder_index = find_index_in_inorder(inorder, in_start, in_end, root->val);
if (root_in_inorder_index == -1) {
// 这通常不应该发生,因为题目保证有效输入
fprintf(stderr, "Error: Root value not found in inorder traversal.\n");
free(root);
return NULL;
}
// 计算左子树的节点数量
int left_subtree_size = root_in_inorder_index - in_start;
// 递归重建左子树
root->left = reConstructBinaryTree_recursive(
preorder, pre_start + 1, pre_start + left_subtree_size,
inorder, in_start, root_in_inorder_index - 1
);
// 递归重建右子树
root->right = reConstructBinaryTree_recursive(
preorder, pre_start + left_subtree_size + 1, pre_end,
inorder, root_in_inorder_index + 1, in_end
);
return root;
}
/**
* @brief 根据前序遍历和中序遍历重建二叉树
*
* @param pre int* 前序遍历数组
* @param pre_len int 前序遍历数组的长度
* @param vin int* 中序遍历数组
* @param vin_len int 中序遍历数组的长度
* @return TreeNode类* 重建后的二叉树根节点
*/
struct TreeNode* reConstructBinaryTree(int* pre, int pre_len, int* vin, int vin_len) {
if (pre == NULL || pre_len == 0 || vin == NULL || vin_len == 0 || pre_len != vin_len) {
return NULL;
}
return reConstructBinaryTree_recursive(pre, 0, pre_len - 1, vin, 0, vin_len - 1);
}
总结: 重建二叉树是分治思想的典型应用。理解前序遍历和中序遍历的特点,并通过根节点在中序遍历中的位置来划分左右子树,是解决这类问题的关键。
12. 输出二叉树的右视图
二叉树的右视图是指从二叉树的右侧观察时,可以看到的节点集合。
12.1 BM19 输出二叉树的右视图
题目要求: 给定一个二叉树的根节点 root
,返回其右视图的节点值。
你的代码分析:
你没有提供此题的代码,因此这里直接给出通用的实现。
实现思路:
右视图的本质是每层最右边的节点。因此,可以使用**层次遍历(BFS)或深度优先遍历(DFS)**来解决。
层次遍历(BFS)思路:
-
使用队列进行层次遍历。
-
在每层遍历开始时,记录当前层有多少个节点。
-
遍历当前层的节点,每次出队一个节点。当遍历到当前层的最后一个节点时,它就是该层最右边的节点,将其值加入结果集。
-
将当前节点的左右子节点(如果非空)入队,为下一层做准备。
改进后的代码(BFS):
同样需要队列结构。
#include <stdlib.h> // For malloc, realloc, free
#include <stdbool.h> // For bool
// 假设TreeNode结构体和Queue结构体及相关函数已在其他地方定义
/**
* @brief 输出二叉树的右视图(层次遍历/BFS)
*
* @param root TreeNode类,二叉树的根节点
* @param returnSize 返回数组的实际元素个数
* @return int* 返回一个包含右视图结果的整型一维数组
*/
int* rightSideView(struct TreeNode* root, int* returnSize) {
int* res = NULL;
int capacity = 0;
*returnSize = 0;
if (root == NULL) {
return NULL;
}
Queue* q = createQueue(10); // 初始队列容量
if (q == NULL) {
fprintf(stderr, "Failed to create queue for rightSideView.\n");
return NULL;
}
enqueue(q, root);
while (!isQueueEmpty(q)) {
int level_size = q->size; // 当前层节点数量
for (int i = 0; i < level_size; i++) {
struct TreeNode* current = dequeue(q);
// 如果是当前层的最后一个节点,它就是右视图的一部分
if (i == level_size - 1) {
if (*returnSize >= capacity) {
capacity = (capacity == 0) ? 10 : (capacity * 2);
int *new_res = (int *)realloc(res, capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in rightSideView.\n");
destroyQueue(q);
free(res);
exit(EXIT_FAILURE);
}
res = new_res;
}
res[(*returnSize)++] = current->val;
}
// 将左右子节点入队
if (current->left != NULL) {
enqueue(q, current->left);
}
if (current->right != NULL) {
enqueue(q, current->right);
}
}
}
destroyQueue(q);
// 最终调整结果数组大小
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in rightSideView.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
深度优先遍历(DFS)思路:
右视图也可以通过深度优先遍历来实现,通常是优先遍历右子树。
-
使用一个变量
max_depth
记录已经访问到的最大深度。 -
在递归遍历时,传入当前节点的深度
depth
。 -
如果当前深度
depth
大于max_depth
,说明这是当前深度第一次访问到的节点(因为优先遍历右子树,所以一定是该层最右边的节点),将其值加入结果集,并更新max_depth
。 -
先递归遍历右子树,再递归遍历左子树。
改进后的代码(DFS):
#include <stdlib.h> // For malloc, realloc, free
#include <stdbool.h> // For bool
// 假设TreeNode结构体已在其他地方定义
/**
* @brief 辅助函数:深度优先遍历获取二叉树的右视图
*
* @param root 当前节点
* @param depth 当前节点的深度(根节点深度为0或1,取决于定义)
* @param max_depth 指向当前已访问到的最大深度的指针
* @param res 指向结果数组指针的指针
* @param capacity 指向结果数组容量的指针
* @param returnSize 指向已收集节点数量的指针
*/
void rightSideView_dfs_recursive(struct TreeNode* root, int depth, int* max_depth,
int** res, int* capacity, int* returnSize) {
if (root == NULL) {
return;
}
// 如果当前深度大于已访问到的最大深度,说明这是该层最右边的节点
if (depth > *max_depth) {
if (*returnSize >= *capacity) {
*capacity = (*capacity == 0) ? 10 : (*capacity * 2);
int *new_res = (int *)realloc(*res, *capacity * sizeof(int));
if (new_res == NULL) {
fprintf(stderr, "Memory reallocation failed in rightSideView_dfs_recursive.\n");
exit(EXIT_FAILURE);
}
*res = new_res;
}
(*res)[(*returnSize)++] = root->val;
*max_depth = depth; // 更新最大深度
}
// 优先遍历右子树
rightSideView_dfs_recursive(root->right, depth + 1, max_depth, res, capacity, returnSize);
// 再遍历左子树
rightSideView_dfs_recursive(root->left, depth + 1, max_depth, res, capacity, returnSize);
}
/**
* @brief 输出二叉树的右视图(深度优先遍历/DFS)
*
* @param root TreeNode类,二叉树的根节点
* @param returnSize 返回数组的实际元素个数
* @return int* 返回一个包含右视图结果的整型一维数组
*/
int* rightSideView_dfs(struct TreeNode* root, int* returnSize) {
int* res = NULL;
int capacity = 0;
*returnSize = 0;
int max_depth = -1; // 记录已访问到的最大深度,初始化为-1或0
rightSideView_dfs_recursive(root, 0, &max_depth, &res, &capacity, returnSize); // 根节点深度为0
// 最终调整结果数组大小
if (*returnSize < capacity) {
int *final_res = (int *)realloc(res, *returnSize * sizeof(int));
if (final_res == NULL && *returnSize > 0) {
fprintf(stderr, "Final memory reallocation failed in rightSideView_dfs.\n");
} else if (*returnSize == 0) {
free(res);
res = NULL;
} else {
res = final_res;
}
}
return res;
}
总结: 右视图问题既可以用BFS也可以用DFS解决。BFS通过记录每层的最后一个节点来获得,DFS则通过优先遍历右子树并记录首次访问的深度来获得。两种方法各有优劣,掌握它们有助于灵活应对类似问题。
结语
至此,我们已经系统地探讨了牛客面试中常见的二叉树题目,包括基础遍历、深度计算、合并、镜像、判断BST/完全树/平衡树,以及最近公共祖先和序列化/反序列化、重建二叉树、右视图。
通过对你提交的代码进行分析,我们发现了一些程序员在练习中常见的误区:
-
硬编码数组大小: 动态数组应使用
malloc
/realloc
动态管理内存,避免固定大小的限制。 -
函数命名不规范: 良好的函数命名能够提高代码的可读性和可维护性。
-
原地修改与创建新对象: 明确题目要求是原地操作还是生成新对象,不确定时通常选择生成新对象。
-
边界条件处理不严谨: 尤其是空指针的检查,是避免运行时错误的关键。
-
重复计算与优化: 在递归问题中,考虑如何通过一次递归(如高度和平衡性判断结合)避免重复计算,提高效率。
-
辅助数据结构的使用: 灵活运用栈和队列来解决迭代遍历、层次遍历等问题。
二叉树作为面试必考点,笔者在二刷这个知识点所有题型的同时,想着都刷了第二遍了,还不如写个技术贴分享出来自己的这么多手写代码,于是有了这篇贴文,觉得不错的还请三联支持
--------------------------------------------------------------------------------------------------------更新于2025.6./13上午10:21分