拓冰建站拓冰建站
首页 / 资讯中心 / 正文

递增顺序搜索树:中序遍历与指针操作的深度解析

1. 背景与题意解读先聊点实在的。递增顺序搜索树这道题在国内外面试中出现的频率相当高LeetCode原题编号是897。它表面上看是一道树的遍历题但实际考察的点很密集中序遍历是否熟练、节点的引用操作是否清晰、递归和迭代的边界意识是否牢固。题目要求很直白给定一棵二叉搜索树BST把它重新排列成一棵只有右子节点的树且这棵树的中序遍历结果和原来完全一致。什么叫“只有右子节点”就是整棵树退化成一条链每个节点只有一个右孩子左孩子全部置空同时整条链上的节点值按从小到大顺序排列。举一个典型例子原始BST是这样一棵树5 / \ 3 6 / \ \ 2 4 8 / \ 7 9调整之后应该变成一条向右延伸的链2 - 3 - 4 - 5 - 6 - 7 - 8 - 9为什么这道题有意思因为二叉搜索树的性质决定了它的中序遍历天然就是递增序列。你把中序遍历的结果拿出来本身就是一个有序数组问题的核心在于如何利用BST的中序特性把这棵树“拉直”成一条只有右指针的单链结构。我在实际辅导候选人时发现多数人能想到中序遍历但真正把代码写对的人不到一半。这些人在哪里翻车主要在处理指针的细节上。比如最后要不要用哑节点dummy node、递归过程中如何保证所有左指针被正确置空、中序遍历完成后最后一个节点的右指针会不会残留旧引用。这些细节点一旦处理不好代码在本地跑没问题一提交就是“内存泄漏”或者“循环引用”报错。从面试官的角度看这道题的考察梯度非常清晰。初级候选人能给出最朴素的解中等候选人会做原地修改的优化高级候选人能通过递归传递状态把代码浓缩到十几行以内。你要想拿高分不能只满足于“把题解出来”还必须能讲清楚三种做法的差异、复杂度来源、以及为什么不能直接用数组收集节点值再重建一棵树。我把这道题的几种主流解法、每一版代码的演进过程、踩坑点全部整理出来配合基于中序遍历特性的延伸思考。不管你是刚开始刷题的新手还是准备跳槽强化算法基础的老手这篇文章都应该能帮到你。2. 核心思路与解法推演2.1 最粗暴的思路中序遍历后重建第一次见到这道题绝大多数人的第一反应是中序遍历整棵BST把节点的值存到一个数组里。再根据这个有序数组新建一棵链状的树。这个思路没有任何问题正确性也很好证明。因为BST的中序遍历一定是严格递增的数组里的顺序天然满足题目要求。接下来只需要一个循环把每个值封装成新节点依次挂在右指针上即可。但这里有一个非常隐蔽的坑题目没有明确说节点是“可变的”很多代码在第一步只收集了节点的值第二步全部用new TreeNode(val)重建。这个做法在LeetCode上是能过的运行结果也完全正确但如果你在原题系统里设置了“空间复杂度”校验这种解法通常拿不到满分。原因很简单。中序遍历需要递归栈或显式栈空间复杂度是O(H)H是树的高度极端情况下退化为O(N)。同时收集完所有值你还需要一个数组来存N个元素空间是O(N)。最终重建还需要创建N个新节点额外空间也是O(N)。三块叠加总空间复杂度是O(N)起步好一点的编译器可能会复用输入树的节点但节点数组那部分仍然省不掉。如果你只是练手用这种解法快速过一遍思路是可以的。但如果在面试中直接写这版代码面试官大概率会追问一句“能不能不要新建节点”这就是整个题目的进阶点。2.2 渐进优化中序遍历直接改链既然中序遍历的结果本身就是有序的为什么不直接在中序遍历过程中修改指针呢具体方案是在中序遍历的同时维护一个“当前链尾节点”cur。每次访问一个新节点node时把cur.right指向node然后把node.left置空再把cur更新为node。遍历完成后原树就自然在遍历过程中被“拉直”了。这个过程看起来很简单但有一个极其容易被忽略的问题如果你直接修改node.left是否会破坏后续遍历的逻辑我们来仔细推演一遍递归中序遍历的过程。标准的递归中序遍历逻辑是void dfs(TreeNode root) { if (root null) return; dfs(root.left); // 访问当前节点处理逻辑放在这里 dfs(root.right); }当你访问到当前节点root时它的左子树已经全部遍历完毕。理论上root.left不会再被使用。但问题是如果当前节点root是通过父节点的left指针找到的你把root.left置空并不会影响父节点的遍历路径因为父节点找右子树时用的是root.right不是root.left。但如果你把某个节点node的左指针切断会不会影响到其他还没访问到的节点不会。中序遍历的顺序保证了“左子树 - 当前节点 - 右子树”。当你正在访问当前节点时左子树已经全部完成右子树还没开始而右子树的访问路径完全依赖node.right的指针链和node.left无关。所以直接改链在逻辑上是安全的。不过递归实现里需要特别处理一件事递归函数的返回边界。你用的是全局变量还是局部变量如果是全局变量要注意递归深度过大时系统栈可能溢出。如果是局部变量得通过返回值或者成员变量传递“链尾指针”。一个让代码更干净的技巧是引入哑节点dummy node。dummy是一个虚拟头节点它的right指针最终指向新链的第一个真实节点。这样做的意义在于你不需要为“第一个节点要不要特殊处理”写判断逻辑统一用cur dummy作为起始状态每次访问新节点时直接cur.right node即可。但这里有一个极易踩的坑当递归返回时链尾节点的right指针可能仍然指向原树中的某个旧节点从而形成环。比如原树中某个节点本来有右孩子但右孩子已经被重新挂到链尾如果链尾节点的right还被旧引用占据遍历时就会出现循环。解决办法很简单在做完整棵树的中序遍历后显式地将最后一个访问节点的right置空。或者在设计递归函数时通过返回值拿到“处理完该子树后的链尾节点”最后在dfs(root)结束后再执行一次tail.right null。2.3 不新建节点的原地修改再往前走一步能不能完全复用原树的节点一个都不新建答案是肯定的。上一版代码里虽然我们没有新建节点但原树的节点被“重新布置”到了新链上所以本质上已经复用了原节点。这一步真正要解决的问题是如何保证在遍历过程中不会因为指针修改而丢失还没访问到的节点。这一点我在刚刷这道题时也踩过坑。写过一版代码在中序遍历过程中直接修改左右指针结果跑着跑着就出现了空指针异常。排查半天发现问题是进入递归处理右子树之前我提前修改了node.right导致递归函数无法继续走到真正的右子树。正确的做法如下递归处理左子树返回后左子树已经被拉成一条链同时链尾在cur处。访问当前节点将cur.right指向当前节点当前节点的left置空。在处理右子树之前用一个临时变量保存right node.right。然后递归处理右子树处理完后返回新的链尾。这四步缺一不可尤其第三步最容易被忽视。如果你不保存右孩子直接递归递归进入时右指针已经被更新成了新链的一部分就会导致后续节点全部丢失。2.4 空间复杂度为 O(1) 的 Morris 遍历法这里多聊一种进阶玩法。如果面试官继续追问“能不能把空间复杂度压到 O(1)”常规递归和迭代栈解法都有O(H)的空间开销H是树高。要用O(1)空间完成中序遍历就需要用到Morris遍历。Morris遍历的核心思想是利用叶子节点空闲的右指针临时构建回溯线索。这样既不用递归栈也不用显式栈遍历过程的空间复杂度就是O(1)。但在本题中如果直接在原树上做Morris遍历有一个绕不开的冲突题目最终要求把树拉平成一条链。Morris遍历过程中会临时修改右指针来建线索而链化过程中也在修改右指针两者叠加后你很难保证同一个节点的右指针不会被重复改写导致线索错乱。实践中用Morris做这道题有点“杀鸡用牛刀”的意思。面试官通常不会要求到这一步但如果候选人能主动提出来并且解释清楚为什么不适合在本题使用反而会加分。建议你在准备阶段把Morris的模板代码过一遍至少能讲清原理不需要真的应用到这道题上。3. 完整实现与复杂度分析3.1 解法一中序遍历收集节点值这是最直白的版本适合5分钟内快速写出第一版。def increasing_bst(root): values [] def inorder(node): if not node: return inorder(node.left) values.append(node.val) inorder(node.right) inorder(root) dummy TreeNode(0) cur dummy for val in values: cur.right TreeNode(val) cur cur.right return dummy.right对应C版本class Solution { public: TreeNode* increasingBST(TreeNode* root) { vectorint vals; functionvoid(TreeNode*) inorder [](TreeNode* node) { if (!node) return; inorder(node-left); vals.push_back(node-val); inorder(node-right); }; inorder(root); TreeNode* dummy new TreeNode(0); TreeNode* cur dummy; for (int v : vals) { cur-right new TreeNode(v); cur cur-right; } return dummy-right; } };时间复杂度是O(N)每个节点遍历一次数组遍历一次。空间复杂度是O(N)主要是存储数组需要的空间。这个解法胜在清晰易懂不易出错适合作为暖场答案。但注意如果你在原题中被告知“必须复用原树节点”这种方法直接就判不合格。实际面试中建议你把这版作为聊思路的起点然后立刻展示下面的优化版本。3.2 解法二中序遍历直接构建新链这个版本不新建节点直接复用原树节点通过中序遍历将节点重新链接。class Solution: def increasingBST(self, root): dummy TreeNode(0) self.cur dummy def inorder(node): if not node: return inorder(node.left) node.left None self.cur.right node self.cur node inorder(node.right) inorder(root) return dummy.right这段代码看着简短但有一个致命问题不知道你发现没有递归到右子树前node.left虽然被置空了但node.right并没有被改所以inorder(node.right)还能正常访问右子树。这没问题。但运行结束后链表的末尾节点的right指针可能还指着旧右子树中的某个节点。比如原树中根节点5的右孩子是6处理到6时6的right先指向8原右子树但8已经会被后续遍历重新挂到6后面。最终代码运行时LeetCode的判题系统不会去遍历这个额外的旧引用所以结果正确。但如果你把这段代码拿到本地跑打印结果时写了一个while node: print(node.val)因为链尾节点的right残留了旧引用极有可能导致死循环。这不是题目代码本身的bug而是你本地测试代码和题解代码边界不一致导致的。如果你想让代码更安全可以在递归结束后手动把链尾节点的right置空。更通用的办法是在递归函数中返回链尾节点这样就能明确拿到最后一个节点了。改造成返回链尾的版本class Solution: def increasingBST(self, root): dummy TreeNode(0) cur dummy def inorder(node): nonlocal cur if not node: return inorder(node.left) node.left None cur.right node cur node inorder(node.right) inorder(root) cur.right None # 显式断开最后一环 return dummy.right这一版在cur.right None处手动切断了残留引用运行起来就完全干净了。3.3 解法三递归返回子树链尾这个解法是最工程化的写法。它不依赖成员变量或全局变量而是通过递归函数的返回值传递“链尾节点”更符合函数式编程的思想代码在并发和测试场景下也更安全。class Solution: def increasingBST(self, root): def dfs(node): if not node: return None left_tail dfs(node.left) node.left None if left_tail: left_tail.right node right_tail dfs(node.right) return right_tail if right_tail else node head root while head and head.left: head head.left dfs(root) return head这里有个很有意思的地方递归函数返回的是“以该节点为根的子树拉平后链的末尾节点”。处理node时先把左子树拉平得到left_tail把node接到left_tail后面再拉平右子树得到right_tail。如果右子树为空链尾就是node本身否则链尾就是right_tail。用这种实现你需要额外找到新链的头节点。最简单的方式是从原根节点一路向左走走到最左下角的节点那个节点就是新链的头。有人会问为什么不能直接用返回的right_tail反推头节点因为递归函数的返回值和头节点没有直接关系。所以你在调用dfs(root)之前先把head找出来会更清晰。3.4 复杂度横向对比解法时间复杂度空间复杂度是否新建节点代码简洁度收集值后重建O(N)O(N)全部新建最直观中序遍历直接改链O(N)O(H)复用原节点简洁递归返回链尾O(N)O(H)复用原节点略复杂Morris遍历O(N)O(1)复用原节点复杂不建议本题使用在实际面试过程中空间复杂度从O(N)到O(H)已经是一个明显的优化梯度足以让面试官认可你的能力。没必要在Morris遍历上死磕除非你面试的岗位对底层细节要求极高。4. 三种主流语言的实现示例4.1 Python 完整版class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right class Solution: def increasingBST(self, root: TreeNode) - TreeNode: dummy TreeNode(-1) cur dummy def inorder(node): nonlocal cur if not node: return inorder(node.left) node.left None cur.right node cur node inorder(node.right) inorder(root) cur.right None return dummy.right这里nonlocal cur是Python 3的写法如果你使用旧版Python 2需要用可变对象比如列表来包装curcur [dummy] def inorder(node): if not node: return inorder(node.left) node.left None cur[0].right node cur[0] node inorder(node.right)这种做法在算法题中很常见属于“利用可变对象模拟引用传递”的技巧理解了原理之后碰到类似场景都能灵活套用。4.2 Java 完整版class Solution { private TreeNode cur; public TreeNode increasingBST(TreeNode root) { TreeNode dummy new TreeNode(0); cur dummy; inorder(root); cur.right null; return dummy.right; } private void inorder(TreeNode node) { if (node null) { return; } inorder(node.left); node.left null; cur.right node; cur node; inorder(node.right); } }Java版本的实现几乎没有任何语法障碍关键在于成员变量cur的使用。注意cur是类成员变量不要在方法内重新声明否则会覆盖引用导致结果丢失。很多从C转Java的候选人会在这里踩坑。4.3 C 完整版class Solution { public: TreeNode* increasingBST(TreeNode* root) { TreeNode* dummy new TreeNode(0); TreeNode* cur dummy; inorder(root, cur); cur-right nullptr; return dummy-right; } private: void inorder(TreeNode* node, TreeNode* cur) { if (!node) return; inorder(node-left, cur); node-left nullptr; cur-right node; cur node; inorder(node-right, cur); } };C版的关键是TreeNode* cur必须用引用类型传递指针否则在函数内部修改cur不会影响外层。如果你用TreeNode* cur直接传值你会发现递归完成后cur还是最初指向dummy链表尾部的连接全部丢失。这个错误非常隐蔽编译期不会报任何错误只有运行结果不对。5. 常见问题与排查技巧5.1 为什么需要将left置空因为题目要求最终生成的树只有右孩子左孩子必须全部置空。如果你不处理左指针最终链上的每个节点可能还残留着左子树引用遍历时会进入左子树导致整条链失效。而且从判题角度LeetCode比较结果时是按树结构逐节点比较的左指针有引用和null的结果完全不同。经验法则在中序遍历访问当前节点时第一时间执行node.left None。这个操作要在把node挂到链尾前完成顺序不能反。5.2 为什么链尾要显式断开right前面提到中序遍历收集节点值的解法天然不会产生残留引用因为所有节点都是新建的。但如果复用了原树节点某些节点的right指针可能在遍历过程中被覆盖或保留旧引用。最典型的场景是原树中有节点AA的右孩子是B。在链化过程中A的right先被赋值成cur的前一个节点B被挂到了更后面的位置。遍历结束时A的right已经指向正确的新位置但B的right如果还是旧的右孩子就可能导致循环引用。所以在递归函数外显式执行cur.right None是保平安的最佳选择。5.3 用迭代栈写中序遍历时需要注意什么很多候选人习惯用递归但面试中面试官偶尔会要求用迭代实现。迭代中序遍历的标准写法是stack [] cur_node root while stack or cur_node: while cur_node: stack.append(cur_node) cur_node cur_node.left cur_node stack.pop() # 访问节点 cur_node.left None cur.right cur_node cur cur_node cur_node cur_node.right这个版本看起来完全正确但有一个大坑当你把cur_node.left置空后下一次while循环还会尝试把cur_node.left压入栈中吗不会因为cur_node此时已经是最左节点cur_node.left是Nonewhile cur_node循环条件为假直接进入pop阶段。迭代版的优势是空间复杂度为O(H)且不需要系统递归栈在树深度很大时不会爆栈。劣势是代码逻辑比递归复杂边界条件更容易出错。5.4 空树和单节点树怎么处理空树直接返回null单节点树直接返回该节点没有任何额外处理。这两个边界条件在递归和迭代解法中都能自然处理不需要单独写特殊逻辑。5.5 什么情况下原树的右子树会丢失这是所有解法中最多人踩的坑。以递归解法为例处理顺序是递归处理左子树。把当前节点挂到链尾。递归处理右子树。注意步骤3必须在步骤2之后。如果你把这两步顺序颠倒比如先递归处理右子树再挂当前节点那么当前节点的右子树已经被拉平但拉平后的右子树链尾没有接到当前节点上整棵子树就散了。我见过有人在代码里不小心把右子树的调用写在赋值cur.right node之前结果调试了半天最后发现是“顺序颠倒”导致的逻辑错误。这个问题的本质是链化过程天然依赖“先父后子”的挂接顺序顺序就是正确性的一半。5.6 如何高效地验证结果的正确性提交前强烈建议做两步验证。第一步结果中序遍历。写一段辅助函数把新树的中序遍历打印出来检查是否为递增序列。这是最直观的验证方式。第二步指针完整性检查。从根节点出发一路只走right指针计数是否等于N个节点。如果链表长度小于N说明有节点被意外丢弃如果链表长度大于N或陷入死循环说明存在环。我把这两步封装成一个小工具函数def verify(root, expected_count): # 检查链长度 cnt 0 node root arr [] while node: if node.left is not None: # 左指针必须为空 print(error: left is not empty) return False arr.append(node.val) cnt 1 node node.right if cnt ! expected_count: print(ferror: count mismatch {cnt} vs {expected_count}) return False if any(arr[i] arr[i1] for i in range(len(arr)-1)): print(error: not increasing) return False return True实测下来DEBUG效率提升非常明显推荐你也在本地养成类似的验证习惯。6. 从这道题延伸出的相关知识点6.1 中序遍历与 BST 的性质二叉搜索树有一个关键特性中序遍历结果严格递增。这个特性衍生出大量经典题目验证一棵树是否是二叉搜索树LeetCode 98求 BST 中第 K 小的元素LeetCode 230BST 转累加树LeetCode 538BST 的两数之和LeetCode 653恢复被交换的两个节点的 BSTLeetCode 99这些题目无一例外都用到了中序遍历这个核心工具。如果你能把中序遍历的递归写法、迭代写法、Morris写法都吃透再碰到上述题目时基本就是套模板的事。具体到本题递增顺序搜索树本质上就是“用中序遍历把BST孩子们重新排队”理解这一点题目就成功了一半。6.2 树的链化与其他经典操作“把树拉成链”的操作不止这一种比如二叉树展开为链表LeetCode 114把二叉树按前序顺序拉平为右指针链。扁平化多级双向链表LeetCode 430。有序链表转换二叉搜索树LeetCode 109。这三道题的核心思路非常接近以某种遍历顺序访问节点同时修改指针将非线性结构转换为线性结构。唯一区别在于遍历的方式和指针的类型不同。6.3 面试中的沟通要点如果你在面试中遇到这道题除了提交正确代码沟通节奏也很重要。我建议按以下顺序表达先说中序遍历的两条结论BST中序是递增的题目要求的就是递增链。先给暴力解明确说明复杂度是O(N)时间和O(N)空间。然后主动追问“能不能复用原树节点”顺势给出中序遍历直接改链的做法。最后解释空间复杂度为什么从O(N)降到了O(H)H取决于树的形状。如果面试官进一步追问再用Morris遍历原理作为附加能力展示。这个递进式的表达逻辑能同时体现你的解题能力、优化意识和沟通清晰度。实际面试中这比单纯把代码写对要加分得多。6.4 相关题目推荐如果你想进一步巩固树和链化相关的算法能力我建议按这个顺序刷题目难度知识点94. 二叉树的中序遍历简单递归迭代Morris897. 递增顺序搜索树简单中序遍历指针操作230. 二叉搜索树中第K小的元素中等BST中序计数114. 二叉树展开为链表中等前序遍历原地拉平109. 有序链表转换二叉搜索树中等快慢指针中序构建99. 恢复二叉搜索树困难中序找逆序对Morris优化刷这些题时手写一遍递归版和迭代版再对比调试能很快建立树的“形状思维”。7. 最后的经验之谈这道题整体难度不大但它对“指针操作的基本功”要求很高。很多刷了几百题的人写这道题依然会卡壳原因不在于思路而在于对节点引用的修改顺序不够敏感。这里分享几个我多次实操后总结的通用技巧。第一动手写代码前先把指针变化过程在纸上画出来。尤其是递归解法画出每个递归层级下cur指针的指向变化能避免大量脑内推演错误。画图不需要多精致画清楚节点间的实线虚线变化就够了。第二在本地开发环境做好辅助测试。LeetCode的在线判题只检查结果不会告诉你“运行到第几步出现空指针”或者“链表成环在哪里”。我习惯在本地用pytest或简单的assert写几组测试用例包括空树、单节点、完全二叉树、左斜树、右斜树这五种形态覆盖全部边界。第三注意不同语言对引用传递的差异。C里要用TreeNode*Python里用nonlocalJava里用成员变量这些语法细节不算难但在面试高压环境下特别容易写错。建议每种语言都准备一份模板临场直接调用。第四递归写法里把“访问节点”和“修改链”分离开来思考。如果你在写DFS函数时脑子里同时想着“我要遍历”和“我要改指针”两件事逻辑容易混乱。更好的方式是先把中序遍历的框架搭好再在访问节点的位置插入指针修改逻辑一步到位。最后多嘴一句算法这条路代码写完只是完成了第一步用语言讲清楚“自己为什么这么写”才是面试真正的分水岭。递增顺序搜索树这道题就是锻炼“讲清楚”能力的好素材。希望这篇拆解能帮你把这题彻底吃透。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门