从零开始写算法——二叉树篇2:二叉树的最大深度 + 翻转二叉树

在二叉树的算法实现中,递归是解决问题的核心手段。虽然代码往往只有寥寥几行,但这背后却蕴含着两种截然不同的递归思维模式。

本文将通过"二叉树的最大深度"和"翻转二叉树"这两道经典题目,剖析**遍历思维(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)提前保存递归结果;要么在递归之前先交换左右指针。

上述代码采用的是自底向上的归并逻辑:

  1. 我对左边说:"你去把你下面整明白,把头节点返给我"。

  2. 我对右边说:"你去把你下面整明白,把头节点返给我"。

  3. 左右都搞定后,我只要做一个动作:交换

3. 复杂度分析

  • 时间复杂度:O(N)

    • 我们需要遍历树中的每一个节点来进行交换操作,每个节点被访问一次。
  • 空间复杂度:O(H)

    • 同上,主要消耗在于递归调用栈。

    • 最坏情况 O(N),平均情况 O(logN)。


三、 总结与对比

通过这两道题,我们可以总结出写递归算法的两种基本模版:

  1. 遍历模式(Traversal)

    • 代表:最大深度(本文写法)。

    • 特征 :函数通常由 void 返回,依靠修改外部变量(如 ans)或内部状态传递(如 depth 参数)来达成目的。

    • 口诀:一路向下,沿途记录。

  2. 分解模式(Divide & Conquer)

    • 代表:翻转二叉树。

    • 特征:函数通常有返回值(返回指针或计算结果),当前节点依赖子函数的返回值来构建自己的逻辑。

    • 口诀:先分后治,最后合并。

掌握这两种思维,在面对复杂的二叉树问题(如二叉树的直径、路径之和等)时,就能根据需要灵活切换,避免陷入逻辑死胡同。

相关推荐
xu_yule2 小时前
算法基础(图论)—拓扑排序
c++·算法·动态规划·图论·拓扑排序·aov网
mu_guang_2 小时前
算法图解1-算法简介
算法·cpu·计算机体系结构
CoovallyAIHub2 小时前
超越CUDA围墙:国产GPU在架构、工艺与软件栈的“三维替代”挑战
深度学习·算法·计算机视觉
小O的算法实验室2 小时前
2024年IEEE TAES SCI2区,一种改进人工电场算法用于机器人路径规划,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进
gugugu.2 小时前
算法:N皇后问题
算法
lLinkl2 小时前
LeetCode-1.两数之和
算法·leetcode·散列表
(❁´◡`❁)Jimmy(❁´◡`❁)2 小时前
F - Manhattan Christmas Tree 2
数据结构·算法
wxdlfkj2 小时前
从算法溯源到硬件极限:解决微小球面小角度拟合与中心定位的技术路径
人工智能·算法·机器学习
高洁012 小时前
基于Tensorflow库的RNN模型预测实战
人工智能·python·算法·机器学习·django