94. 二叉树的中序遍历
这个问题是 “二叉树的中序遍历” (Binary Tree Inorder Traversal) 。在树的各种遍历方式中,中序遍历是最有“礼貌”的一种,因为它遵循着严格的左中右顺序。
🏠 生活案例:公司汇报流程
想象一家公司的组织架构是一棵二叉树。CEO 是根节点,他有两个副总(左副总和右副总),每个副总下面还有自己的经理。
中序遍历就像是一场从基层到高层的汇报会,规则是: “先听左边的下属汇报,再听自己汇报,最后听右边的下属汇报。”
- CEO 很有耐心:他想说话前,先让左副总去说话。
- 左副总也很有耐心:他想说话前,先让他的左经理去说话。
- 循环往复:直到最左边那个没下属的基层员工说完话,他的上级才开口。
这种“左 -> 中 -> 右”的顺序,在搜索二叉树里还有一个神奇的效果:排出来的数字正好是从小到大升序的。
💻 代码实现与生活化注释
这段代码使用了 递归 (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; // 返回完整的汇报名单
};
🗝️ 核心逻辑拆解
-
递归的本质: 递归就像是“套娃”。当你调用
dfs(node.left)时,当前的dfs(node)会暂时挂起,进入下一层。直到最深处的node.left为空返回了,才会执行下一行的res.push。 -
遍历的三种顺序(其实就看
res.push放在哪):- 前序遍历(根左右):领导先讲话,下属再讲。
- 中序遍历(左根右):也就是咱们这段代码,左下属 -> 领导 -> 右下属。
- 后序遍历(左右根):下属全讲完了,领导最后总结。
-
执行流程示例: 看你图片里的示例 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 层),数了一下大厅有几个人。
- 第 2 分钟:你让大厅所有人去他们能到的下一层房间。等他们都站好了,你记下:现在是第 2 层。
- 循环往复:只要还有人能往更深的一层走,你就把层数加 1。
- 结果:当所有人发现自己都走到了尽头(叶子节点),你最后记录的那个数字就是这栋楼的最大深度。
💻 代码实现与生活化注释
这段代码使用了 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; // 返回总层数
};
🗝️ 核心逻辑拆解
- 为什么用
size? 在while循环里,queue的长度是会不断变化的(因为我们在往里塞下一层的节点)。用let size = queue.length提前锁死当前这一层的人数,能保证我们不多数也不少数学。 - 空间换时间: BFS 遍历的特点是平推。它先看完全部第 2 层的房间,再去看第 3 层的。这在求“最短路径”或“最大深度”时非常直观。
- 对比另一种解法(DFS 递归) : 其实这题还有一种更常用的写法:
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;。 那就像是派两个探险队,一个去左边最高处,一个去右边最高处,最后取两人中更高的高度加 1。
总结: 你的这段代码就像是在玩**“消消乐”**,每次把一整排(一层)消掉,直到没得消为止,消了几次就是几层。
226. 翻转二叉树
这个问题是著名的 “翻转二叉树” (Invert Binary Tree) 。传说 Homebrew 的作者去 Google 面试,就是因为没在白板上写出这道题被拒的,所以它在程序员圈子里非常出名。
🏠 生活案例:照镜子
想象这棵二叉树正在照镜子:
- 本体:左手拿着苹果,右手拿着香蕉。
- 镜像:在镜子里,你的左手(本体的右手)变成了香蕉,你的右手(本体的左手)变成了苹果。
代码的逻辑逻辑: 你作为一个“整容医生”,从最高层的领导(根节点)开始往下走:
- 交换:先把当前领导身下的“左副总”和“右副总”调换位置。
- 递归:调换完后,还没完!你得去左副总办公室,把他手下的经理们也对调一下;再去右副总办公室,如法炮制。
- 到底为止:直到你发现某个办公室是空的(节点为
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;
};
🗝️ 核心逻辑拆解
-
自上而下的交换: 代码里先执行了
[root.left, root.right] = [root.right, root.left],这意味着我们是先处理父节点,再处理子节点。这在树的遍历中叫做前序遍历。 -
解构赋值的妙用:
[a, b] = [b, a]是 JS 的语法糖。如果不这么写,你需要一个中间变量:JavaScript
let temp = root.left; root.left = root.right; root.right = temp;效果是一模一样的,都是为了互换两边的“枝叶”。
-
递归的深度: 递归会一直深入到树的叶子节点(最底层的员工)。虽然我们是从上往下交换的,但递归能保证每一个细小的分叉都被翻转过。
总结: 翻转二叉树其实就是递归地交换每一个节点的左右子节点。只要你每一个小分叉都左右互换了,整棵树看起来就是镜像的了。
101. 对称二叉树
这个问题是 “对称二叉树” (Symmetric Tree) 。它考察的是你对二叉树结构和递归逻辑的理解。
🏠 生活案例:折纸实验
想象你手里有一张画着树状图的透明纸(二叉树):
-
中轴线:你沿着根节点(最上面的
1)垂直画一条虚线。 -
对折:你把纸沿着这条虚线对折。
-
重合检查:
- 左边的“左手”是不是正好叠在了右边的“右手”上?(比如示例中的两个
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);
};
🗝️ 核心逻辑拆解
-
镜像对比 vs 相同对比:
- 如果你检查两棵树是否完全相同,你会比
left.left和right.left。 - 但检查对称,你必须比
left.left(最左端)和right.right(最右端),这就是“镜像”的含义。
- 如果你检查两棵树是否完全相同,你会比
-
递归的“短路效应” :
- 在
compare函数中,只要发现任何一个点不匹配(比如一个是3一个是null),return false会像多米诺骨牌一样传回最上层,立刻得出“不对称”的结论。
- 在
-
为什么
!root返回true?- 在算法逻辑中,空集合通常被认为是满足对称性定义的。
总结: 判定对称二叉树,就是把**“左子树的左”与“右子树的右”对比,再把“左子树的右”与“右子树的左”**对比。
543. 二叉树的直径
这个问题是 “二叉树的直径” (Diameter of Binary Tree) 。题目要求找到树中任意两个节点之间最长的路径长度。
🏠 生活案例:寻找最长通话线路
想象你负责在一个分布在山区的村庄(节点)之间架设电话线。这些村庄的连接结构是一棵二叉树。
规则是:
- 两个村庄之间的“距离”是它们之间经过的电线杆(边)的数量。
- 你想找到整棵树中,哪两个村庄之间的距离最远。
逻辑发现: 最长的线路不一定非得经过“最高领导”(根节点)。它可能出现在:
- 跨越某个节点:从左边最深的村庄一直连到右边最深的村庄。
- 完全在某一侧:如果左子树特别庞大,最长路径可能完全在左边。
所以,对于每一个村庄(节点),我们都要算一下: “经过我这个点,左边最深能走多远 + 右边最深能走多远” 。
💻 代码实现与生活化注释
这段代码巧妙地利用了计算“深度”的过程来顺便更新“直径”:
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; // 返回最终找到的最长线路
};
🗝️ 核心逻辑拆解
-
“买一送一”的逻辑: 我们原本的函数目的是计算深度(即从当前点往下走最长能走几步)。但在计算每个点的深度时,我们顺便做了一道加法:
leftDepth + rightDepth。这行加法算出的就是**以当前节点为“最高转折点”**的最长路径。 -
边 vs 节点:
- 深度通常返回的是节点的数量。
- 直径(距离)算的是边的数量。
- 因为
leftDepth和rightDepth分别代表左右两侧的边数(如果是按高度算的,结果也是一样的),所以直接相加不需要再减 1。
-
递归的顺序: 这是一个后序遍历。我们必须先知道左边有多深,右边有多深,才能算出经过“我”这个点的路径有多长。
总结: 每一门课(节点)都问一下自己的左右下属:“你们那边最深的坑有多深?” 然后把两边的深度一加,看看是不是打破了世界纪录(max)。
102. 二叉树的层序遍历
这个问题是 “二叉树的层序遍历” (Binary Tree Level Order Traversal) 。它要求你按照“从上到下、从左到右”的顺序,把每一层的节点分别打包存起来。
🏠 生活案例:公司年度合照
想象你要给一家公司拍年度合照。公司的人员结构是二叉树状的(CEO 在最上面,下面是副总,再下面是经理)。
你的拍摄要求是:
- 按层拍:第一张照片拍 CEO(第一层),第二张照片拍所有的副总(第二层),第三张拍所有的经理(第三层)。
- 左到右:每一层拍照时,人必须按照从左到右的顺序站好。
逻辑实现(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;
};
🗝️ 核心逻辑拆解
- 为什么是嵌套数组
number[][]? 因为题目要求每一层独立存放。所以我们每进入一次while循环,就新建一个path = [],用来装这一层的所有数值。 let size = queue.length的关键作用: 这是区分“这一层”和“下一层”的界限。当你开始处理第一层时,队列里只有 CEO,size是 1。在你处理 CEO 的时候,他的两个孩子进队了,队列长度变成了 2。但因为size已经固定是 1 了,循环只跑一次,保证了两个孩子会被留到下一轮while中处理。- 先进先出 (FIFO) : 队列
shift()和push()保证了顺序。左边的孩子先辈 push 进去,就会先被 shift 出来,这就实现了“从左到右”。
总结: 层序遍历就是利用一个“排队区”,让每一层的人在出队的同时,把下一层的人按顺序安排好。
108. 将有序数组转换为二叉搜索树
这个问题是 “将有序数组转换为二叉搜索树” (Convert Sorted Array to Binary Search Tree) 。它的核心目标是利用数组已经“排好序”的特点,构建出一棵左右平衡的搜索树。
🏠 生活案例:平衡木与天平
想象你手里有几块重量不一的积木,已经按从轻到重排好了序(比如:[-10, -3, 0, 5, 9])。你想把它们搭成一个架子,要求是:架子不能歪,左右两边的积木数量要尽量一样多。
你的操作逻辑:
-
找中心:为了让架子平衡,你肯定会挑最中间的那块积木(比如
0)作为最顶端的支撑点。 -
分左右:
- 比
0轻的积木([-10, -3])全部扔到左边。 - 比
0重的积木([5, 9])全部扔到右边。
- 比
-
套娃操作:左边的两块积木怎么搭?重复第一步,再找它们中间的作为支撑点。
这样搭出来的架子(树),不仅左边永远比右边轻(符合搜索树规则),而且高度也是最平均的(平衡)。
💻 代码实现与生活化注释
这段代码使用了经典的 分治法 (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, 2, 3, 4, 5],你选1当根节点,那所有数都在右边,树就变成了一根“棍子”,一点都不平衡。选中间的3,左右各两个,结构最稳定。 -
二叉搜索树 (BST) 的特性:
- 左子树的所有节点都比根节点小。
- 右子树的所有节点都比根节点大。
- 因为数组是有序的,所以我们选中间值,天然就保证了左边范围的数全比它小,右边全比它大。
-
平衡 (Height-Balanced) 的实现: 由于我们每次都从正中间切分,左右两边的节点数量差距最大不会超过 1,这自动满足了“平衡”的要求。
总结: 这个问题就像是在切西瓜,每次从中间切一刀,左半边交给左手处理,右半边交给右手处理,直到切成一小块一小块的果肉(叶子节点)为止。
98. 验证二叉搜索树
这个问题是 “验证二叉搜索树” (Validate Binary Search Tree) 。它考察的是你是否真正理解二叉搜索树(BST)的严格定义。
🏠 生活案例:公司职级的“层层压制”
想象你在一家管理非常严苛的公司,每个人都有一个“职级点数”。为了保证管理不混乱,公司有一条铁律:
- 左属下: 某位主管左边管辖的所有人,点数必须严格小于这位主管。
- 右属下: 某位主管右边管辖的所有人,点数必须严格大于这位主管。
容易犯错的点: 仅仅比较主管和他的直接下属是不够的。比如 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);
};
🗝️ 核心逻辑拆解
-
上下界的收紧:
- 当我们向左走时,我们更新了上界(
max变成了当前节点的值)。 - 当我们向右走时,我们更新了下界(
min变成了当前节点的值)。 - 这种层层收紧,保证了所有的子孙节点都必须服从祖先节点设定的范围。
- 当我们向左走时,我们更新了上界(
-
严格大于/小于:
- 题目要求是“严格”,所以代码中用了
<= min和>= max。如果两个节点值相等,也不符合 BST 的定义。
- 题目要求是“严格”,所以代码中用了
-
另一种思路(中序遍历) :
- 还记得之前说过的吗?BST 的中序遍历结果必须是一个严格递增的数组。
- 你也可以先中序遍历一遍,再看结果是否有序。但这种带范围的递归法在空间效率上通常更优。
总结: 验证 BST 的诀窍就是:不能只看父子,要看老祖宗定下的规矩(区间限制)。
230. 二叉搜索树中第 K 小的元素
这个问题是 “二叉搜索树中第 K 小的元素” (Kth Smallest Element in a BST) 。它完美利用了二叉搜索树(BST)的一个核心物理特性。
🏠 生活案例:排队的班级
想象一个班级的学生按照身高排成了一棵二叉搜索树:
- 老师站在中间(根节点)。
- 所有比老师矮的学生都在左边支路。
- 所有比老师高的学生都在右边支路。
- 每个小支路的主管也遵循同样的规则。
你的任务是: 找出全班身高排名第 的同学。
最聪明的策略:
你不需要把所有人拉出来重新排队。你只需要按照 “左 -> 中 -> 右” 的顺序去点名。
- 你先去最左边的角落,那里一定坐着全班最矮的人。
- 点完最矮的,再点他的上级,然后点上级的右边。
- 你手里拿个计数器,点一个名就按一下。当计数器跳到 的时候,眼前这位同学就是你要找的人。
💻 代码实现与生活化注释
这段代码使用了 中序遍历 (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 个人的名字
};
🗝️ 核心逻辑拆解
-
BST 的天然优势:
- 普通的二叉树找第 小需要全部遍历再排序,时间复杂度 。
- BST 只需要中序遍历,时间复杂度 ( 是树的高度),效率极高。
-
为什么
ans要初始化为root.val?- 其实初始化为什么都行,只要最后被
node.val覆盖即可。代码中直接在满足count == k时更新它。
- 其实初始化为什么都行,只要最后被
-
提前结束(优化点) :
- 你上传的代码在找到
count == k后虽然赋值了,但递归还会继续跑完剩余部分。 - 优化建议:可以加一个
found布尔值,一旦找到就直接return,不再进递归。
- 你上传的代码在找到
总结: 找 BST 的第 小,本质上就是做一个 “有记数功能的中序遍历” 。
199. 二叉树的右视图
这个问题是 “二叉树的右视图” (Binary Tree Right Side View) 。题目要求你想象自己站在树的右侧,从上到下记录你第一眼能看到的节点值。
🏠 生活案例:排队领物资
想象一队人在排队领物资,他们站成了一个二叉树的阵型: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 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;
};
🗝️ 核心逻辑拆解
-
为什么记录
i == size - 1?- 在层序遍历中,我们是一层一层处理的。
- 每一层的节点进入队列的顺序是“从左到右”。
- 因此,该层最后一个出列的节点(即索引为
size - 1的那个),必然是该层最靠右的节点。
-
视线遮挡的模拟:
- 即使左边有很长的分支(比如示例 1 中的
5),只要右边有节点(比如4),4就会在它的那一层成为“最后一个”,从而把5挡住。
- 即使左边有很长的分支(比如示例 1 中的
-
BFS vs DFS:
- 虽然这题也可以用 DFS(深度优先搜索)做,但 BFS 非常直观,因为它天然就是按“层”组织的,非常符合“每一层只取一个”的逻辑。
总结: 二叉树的右视图,其实就是层序遍历的“选代表”版——每一层只选最右边的那个节点当代表。
114. 二叉树展开为链表
这个问题是 “二叉树展开为链表” (Flatten Binary Tree to Linked List) 。它的目标是将一棵树结构平铺成一条“向右倒”的直线,顺序必须符合二叉树的 先序遍历。
🏠 生活案例:整理晾衣架
想象你有一把那种多层的、带分叉的晾衣架(二叉树)。
-
分叉:衣架有主杆,主杆下面分出了左边的一捆和右边的一捆。
-
整理目标:你想把这个复杂的架子折叠起来,最后变成一根长长的、直直的杆子。
-
折叠规则:
- 所有的衣服(节点)必须按照你平时看它们的顺序(先看中间,再看左边,最后看右边)连在一起。
- 所有的衣服都必须挂在右边,左边要全部空出来。
逻辑实现: 你先准备好一个本子(数组),按照“中 -> 左 -> 右”的顺序把所有衣服的编号记下来。然后,按照名单顺序,把第一件衣服拿出来,左手边清空,右边连上第二件;第二件左手边清空,右边连上第三件……以此类推。
💻 代码实现与生活化注释
这段代码使用了 “先记录,再重组” 的简单策略:
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下面连着2和5。按照先序遍历(中-左-右),顺序是1 -> 2 -> 3 -> 4 -> 5 -> 6。这就是我们“拉直”后的新顺序。 - 左边必须置空 (
null) : 题目要求展开后的单链表,左子针必须始终为null。如果你忘记写arr[i].left = null,它就还是一个分叉的树,不是链表。 - 原地修改 (In-place) : 虽然我们借用了数组,但我们最后是直接修改了节点对象的
.left和.right属性。这意味着如果你手里还拿着最初的那个root引用,你会发现它已经变成了一根长线。
总结: 这个问题其实就是把树拆掉,按特定的点名顺序重新用线穿起来。
105. 从前序与中序遍历序列构造二叉树
这个问题是 “从前序与中序遍历序列构造二叉树” (Construct Binary Tree from Preorder and Inorder Traversal) 。这是一个非常经典的题目,它考察你是否理解不同遍历方式如何共同“锁定”一棵树的结构。
🏠 生活案例:根据“排位”还原阵型
想象一个班级在操场上排队,你有两张名单:
- 点名名单 (Preorder) :老师站在最前面,按“自己 -> 左边同学 -> 右边同学”的顺序喊名字。这张名单的第一名永远是当前最高领导(根节点) 。
- 身高名单 (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, 2, 3]可能是条直线,也可能是个分叉。 - Preorder 告诉你谁是 Boss(根)。
- Inorder 告诉你 Boss 的地盘是怎么划分的(左边和右边)。
- 只给一张表,你无法确定谁是谁的儿子。比如
-
slice的含义:- 这就像是在剪报纸。每确定一个根节点,我们就把剩下的名单剪成两半,一份给左手去盖楼,一份给右手去盖楼。
-
性能小提醒:
- 你上传的代码中用了
indexOf,在非常大的树中,每次都找一遍位置会有点慢(时间复杂度 )。 - 优化方法:可以先用一个
Map把inorder的值和索引存起来,这样找mid的时候就是 了。
- 你上传的代码中用了
总结: 构造二叉树的过程就是:找根节点 -> 划分子树范围 -> 递归构造。
437. 路径总和 III
这个问题是 “二叉树的最近公共祖先” (Lowest Common Ancestor of a Binary Tree) 。这是一个在家族族谱或组织架构中经常需要解决的问题。
🏠 生活案例:寻找共同的老祖宗
想象你正在翻阅一份庞大的家族族谱。
- 你想找到两位亲戚:张三 (p) 和 李四 (q) 。
- 你的目标是找到离他们最近的那个共同长辈(最近公共祖先)。
查找逻辑:
-
向上寻找: 如果你是张三,你向上找爸爸、爷爷……如果你是李四,你也向上找。第一个在你们两条线上都出现的长辈就是答案。
-
代码实现逻辑(递归): 我们从家族最顶端的祖先(根节点)开始往下派人寻找:
- 如果在左边分支找到了张三,右边分支找到了李四,那当前这个祖先就是他们的“最近公共祖先”。
- 如果两名亲戚都在左边,那就继续去左边深挖。
- 特殊情况: 如果张三本人就是李四的长辈,那么张三就是我们要找的答案。
💻 代码实现与生活化注释
这段代码利用了 后序遍历 的思想,先去左右子树打听,再在当前层做判断:
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);
};
🗝️ 核心逻辑拆解
-
“自底向上”的回传:
- 虽然递归是从根节点开始的,但判断逻辑是在“回溯”阶段完成的。
- 当
left和right同时不为空时,这个节点就像是一个汇合点,它会把自己作为结果一层层传回给最顶层。
-
如果是父子关系怎么办?
- 假设
p是q的父亲。在递归到p时,if (node == p)会直接触发,函数立刻返回p。 - 此时程序甚至不需要去
p的子树里找q了,因为p既然是q的祖先,那p本身就是他们的“最近公共祖先”。
- 假设
-
结果的唯一性:
- 只要
p和q都在树里,这个算法一定能找到唯一的那个汇合点。
- 只要
总结: 这个问题就像是在分岔路口做统计:如果左路和右路各发现了一个目标,那当前路口就是答案;如果只有一边有发现,就顺着那一边的线索继续往上看。
236. 二叉树的最近公共祖先
这个问题是 “二叉树中的最大路径和” (Binary Tree Maximum Path Sum) 。这被认为是二叉树题目中最难的一类,因为它不仅考察递归,还考察你对“路径”定义的深刻理解。
🏠 生活案例:寻找最赚钱的登山路线
想象你正在一座山脉(二叉树)中探险,每个山峰(节点)上都有不同数量的金币。
- 规则 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;
};
🗝️ 核心逻辑拆解
-
为什么要
Math.max(..., 0)?- 如果一个子树的收益是负的(比如全是亏钱的山峰),我们宁愿不去碰它。这叫“及时止损”。
-
“单边”与“双边”的区别:
- 双边(
node.val + leftMax + rightMax) :这是计算路径的终态。它是一个拱形,像个彩虹。一旦形成了这个拱形,它就不能再作为更大路径的一部分往上连了(否则路径会出现分叉)。 - 单边(
node.val + max(leftMax, rightMax)) :这是为了给父节点提供线索。
- 双边(
-
全局变量
maxSum:- 每一个节点在递归过程中都会尝试做一次“彩虹中心”。有的“彩虹”可能就在树的一角(比如示例 2 中的
15-20-7这一块),不一定非要经过整棵树的根节点。
- 每一个节点在递归过程中都会尝试做一次“彩虹中心”。有的“彩虹”可能就在树的一角(比如示例 2 中的
总结: 每一层递归其实在做两件事:
- 私活:算算以我为中心的最强路径,看看能不能破世界纪录。
- 公事:选一条最强的分身,报给老板听,让老板去继续组合。