在二叉树的算法实现中,递归是解决问题的核心手段。虽然代码往往只有寥寥几行,但这背后却蕴含着两种截然不同的递归思维模式。
本文将通过"二叉树的最大深度"和"翻转二叉树"这两道经典题目,剖析**遍历思维(Traversal)与分解思维(Divide & Conquer)**的区别,并深入探讨指针操作中的细节与时空复杂度分析。
一、 二叉树的最大深度:自顶向下的"遍历思维"
求二叉树最大深度通常有两种思路:一是分解问题(max(left, right) + 1),二是遍历整棵树。这里我们采用**遍历(Traversal)**的思路来实现。
1. 代码实现
这种写法的核心在于:我们像一个游标一样游走在树的每一个节点上,并且在游走的过程中,把"当前处于第几层"这个状态(depth)一直传递下去。
C++代码实现:
cpp
class Solution {
// 全局变量记录最大深度,相当于一个"记分牌"
int ans = 0;
// dfs函数负责遍历,depth参数负责"携带状态"
void dfs(TreeNode* root, int depth) {
// 进入当前节点,深度+1
depth += 1;
// 递归终止条件(触底)
if (root == nullptr) {
return;
}
// 每次进入一个非空节点,都尝试更新全局最大值
ans = max(depth, ans);
// 继续带着当前的深度状态,向左右子树探索
// 注意:这里的depth是值传递,进入下一层时会有副本,互不干扰
dfs(root->left, depth);
dfs(root->right, depth);
}
public:
int maxDepth(TreeNode* root) {
// 初始深度为0(或者根据定义从1开始,视题目而定)
dfs(root, 0);
return ans;
}
};
2. 深度解析:为什么不需要回溯?
简单来说,因为depth不是&depth,每个depth都是单独一个副本。
很多初学者在写 DFS 时会纠结是否需要"恢复现场"(即 depth--)。
在上述代码中,我们利用了 C++ 函数传参的特性------值传递(Pass by Value)。
-
void dfs(TreeNode* root, int depth)中的depth是一个局部变量。 -
当我们要去递归左子树时,系统复制了一份当前的
depth传给下一层。 -
下一层对
depth的任何修改(depth += 1),都只发生在下一层的栈帧中,不会影响当前层 手中的depth变量。 -
因此,当左子树递归返回时,当前层的
depth依然保持原样,我们可以直接把它传给右子树,无需手动回溯。
这种写法体现了**"自顶向下"**的思维:父节点把数据传给子节点,子节点利用这个数据进行计算(更新 ans)。
3. 复杂度分析
-
时间复杂度:O(N)
- 其中 N 为二叉树的节点数。DFS 保证每个节点仅被访问一次。
-
空间复杂度:O(H)
-
其中 H 为二叉树的高度。
-
最坏情况:树退化成链表,递归栈深度为 N,空间复杂度为 O(N)。
-
平均/最好情况:树是平衡的,递归栈深度为 logN,空间复杂度为 O(logN)。
-
注意:这里只计算了递归调用栈的辅助空间,未使用额外的数据结构。
-
二、 翻转二叉树:自底向上的"分解思维"
如果说求深度是"带着数据往下跑",那么翻转二叉树则更适合"把结果拿上来"。我们要做的就是:先把左子树翻转好,再把右子树翻转好,最后交换这两个已经翻转好的子树。
1. 代码实现
这是一个典型的**后序遍历(Post-order Traversal)**逻辑。
C++代码实现:
cpp
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
// 递归的终止条件:空节点不需要翻转
if (root == nullptr) return nullptr;
// 1. 递归处理子问题(先下去解决子树)
// 这里利用 auto 接收返回值,相当于先把"翻转好的左/右子树"的指针保存下来
auto left = invertTree(root->left);
auto right = invertTree(root->right);
// 2. 在当前节点进行操作(交换)
// 核心细节:此时 left 和 right 已经是处理好的新子树根节点了
root->left = right;
root->right = left;
// 3. 返回当前处理好的根节点给上一层
return root;
}
};
2. 深度解析:指针覆盖的陷阱
在实现这道题时,最容易犯的错误是直接操作指针而忽略了数据的保存。
错误的写法:
C++代码实现:
cpp
// 此时 root->left 被修改为翻转后的右子树
root->left = invertTree(root->right);
// 下一行再调用 invertTree(root->left) 时,
// 传入的其实是"刚才翻转好的右子树",而原本的左子树指针已经丢失(覆盖)了!
root->right = invertTree(root->left);
正确的逻辑(代码中的做法): 我们要么使用临时变量(如代码中的 auto left, auto right)提前保存递归结果;要么在递归之前先交换左右指针。
上述代码采用的是自底向上的归并逻辑:
-
我对左边说:"你去把你下面整明白,把头节点返给我"。
-
我对右边说:"你去把你下面整明白,把头节点返给我"。
-
左右都搞定后,我只要做一个动作:交换。
3. 复杂度分析
-
时间复杂度:O(N)
- 我们需要遍历树中的每一个节点来进行交换操作,每个节点被访问一次。
-
空间复杂度:O(H)
-
同上,主要消耗在于递归调用栈。
-
最坏情况 O(N),平均情况 O(logN)。
-
三、 总结与对比
通过这两道题,我们可以总结出写递归算法的两种基本模版:
-
遍历模式(Traversal):
-
代表:最大深度(本文写法)。
-
特征 :函数通常由
void返回,依靠修改外部变量(如ans)或内部状态传递(如depth参数)来达成目的。 -
口诀:一路向下,沿途记录。
-
-
分解模式(Divide & Conquer):
-
代表:翻转二叉树。
-
特征:函数通常有返回值(返回指针或计算结果),当前节点依赖子函数的返回值来构建自己的逻辑。
-
口诀:先分后治,最后合并。
-
掌握这两种思维,在面对复杂的二叉树问题(如二叉树的直径、路径之和等)时,就能根据需要灵活切换,避免陷入逻辑死胡同。