
看到D.二分查找-进阶——2476. 二叉搜索树最近节点查询这个标题我第一反应是又是一道套着二叉树壳子的二分题等真动手写完才发现这个判断只说对了一半。二分查找确实是最后一步的关键动作但真正决定这道题难易的是你有没有意识到一个前置操作——把二叉搜索树压平成一个有序数组。这个转换一旦想通整道题就退化成了教科书级的二分模板题在一个递增数组上为每个查询值分别找“不大于它的最大值”和“不小于它的最小值”。这道题适合两类人一类是在刷 LeetCode 二叉树和二分查找专题、但总是卡在“树和二分怎么结合”上的读者另一类是准备算法面试、想搞清二分边界变体区别的人。题目本身的难度接近中等偏下但信息密度不低它把二叉搜索树的中序有序性、lower_bound / upper_bound两个边界函数、以及 -1 的异常处理全部串在一条链路里。面试时能把这条链路讲干净是很加分的。拿题目自带例子来说树结构是[6,2,13,1,4,9,15]查询数组是[2,5,16]。先对树做中序遍历得到[1,2,4,6,9,13,15]。然后逐个查q2时树里不大于 2 的最大值是 2不小于 2 的最小值也是 2所以结果是[2,2]q5时不大于 5 的最大值是 4不小于 5 的最小值是 6结果[4,6]q16时没有大于等于 16 的节点第二项填 -1不大于 16 的最大值是 15结果[15,-1]。做完这三个查询你就能感觉到问题本身一点都不玄难的是怎么把“树上找前驱后继”翻译成数组上的两次二分。1. 题目拆解最近节点查询到底在问什么1.1 大白话版就是在一棵树上找前驱和后继先把术语揉碎了说。所谓“不大于 query 的最大值”在数据结构里叫前驱predecessor也就是“最接近且不超过目标值的节点”“不小于 query 的最小值”叫后继successor也就是“最接近且不低于目标值的节点”。你可以想象一本字典你要找一个词的读音和它前面、后面的词这个词本身存在就查它自己不存在就找它前面的最后一个词和后面的第一个词。树上的最近节点查询本质就是这个问题。生活化类比一下你去水果摊买西瓜心里价位是 5 块一斤。老板先给你找一只能买得起的最大的瓜——这就是前驱如果他觉得你预算能再加点再给你看一只刚好超过 6 块的瓜——这就是后继。两道题合在一起就是要你把这两种“最接近”都找出来。算法题里这类“查前驱后继”的需求非常常见。普通数组上排序后用一个二分就能查而二叉搜索树结构本身就隐含了有序性所以这道题没有让你去排序而是考你会不会利用树已有的顺序做一次“结构转换”。题目给出的节点值范围很大查询数量也可以到 10^5 量级这就排除了每个查询都遍历整棵树的暴力做法必须用对数级别的查询。1.2 中序遍历的有序性才是整道题的题眼二叉搜索树BST有两条铁律左子树所有节点的值都小于根右子树所有节点的值都大于根。正是这两条铁律让二叉树有了“可排序”的潜力。而把这种顺序性真正释放出来的动作就是中序遍历——按“左子树、根节点、右子树”的顺序访问所有节点。为什么偏偏是中序因为前序遍历根、左、右和后序遍历左、右、根都不满足单调性只有中序遍历把根放在左右子树中间配合 BST 的左小右大性质得到的序列才是从左到右严格递增的。换句话说BST 的中序序列就是这棵树上所有节点从小到大排好队的样子。树里原本绕来绕去的“前驱后继”关系在这个序列里变得非常直接一个节点的前驱就是序列中它左边那位后继就是它右边那位。我在实际做题中的体会是这种“把树转成数组”的思维几乎是一个通用模板。后面遇到很多看起来复杂的树题比如求第 k 小节点、验证二叉搜索树合法性、找两个节点的关系都可以先考虑中序遍历转数组问题往往会立刻简化一个维度。2476 这道题就是典型代表它懒得让你在树上绕来绕去只要你能想到“树可以压平成数组”局面就打开了。1.3 为什么二分查找非它莫属有序数组上做范围查询标准手段就是二分查找。原因很好算线性扫描一个长度为 n 的数组每次查询 O(n)m 次查询就是 O(nm)。当 n 和 m 都到 10^5 时10^10 次操作在任何普通评测环境下都跑不动大约要几分钟甚至更久而标准要求的运行时间通常是秒级。二分查找每次把搜索区间折半单次查询 O(log n)m 次查询合计 O(m log n)。同样是 10^5 的数据规模log 一下大概 17 次比较总操作量骤降到百万量级耗时差距是肉眼可见的大。用查字典类比线性查找是从第一页翻到最后一页二分查找是每次翻开中间看偏左还是偏右十几步就能锁到目标。所以这道题的技术路径非常清晰先用一次中序遍历拿到有序数组再对每个查询做两次二分。复杂度从暴力的 O(nm) 降到 O(n m log n)这也是整道题最核心的优化逻辑。2. 三种解法对比选模板之前先算一笔复杂度账2.1 树上直接搜索前驱后继最直观但最不稳看到“在 BST 里找前驱后继”很多人第一反应是直接在树上搜索不转数组。这个思路确实存在而且写起来也短。找不大于 q 的最大值可以这么做从根节点开始如果当前节点值小于等于 q把答案记下来然后往右子树走因为右子树可能有更大的但仍满足条件的节点如果当前节点值大于 q直接往左子树走因为右子树整体都不满足条件。找不小于 q 的最小值对称当前节点值大于等于 q 时记录答案并往左走否则往右走。这个方法的优点是空间省不需要额外数组树有多高递归栈就有多深。但问题也很明显每次查询都要从根再走一遍单次复杂度是 O(h)其中 h 是树高。如果输入是一棵退化成链的 BSTh 就等于 nm 个查询最坏就是 O(nm)和暴力没什么区别。LeetCode 这种题不会保证你的树一定平衡所以这个方案天然不够稳。还要注意树上搜索的代码虽然短但方向判断很容易写反。我就犯过把“往右走还是往左走”记反的错样例过了一换测试就挂。这种实现方式适合在树上做单次查询不适合本题这种“大量查询”的场景。2.2 中序遍历转数组加二分公认的标准解法既然树上多次走太慢那就干脆一次性把树压平成数组。中序遍历 O(n) 得到递增序列然后每个查询在数组上做两次二分 O(log n)总复杂度稳定在 O(n m log n)。这个方案还有一个隐性优点数组一旦建好树本身就不再参与查询了所有逻辑都集中在“有序数组的二分边界”上推导起来特别干净。我就是在这个过程中真正理解“为什么中序数组是 BST 的统一接口”的。以前觉得 BST 上查前驱后继要么靠树上走要么靠平衡树容器遇到这道题才发现一次性转数组加二分不但能过题而且是最不容易写错的一种。它把复杂问题拆分成了两个独立子问题先遍历树再查数组。每个子问题的调试范围都很小出错了也容易定位。代码结构上它也最直白一个函数负责中序遍历收集数组一个循环负责对每个查询做二分结果依次放到返回数组里。不需要维护额外状态不需要递归套递归。而且这套思路有很强的迁移性后续碰到“静态树上做多次查询”的题目我第一反应都会是“能不能先转数组”。2.3 用有序集合容器看起来省事其实没必要还有一种思路借助现成的有序集合容器。比如 C 里把节点值全部塞进std::set然后对每个查询调用lower_bound和upper_bound。这样连手写中序转数组都省了树的顺序性由红黑树来保障。这么做单次查询依然是 O(log n)看起来完全可行。但仔细一算就能发现问题第一把值插入 set 的过程是 O(n log n)而直接中序遍历只要 O(n)反而多了一步第二set 底层是节点指针组织cache 不友好常数因子比 vector 大不少查询一多就会拉开差距第三在 Python 标准库里并没有内置的平衡树容器或有序数组容器想依赖现成集合还得靠第三方库。也就是说是“能写但没必要”属于给自己加戏。如果树本身是动态的也就是后续会频繁插入删除节点那 set/TreeSet 的价值才体现出来。可本题的树是静态的查询再多也不改树那就应该用最简单的有序数组稳、快、好解释。2.4 方案横评表格方案预处理单次查询整体复杂度空间代码量适用场景树上直接搜索无O(h)O(mh)O(h)短但易错单次查询中序转数组二分O(n)O(log n)O(n m log n)O(n)短且清晰静态树多次查询set/平衡树O(n log n)O(log n)O((nm) log n)O(n)依赖容器动态树从表格可以很直观看出静态树多次查询的标准解就是第二行。它既没有退化风险也没有依赖第三方的隐藏成本是最平衡的选择。3. 中序遍历细节递归与迭代怎么选3.1 递归写法三行搞定但有隐患中序遍历的递归写法几乎是刻在肌肉记忆里的。对于一个节点先遍历左子树再访问根最后遍历右子树。写成 Python 大概是下面这样def inorder(node, nums): if not node: return inorder(node.left, nums) nums.append(node.val) inorder(node.right, nums)就这三行核心逻辑没有任何多余的动作。新手容易犯的错是访问顺序不对比如先 append 再递归左子树那得到的就是根在左子树之前顺序直接乱了。另一个容易忽略的是想当然写return inorder(...)之类把纯收集函数写成了带返回值的版本结果递归返回时把 None 拼到结果里。正确做法是把nums作为参数一路往下传在叶子处往列表里追加即可。递归的优点是代码和思维完全同步我在讲解这类题时也喜欢先用递归版本表达思路。但它的隐患同样藏在“简洁”两个字里每一层递归都占用调用栈树高是多少递归深度就是多少。LeetCode 默认的 Python 递归上限是 1000如果测试数据是一条 10000 层的链递归直接抛异常代码“看起来对”但跑不过。3.2 迭代写法显式栈的每一步拆解为了绕开递归深度限制可以手写栈模拟系统调用。迭代中序遍历模板如下def inorder_iter(root): nums [] stack [] cur root while stack or cur: while cur: stack.append(cur) cur cur.left cur stack.pop() nums.append(cur.val) cur cur.right return nums逐行解释一下外循环条件是“栈不空或者当前节点不为空”它保证了第一次进入栈为空时也能启动也保证了最后一轮节点处理完之后能退出。内层while cur做的事情是把当前节点和它的所有左子孙一路压栈一直压到最左的叶子。然后弹出栈顶访问它再转向右子树。这个“转到右子树”的动作会被下一轮外循环接住如果右子树还有左链又会继续压栈。这个模板我建议直接背熟答题时不需要现场推导。容易错的地方有三个一是忘了在外层循环里重置cur导致内层反复从同一个节点压栈形成死循环二是while stack or cur写成了while stack树为空或首次进入时直接跳过三是 pop 之后没有把nums.append(cur.val)放在转向右子树之前。顺序反了访问序列就会变成根偏后的变体。3.3 什么时候必须换成迭代如果是自己本地调试、树很小递归怎么舒服怎么写。但一旦涉及提交到平台、或者处理大型测试数据我建议默认上迭代。真实踩过一次坑有一道类似的平衡问题我图省事写了递归跑一个 2 万节点的链式测试直接RecursionError。当时本地代码完全没问题一接大数据就崩最后排查了半天才意识到是递归深度超限。处理办法有两个要么改成迭代版从根源上不依赖调用栈要么先执行sys.setrecursionlimit(1 20)把上限调大。后者的风险在于 Python 解释器所在进程的栈空间是 C 栈设太大在极端情况下会让进程崩溃所以只能算一个临时手段。正规、可复现的解法还是迭代。尤其 2476 这道题树深度可以很大我最终选择的就是迭代式中序遍历。4. 二分查找进阶lower_bound 与 upper_bound 的边界账本4.1 minVal 和 maxVal 到底对应哪个函数很多人的二分边界恐惧症本质是分不清lower_bound和upper_bound的区别。Python 的bisect_left对应 C 的lower_bound返回第一个“大于等于 target”的位置bisect_right对应upper_bound返回第一个“大于 target”的位置。一张映射表记清楚找maxVal不小于 q 的最小值用lower_bound/bisect_left。因为它的定义是第一个 q的元素刚好就是这个函数返回的位置。找minVal不大于 q 的最大值用upper_bound/bisect_right。bisect_right返回第一个 q的位置前一个位置就是最后一个 q的元素。这里最容易搞反的是我当年第一次写的代码下意识觉得“不大于的最小值用 lower_bound”结果拿到的全是错的答案。理解一个关键点就再也不会乱了lower_bound管“右边”upper_bound管“左边”。要找右边界用左函数要找左边界用右函数再退一格。4.2 Python bisect 库的两行定位Python 直接用标准库bisect就能完成两次定位代码非常短from bisect import bisect_left, bisect_right pos_ge bisect_left(nums, q) # 第一个 q 的位置 pos_gt bisect_right(nums, q) # 第一个 q 的位置 max_val nums[pos_ge] if pos_ge len(nums) else -1 min_val nums[pos_gt - 1] if pos_gt 0 else -1两行二分两行判边界逻辑清晰到不会写错。bisect模块底层是 C 实现性能也比我手写 Python 二分好很多。我平时刷题只要是 Python就默认这套写法没有必要自己造轮子除非题目明确要求手写二分。这里想再强调的是bisect_right落在数组里所有等于 q 的元素之后所以pos_gt - 1一定能给出最后一个不大于 q 的位置即使数组里有很多个 qmin_val也依然是 q不会出错。4.3 手写二分的左闭右开模板如果在 C 里lower_bound和upper_bound是标准库函数直接用就行。但如果面试官心血来潮要求手写或者你身处一个不允许带库的评测环境还是要会写模板。我个人推荐左闭右开区间的写法不容易死循环def lower_bound(arr, target): l, r 0, len(arr) while l r: mid (l r) // 2 if arr[mid] target: l mid 1 else: r mid return l这个函数返回第一个 target的坐标如果全都小于 target返回len(arr)。upper_bound只需要把判断条件从改成def upper_bound(arr, target): l, r 0, len(arr) while l r: mid (l r) // 2 if arr[mid] target: l mid 1 else: r mid return l为什么这样写安全因为维护的区间始终是左闭右开l是可能答案的下界r是答案的排除上界。当arr[mid] target时mid肯定不是答案所以l mid 1否则mid可能是答案保留它把r mid。循环结束条件是l r这个位置就是第一个满足条件的位置。这套模板不会出现l mid之后区间不缩小的死循环问题核心原因就是每次l至少增加 1r也只会降到mid或保持。4.4 最容易被扣分的 -1 返回值-1的条件必须单独判这也是很多人在边界上翻车的地方。具体三条bisect_left返回len(nums)说明所有元素都小于 qmax_val不存在设为 -1。bisect_right返回 0说明所有元素都大于 qmin_val不存在设为 -1。其余情况用第 4.2 节的两行直接用下标取值即可。还有一种省事一点的替代写法只用一个bisect_left也能同时算两个值。先拿到第一个 q的位置pos那么max_val直接取nums[pos]或 -1而min_val要分情况如果pos len(nums)且nums[pos] q说明查到了精确等于 q 的节点前驱就是 q 本身否则前驱就是nums[pos - 1]当然前提是pos 0。这种写法显式处理相等情况逻辑稍微绕一点不推荐新手用但知道它存在也有助于加深理解。5. 完整代码与复杂度复盘5.1 Python 完整解法结合上面的所有分析给出可直接提交的完整代码from typing import List, Optional from bisect import bisect_left, bisect_right class Solution: def closestNodes(self, root: Optional[TreeNode], queries: List[int]) - List[List[int]]: nums [] self._inorder(root, nums) ans [] for q in queries: pos_ge bisect_left(nums, q) max_val nums[pos_ge] if pos_ge len(nums) else -1 pos_gt bisect_right(nums, q) min_val nums[pos_gt - 1] if pos_gt 0 else -1 ans.append([min_val, max_val]) return ans def _inorder(self, node: Optional[TreeNode], nums: List[int]) - None: if not node: return self._inorder(node.left, nums) nums.append(node.val) self._inorder(node.right, nums)代码很短结构就两层先遍历再查询。如果你担心递归深度可以把_inorder换成第 3.2 节的迭代版本其他部分完全不用动。这个分离设计正是我推荐的原因遍历逻辑和查询逻辑互不干扰测试时想单独验证数组也很容易。5.2 C 完整解法C 的标准库同样提供了两个边界函数代码结构几乎一致class Solution { public: vectorvectorint closestNodes(TreeNode* root, vectorint queries) { vectorint nums; inorder(root, nums); vectorvectorint ans; for (int q : queries) { int minVal -1, maxVal -1; auto itGe lower_bound(nums.begin(), nums.end(), q); if (itGe ! nums.end()) maxVal *itGe; auto itGt upper_bound(nums.begin(), nums.end(), q); if (itGt ! nums.begin()) minVal *prev(itGt); ans.push_back({minVal, maxVal}); } return ans; } private: void inorder(TreeNode* node, vectorint nums) { if (!node) return; inorder(node-left, nums); nums.push_back(node-val); inorder(node-right, nums); } };这里prev(itGt)就是itGt的前一个迭代器因为upper_bound返回的是第一个大于 q 的位置它的前一个一定是最后一个小于等于 q 的位置。边界判断也很直观itGe end()说明没有后继itGt begin()说明没有前驱。5.3 复杂度推导为什么 nm log n 能过先算时间中序遍历对每个节点恰好访问一次O(n)。每个查询做两次二分每次 O(log n)m 个查询 O(m log n)。总复杂度 O(n m log n)。在 n 和 m 都到 10^5 时log 约为 17实际操作量大概是10^5 2 * 10^5 * 17也就是三百五十万次比较的量级任何语言都能在毫秒到数十毫秒内完成。再算空间数组占用 O(n)递归调用深度最坏等于树高 h最坏退化到 n因此空间最坏是 O(n)。迭代版中序遍历额外用到一个显式栈最坏也是 O(h)整体同样 O(n)。对于题目给的限制范围这个空间开销完全可接受。实际运行中还有一个隐藏优势bisect和lower_bound的常数非常小操作的是连续内存的 vector 或 listcache 友好。对比每次查询都在树上跳来跳去访问的是散落的内存地址速度差距在数据量大时会更明显。5.4 手撕一组测试用例我用题目例子验证一遍输入: root [6,2,13,1,4,9,15], queries [2,5,16] 中序数组: [1,2,4,6,9,13,15] q2 - bisect_left1 - max_val2; bisect_right2 - min_valnums[1]2 - [2,2] q5 - bisect_left3 - max_val6; bisect_right3 - min_valnums[2]4 - [4,6] q16 - bisect_left7 - max_val-1; bisect_right7 - min_valnums[6]15 - [15,-1]输出与题目要求一致。再补两组边界测试树只有根节点 7queries [1,7,10] q1 - bisect_left0 - max7; bisect_right0 - min-1 - [-1,7] q7 - bisect_left0 - max7; bisect_right1 - min7 - [7,7] q10 - bisect_left1 - max-1; bisect_right1 - min7 - [7,-1]这组用例把三个方向都覆盖了查询值小于所有节点、等于某个节点、大于所有节点。我在写题时习惯先用这种三维边界用例测一遍再提交。6. 常见问题与调试实录6.1 二分死循环的典型现场手写二分最容易踩的坑是区间不收缩。比如有人用闭区间模板写l, r 0, len(arr) - 1 while l r: mid (l r) // 2 if arr[mid] target: l mid # 错可能死循环 else: r mid当l k, r k1且arr[k] target时mid等于 kl mid之后 l 还是 k区间永远缩不小程序卡死。正确的闭区间写法要用l mid 1或配合mid (l r 1) // 2上取整。我排查过的多数“超时”问题最后都发现是死循环而不是真正的性能问题。把这些模板背熟并且在本地用长度为 2、3 的最小用例自测能极大减少这类低级事故。6.2 递归爆栈的实测教训前面说过递归深度上限的事。这里讲一个真实的调试过程有一版我用递归中序小样例全过一提交就 Runtime Error。一开始以为是数组越界打了半天日志才发现RecursionError。后来用sys.setrecursionlimit(1000000)勉强跑过但心里一直不踏实最后还是老老实实换了迭代版。实测迭代版跑那组链式数据完全没有问题耗时也稳定。我的建议很直接只要是二叉树题并且树的形状不确定用迭代式中序遍历作为默认实现。这个模板不难多敲几遍就熟了换来的是对输入数据形状的彻底免疫。6.3 树里有重复值时边界还成立吗题目通常假设 BST 节点值互不相同但现实中可能会有“弱化版 BST”允许重复值。这种情况下中序数组变成非递减序列比如[1,2,2,3]。看两个查询q2 - bisect_left1 - ceil2; bisect_right3 - floornums[2]2 - [2,2] q2.5 - bisect_left3 - ceil3; bisect_right3 - floornums[2]2 - [2,3]结果依然正确。原因在于bisect_left返回第一个等于 q 的位置bisect_right返回最后一个等于 q 之后的位置两者在处理重复值时都不会丢数据。C 的lower_bound和upper_bound在重复元素上的行为也一样。所以这个解法天然兼容重复值不需要额外判断。6.4 这几个“二分/搜索树”题目别搞混搜索相关热词里经常看到“不同的二叉搜索树”“最优二叉搜索树c语言”“二分查找pta函数”我在这道题的评论区见过不少把概念混在一起的提问。这里给个快速区分表关键词实际考点解法类别2476. 二叉搜索树最近节点查询给定树多次查询求前驱后继中序 二分不同的二叉搜索树数 n 个节点能构成多少种不同结构的 BST动态规划 / 卡特兰数最优二叉搜索树给定关键字和概率构造期望查找代价最小的 BST区间 DP二分查找 pta 函数实现一个二分查找函数往往考边界条件手写二分“不同的二叉搜索树”和“最优二叉搜索树”都是构造或计数问题和本题的“在已有树上查询”不是一回事。刷题时千万不要因为标题里有“二叉搜索树”就把 DP 解法套到这题上来。7. 扩展思路这道题还能怎么进化7.1 查询有序时的离线指针优化如果查询数组本身是按升序排好的其实还有一个更快的离线做法把中序数组准备出来后对排序后的查询用两个单调指针扫描前驱和后继的位置都不会回退这样可以在 O(n m) 的总时间内回答全部查询。需要先保存查询的原始下标算完再填回对应位置。不过这个优化在实践中没什么必要因为 O(n m log n) 已经很快了排序和恢复下标的代码反而让逻辑变复杂。但如果你在面试里主动提一句“如果查询有序可以离线做到线性”这会让面试官觉得你对复杂度有感觉属于加分动作。7.2 树动态变化时的平衡树方案本题的树是静态的所以数组一次成型永久复用。但如果后续还有插入、删除操作中序数组会很快过期这时就需要平衡树容器来支撑动态查询。C 的std::set可以直接对插入的元素调用lower_bound和upper_bound单次查询 O(log n)。Java 用TreeMap思路相同。Python 标准库没有原生平衡树通常需要借助第三方库或者手写。反过来说这也是为什么“2.3 里的 set 方案”被否掉的原因题目给的是静态树用 set 是主动放弃更简单的数组方案属于杀鸡用牛刀。7.3 面试回答这道题的加分话术如果你在面试现场拿到这道题我建议按这个顺序讲思路。第一步点出 BST 中序有序性说明树可以压平成数组。第二步提出一次中序 O(n)把数组备好之后每个查询两次二分 O(log n)。第三步说清楚两个二分分别对应前驱和后继顺便提 -1 的边界处理。第四步提一下如果查询有序可以离线扫描优化但当前方案已经最优可用。第五步可以反问面试官树的定义里有没有重复值这会影响我对二分的细节选择。我个人在实际回答时会刻意把“为什么不用树上走”说清楚树高不保证多次查询可能退化到 O(nm)数组方案把最坏情况锁死在 O(n m log n)稳定性更强。面试官听到这个比较基本就能确定你是真的理解复杂度而不是背题。写到最后还想分享一个小技巧这题做完之后可以自己尝试把迭代中序、递归中序、手写二分的四种组合各写一遍。虽然最终提交只需要一种但这个“一题多写”的过程能让你把二叉树的遍历和二分边界都练扎实。以后再遇到“静态树多次查询”的题目你第一反应就会是“转数组上二分”这一步的思维转换就是这道题留给你的最大价值。