ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二叉搜索树转排序双向链表:中序遍历与指针重构详解

二叉搜索树转排序双向链表:中序遍历与指针重构详解

1. 先把这个题目彻底看透

第一次看到"把二叉搜索树转换成排序双向链表"这句描述,不少人的第一反应是:题目里每个字都认识,连在一起不知道从哪下手。这也正常,这道题几乎是大厂面试里二叉树考题的"分水岭"——它同时考察对二叉搜索树性质的理解、对中序遍历的敏感度,以及在不新建节点的情况下操作指针的能力。

先说题目到底要求什么。输入是一棵普通的二叉搜索树,每个节点有 left 和 right 两个指针,要求把这棵树原地改造成一条有序的双向链表,不能用任何额外的节点空间,只能修改原有节点指针的指向。转换完成后,链表的顺序必须和 BST 中序遍历的顺序一致,链表中每个节点的 left 相当于双向链表的 pre 指针,right 相当于 next 指针。有的版本还会额外要求"首尾相连成环"(循环双向链表),这个后面单独说。

这个问题的经典程度不用我强调,剑指Offer原题,各大刷题平台也都收录了。适合的人群很明确:准备校招或社招算法面试的同学、正在复习数据结构想加深理解的开发者,以及很久没碰二叉树想找回手感的老程序员。难度定位是"中等偏上但解法并不复杂",只要思路通了,代码量其实非常小。

1.1 核心考点拆解:到底在考什么

面试官抛出这道题,绝对不是只想听你背一段代码。他真正想看的是三件事。

第一,你是否理解 BST 的中序遍历结果就是有序序列。这是全题的钥匙,一句话说得出来,后面所有解法都是围绕它展开的。二叉搜索树满足"左子树所有节点值 < 当前节点值 < 右子树所有节点值",中序遍历按 左-根-右 的顺序访问,输出天然有序。我们要的"排序双向链表",本质就是把中序遍历访问节点的顺序记录下来,再用指针串起来。

第二,你是否敢动原有结构。题目明说不能创建新节点,意味着你需要复用每个节点的 left 和 right 字段。很多新手被"树"这个概念框住,潜意识里觉得 left/right 就是子节点指针,不敢拿它们当双向链表的 pre/next 用。转换完成后这棵树也就不再是树了,理解这一点,代码就敢写了。

第三,边界处理能力。空树、单节点、只有左孩子或只有右孩子的斜树,这些极端情况能不能一次写对,往往决定面试结果。后面我会专门列一组测试用例,建议大家拿它们逐一验证自己的写法。

1.2 为什么说中序遍历是唯一正确的切入点

题变成链表之后,它的顺序是固定的,就是中序序列。你不可能用前序或后序去排出一个有序链表,因为 BST 只有中序遍历能保证升序。这也意味着,无论用递归、迭代还是分治来实现,遍历顺序一定是 左-当前-右 这个框架。

想清楚这一层,"转换"这个动作就简化成一件事:在中序遍历的过程中,把当前访问的节点与上一个访问的节点用双向指针连起来。上一个节点的 right 指向当前,当前节点的 left 指向上一个。整个序列串完,双向链表自然成型。

2. 解法一:递归中序遍历,用一个全局指针串起所有节点

2.1 核心思路

最容易理解也最推荐面试优先写的方案,就是中序遍历递归 + 一个记录"上一个节点"的指针。

具体做法是:维护两个成员变量,head 记录链表头(即中序遍历第一个节点),pre 记录当前已处理部分的最后一个节点。递归进行中序遍历,每访问到一个节点,就把 pre.right 指向当前节点、当前节点.left 指向 pre,然后更新 pre 为当前节点。遍历结束后返回 head。

为什么需要 pre?因为双向链表连接的是"相邻"两个节点,而在递归过程中我们只能拿到当前节点,上一个节点不会自动出现在参数里,所以必须用一个外部变量记住它。

2.2 Python 实现

class TreeNode: def __init__(self, val): self.val = val self.left = None self.right = None class Solution: def __init__(self): self.pre = None self.head = None def convert(self, root: TreeNode) -> TreeNode: if not root: return None self.inorder(root) return self.head def inorder(self, cur: TreeNode) -> None: if not cur: return # 左子树 self.inorder(cur.left) # 处理当前节点:与 pre 建立双向连接 if self.pre: self.pre.right = cur cur.left = self.pre else: # pre 为空,说明当前是最左节点,也就是链表头 self.head = cur self.pre = cur # 右子树 self.inorder(cur.right)

注意:个别平台或面试场景要求不允许使用类成员变量。这种情况下可以用"返回值 + 闭包数组"或"函数参数传引用"替代。Python 里可以用一个长度为 1 的列表[None]存放 pre,避免类成员变量带来的状态残留问题。

2.3 用一棵示例树完整走一遍

我拿下面这棵 BST 做演示:

4 / \ 2 6 / \ / \ 1 3 5 7

中序遍历顺序是 1 2 3 4 5 6 7。递归过程:

  • 先一路向左走到节点 1。此时 pre 为 None,所以 head = 1,然后 pre = 1。
  • 回溯到节点 2。pre 不为空,执行 pre.right = 2、2.left = pre,即 1 和 2 互相连接,pre = 2。
  • 访问节点 3,和节点 2 互相连接,pre = 3。
  • 继续回溯到根节点 4,和节点 3 互相连接,pre = 4。
  • 右子树同理,5 连到 4,6 连到 5,7 连到 6。

最终从左往右沿着 right 指针遍历:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7;从右往左沿着 left 指针遍历:7 -> 6 -> 5 -> 4 -> 3 -> 2 -> 1。双向链表完成。

时间复杂度 O(n),每个节点访问一次;空间复杂度 O(h),h 是树高,因为递归栈占用。极端情况下树退化成链表,h = n,递归深度就是 n,这时候空间复杂度退化为 O(n)。这也是后面要讲迭代解法的一个原因。

3. 解法二:分治递归,让每个子树自己返回头和尾

3.1 思路差异

如果说解法一是"边遍历边串",解法二就是"先各自搞定,再拼接"。

核心套路是:写一个递归函数处理以 node 为根的子树,返回两个值——这棵子树转换完成后的链表头 head 和链表尾 tail。处理过程分三步:先递归转换左子树得到 leftHead 和 leftTail;再递归转换右子树得到 rightHead 和 rightTail;最后把当前节点接到左链表尾部、右链表头部接在根之后。

这个思路的好处是递归逻辑非常对称,面试时口头描述很清晰:"每棵子树负责把自己变成一个完整的双向链表段,根节点负责把左右两段拼起来。"

3.2 Python 实现

class Solution: def convert(self, root: TreeNode) -> TreeNode: if not root: return None head, _ = self.convert_subtree(root) return head def convert_subtree(self, node: TreeNode): # 返回 (链表头, 链表尾) if not node: return None, None left_head, left_tail = self.convert_subtree(node.left) right_head, right_tail = self.convert_subtree(node.right) # 左链表尾 -> 当前节点 if left_tail: left_tail.right = node node.left = left_tail # 当前节点 -> 右链表头 if right_head: node.right = right_head right_head.left = node head = left_head if left_head else node tail = right_tail if right_tail else node return head, tail

3.3 两种递归解法的取舍

对比维度解法一(全局 pre)解法二(分治返回头尾)
代码量更短,逻辑集中稍长,但结构对称
理解难度依赖对"pre 变量"的理解更接近"分而治之"直觉
面试风险类成员变量易在多测试用例间残留状态无状态残留问题
处理循环链表变体需要额外找头/尾串环拿到 tail 直接串环更方便

我给的建议是:面试优先写解法一,因为它代码最少、最容易背、出错概率低;但解法二也要能讲清楚,尤其当面试官追问"如果要求返回环形双向链表怎么办"时,解法二天然有尾指针,三行就能改成环形版本。

实战心得:很多同学两个解法都背过,但一写就错在"head 是否为空"或"tail 是否为空"这种分支上。我的经验是,先画一棵三层满二叉树,把左右子树的 head/tail 用记号标出来,再对着图写 if 判断,基本不会漏。

4. 解法三:迭代中序遍历,用栈替代递归

4.1 为什么要准备这个版本

递归解法在绝大多数面试场景下已经够用,但有两个场景会让你需要迭代版。

第一个是树很高的情况。Python 默认递归深度约 1000 层,如果面试官出的是类似"节点数很大、近似链化的 BST",递归直接触发 RecursionError。写迭代版至少能证明你考虑到了工程里的深度限制。

第二个是"加分项"。当你用递归写完并讲完复杂度后,主动补一句"如果担心栈溢出,我还可以用显式栈模拟中序遍历",面试官通常会觉得你基本功扎实。

4.2 迭代版实现

class Solution: def convert(self, root: TreeNode) -> TreeNode: if not root: return None stack = [] cur = root pre = None head = None while cur or stack: # 一路向左,把路过的节点压栈 while cur: stack.append(cur) cur = cur.left cur = stack.pop() # 与 pre 建立双向连接 if pre: pre.right = cur cur.left = pre else: head = cur pre = cur # 转向右子树 cur = cur.right return head

这段代码的本质就是把递归中序遍历改成显式栈版本,连接节点的动作和解法一完全一致,只是 pre 从成员变量变成了局部变量。因为栈在函数内创建,天然不担心多测试用例状态污染,写在一个方法里就能直接跑。

空间复杂度同样是 O(h),但这里的 h 是栈的最大深度,且不受语言递归栈限制的影响,可控性更好。

5. 边界情况与验证方法

5.1 必测的四种输入

面试和实际刷题时,不要只跑题目给出的正常用例。我每次写这种二叉树题,都会固定测这四类输入:

  • 空树:root 为 None,返回值应该是 None,代码不能崩溃。
  • 单节点树:只有一个根节点,转换后链表头尾都是它自己,left 和 right 都应为 None。
  • 只有左子树的斜树:例如节点按 3-2-1 排列,中序遍历是 1 2 3,要确保 head 是最深的左孩子而不是根。
  • 只有右子树的斜树:例如 1-2-3,中序遍历也是 1 2 3,要确保 head 是根节点。

这四类用例几乎能覆盖 80% 的指针连接 bug。核心就一句话:链表头一定是中序遍历第一个访问的节点,而不是树的根节点。

5.2 验证结果是否正确的技巧

转换完以后怎么确认没写错?最直接的办法是双向走一遍。先沿着 head 的 right 指针走到尾,记录访问节点个数;再从尾沿着 left 指针走回头,记录节点个数。两次个数相等且都等于原树节点数,同时遍历过程中每个节点的值严格递增,就基本正确了。

def verify(head: TreeNode, total: int) -> bool: if not head: return total == 0 # 正向遍历 cur = head cnt = 0 pre_val = None while cur: if pre_val is not None and cur.val <= pre_val: return False pre_val = cur.val cur = cur.right cnt += 1 # 反向遍历 cur = head while cur and cur.right: cur = cur.right cnt2 = 0 pre_val = None while cur: if pre_val is not None and cur.val >= pre_val: return False pre_val = cur.val cur = cur.left cnt2 += 1 return cnt == cnt2 == total

注意:这个验证函数是本地调试用的,面试时通常不用写完整,但你要有意识在脑子里跑一遍双向遍历来检查自己的输出结构。

5.3 临时输出检查法

如果你用的是支持可视化调试的 IDE,可以在每次连接后打印当前节点的连接关系。更轻量的办法是把链表 dump 成数组对比:

def dump(head: TreeNode): res = [] cur = head while cur: res.append(cur.val) cur = cur.right return res

原地改造类的题目,dump 成数组和理论中序序列对比是最直观的验证方式,没有之一。

6. 高频变体:转成循环双向链表

6.1 题目怎么变

有些公司会在这道题后面追加一句"转换后首尾也要相连,形成循环双向链表"。这个要求在不少平台的题目里出现过(把二叉树展平成循环链表)。难度不高,但对理解双向链表连接有更高要求。

6.2 怎么改

在非循环版本结束后,我们已经有了链表头 head 和链表尾 tail(解法一里需要额外遍历到尾部拿到 tail,解法二直接返回了 tail)。只需要两条连接:

if tail: tail.right = head head.left = tail

注意一个坑:如果你用解法一的递归,pre 在遍历结束后正好指向链表尾,所以你不需要再走一遍找 tail,直接用 pre 当 tail 即可。这个细节很多人不知道,白白多写了一个 while 循环。

6.3 验证循环版本

循环链表没法用 None 判断结尾,改法是用一个计数变量,走到 total 个节点就停止。也可以在转换前先数一下节点总数。我个人更推荐把总数当参数传进验证函数,循环版本里正向走 total 次、反向走 total 次,都能回到 head 就说明连接没断。

7. 常见 bug 与排查实录

7.1 head 被覆盖成 None

新手最常见的错误是:递归到第一个节点时,先判断 pre 是否为空,如果为空就设置 head;但如果把这段写在"更新 pre"之后,或者提前 return,head 就可能一直是 None。排查思路很简单,在设置 head 的那一行打点,确认只执行一次。

7.2 左指针没有被清零

转换前每个节点的 left 指向左孩子,转换后 left 是链表的前驱。如果某节点的 left 还指向原左孩子,正向遍历可能看不出问题,反向遍历会死循环。这也是为什么我建议一定要双向验证。

7.3 类成员变量状态残留

解法一如果用类成员 pre/head,在同一个 Solution 对象上连续跑两个测试用例,第二个用例读到的 pre 还是第一个用例的尾部节点,直接导致结果错乱。解决办法有两个:在 convert 方法开始时重置 pre 和 head;或者改用局部变量 + 列表传递。多数刷题网站会新建对象,但本地调试时容易踩这个坑。

7.4 递归深度过深

斜树导致递归深度等于节点数,上面提过。遇到报 RecursionError,要么转迭代,要么在面试里明确说明"生产环境我会用栈模拟"。

7.5 忘记处理空树

这是最简单也最伤人的 bug。任何树类题,第一行写if not root: return None都是好习惯,检查一下没坏处。

8. 面试现场怎么讲,以及这道题还可以怎么扩展

这道题面试现场的表达顺序,我建议按这个节奏来:先花 20 秒说清思路"BST 中序遍历有序,所以用中序遍历边遍历边连指针";然后画一棵示例树,口头演示两个节点怎么互连;再写代码,边写边强调 pre 和 head 的维护;最后主动说复杂度。整个过程控制在三到五分钟。如果面试官追问,重点往往在"能不能不递归"和"能不能返回环形链表"这两点上,也就是上面解法三和变体部分。

我个人在实际操作中的体会是:二叉树相关的题,最忌上来就写代码。先把"中序序列 = 链表顺序"这句话写在草稿纸上,再开始动笔,思路基本不会跑偏。这道题我前后给不下十个同学讲过,卡住的几乎都不是递归不会写,而是不敢把 left/right 当成链表的 pre/next 用。想通了这一点,后面再遇到"把一棵树改成某种线性结构"的题,都是一个套路。

另外,这题的思路可以迁移到不少相邻场景:把二叉搜索树转成有序双向链表、把 BST 转成递增序列数组、先序遍历版本的展平二叉树为链表、甚至 Morris 遍历优化的空间压缩。练习时可以试着把解法一改成 C++ 或 Java 再写一遍,感受指针和引用的差异,对面试帮助很大。最后再分享一个小技巧:本地练习时,把树构建、转换、验证三条逻辑写在一个测试脚本里,每次改完代码一键跑通,比手动造数据高效得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进