讲实话,刷到二叉树第21到29题这一段,正好是一个分水岭。前面还在各种遍历里打转,从LeetCode 530开始,题目突然就“用”起二叉树了——搜索树的最小绝对差、众数、公共祖先、插入、删除、修剪、有序数组建树、累加树。这一组九道题,表面看是零散的小题,实际上全部围绕二叉搜索树(BST)的中序有序性、递归构造、以及树结构的修改操作展开。把这九道题放在一起系统性刷一遍,你对二叉树的“框架感”会完全不一样。这篇文章就是我用两组题目做对比拆解、把每道题的切入点和易错点都捋清楚后的完整记录,既有代码思路,也有调试过程中踩过的坑,适合正在按题号顺序刷二叉树、或者二刷前想巩固BST核心操作的读者。
1. 整体设计思路:九道题凭什么放在同一段
先别急着逐题刷,站在题目编排的角度看这一组,它们其实是一套“BST专项训练包”。530、501是BST特性的利用题,核心是“中序遍历得到有序序列”;236、235是公共祖先两道题,分别考普通二叉树和BST的差异化解法;701、450是BST的插入和删除,直接关系到结构修改时指针怎么接;669是修剪,比删除更进阶,涉及“按区间批量裁剪”;108是构造类题目,考递归建树的分治思想;538则是把中序遍历反着用的累加树。
从做题策略角度,我强烈建议你打破题号顺序,按“先特性、再查找、后修改、再构造”的阶梯来刷。因为BST题目最大的特点是:解法几乎都能归结到“中序有序”这四个字。你先把530和501做透,中序遍历的“记录前一个节点”这个手法就会形成肌肉记忆,后面做538累加树、669修剪,你会发现套路惊人地一致。
再说到难度分层。这一组里,236题(普通二叉树最近公共祖先)和450题(删除BST节点)是公认的难点,前者考递归回溯的逆向思维,后者考五种情况枚举和指针衔接。而235、701、108相对友好,适合用来建立信心。530、501虽然代码短,但对“前一个节点的状态维护”要求精细,容易被判重逻辑绊住。538则是个巧题,理解了反中序遍历的本质后代码能压到八行。把这九道题按“理解成本”而不是“题号”排序,刷起来节奏感会好很多。
另外一个容易被忽略的点:这一组题是“写二叉树程序时为什么总是报运行时错误”这个问题的高发区。空指针、根节点变化未接收返回值、递归边界漏判,三类错误在450、669、701里出现概率极高。我后面专门用一节讲运行时错误的排查思路,就是基于这九道题里踩过的真实报错场景。
2. 核心方法论:BST题目绕不开的三板斧
2.1 中序遍历:BST的“有序”才是解题钥匙
二叉搜索树最核心的性质很简单:中序遍历得到一个递增序列。这个性质在530、501、538三道题里分别以三种形态出现——最小绝对差是“取相邻两个节点的差值最小值”,众数是“统计连续相同值的最大长度”,累加树是“反向中序遍历累加”。三道题本质都建立在“中序遍历产生的序列是有序的”这个基础上。
以530题为例,求BST任意两节点差值的最小值。你当然可以把中序遍历结果存进数组再扫一遍,但这需要O(n)的额外空间。最优解是“双指针式遍历”:维护一个全局变量pre记录上一次访问的节点,当前节点和pre的差值就代表“有序序列中相邻两数的差值”。因为BST中序遍历后相邻元素的差值一定覆盖了全局最小差(你能证明一下:有序数组中任意两个元素的差,一定不小于某个相邻元素对的差,对吧),所以边遍历边更新即可。
501题就更典型了。求众数,也就是出现频率最高的元素值。常规思路是遍历两次:第一遍用哈希表统计频率并找到最大频率,第二遍筛出达到该频率的节点。但如果要求O(1)空间(除了递归栈),就需要在中序遍历中维护两个关键状态:当前值的连续出现次数count,以及最大频率maxCount。关键点在于“结算时机”——当节点值切换时,才需要比较count和maxCount。很多人在节点值相同的连续区间内提前更新maxCount,导致次高频的值被漏掉。稳妥做法是先判断当前节点值是否等于pre的值,相等则count加1,不相等则把count归1,然后用count和maxCount比较,等于maxCount就加入结果集,大于就清空结果集重新开始。这里有个细节:如果实时维护maxCount,就必须在“值切换”时做一个收尾,否则连续区间的最后一个元素没参与比较。
这个“记录前一个节点”的技巧,在链表题里叫快慢指针,在BST题里就是pre节点遍历,本质是“空间换状态”的经典操作,后面做669、538同样反复用到。
2.2 递归三要素:所有“改树”题目的骨架
701、450、669这三道题的共同点是都要修改BST结构,而它们的解法全都依赖“递归返回值”这个手法。我见过太多人在写这类代码时犯同一个错误——在递归里修改了节点,但没有把新节点返回给上一层,导致改动丢失。
递归三要素再强调一遍:一是函数定义要明确(做什么、返回什么);二是终止条件要清晰;三是单层递归的逻辑要覆盖所有分支。对这个“修改树”的题型来说,最关键的是第三点里的“返回值接收”。以450删除节点为例,删除一个BST节点后,你需要返回“删除完成后这棵子树的新根节点”。所以每一层递归都写成root.left = 删除逻辑(root.left)或root.right = 删除逻辑(root.right),把结构调整的结果接住。如果漏了接收返回值,删除操作只会在叶子层生效,往上传就断了,最终树的形态还是老的。
为什么递归返回值在这种场景里是“必须”的?因为树的物理结构是单向的。父节点只能通过左/右指针找到子节点,子节点没有办法自己通知父节点“我被替换了”。所以必须由子节点把新节点返回给父节点,再由父节点把指针重新指向它。这就是“递归修改树”的通信协议——不遵守这个协议,结构修改就是空谈。
另一个容易忽视的三要素细节是终止条件里要“想清楚返回什么”。以669修剪为例,当根节点值小于区间左边界时,整棵左子树都应该被剪掉,这时不是直接返回null,而是递归修剪右子树后返回右子树的结果。因为右子树里也可能有小于左边界的节点,不能一剪了之。这个“该剪的不止一层”的意识,是递归题里最常见的思维盲区。
2.3 分治与有序:构造类题目的核心逻辑
108题是把有序数组转成平衡BST。这道题的价值不只是“背一个递归模板”,而是理解“分治构造”的普适性。思路是:数组是有序的,BST中序遍历后也是有序的,那么数组中间位置的元素就应该是BST的根节点。根左边的是左子树的中序序列,右边的是右子树的中序序列,然后递归处理。
有人会问:为什么要取中间元素?因为要让左右子树节点数尽量均衡,这样树高是O(log n),才能叫“高度平衡”。取中间位置本质是一种“贪心+分治”,它保证了每一层的左右规模差不超过1。实现上注意边界:用mid = left + (right - left) / 2取中间左侧的元素,然后递归[left, mid-1]和[mid+1, right]。边界条件就是left大于right返回null。这道题很多初学者写不顺,问题往往出在数组下标的开闭区间上,我建议你写的时候固定“左闭右闭”区间,不要今天写左闭右开明天写全闭区间,混着写必出越界错误。
538题的累加树,从构造角度看是个“反向分治”的变种。BST中序是升序,那么反中序就是降序。累加树的定义是每个节点的值改成“原树中大于等于该节点值的所有节点值之和”。所以按“右-中-左”的顺序遍历,沿途把已访问的节点值累加到一个变量sum里,当前节点的新值就是sum加上自己的原值。这一步理解了代码特别短,但如果你把遍历顺序写成左中右,累加的就是“小于自己的节点”,完全反了。
3. 实操拆解:九道题每一道的切入点和易错点
3.1 530题:最小绝对差的双指针遍历
进入实际代码前先明确需求:给定一棵BST,返回任意两节点值之差的最小值。常规方法可能是把所有节点值收集排序再算相邻差,但既然题干已经给了BST,就要充分利用它的有序性,否则这题就失去训练意义。
我的模板是:定义一个全局变量pre初始为null,一个minDiff初始为正无穷。在中序遍历的“处理节点”阶段完成判断:
private TreeNode pre; private int minDiff = Integer.MAX_VALUE; private void inorder(TreeNode root) { if (root == null) return; inorder(root.left); if (pre != null) { minDiff = Math.min(minDiff, root.val - pre.val); } pre = root; inorder(root.right); }很多人在这个代码上犯的错误是:只记录pre但不判断null,直接root.val - pre.val导致空指针。这题因为求的是“与之前的差值”,pre为null时显然不能计算,所以要加空值保护。另一个值得注意的点是:差值为什么不取绝对值?因为BST中序遍历是递增的,当前节点值必然大于等于前一个节点值,所以root.val - pre.val一定是非负的。这个性质本身就是BST有序性的体现。
530这道题的时间复杂度是O(n),空间复杂度是O(h),h是树高。这个空间来自递归栈,在极端退化链状树时h可能等于n,所以严格说空间是O(n)。如果你面试时被问到“能否用迭代实现”,可以用栈模拟中序遍历,本质上没有区别,因为栈也需要O(h)空间。真正的O(1)空间需要Morris遍历,但一般面试不要求到这个深度。
3.2 501题:众数处理的“结算时机”
501题求BST中出现次数最多的元素值。这题的难点不在“统计频率”,而在“如何在O(1)额外空间内完成统计”。前面说过,最稳妥的做法是遍历过程中维护当前值的连续出现次数和全局最大次数。我直接给一个经过多次测试的模板:
private TreeNode pre; private int count; private int maxCount; private List<Integer> result = new ArrayList<>(); private void inorder(TreeNode root) { if (root == null) return; inorder(root.left); if (pre == null) { count = 1; } else if (root.val == pre.val) { count++; } else { count = 1; } if (count > maxCount) { maxCount = count; result.clear(); result.add(root.val); } else if (count == maxCount) { result.add(root.val); } pre = root; inorder(root.right); }这个模板里最关键的是“值切换时count要重新计数”,以及“count与maxCount相等时也要记录”。最容易踩的坑就是把count和maxCount的比较放在“值切换”分支外还是内的问题——如果放在值切换分支内,连续相同的值在切换前永远不会触发比较,比如一个值连续出现5次,你只在最后一次切换时才检查count,那结果没错。但如果这个值是全局众数且树遍历结束前没有切换值(即它出现在序列末尾),就会漏记录。我上面这个写法每次处理节点时都实时比较count和maxCount,就不存在漏结算的问题,逻辑也更统一。
有些实现会先用两次遍历拿哈希表,我个人的观点是:笔试场景下哈希表方案更不容易错,能Accepted就能过;但面试场景下如果你主动写出O(1)空间的版本,观感明显更好。尤其是当面试官追问“能否不借助额外空间”时,这个基于中序有序性的计数法就是标准答案。
3.3 236题:普通二叉树的最近公共祖先
这是九道题里难度最高的一道,也是递归回溯思想的经典代表。给定一棵普通二叉树和两个节点p、q,找它们的最近公共祖先。这里的“最近公共祖先”定义为一个节点,它同时是p和q的祖先,并且深度尽可能大。注意,p和q本身也可以是它们的公共祖先。
核心思路是自底向上的回溯:递归函数lowestCommonAncestor(root, p, q)返回的是“以root为根的子树中,p和q的最低公共祖先,或者在该子树中找到的p或q”。逻辑可以这样拆:
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null || root == p || root == q) return root; TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); if (left != null && right != null) return root; return left != null ? left : right; }这段代码极短,但信息密度极高。逐行解释:基线条件有三个——root为空说明找不到,返回空;root等于p或q,说明找到了目标节点,直接返回root。递归后,如果左子树找到了p或q(或它们的祖先),left就不为空;右子树同理。这时分三种情况:left和right都不为空,说明p和q分别落在root的左右子树里,那root就是最近公共祖先;只有left不为空,说明两个节点都在左子树里,那最近公共祖先也在左子树里,直接返回left;只有right不为空则对称处理。
这道题的易错点是:很多人担心“如果p是q的祖先,那找到p后直接return p,会不会错过真正的公共祖先”。其实不会,因为p既然是q的祖先,那p就是最近公共祖先。而且注意,这里返回的节点会被一层层向上传递,最终作为“该子树中找到的一个目标节点”继续参与上层的判断。我当初理解这段代码费了不少劲,后来自己画了一棵三层二叉树,手动模拟了两个节点分别位于不同分支和位于同一条路径上的两种情况,才算真正通了。建议你刷这题时也手动模拟,至少走一遍“p在左子树、q在右子树”和“p是q祖先”这两个场景。
3.4 235题:BST的公共祖先,利用有序性简化
如果236题是通用的递归回溯,那235题就是“打不过就加入”的另一种思路——既然BST有有序性,就不需要遍历整棵树,而是通过比较节点的值来定位。思路非常直观:从根出发,如果p和q的值都小于当前节点值,说明它们都在左子树,往左走;如果都大于当前节点值,往右走;否则当前节点就是分岔点,也就是最近公共祖先。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { while (root != null) { if (root.val > p.val && root.val > q.val) { root = root.left; } else if (root.val < p.val && root.val < q.val) { root = root.right; } else { return root; } } return null; }为什么“分岔点”一定就是最近公共祖先?因为BST的性质保证了:当前节点值介于p和q之间(或等于其中一个),意味着p和q不在同一侧子树里,那么当前节点就是能够同时通往p和q的最高节点,再往上一层走,必定会把p、q分到不同子树,就不再是公共祖先了。用这个逻辑写迭代版本,时间复杂度是O(h),空间O(1),比236通用解法高效得多。
有读者可能会问:如果p和q有祖先关系,比如p是q的祖先,那这个代码还能找到正确结果吗?能。假设当前节点就是p,那么root.val > p.val && root.val > q.val不成立(因为root.val等于p.val),第二个条件也不成立(因为p.val小于q.val),走else分支直接返回root,而p确实是最近公共祖先,正确。
3.5 701题:BST插入的递归与迭代双写法
插入操作的思路是:因为BST本身有序,插入一个节点总能找到一个叶子位置。如果待插入值比当前节点值小,就往左走;大就往右走;等于的情况题目通常保证不出现。实现方式有迭代和递归两个版本,我两个都写一下,因为面试官可能让你分别用两种方式实现。
递归版:
public TreeNode insertIntoBST(TreeNode root, int val) { if (root == null) return new TreeNode(val); if (root.val > val) { root.left = insertIntoBST(root.left, val); } else { root.right = insertIntoBST(root.right, val); } return root; }迭代版:
public TreeNode insertIntoBST(TreeNode root, int val) { if (root == null) return new TreeNode(val); TreeNode cur = root; while (cur != null) { if (cur.val > val) { if (cur.left == null) { cur.left = new TreeNode(val); break; } cur = cur.left; } else { if (cur.right == null) { cur.right = new TreeNode(val); break; } cur = cur.right; } } return root; }迭代版的易错点是忘记“如果当前节点的左/右孩子为空,直接挂上去并跳出”。很多新手写成cur = cur.left,结果cur变成null后循环结束,新节点却从未被挂上树。这个问题我在评论区里见过无数次,提醒你注意。两道代码的时间复杂度都是O(h),空间上递归需要栈、迭代O(1)。
701这道题虽然简单,但它给后面450题打了个底——当插入变成删除时,同样需要在层层递归中接收新的子树根节点,插入是“空位直接建节点”,删除是“删除后需要填补位置”,思维方式是一脉相承的。
3.6 450题:删除BST节点,五种情况全枚举
450题是这一组里代码量最大、细节最多的一道。删除一个BST节点后,要保持BST的有序性和结构完整。根据待删除节点的子节点情况,可以分成五种情况:
- 第一种:没找到节点,返回原树。
- 第二种:待删除节点是叶子节点,直接返回null,让父节点指针指向空。
- 第三种:待删除节点没有左孩子,把右孩子提上来。
- 第四种:待删除节点没有右孩子,把左孩子提上来。
- 第五种:待删除节点左右孩子都有,需要用“右子树的最小节点”替换它。
前四种都好理解,核心在第五种:为什么选右子树的最小节点?因为当你删除一个同时有左右孩子的节点时,你需要一个新节点来顶替它的位置,这个新节点必须大于左子树所有节点、小于右子树所有节点。右子树的最小节点恰好满足这个条件。具体步骤是:先找到右子树中最左边的节点,用它的值覆盖待删除节点的值,然后递归删除那个“右子树最小节点”。
public TreeNode deleteNode(TreeNode root, int key) { if (root == null) return null; if (root.val == key) { if (root.left == null) return root.right; if (root.right == null) return root.left; TreeNode minNode = findMin(root.right); root.val = minNode.val; root.right = deleteNode(root.right, minNode.val); } else if (root.val > key) { root.left = deleteNode(root.left, key); } else { root.right = deleteNode(root.right, key); } return root; } private TreeNode findMin(TreeNode node) { while (node.left != null) node = node.left; return node; }这个实现里最容易出问题的点是:root.val = minNode.val后,你相当于“把待删除节点的值换成了右子树最小节点的值”,然后去右子树里把那个“值等于minNode.val的节点”删掉。由于这个节点是右子树最左节点,它最多只有一个右孩子(不可能有左孩子),所以删除它时只会命中第一或第三种情况,递归不会无限加深。这也是这个解法能保证终止的原因之一。
每次删除后别忘返回当前根节点,这个“返回值向上传递”的习惯对450题尤其重要——很多人写完代码发现删除不生效,回头看根节点返回的是原来的root,但局部结构已经变了,问题几乎都出在漏接收返回值上。
3.7 669题:修剪BST,按区间批量裁剪节点
669题是修剪二叉搜索树,给定一个区间[low, high],删除所有值不在区间内的节点,并保持BST结构。这题可以看成450题的批量版,但实现上更简单也更难。说它简单,因为不需要“替换节点”的复杂操作;说它难,因为要处理区间两侧的整棵子树裁切。
核心逻辑依然用递归返回值接树。如果当前节点的值小于low,说明当前节点和它的左子树所有节点都小于low(BST性质),整棵左子树都要舍弃,但右子树里可能还有符合区间的节点,所以递归修剪右子树并返回右子树的结果;如果当前节点的值大于high,对称处理左子树;如果当前节点值在区间内,就递归修剪左右子树,然后返回当前节点。
public TreeNode trimBST(TreeNode root, int low, int high) { if (root == null) return null; if (root.val < low) { return trimBST(root.right, low, high); } if (root.val > high) { return trimBST(root.left, low, high); } root.left = trimBST(root.left, low, high); root.right = trimBST(root.right, low, high); return root; }这段代码大部分人都能背下来,但真正自己写的时候容易犯一个错误:当root.val < low时,直接return trimBST(root.right, low, high),而没有递归处理右子树内部的条件判断——实际上上面的写法已经递归处理了。要注意的是,“返回右子树”不是简单返回整个right子树,因为right里也可能有小于low的节点。只有递归修剪后才能保证整棵子树都合规。
另外一个易错点是:如果root.val在区间内,别忘记递归修剪左右子树并把结果接回去。我见过有人只写了三个if判断后直接return root,结果左右子树里该剪的节点一样没剪。669题考的就是“递归返回值接住剪枝结果”的习惯,和701、450一脉相承。
3.8 108题:有序数组转平衡BST,分治建树
这道题是从数组构造BST,属于“给定遍历序列,重建二叉树”的类别。思路前面已经讲过了,取中点作为根,左半边递归建左子树,右半边递归建右子树。实现时用左闭右闭区间:
public TreeNode sortedArrayToBST(int[] nums) { return build(nums, 0, nums.length - 1); } private TreeNode build(int[] nums, int left, int right) { if (left > right) return null; int mid = left + (right - left) / 2; TreeNode root = new TreeNode(nums[mid]); root.left = build(nums, left, mid - 1); root.right = build(nums, mid + 1, right); return root; }两个细节。一是mid = left + (right - left) / 2,这样写在数学上等同于(left + right) / 2,但避免了left+right的整数溢出问题。这道题用Java写其实left+right不太可能溢出,但养成这个习惯在滑动窗口等题目中能避免一些边界隐患。二是递归边界是left > right,不是left == right,因为当区间只剩一个元素时,mid等于left,左区间是[begin, mid-1]为空、右区间是[mid+1, end]也为空,递归就自然结束了。
有读者会问:为什么不取中间偏右的那个位置?其实取哪个都可以,只要每次居中就能保证平衡。取中间偏左或偏右会生成不同的树形态,但高度都是O(log n)。这个细节不影响正确性,但在对比别人代码时看到差异不用慌。
3.9 538题:累加树,反中序遍历一把梭
累加树的定义重新读一遍:每个节点的值都改成它自身值加所有大于等于它的节点值之和。BST的中序是升序,所以“大于等于自己的节点”在序列里排在当前节点之后。因此,反向中序遍历时,用一个累计变量sum记录已经访问过的节点值总和,每到一个新节点,把当前节点的值更新为自身的值加sum,然后把这个新值再累加到sum里。这句话写出来就是代码:
private int sum = 0; public TreeNode convertBST(TreeNode root) { traverse(root); return root; } private void traverse(TreeNode root) { if (root == null) return; traverse(root.right); sum += root.val; root.val = sum; traverse(root.left); }这段代码里“右-中-左”的顺序是全部正确性的根基。反着遍历就是降序序列,sum在遍历过程中自然成为“所有已访问过的(也就是比当前节点值更大的)节点值之和”。如果你改成常规的左中右顺序,sum累加的是比当前节点小的节点值,得到的结果就完全不对。
538题的易错点很少,但理解成本高。我刷这道题时一度以为需要两层遍历,后来才意识到一次反向中序就能完成。这也是为什么我强调中序遍历的有序性是BST题目最核心的思维工具——正向用一个技巧,反向又是一种技巧,但本质都是有序序列的线性扫描。
4. 常见问题与排查技巧实录
4.1 空指针错误的三个典型场景
写这九道题的过程中,我遇到的运行时报错,十有八九是空指针。归纳起来就三个典型场景。
场景一:530题里pre未初始化就参与差值计算。处理方式就是遍历主体里先判pre != null,pre是null时说明这是第一个访问的节点,没有“前驱”可以比较。
场景二:450题的findMin函数里,如果传入的root本身不是null,但root.left在while循环中被一路走到null,最后返回null,再对null取.val会抛异常。解决方式是:在用root.right调用删除之前,先确认它不为空。其实在删除逻辑的第五种情况里,我们已经确保root.right != null了,所以这个场景算是“逻辑上不可能但代码上仍需稳健”的情况。
场景三:669的修剪递归中,当root.val < low时直接return trimBST(root.right,...),此时如果root.right为null,递归返回null,是可以的,但如果后续在上一层继续调用returnResult.right就会空指针。这说明递归边界条件和上层判断必须配套,建议写完代码后先用只有一个节点的树做边界测试。
4.2 删除不生效:返回值没接住
450题的评论区常有人问“为什么我的deleteNode运行后,树没变化”。排查步骤很简单:先在纸上画一棵三层BST,手动执行一次删除叶子节点,观察每层递归返回的值;再对照代码看每层是否用了类似root.left = deleteNode(root.left, key)的接收语句。任何一个分支里写了deleteNode(root.left, key)而不是root.left = deleteNode(...),删除结果就会在回溯时丢失。这个问题在701、669、450里都会出现,排查思路完全一致。
4.3 中序序列和BST的有序性验证
做501、530、538时,如果结果总是不对,一个高效的调试方法就是:先写一个中序遍历函数,把结果打出来。如果中序序列不是递增的,说明你的BST构建或修改逻辑破坏了有序性,问题大概率在插入、删除或修剪时没有正确维护性质;如果序列递增但结果仍不对,那问题就在处理逻辑里,重点检查“结算时机”和“比较方向”。
拿538举例,如果你遍历顺序写成了左中右,你可以看到累加值递增而不是递减,一下就暴露了方向错误。这种“输出中间结果”的调试方式,比盯着代码干想有效率得多。
5. 常见问题速查表
| 题号 | 核心考点 | 易错点 | 解题锚点 |
|---|---|---|---|
| 530 | BST中序相邻差最小 | pre空指针 | 中序遍历记录pre节点 |
| 501 | 众数统计 | 值切换时未重新计数 | 中序的连续相同值计数 |
| 236 | 普通二叉树公共祖先 | 递归回溯理解不透 | 左右子树返回值判断 |
| 235 | BST公共祖先 | 忽略分岔点逻辑 | 值与当前节点比较 |
| 701 | BST插入 | 迭代版没挂上新节点 | 递归返回值接住子树 |
| 450 | BST删除 | 第五种情况替换后未删 | 找右子树最小节点替换 |
| 669 | 区间修剪 | 只剪当前层不递归 | 返回值接收修剪结果 |
| 108 | 有序数组构造BST | 区间边界写错 | 左闭右闭取中点建树 |
| 538 | 累加树 | 遍历方向写反 | 右-中-左降序累加 |
6. 实操心得:九道题连刷后的三个进阶建议
九道题全部刷完一遍之后,我建议你有意识做三件额外的事。
第一,把530、501、538这三道题的代码并排放在一起对比。你会发现它们共享同一套中序遍历框架,差异只在“处理节点”里的业务逻辑。这个对比会让你真正记牢“BST的有序性通过中序遍历来利用”这个思维模式,而不是每道题都重新想一遍。
第二,把450和669放在一起对比。这两道题都是“修改树结构”,都是靠递归返回值向上传递新节点。区别在于450的删除面临“如何找替代节点”的问题,669的裁剪面临“如何批量舍弃子树”的问题。把这两个场景并举,你会对“递归修改树的返回值协议”有更深的肌肉记忆。
第三,建议你自己造一组边界测试用例:空树、单节点树、链状树(即所有节点只有右孩子)、完全二叉树。这些用例跑一遍,530、450、669、108里的边界问题会现出原形。
从第21题刷到第29题,恰好是二叉树从“遍历模板”走向“BST专项应用”的关键一段。这段里的每一道题都是后面更复杂树题目的基础构件。我个人的体会是,刷完这一组后再回去看“二叉树的深度”“遍历”“热门100题”这些经典话题,思路会比以前清晰很多——因为你不只是在背模板,而是明白了这些模板为什么长这样,以及什么时候该用哪一个。