110. 平衡二叉树
给定一个二叉树,判断它是否是 平衡二叉树
cpp
// 递归法(后序遍历 + 剪枝)
class Solution {
public:
// 返回值:如果平衡返回高度,不平衡返回 -1
int height(TreeNode* root) {
if (root == NULL) return 0;
// 1. 递归左子树
int left = height(root->left);
if (left == -1) return -1; // 左子树不平衡,直接返回 -1 剪枝
// 2. 递归右子树
int right = height(root->right);
if (right == -1) return -1; // 右子树不平衡,直接返回 -1 剪枝
// 3. 判断当前节点是否平衡
// 如果左右高度差 > 1,返回 -1 表示不平衡
// 否则返回当前节点的高度
return abs(left - right) > 1 ? -1 : max(left, right) + 1;
}
bool isBalanced(TreeNode* root) {
// 只要返回值不是 -1,说明整棵树平衡
return height(root) != -1;
}
};
// 递归法(全局标志位)
class Solution {
public:
bool flag; // 全局标志位
int height(TreeNode* root) {
if (root == NULL) return 0;
// 递归计算高度
int left = height(root->left);
int right = height(root->right);
// 【关键】检查高度差,如果不平衡,修改标志位
if (abs(left - right) > 1) flag = false;
// 返回正常的高度计算结果
return max(left, right) + 1;
}
bool isBalanced(TreeNode* root) {
flag = true; // 初始化为 true
height(root);
return flag;
}
};
总结
1. 核心区别
| 方法 | 时间复杂度 | 特点 | 评价 |
|---|---|---|---|
| 方法1 (剪枝) | O(N) | 自底向上,发现不平衡立即返回 | 最优解,效率最高 |
| 方法2 (标志位) | O(N) | 自底向上,但发现不平衡不会立即停止计算 | 逻辑清晰,但不如方法1高效 |
2. 为什么方法1最好?
- 剪枝机制:在方法1中,
if (left == -1) return -1;这一行非常关键。一旦左子树返回-1,程序直接结束当前分支的递归,不再去计算右子树的高度。这避免了无意义的遍历。 - 方法2 虽然复杂度也是 O(N),但即使
flag变为false,递归函数height依然会执行完毕直到栈回溯完毕。
3. 复杂度总结
- 时间复杂度:
- 方法1和方法2:每个节点只访问一次,O(N)。
- 方法3:每个节点被多次访问(计算高度时重复遍历),O(N²)。
- 空间复杂度:
- 均取决于递归栈深度,最坏 O(N)。
257. 二叉树的所有路径
给你一个二叉树的根节点
root,按 任意顺序 ,返回所有从根节点到叶子节点的路径。叶子节点 是指没有子节点的节点。
cpp
class Solution {
public:
vector<string> ans;
// dfs 函数参数:当前节点,以及到达当前节点的路径字符串
// 【关键点】:这里的 path 是"值传递",不是"引用传递"
void dfs(TreeNode* root, string path) {
// 1. 终止条件:遇到叶子节点(左右孩子都为空)
if (root->left == NULL && root->right == NULL) {
ans.push_back(path); // 将当前路径加入结果集
return;
}
// 2. 递归逻辑
// 如果左孩子存在,将 "左孩子值" 拼接到路径中,传入下一层
if (root->left) {
// path + "->" + ... 会创建一个新的临时 string 对象传给 dfs
// 这意味着回到当前层时,path 的值并没有改变,自动实现了"回溯"
dfs(root->left, path + "->" + to_string(root->left->val));
}
// 同理处理右孩子
if (root->right) {
dfs(root->right, path + "->" + to_string(root->right->val));
}
}
vector<string> binaryTreePaths(TreeNode* root) {
if (root == NULL) return ans; // 鲁棒性判断:空树直接返回
// 初始化:根节点的值作为路径的起点
// 注意:这里传入的是 root->val,而不是 "->" + val
string path = to_string(root->val);
// 开始递归
dfs(root, path);
return ans;
}
};
总结
1. 核心技巧:值传递代替显式回溯
通常 DFS 题目为了避免空间浪费,我们会传引用 string& path,然后在递归返回时手动 path.pop_back() 或 path.erase()(显式回溯)。
上述代码采用了另一种巧妙的方法:
- 传值:
void dfs(..., string path)。 - 原理:每次调用
dfs时,path + "->" + val会生成一个新的字符串副本传递给下一层。 - 效果:当下一层递归结束返回到本层时,本层的
path变量从未被修改过,因此不需要写回溯代码,直接处理下一个分支(如右孩子)即可。 - 代价:虽然代码简洁,但每次递归都会复制字符串,空间开销略大(路径越长,复制开销越大),但在本题数据量下通常可以接受。
2. 代码细节
- 初始化:在
binaryTreePaths函数中,先写入根节点的值。这样做是为了保证dfs内部的逻辑统一(不用特殊处理开头的->)。 - 拼接时机:在进入下一层递归之前进行拼接。这样做的好处是
dfs函数接收到的path参数就是完整的、可以直接存入ans的路径。
3. 复杂度分析
- 时间复杂度:O(N²)
- 其中 N 是节点数。需要遍历每个节点一次。
- 但每个节点都要进行字符串拼接,字符串复制的时间与路径长度成正比。最坏情况(链状树)总复杂度为 1+2+...+N = O(N²)。
- 空间复杂度:O(N)
- 主要取决于递归调用栈的深度。虽然字符串复制占用额外空间,但通常视为临时空间,不计入栈空间分析。
404. 左叶子之和
给定二叉树的根节点
root,返回所有左叶子之和。
cpp
class Solution {
public:
int sum; // 全局变量,用于累加左叶子之和
// 后序遍历函数
void getLeftSum(TreeNode* root) {
if (root == NULL) return;
// 1. 递归左右子树
getLeftSum(root->left);
getLeftSum(root->right);
// 2. 处理当前节点逻辑
// 判断当前节点的左孩子是否为"左叶子"
// 条件:左孩子存在 && 左孩子没有左孩子 && 左孩子没有右孩子(即左孩子是叶子节点)
if (root->left && !root->left->left && !root->left->right) {
sum += root->left->val;
}
}
int sumOfLeftLeaves(TreeNode* root) {
sum = 0; // 初始化
getLeftSum(root);
return sum;
}
};
总结
1. 核心难点:如何判断"左叶子"?
左叶子 的定义是:
- 它是某个节点的左孩子。
- 它本身是一个叶子节点(左右孩子都为空)。
为什么无法通过节点自身判断?
如果你站在某个节点 node 上,你无法判断自己是不是"左叶子",因为你不知道自己是不是父节点的左孩子。
解决方案:
必须站在父节点的角度看孩子。
- 代码逻辑:
root->left存在,且root->left的左右孩子都为空。 - 这就是父节点
root识别左孩子是否为叶子的过程。
2. 复杂度分析
- 时间复杂度:O(N)
- 每个节点都会被访问一次。
- 空间复杂度:O(N)
- 取决于递归栈的深度,最坏情况(链表)为 O(N)。
222. 完全二叉树的节点个数
给你一棵 完全二叉树 的根节点
root,求出该树的节点个数。完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第
h层(从第 0 层开始),则该层包含1 ~ 2h个节点。
cpp
// 递归法(普通后序遍历)
class Solution {
public:
int countNodes(TreeNode* root) {
if (root == NULL) return 0;
// 后序遍历:先算左子树数量,再算右子树数量
int left = countNodes(root->left);
int right = countNodes(root->right);
// 节点总数 = 左子树 + 右子树 + 1 (当前节点)
return left + right + 1;
}
};
// 递归法(利用完全二叉树特性)
class Solution {
public:
int countNodes(TreeNode* root) {
if (root == NULL) return 0;
TreeNode* left = root->left;
TreeNode* right = root->right;
int leftdepth = 0, rightdepth = 0;
// 1. 计算左子树的最左深度(一直往左走)
while (left) {
leftdepth++;
left = left->left;
}
// 2. 计算右子树的最右深度(一直往右走)
while (right) {
rightdepth++;
right = right->right;
}
// 3. 核心判断
// 如果左最深度 == 右最深度,说明这棵树是满二叉树
// 满二叉树节点数公式:2^depth - 1
// 注意:2 << leftdepth 等价于 2^(leftdepth+1)
// 代码中 leftdepth 是层数(从0开始算深度),公式应理解为 2^(h) - 1,即 (2 << (h-1)) - 1
// leftdepth 是遍历计数,实际上代表"除了根节点还有几层"
// 举例:根节点深度0,left=1,遍历一次 leftdepth=1。
// 2 << 1 = 4, 4-1 = 3 (根+左+右),正确。
if (leftdepth == rightdepth) {
return (2 << leftdepth) - 1;
}
// 4. 如果不是满二叉树,则正常递归
// 这种递归会迅速触底进入"满二叉树"分支,效率极高
return countNodes(root->left) + countNodes(root->right) + 1;
}
};
总结
1. 核心区别
| 方法 | 时间复杂度 | 原理 | 适用性 |
|---|---|---|---|
| 普通递归 | O(N) | 遍历每个节点 | 任意二叉树 |
| 完全二叉树递归 | O(logN * logN) | 每次判断是否为满二叉树,快速剪枝 | 仅完全二叉树 |
2. 为什么第二种方法快?
- 满二叉树判定:对于完全二叉树,递归下去的过程中,必定有一半的子树是满二叉树。
- 公式计算:一旦判定为满二叉树,直接用公式返回,省去了该子树所有节点的遍历。
- 递归深度 :递归深度为树高 O(logN),每层计算深度也是 O(logN),总复杂度 O(log²N)。在数据量大时(如 10^5 节点),O(N) 是 10^5 次运算,O(log²N) 仅约 400 次运算,性能提升巨大。
3. 易错点:位运算
2 << leftdepth等价于 2*(2^leftdepth)。- 因为
leftdepth是计数出来的,实际上是除了 root 之外的层数。 - 例如深度为 1 的满二叉树(只有根),循环不执行,
leftdepth=0,结果(2 << 0) - 1 = 1。正确。 - 例如深度为 2 的满二叉树(根+左+右),循环执行 1 次,
leftdepth=1,结果(2 << 1) - 1 = 3。正确。