3 阅读38分钟

94. 二叉树的中序遍历

这个问题是 “二叉树的中序遍历” (Binary Tree Inorder Traversal) 。在树的各种遍历方式中,中序遍历是最有“礼貌”的一种,因为它遵循着严格的左中右顺序。


🏠 生活案例:公司汇报流程

想象一家公司的组织架构是一棵二叉树。CEO 是根节点,他有两个副总(左副总和右副总),每个副总下面还有自己的经理。

中序遍历就像是一场从基层到高层的汇报会,规则是: “先听左边的下属汇报,再听自己汇报,最后听右边的下属汇报。”

  1. CEO 很有耐心:他想说话前,先让左副总去说话。
  2. 左副总也很有耐心:他想说话前,先让他的左经理去说话。
  3. 循环往复:直到最左边那个没下属的基层员工说完话,他的上级才开口。

这种“左 -> 中 -> 右”的顺序,在搜索二叉树里还有一个神奇的效果:排出来的数字正好是从小到大升序的。


💻 代码实现与生活化注释

这段代码使用了 递归 (Recursion) 的方式,非常简洁:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 公司的最高领导(根节点)
 * @return {number[]} - 最终的汇报顺序名单
 */
var inorderTraversal = function(root) {
    let res = []; // 准备一份名单记录汇报顺序

    /**
     * DFS 函数:深度优先搜索,也就是去每一个工位听汇报
     */
    let dfs = (node) => {
        // 如果这个位子是空的,直接回去,不用听了
        if (!node) return;

        // 【左】:先去左手边的下属那里(递归)
        dfs(node.left);

        // 【中】:左边全部汇报完了,轮到当前这个节点把自己的名字写在名单上
        res.push(node.val);

        // 【右】:自己汇报完了,再去右手边的下属那里(递归)
        dfs(node.right);
    }

    dfs(root); // 从 CEO 开始发起汇报流程
    return res; // 返回完整的汇报名单
};

🗝️ 核心逻辑拆解

  1. 递归的本质: 递归就像是“套娃”。当你调用 dfs(node.left) 时,当前的 dfs(node) 会暂时挂起,进入下一层。直到最深处的 node.left 为空返回了,才会执行下一行的 res.push

  2. 遍历的三种顺序(其实就看 res.push 放在哪):

    • 前序遍历(根左右):领导先讲话,下属再讲。
    • 中序遍历(左根右):也就是咱们这段代码,左下属 -> 领导 -> 右下属。
    • 后序遍历(左右根):下属全讲完了,领导最后总结。
  3. 执行流程示例: 看你图片里的示例 1(树结构是 1 -> 空, 2 -> 3, 空):

    • 进入 1,先去 1 的左边(空,返回)。
    • 记录 1
    • 去 1 的右边(进入 2)。
    • 进入 2,先去 2 的左边(进入 3)。
    • 进入 3,左右都为空,记录 3,回到 2。
    • 左边回来的,记录 2
    • 最后名单就是 [1, 3, 2]

总结: 中序遍历就是一个“左顾右盼”的过程。它保证了在处理任何一个节点之前,它左边的“势力范围”已经全部处理完毕。

104. 二叉树的最大深度

这个问题是 “二叉树的最大深度” (Maximum Depth of Binary Tree) 。简单来说,就是数一数这棵树最高的一支一共有多少层。


🏠 生活案例:数楼层

想象你是一个物业管理员,站在一栋造型奇特的办公楼(二叉树)门口。这栋楼的结构是:大厅(根节点)分出两个通道,每个通道可能通往更多的办公室,也可能直接到头了。

你的任务是: 算出这栋楼最高有多少层。

代码逻辑(层序遍历 BFS): 你决定采用**“地毯式搜索”**:

  1. 第 1 分钟:你站在大厅(第 1 层),数了一下大厅有几个人。
  2. 第 2 分钟:你让大厅所有人去他们能到的下一层房间。等他们都站好了,你记下:现在是第 2 层。
  3. 循环往复:只要还有人能往更深的一层走,你就把层数加 1。
  4. 结果:当所有人发现自己都走到了尽头(叶子节点),你最后记录的那个数字就是这栋楼的最大深度。

💻 代码实现与生活化注释

这段代码使用了 BFS(广度优先搜索/层序遍历) ,这种方法就像剥洋葱,一层一层地数:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {number} - 树的最大深度
 */
var maxDepth = function(root) {
    // 如果连大厅都没有,那深度就是 0
    if(!root) return 0;

    let depth = 0; // 楼层计数器
    let queue = [root]; // 准备一个队列,先把根节点放进去

    // 只要队列里还有人,说明还没数完
    while(queue.length > 0){
        depth = depth + 1; // 准备开始数新的一层,层数加 1
        
        let size = queue.length; // 重要!记录当前这一层一共有多少个房间

        // 把当前这一层的所有房间都处理掉
        for(let i = 0; i < size; i++){
            let node = queue.shift(); // 走进其中一个房间
            
            // 如果这个房间左边还有通道,把它记下来,下一层去搜
            if(node.left) queue.push(node.left);
            
            // 如果这个房间右边还有通道,也记下来,下一层去搜
            if(node.right) queue.push(node.right);
        }
        // 循环结束时,当前层的所有房间都出队了,下一层的所有房间都进队了
    }

    return depth; // 返回总层数
};

🗝️ 核心逻辑拆解

  1. 为什么用 sizewhile 循环里,queue 的长度是会不断变化的(因为我们在往里塞下一层的节点)。用 let size = queue.length 提前锁死当前这一层的人数,能保证我们不多数也不少数学。
  2. 空间换时间: BFS 遍历的特点是平推。它先看完全部第 2 层的房间,再去看第 3 层的。这在求“最短路径”或“最大深度”时非常直观。
  3. 对比另一种解法(DFS 递归) : 其实这题还有一种更常用的写法:return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;。 那就像是派两个探险队,一个去左边最高处,一个去右边最高处,最后取两人中更高的高度加 1。

总结: 你的这段代码就像是在玩**“消消乐”**,每次把一整排(一层)消掉,直到没得消为止,消了几次就是几层。

226. 翻转二叉树

这个问题是著名的 “翻转二叉树” (Invert Binary Tree) 。传说 Homebrew 的作者去 Google 面试,就是因为没在白板上写出这道题被拒的,所以它在程序员圈子里非常出名。


🏠 生活案例:照镜子

想象这棵二叉树正在照镜子

  1. 本体:左手拿着苹果,右手拿着香蕉。
  2. 镜像:在镜子里,你的左手(本体的右手)变成了香蕉,你的右手(本体的左手)变成了苹果。

代码的逻辑逻辑: 你作为一个“整容医生”,从最高层的领导(根节点)开始往下走:

  • 交换:先把当前领导身下的“左副总”和“右副总”调换位置。
  • 递归:调换完后,还没完!你得去左副总办公室,把他手下的经理们也对调一下;再去右副总办公室,如法炮制。
  • 到底为止:直到你发现某个办公室是空的(节点为 null),你就撤回。

💻 代码实现与生活化注释

这段代码使用了 递归 (Recursion) 的思想,非常优雅:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {TreeNode} - 翻转后的树根
 */
var invertTree = function(root) {
    // 【边界情况】:如果办公室是空的(没有节点),直接返回,不需要翻转
    if (!root) return root;

    // 【核心步骤:对调左右】:
    // 就像在镜子里,左右手互换位置。
    // 这里使用了 JavaScript 的解构赋值 [a, b] = [b, a] 来快速交换
    [root.left, root.right] = [root.right, root.left];

    // 【递归:搞定下属】:
    // 既然我这一层对调完了,我的左孩子也需要去对调他自己的手下
    if (root.left) invertTree(root.left);
    
    // 我的右孩子也需要去对调他自己的手下
    if (root.right) invertTree(root.right);

    // 全部对调完毕,把变样后的“新领导”交回去
    return root;
};

🗝️ 核心逻辑拆解

  1. 自上而下的交换: 代码里先执行了 [root.left, root.right] = [root.right, root.left],这意味着我们是先处理父节点,再处理子节点。这在树的遍历中叫做前序遍历

  2. 解构赋值的妙用[a, b] = [b, a] 是 JS 的语法糖。如果不这么写,你需要一个中间变量:

    JavaScript

    let temp = root.left;
    root.left = root.right;
    root.right = temp;
    

    效果是一模一样的,都是为了互换两边的“枝叶”。

  3. 递归的深度: 递归会一直深入到树的叶子节点(最底层的员工)。虽然我们是从上往下交换的,但递归能保证每一个细小的分叉都被翻转过。

总结: 翻转二叉树其实就是递归地交换每一个节点的左右子节点。只要你每一个小分叉都左右互换了,整棵树看起来就是镜像的了。

101. 对称二叉树

这个问题是 “对称二叉树” (Symmetric Tree) 。它考察的是你对二叉树结构和递归逻辑的理解。


🏠 生活案例:折纸实验

想象你手里有一张画着树状图的透明纸(二叉树):

  1. 中轴线:你沿着根节点(最上面的 1)垂直画一条虚线。

  2. 对折:你把纸沿着这条虚线对折。

  3. 重合检查

    • 左边的“左手”是不是正好叠在了右边的“右手”上?(比如示例中的两个 3
    • 左边的“右手”是不是正好叠在了右边的“左手”上?(比如示例中的两个 4
    • 如果每一个位置的图案和颜色都一模一样,那这棵树就是对称的。

核心逻辑: 检查对称不是看一个节点自己,而是看两个对应的节点是否互为镜像


💻 代码实现与生活化注释

这段代码使用了 递归 (Recursion) ,就像是一对双胞胎在互相检查对方的动作是否同步。

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {boolean} - 是否对称
 */
var isSymmetric = function (root) {
    // 【特殊情况】:如果连树根都没有,那是空对空,算对称
    if (!root) return true;

    /**
     * 辅助函数:就像两个检查员,一个看左子树,一个看右子树
     * @param {TreeNode} left - 左边的对应位置节点
     * @param {TreeNode} right - 右边的对应位置节点
     */
    let compare = (left, right) => {
        // 情况 1:两边都没东西了(都走到头了),说明到目前为止是对称的
        if (left == null && right == null) return true;
        
        // 情况 2:一边有一边没有,或者两边数字不一样
        // 这就像双胞胎动作不一致,肯定不对称
        if (left == null || right == null) return false;
        if (left.val !== right.val) return false;

        // 情况 3:核心递归
        // 检查员 A 去看:左边的左孩子(left.left) 和 右边的右孩子(right.right) 是否相等
        // 检查员 B 去看:左边的右孩子(left.right) 和 右边的左孩子(right.left) 是否相等
        // 只有这两组“镜像位置”都相等,整体才对称
        return compare(left.left, right.right) && compare(left.right, right.left);
    }

    // 从根节点的左右孩子开始启动“对折检查”
    return compare(root.left, root.right);
};

🗝️ 核心逻辑拆解

  1. 镜像对比 vs 相同对比

    • 如果你检查两棵树是否完全相同,你会比 left.leftright.left
    • 但检查对称,你必须比 left.left(最左端)和 right.right(最右端),这就是“镜像”的含义。
  2. 递归的“短路效应”

    • compare 函数中,只要发现任何一个点不匹配(比如一个是 3 一个是 null),return false 会像多米诺骨牌一样传回最上层,立刻得出“不对称”的结论。
  3. 为什么 !root 返回 true

    • 在算法逻辑中,空集合通常被认为是满足对称性定义的。

总结: 判定对称二叉树,就是把**“左子树的左”与“右子树的右”对比,再把“左子树的右”与“右子树的左”**对比。

543. 二叉树的直径

这个问题是 “二叉树的直径” (Diameter of Binary Tree) 。题目要求找到树中任意两个节点之间最长的路径长度。


🏠 生活案例:寻找最长通话线路

想象你负责在一个分布在山区的村庄(节点)之间架设电话线。这些村庄的连接结构是一棵二叉树。

规则是:

  1. 两个村庄之间的“距离”是它们之间经过的电线杆(边)的数量。
  2. 你想找到整棵树中,哪两个村庄之间的距离最远。

逻辑发现: 最长的线路不一定非得经过“最高领导”(根节点)。它可能出现在:

  • 跨越某个节点:从左边最深的村庄一直连到右边最深的村庄。
  • 完全在某一侧:如果左子树特别庞大,最长路径可能完全在左边。

所以,对于每一个村庄(节点),我们都要算一下: “经过我这个点,左边最深能走多远 + 右边最深能走多远”


💻 代码实现与生活化注释

这段代码巧妙地利用了计算“深度”的过程来顺便更新“直径”:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {number} - 树的直径
 */
var diameterOfBinaryTree = function(root) {
    let max = 0; // 全局变量:记录目前为止发现的最长线路

    /**
     * depth 函数:表面上是算深度,其实在暗暗观察直径
     */
    let depth = (node) => {
        // 如果到了空村庄,深度是 0
        if (node == null) return 0;

        // 【递归】:分别去算左子树和右子树的最深高度
        let leftDepth = depth(node.left);
        let rightDepth = depth(node.right);

        // 【关键点】:经过当前节点的最长路径 = 左边最深高度 + 右边最深高度
        // 我们把它和之前记录的最大值比一比,谁大留谁
        //         max = Math.max(max, leftDepth + rightDepth);

        // 【返回值】:向上级汇报时,返回自己这一支最长的一条路(记得加自己这一层)
        return Math.max(leftDepth, rightDepth) + 1;
    }

    depth(root); // 从根节点开始探查
    return max;  // 返回最终找到的最长线路
};

🗝️ 核心逻辑拆解

  1. “买一送一”的逻辑: 我们原本的函数目的是计算深度(即从当前点往下走最长能走几步)。但在计算每个点的深度时,我们顺便做了一道加法:leftDepth + rightDepth。这行加法算出的就是**以当前节点为“最高转折点”**的最长路径。

  2. 边 vs 节点

    • 深度通常返回的是节点的数量。
    • 直径(距离)算的是边的数量。
    • 因为 leftDepthrightDepth 分别代表左右两侧的边数(如果是按高度算的,结果也是一样的),所以直接相加不需要再减 1。
  3. 递归的顺序: 这是一个后序遍历。我们必须先知道左边有多深,右边有多深,才能算出经过“我”这个点的路径有多长。

总结: 每一门课(节点)都问一下自己的左右下属:“你们那边最深的坑有多深?” 然后把两边的深度一加,看看是不是打破了世界纪录(max)。

102. 二叉树的层序遍历

这个问题是 “二叉树的层序遍历” (Binary Tree Level Order Traversal) 。它要求你按照“从上到下、从左到右”的顺序,把每一层的节点分别打包存起来。


🏠 生活案例:公司年度合照

想象你要给一家公司拍年度合照。公司的人员结构是二叉树状的(CEO 在最上面,下面是副总,再下面是经理)。

你的拍摄要求是:

  1. 按层拍:第一张照片拍 CEO(第一层),第二张照片拍所有的副总(第二层),第三张拍所有的经理(第三层)。
  2. 左到右:每一层拍照时,人必须按照从左到右的顺序站好。

逻辑实现(BFS): 你手里拿一个排队清单(队列)

  • 你先请 CEO 进场排队。
  • 第一轮:CEO 出队拍照,拍照前他得交待:“我左边的副总和右边的副总,你们去排队区等着下一轮拍。”
  • 第二轮:所有的副总按顺序出队拍照,他们每个人走之前,也得把各自手下的经理安排进排队区。
  • 这样,你每一轮拍到的都是同一级的人。

💻 代码实现与生活化注释

这段代码使用了 BFS(广度优先搜索) ,配合一个队列来实现:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 公司的最高领导
 * @return {number[][]} - 每一层的合照清单
 */
var levelOrder = function(root) {
    // 如果公司没人,返回空数组
    if(!root) return [];

    let res = []; // 最终的相册,里面存每一层的照片
    let queue = [root]; // 排队区,CEO 先进来

    // 只要排队区还有人,拍照就没结束
    while(queue.length > 0){
        let path = []; // 这一层照片的“底片”
        let size = queue.length; // 重要!看看这一层目前排了多少人(防止下一层的人混进来)

        // 这一分钟,把当前排队的 size 个人全部处理完
        for(let i = 0; i < size; i++){
            let node = queue.shift(); // 领头的人出队去拍照
            path.push(node.val); // 把他的名字记在这一层的底片上

            // 拍完照,他得让他的下属按顺序进队
            if(node.left) queue.push(node.left); // 左手边的下属先去排队
            if(node.right) queue.push(node.right); // 右手边的下属后排队
        }
        
        // 这一层所有人都拍完了,把底片存入相册
        res.push(path);
    }

    return res;
};

🗝️ 核心逻辑拆解

  1. 为什么是嵌套数组 number[][] 因为题目要求每一层独立存放。所以我们每进入一次 while 循环,就新建一个 path = [],用来装这一层的所有数值。
  2. let size = queue.length 的关键作用: 这是区分“这一层”和“下一层”的界限。当你开始处理第一层时,队列里只有 CEO,size 是 1。在你处理 CEO 的时候,他的两个孩子进队了,队列长度变成了 2。但因为 size 已经固定是 1 了,循环只跑一次,保证了两个孩子会被留到下一轮 while 中处理。
  3. 先进先出 (FIFO) : 队列 shift()push() 保证了顺序。左边的孩子先辈 push 进去,就会先被 shift 出来,这就实现了“从左到右”。

总结: 层序遍历就是利用一个“排队区”,让每一层的人在出队的同时,把下一层的人按顺序安排好。

108. 将有序数组转换为二叉搜索树

这个问题是 “将有序数组转换为二叉搜索树” (Convert Sorted Array to Binary Search Tree) 。它的核心目标是利用数组已经“排好序”的特点,构建出一棵左右平衡的搜索树。


🏠 生活案例:平衡木与天平

想象你手里有几块重量不一的积木,已经按从轻到重排好了序(比如:[-10, -3, 0, 5, 9])。你想把它们搭成一个架子,要求是:架子不能歪,左右两边的积木数量要尽量一样多。

你的操作逻辑:

  1. 找中心:为了让架子平衡,你肯定会挑最中间的那块积木(比如 0)作为最顶端的支撑点。

  2. 分左右

    • 0 轻的积木([-10, -3])全部扔到左边。
    • 0 重的积木([5, 9])全部扔到右边。
  3. 套娃操作:左边的两块积木怎么搭?重复第一步,再找它们中间的作为支撑点。

这样搭出来的架子(树),不仅左边永远比右边轻(符合搜索树规则),而且高度也是最平均的(平衡)。


💻 代码实现与生活化注释

这段代码使用了经典的 分治法 (Divide and Conquer)

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {number[]} nums - 有序数组
 * @return {TreeNode} - 平衡二叉搜索树的根节点
 */
var sortedArrayToBST = function(nums) {
    
    /**
     * 辅助函数:负责在指定的范围内盖房子
     * @param {number} left - 范围左边界
     * @param {number} right - 范围右边界
     */
    function setTree(left, right) {
        // 【停止条件】:如果左边界超过了右边界,说明这块地没积木了
        if (left > right) return null;

        // 【找中心点】:取范围正中间的索引
        // Math.floor((left + right) / 2) 确保拿到中间那个数
        let mid = Math.floor((left + right) / 2);

        // 【盖楼】:以中间这个数作为当前的“支撑点”创建一个新节点
        let node = new TreeNode(nums[mid]);

        // 【分配任务】:
        // 递归去搞定左边那一堆积木,连接到当前节点的左手边
        node.left = setTree(left, mid - 1);
        
        // 递归去搞定右边那一堆积木,连接到当前节点的右手边
        node.right = setTree(mid + 1, right);

        // 返回搭好的这部分架子
        return node;
    }

    // 从数组的头(0)到尾(nums.length - 1)开始搭建
    return setTree(0, nums.length - 1);
};

🗝️ 核心逻辑拆解

  1. 为什么选中间值? 如果数组是 [1, 2, 3, 4, 5],你选 1 当根节点,那所有数都在右边,树就变成了一根“棍子”,一点都不平衡。选中间的 3,左右各两个,结构最稳定。

  2. 二叉搜索树 (BST) 的特性

    • 左子树的所有节点都比根节点
    • 右子树的所有节点都比根节点
    • 因为数组是有序的,所以我们选中间值,天然就保证了左边范围的数全比它小,右边全比它大。
  3. 平衡 (Height-Balanced) 的实现: 由于我们每次都从正中间切分,左右两边的节点数量差距最大不会超过 1,这自动满足了“平衡”的要求。

总结: 这个问题就像是在切西瓜,每次从中间切一刀,左半边交给左手处理,右半边交给右手处理,直到切成一小块一小块的果肉(叶子节点)为止。

98. 验证二叉搜索树

这个问题是 “验证二叉搜索树” (Validate Binary Search Tree) 。它考察的是你是否真正理解二叉搜索树(BST)的严格定义。


🏠 生活案例:公司职级的“层层压制”

想象你在一家管理非常严苛的公司,每个人都有一个“职级点数”。为了保证管理不混乱,公司有一条铁律:

  1. 左属下: 某位主管左边管辖的所有人,点数必须严格小于这位主管。
  2. 右属下: 某位主管右边管辖的所有人,点数必须严格大于这位主管。

容易犯错的点: 仅仅比较主管和他的直接下属是不够的。比如 CEO 点数是 10,他的左副总是 6。那么左副总手下的所有人,不仅要比 6 小,还必须全部比 10 小。如果左副总偷偷招了一个点数是 11 的经理,虽然 11>6 满足了左副总的局部规则,但 11>10 违反了 CEO 的全局规则。

解决办法: 每个人入职时,手里都攥着一个“允许范围卡片”,记录了他在这个位置上,点数必须在 (最小值, 最大值) 之间。


💻 代码实现与生活化注释

这段代码使用了 递归 (DFS) ,并给每个节点传递了一个不断缩小的“合法区间”:

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {boolean} - 是否为有效的二叉搜索树
 */
var isValidBST = function (root) {
    /**
     * DFS 函数:带着“职级范围卡片”去检查
     * @param {TreeNode} node - 当前要检查的员工
     * @param {number} min - 允许的最小值(不包含)
     * @param {number} max - 允许的最大值(不包含)
     */
    function dfs(node, min, max) {
        // 【情况 1】:位置是空的,没问题,返回 true
        if (!node) return true;

        // 【情况 2】:越界检查
        // 如果点数比最小值还小,或者比最大值还大,说明违反了公司铁律
        if (node.val <= min || node.val >= max) return false;

        // 【情况 3】:向下传递规则(关键!)
        // 1. 去检查左属下:
        //    左属下的“最大值”不能超过当前主管的点数 (node.val),最小值继承自当前
        // 2. 去检查右属下:
        //    右属下的“最小值”必须超过当前主管的点数 (node.val),最大值继承自当前
        return dfs(node.left, min, node.val) && dfs(node.right, node.val, max);
    }

    // CEO(根节点)入职时,没有上限和下限,给它正负无穷大
    return dfs(root, -Infinity, Infinity);
};

🗝️ 核心逻辑拆解

  1. 上下界的收紧

    • 当我们向走时,我们更新了上界max 变成了当前节点的值)。
    • 当我们向走时,我们更新了下界min 变成了当前节点的值)。
    • 这种层层收紧,保证了所有的子孙节点都必须服从祖先节点设定的范围。
  2. 严格大于/小于

    • 题目要求是“严格”,所以代码中用了 <= min>= max。如果两个节点值相等,也不符合 BST 的定义。
  3. 另一种思路(中序遍历)

    • 还记得之前说过的吗?BST 的中序遍历结果必须是一个严格递增的数组。
    • 你也可以先中序遍历一遍,再看结果是否有序。但这种带范围的递归法在空间效率上通常更优。

总结: 验证 BST 的诀窍就是:不能只看父子,要看老祖宗定下的规矩(区间限制)。

230. 二叉搜索树中第 K 小的元素

这个问题是 “二叉搜索树中第 K 小的元素” (Kth Smallest Element in a BST) 。它完美利用了二叉搜索树(BST)的一个核心物理特性。


🏠 生活案例:排队的班级

想象一个班级的学生按照身高排成了一棵二叉搜索树:

  • 老师站在中间(根节点)。
  • 所有比老师矮的学生都在左边支路。
  • 所有比老师高的学生都在右边支路。
  • 每个小支路的主管也遵循同样的规则。

你的任务是: 找出全班身高排名第 kk 的同学。

最聪明的策略:

你不需要把所有人拉出来重新排队。你只需要按照 “左 -> 中 -> 右” 的顺序去点名。

  1. 你先去最左边的角落,那里一定坐着全班最矮的人。
  2. 点完最矮的,再点他的上级,然后点上级的右边。
  3. 你手里拿个计数器,点一个名就按一下。当计数器跳到 kk 的时候,眼前这位同学就是你要找的人。

💻 代码实现与生活化注释

这段代码使用了 中序遍历 (Inorder Traversal) ,因为 BST 的中序遍历结果就是一个从小到大的有序序列。

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @param {number} k - 要找第几个小的数
 * @return {number}
 */
var kthSmallest = function(root, k) {
    let ans = root.val; // 准备一个变量存最后的结果
    let count = 0;      // 计数器,记录现在是第几小

    function inOrder(node){
        // 【情况 1】:走到头了,或者已经找到答案了,直接返回
        if(!node) return;

        // 【左】:先去左边(更小的数)
        inOrder(node.left);

        // 【中】:处理当前节点
        count = count + 1; // 计数器加 1
        if(count == k){    // 如果正好是第 k 个
            ans = node.val; // 记下名字
            return;        // 功成身退
        }

        // 【右】:如果还没数到 k,再去右边看看
        // 注意:如果在左边或中间已经找到了,由于递归栈的特性,右边可能还会被触发
        // 在严谨的实现中,可以在 count == k 后增加标记位停止递归
        inOrder(node.right);
    }

    inOrder(root); // 开始点名
    return ans;    // 返回第 k 个人的名字
};

🗝️ 核心逻辑拆解

  1. BST 的天然优势

    • 普通的二叉树找第 kk 小需要全部遍历再排序,时间复杂度 O(NlogN)O(N \log N)
    • BST 只需要中序遍历,时间复杂度 O(H+k)O(H + k)HH 是树的高度),效率极高。
  2. 为什么 ans 要初始化为 root.val

    • 其实初始化为什么都行,只要最后被 node.val 覆盖即可。代码中直接在满足 count == k 时更新它。
  3. 提前结束(优化点)

    • 你上传的代码在找到 count == k 后虽然赋值了,但递归还会继续跑完剩余部分。
    • 优化建议:可以加一个 found 布尔值,一旦找到就直接 return,不再进递归。

总结: 找 BST 的第 kk 小,本质上就是做一个 “有记数功能的中序遍历”

199. 二叉树的右视图

这个问题是 “二叉树的右视图” (Binary Tree Right Side View) 。题目要求你想象自己站在树的右侧,从上到下记录你第一眼能看到的节点值。


🏠 生活案例:排队领物资

想象一队人在排队领物资,他们站成了一个二叉树的阵型:CEO(根节点)站在最前面,后面跟着他的下属。

规则是:

  1. 按层站位: 所有人按职级分层站好(第一层是 CEO,第二层是副总,以此类推)。
  2. 侧面观察: 你站在这些人的右侧面
  3. 视线阻挡: 对于每一层的人,因为你站在右边,所以你只能看到该层站在最右边的那个人。他会把该层左边的所有人都挡住。

你的任务: 从第一层开始往后看,把每一层你看到的那个人的名字记下来。


💻 代码实现与生活化注释

这段代码使用了 BFS(层序遍历) 的变体。它的核心逻辑是:遍历每一层,但只记录该层最后出队的那个节点。

JavaScript

/**
 * 二叉树节点的定义
 * function TreeNode(val, left, right) {
 * this.val = (val===undefined ? 0 : val)
 * this.left = (left===undefined ? null : left)
 * this.right = (right===undefined ? null : right)
 * }
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {number[]} - 从右边看到的节点列表
 */
var rightSideView = function(root) {
    let res = []; // 最终的右视图名单
    let queue = [root]; // 排队区,初始化放入根节点

    /**
     * BFS 函数:一层一层地“扫射”
     */
    function bfs(node) {
        // 【情况 1】:如果根节点就是空的,直接收工
        if (!node) return;

        // 只要排队区还有人
        while (queue.length > 0) {
            let size = queue.length; // 锁死当前这一层的人数

            // 开始处理当前这一层的所有人
            for (let i = 0; i < size; i++) {
                let node = queue.shift(); // 队伍最前面的人出列

                // 【核心逻辑】:如果当前这个人是这一层的最后一个(i == size - 1)
                // 说明他在最右边,记下他的名字
                if (i == size - 1) res.push(node.val);

                // 按照“先左后右”的顺序,把下一层的人放入排队区
                if (node.left) queue.push(node.left); //
                if (node.right) queue.push(node.right); //
            }
        }
    }

    bfs(root); // 开始执行扫射任务
    return res;
};

🗝️ 核心逻辑拆解

  1. 为什么记录 i == size - 1

    • 在层序遍历中,我们是一层一层处理的。
    • 每一层的节点进入队列的顺序是“从左到右”。
    • 因此,该层最后一个出列的节点(即索引为 size - 1 的那个),必然是该层最靠右的节点。
  2. 视线遮挡的模拟

    • 即使左边有很长的分支(比如示例 1 中的 5),只要右边有节点(比如 4),4 就会在它的那一层成为“最后一个”,从而把 5 挡住。
  3. BFS vs DFS

    • 虽然这题也可以用 DFS(深度优先搜索)做,但 BFS 非常直观,因为它天然就是按“层”组织的,非常符合“每一层只取一个”的逻辑。

总结: 二叉树的右视图,其实就是层序遍历的“选代表”版——每一层只选最右边的那个节点当代表。

114. 二叉树展开为链表

这个问题是 “二叉树展开为链表” (Flatten Binary Tree to Linked List) 。它的目标是将一棵树结构平铺成一条“向右倒”的直线,顺序必须符合二叉树的 先序遍历


🏠 生活案例:整理晾衣架

想象你有一把那种多层的、带分叉的晾衣架(二叉树)。

  • 分叉:衣架有主杆,主杆下面分出了左边的一捆和右边的一捆。

  • 整理目标:你想把这个复杂的架子折叠起来,最后变成一根长长的、直直的杆子。

  • 折叠规则

    1. 所有的衣服(节点)必须按照你平时看它们的顺序(先看中间,再看左边,最后看右边)连在一起。
    2. 所有的衣服都必须挂在右边,左边要全部空出来。

逻辑实现: 你先准备好一个本子(数组),按照“中 -> 左 -> 右”的顺序把所有衣服的编号记下来。然后,按照名单顺序,把第一件衣服拿出来,左手边清空,右边连上第二件;第二件左手边清空,右边连上第三件……以此类推。


💻 代码实现与生活化注释

这段代码使用了 “先记录,再重组” 的简单策略:

JavaScript

/**
 * 二叉树节点的定义
 */

/**
 * @param {TreeNode} root - 树的根节点
 * @return {void} - 不返回任何内容,直接修改原树
 */
var flatten = function(root) {
    // 【边界情况】:如果衣架是空的,直接收工
    if(!root) return [];

    let arr = []; // 这是一个临时的小本子,用来记录顺序

    /**
     * 先序遍历函数(中 -> 左 -> 右)
     */
    function preOrder(node){
        if(!node) return;

        // 1. 先记下当前的衣服
        arr.push(node);
        
        // 2. 去记录左边分叉的衣服
        if(node.left) preOrder(node.left);
        
        // 3. 去记录右边分叉的衣服
        if(node.right) preOrder(node.right);
    }

    // 第一步:按照先序遍历,把所有节点装进数组
    preOrder(root);

    // 第二步:开始“拉直”重组
    for(let i = 0; i < arr.length - 1; i++){
        // 让当前这件衣服的左手边变空
        arr[i].left = null;
        
        // 让当前这件衣服的右边连向名单里的下一件
        arr[i].right = arr[i + 1];
    }

    // 最后一件衣服,左右都要清空,防止出现循环或者残留
    if(arr.length > 0) {
        arr[arr.length - 1].left = null;
        arr[arr.length - 1].right = null;
    }
};

🗝️ 核心逻辑拆解

  1. 先序遍历是灵魂: 示例 1 中,树的结构是 1 下面连着 25。按照先序遍历(中-左-右),顺序是 1 -> 2 -> 3 -> 4 -> 5 -> 6。这就是我们“拉直”后的新顺序。
  2. 左边必须置空 (null) : 题目要求展开后的单链表,左子针必须始终为 null。如果你忘记写 arr[i].left = null,它就还是一个分叉的树,不是链表。
  3. 原地修改 (In-place) : 虽然我们借用了数组,但我们最后是直接修改了节点对象的 .left.right 属性。这意味着如果你手里还拿着最初的那个 root 引用,你会发现它已经变成了一根长线。

总结: 这个问题其实就是把树拆掉,按特定的点名顺序重新用线穿起来。

105. 从前序与中序遍历序列构造二叉树

这个问题是 “从前序与中序遍历序列构造二叉树” (Construct Binary Tree from Preorder and Inorder Traversal) 。这是一个非常经典的题目,它考察你是否理解不同遍历方式如何共同“锁定”一棵树的结构。


🏠 生活案例:根据“排位”还原阵型

想象一个班级在操场上排队,你有两张名单:

  1. 点名名单 (Preorder) :老师站在最前面,按“自己 -> 左边同学 -> 右边同学”的顺序喊名字。这张名单的第一名永远是当前最高领导(根节点)
  2. 身高名单 (Inorder) :全班按“左边同学 -> 自己 -> 右边同学”的顺序排好。这张名单里,领导的左边全是他的左属下,右边全是他的右属下

还原逻辑:

  • 你看点名名单,第一个人是 3。哦!3 就是大 Boss。
  • 你去身高名单里找 3。发现 3 的左边只有 9,右边有 15, 20, 7
  • 结论3 的左子树只有 9 这一坨,右子树包含 15, 20, 7 这一坨。
  • 递归:剩下的那一坨怎么办?重复上面的步骤,继续看点名名单里的下一个人是谁。

💻 代码实现与生活化注释

这段代码使用了 分治法 (Divide and Conquer) ,不断把问题拆小:

JavaScript

/**
 * 二叉树节点的定义
 */

/**
 * @param {number[]} preorder - 点名名单
 * @param {number[]} inorder - 身高名单
 * @return {TreeNode} - 还原出的整棵树
 */
var buildTree = function(preorder, inorder) {
    // 【停止条件】:如果名单空了,说明这部分没孩子了
    if(preorder.length == 0) return null;

    // 1. 点名名单的第一个人必然是当前的“头儿”
    let nodeVal = preorder[0];
    let node = new TreeNode(nodeVal);

    // 2. 在身高名单里找到这个“头儿”的位置
    // mid 左边的就是左子树,右边的就是右子树
    let mid = inorder.indexOf(nodeVal);

    // 3. 切分名单(这是最关键的一步)
    // 【左子树的身高名单】:身高名单里 mid 之前的所有人
    let leftInorder = inorder.slice(0, mid);
    // 【右子树的身高名单】:身高名单里 mid 之后的所有人
    let rightInorder = inorder.slice(mid + 1);

    // 【左子树的点名名单】:
    // 因为左子树的人数已经通过 leftInorder 确定了,
    // 所以在 preorder 里,从第 2 个(索引 1)开始,往后数相应人数就行
    let leftPreorder = preorder.slice(1, leftInorder.length + 1);
    
    // 【右子树的点名名单】:preorder 剩下的部分全属于右边
    let rightPreorder = preorder.slice(leftInorder.length + 1);

    // 4. 【套娃】:把切好的小名单交给下级去处理,最后连在自己身上
    node.left = buildTree(leftPreorder, leftInorder);
    node.right = buildTree(rightPreorder, rightInorder);

    return node;
};

🗝️ 核心逻辑拆解

  1. 为什么需要两张表?

    • 只给一张表,你无法确定谁是谁的儿子。比如 [1, 2, 3] 可能是条直线,也可能是个分叉。
    • Preorder 告诉你谁是 Boss(根)。
    • Inorder 告诉你 Boss 的地盘是怎么划分的(左边和右边)。
  2. slice 的含义

    • 这就像是在剪报纸。每确定一个根节点,我们就把剩下的名单剪成两半,一份给左手去盖楼,一份给右手去盖楼。
  3. 性能小提醒

    • 你上传的代码中用了 indexOf,在非常大的树中,每次都找一遍位置会有点慢(时间复杂度 O(N2)O(N^2))。
    • 优化方法:可以先用一个 Mapinorder 的值和索引存起来,这样找 mid 的时候就是 O(1)O(1) 了。

总结: 构造二叉树的过程就是:找根节点 -> 划分子树范围 -> 递归构造

437. 路径总和 III

这个问题是 “二叉树的最近公共祖先” (Lowest Common Ancestor of a Binary Tree) 。这是一个在家族族谱或组织架构中经常需要解决的问题。


🏠 生活案例:寻找共同的老祖宗

想象你正在翻阅一份庞大的家族族谱。

  • 你想找到两位亲戚:张三 (p)李四 (q)
  • 你的目标是找到离他们最近的那个共同长辈(最近公共祖先)。

查找逻辑:

  1. 向上寻找: 如果你是张三,你向上找爸爸、爷爷……如果你是李四,你也向上找。第一个在你们两条线上都出现的长辈就是答案。

  2. 代码实现逻辑(递归): 我们从家族最顶端的祖先(根节点)开始往下派人寻找:

    • 如果在左边分支找到了张三,右边分支找到了李四,那当前这个祖先就是他们的“最近公共祖先”。
    • 如果两名亲戚都在左边,那就继续去左边深挖。
    • 特殊情况: 如果张三本人就是李四的长辈,那么张三就是我们要找的答案。

💻 代码实现与生活化注释

这段代码利用了 后序遍历 的思想,先去左右子树打听,再在当前层做判断:

JavaScript

/**
 * 二叉树节点的定义
 */

/**
 * @param {TreeNode} root - 整个家族的最高祖先
 * @param {TreeNode} p - 亲戚 A
 * @param {TreeNode} q - 亲戚 B
 * @return {TreeNode} - 最近公共祖先
 */
var lowestCommonAncestor = function(root, p, q) {
    
    /**
     * 递归函数:去下面各层打听 p 和 q 的下落
     */
    let find = (node) => {
        // 【停止条件】:
        // 1. 走到了尽头 (null)
        // 2. 碰巧遇到了 p 或者 q 本人
        // 遇到本人就直接返回本人,告诉上级:“我找到其中一个啦!”
        if (node == null || node == p || node == q) {
            return node;
        }

        // 【打听】:让左边和右边的下属分别去寻找
        let left = find(node.left);
        let right = find(node.right);

        // 【判断逻辑】:
        // 1. 如果左边找到了一个人,右边也找到了一个人
        // 说明 p 和 q 分布在我的两侧,那么我就是那个“最近公共祖先”!
        if (left != null && right != null) {
            return node;
        }

        // 2. 如果只有左边有消息,右边空手而归
        // 说明两个亲戚可能都在左边,或者只找到了一个,把左边的结果向上汇报
        if (left != null) {
            return left;
        }

        // 3. 同理,如果只有右边有消息
        if (right != null) {
            return right;
        }

        // 4. 两边都没找到,返回 null
        return null;
    }

    return find(root);
};

🗝️ 核心逻辑拆解

  1. “自底向上”的回传

    • 虽然递归是从根节点开始的,但判断逻辑是在“回溯”阶段完成的。
    • leftright 同时不为空时,这个节点就像是一个汇合点,它会把自己作为结果一层层传回给最顶层。
  2. 如果是父子关系怎么办?

    • 假设 pq 的父亲。在递归到 p 时,if (node == p) 会直接触发,函数立刻返回 p
    • 此时程序甚至不需要去 p 的子树里找 q 了,因为 p 既然是 q 的祖先,那 p 本身就是他们的“最近公共祖先”。
  3. 结果的唯一性

    • 只要 pq 都在树里,这个算法一定能找到唯一的那个汇合点。

总结: 这个问题就像是在分岔路口做统计:如果左路和右路各发现了一个目标,那当前路口就是答案;如果只有一边有发现,就顺着那一边的线索继续往上看。

236. 二叉树的最近公共祖先

这个问题是 “二叉树中的最大路径和” (Binary Tree Maximum Path Sum) 。这被认为是二叉树题目中最难的一类,因为它不仅考察递归,还考察你对“路径”定义的深刻理解。


🏠 生活案例:寻找最赚钱的登山路线

想象你正在一座山脉(二叉树)中探险,每个山峰(节点)上都有不同数量的金币。

  • 规则 1:你可以从任何山峰开始,到任何山峰结束。
  • 规则 2:路径必须是连贯的(不能跳跃)。
  • 坑人的地方:有些山峰不仅没有金币,还会让你亏钱(负数节点)。

你的目标:找到一条路线,让你最后手里的金币最多。

面临的选择: 当你站在某个山峰(当前节点)时,为了给上层汇报你能贡献的最大价值,你只能在以下两个方案中选一个:

  1. 作为“参与者”向上汇报:只能选“自己 + 左边最赚钱的一条线”或者“自己 + 右边最赚钱的一条线”。因为如果你要把左右两边都连起来,你就没法往上走了(路径不能分叉)。
  2. 作为“终点/转折点”更新记录:你偷偷算了一下“左边最赚的 + 自己 + 右边最赚的”,发现这可能就是全山脉最赚钱的路线了!如果是,你就把它记在全局最高纪录里。

💻 代码实现与生活化注释

这段代码的核心在于:函数返回的是“能提供给上层的最大收益”,但在这个过程中顺便更新了“包含当前节点的最大路径”。

JavaScript

/**
 * @param {TreeNode} root
 * @return {number}
 */
var maxPathSum = function(root) {
    // 全局最高纪录,初始化为最小值
    let maxSum = -Infinity;

    /**
     * 递归函数:计算每个节点能为上级贡献的最大“单边”价值
     */
    function dfs(node) {
        // 如果到了空山峰,贡献为 0
        if (!node) return 0;

        // 【左】:看看左边支路能赚多少。如果赚的是负数,我就不带它玩了,取 0
        let leftMax = Math.max(dfs(node.left), 0);
        
        // 【右】:看看右边支路能赚多少。同理,负数就取 0
        let rightMax = Math.max(dfs(node.right), 0);

        // 【关键逻辑:更新全局纪录】:
        // 尝试以当前节点为“最高点”,把左右两边连成一条完整的路径
        // 
        let currentPathSum = node.val + leftMax + rightMax;
        maxSum = Math.max(maxSum, currentPathSum);

        // 【返回值:向上级汇报】:
        // 告诉爸爸:我加上我左/右两边最争气的那一个,总共能让你赚多少
        // 只能选一条路往上走,所以是 node.val + max(leftMax, rightMax)
        return node.val + Math.max(leftMax, rightMax);
    }

    dfs(root);
    return maxSum;
};

🗝️ 核心逻辑拆解

  1. 为什么要 Math.max(..., 0)

    • 如果一个子树的收益是负的(比如全是亏钱的山峰),我们宁愿不去碰它。这叫“及时止损”。
  2. “单边”与“双边”的区别

    • 双边(node.val + leftMax + rightMax :这是计算路径的终态。它是一个拱形,像个彩虹。一旦形成了这个拱形,它就不能再作为更大路径的一部分往上连了(否则路径会出现分叉)。
    • 单边(node.val + max(leftMax, rightMax) :这是为了给父节点提供线索。
  3. 全局变量 maxSum

    • 每一个节点在递归过程中都会尝试做一次“彩虹中心”。有的“彩虹”可能就在树的一角(比如示例 2 中的 15-20-7 这一块),不一定非要经过整棵树的根节点。

总结: 每一层递归其实在做两件事:

  • 私活:算算以我为中心的最强路径,看看能不能破世界纪录。
  • 公事:选一条最强的分身,报给老板听,让老板去继续组合。