题目描述

[!green]

牛客原题: ✅ 补充题 195. 二叉搜索树转非循环双向链表

给定二叉搜索树 root,原地转换为按节点值递增的普通双向链表。

left 指向前驱,right 指向后继;头的前驱和尾的后继均为空,返回头节点。

示例 1:

输入: root = [2,1,3]
输出: 1 <-> 2 <-> 3
解释: 头节点 left 和尾节点 right 均为空。

提示:

  • 允许空树。
  • 节点值互不相同。
  • 只能调整原节点指针,不能创建新的链表节点。
  • 牛客原题要求 O(n) 时间、O(1) 额外空间;补充解法满足这一空间要求。

题意分析

二叉搜索树的中序顺序已经符合链表排序要求,核心是把访问顺序转换成前驱、后继指针。节点本身可以复用,但仍须区分递归栈开销和常数空间方案;结果是一条首尾为空的普通双向链表。

解法:中序连接

核心思路

[!blue]

二叉搜索树中序遍历得到升序节点流。pre 保存已经输出的尾节点,head 保存第一个输出节点;访问当前节点时,让 pre.right 指向当前节点、当前节点的 left 指回 pre,再推进尾指针。

第一次访问没有前驱,只记录 head。中序遍历完成后,头的 left 和尾的 right 都置空,形成普通双向链表;不能再把首尾连接成环。

递归栈负责保留尚未访问的祖先,因此这版容易按“左、根、右”现场写出,但辅助空间是树高 O(h),不能因为节点复用就宣称额外空间为常数。

解题步骤

  1. 重置 head 和 pre,再递归访问根节点的左子树。
  2. 访问当前节点时,若 pre 为空则记录链表头,否则连接 pre.right 与当前节点。
  3. 将当前节点的 left 指向 pre,更新 pre 后继续访问右子树。
  4. 遍历完成后将头的 left、尾的 right 置空,返回 head。

代码实现

class Solution {
    private Node head;
    private Node pre;

    public Node treeToDoublyList(Node root) {
        head = null;
        pre = null;

        if (root == null) {
            return null;
        }

        dfs(root);
        head.left = null;
        pre.right = null;

        return head;
    }

    private void dfs(Node node) {
        if (node == null) {
            return;
        }

        dfs(node.left);

        if (pre == null) {
            head = node;
        } else {
            pre.right = node;
            node.left = pre;
        }

        pre = node;
        dfs(node.right);
    }
}
func treeToDoublyList(root *Node) *Node {
    if root == nil {
        return nil
    }

    var head *Node
    var pre *Node
    var dfs func(*Node)
    dfs = func(node *Node) {
        if node == nil {
            return
        }

        dfs(node.Left)
        if pre == nil {
            head = node
        } else {
            pre.Right = node
            node.Left = pre
        }
        pre = node
        dfs(node.Right)
    }

    dfs(root)
    head.Left = nil
    pre.Right = nil
    return head
}

复杂度分析

  • 时间复杂度:$O(n)$。
  • 空间复杂度:递归栈空间 $O(h)$,$h$ 为树高。

关键点总结

[!green]

中序遍历时连接前驱和当前节点,最终将首尾边界显式置空,不再闭合成环。

补充解法:右旋展开为常数空间双向链表

核心思路

[!blue]

牛客原题要求额外空间为常数。递归版虽然复用了原节点,仍有调用栈;可以改用右旋,把尚未处理的树逐步展开成只有右孩子的有序链。

cur 指向待处理部分的根,pre 指向已经排好序的链表尾部。若 cur 有左孩子 left,让 left 上升:先将 left.right 接到 cur.left,再令 left.right = cur。右旋保持中序顺序不变,并让一个更小的节点来到当前入口。已有前缀时将 pre.right 接到新入口,否则更新 head,然后继续检查上升后的节点。

当 cur.left 为空时,当前节点就是剩余部分中的最小节点,可以正式接入链表。令 cur.left = pre 建立前驱,再将 pre 推进到当前节点、cur 推进到右孩子。已完成部分只通过 right 向后连接,不再参与旋转。

所有节点依次接入后,右指针已经构成升序链,左指针也逐个连向前驱。第一个节点的前驱自然为空,最后一个节点的后继自然为空,整条链不会首尾相接。整个过程只修改原节点的指针,不创建新节点。

解题步骤

  1. 令 head、cur 指向原根,pre 为空。
  2. 当前节点有左孩子时,右旋一次并更新待处理部分的入口。
  3. 当前节点无左孩子时,把它接入有序前缀,并沿右指针继续处理。
  4. 当前节点为空时返回 head。

代码实现

class Solution {
    public Node treeToDoublyList(Node root) {
        Node head = root;
        Node pre = null;
        Node cur = root;

        while (cur != null) {
            if (cur.left != null) {
                Node left = cur.left;
                cur.left = left.right;
                left.right = cur;
                if (pre == null) {
                    head = left;
                } else {
                    pre.right = left;
                }
                cur = left;
            } else {
                cur.left = pre;
                pre = cur;
                cur = cur.right;
            }
        }

        return head;
    }
}
func treeToDoublyList(root *Node) *Node {
    head, cur := root, root
    var pre *Node

    for cur != nil {
        if cur.Left != nil {
            left := cur.Left
            cur.Left = left.Right
            left.Right = cur
            if pre == nil {
                head = left
            } else {
                pre.Right = left
            }
            cur = left
        } else {
            cur.Left = pre
            pre = cur
            cur = cur.Right
        }
    }

    return head
}

复杂度分析

  • 时间复杂度:$O(n)$。每个节点正式接入一次;每次右旋会让从头沿右指针能访问到的节点数增加一个,右旋总次数不超过 $n-1$。
  • 空间复杂度:$O(1)$,只使用固定数量的节点引用,没有递归栈、显式栈或新节点。

关键点总结

[!green]

  • 右旋保持中序顺序,左孩子的原右子树需要先接回,不能丢失。
  • 只有当前节点没有待处理左子树时,才能把左指针改成链表前驱。
  • 修改待处理部分入口后,要同步连接已完成的前缀尾部或更新头节点。
  • 原树结构会被转换成普通双向链表,首尾指针保持为空。

易错点总结

[!yellow]

  • 同一个求解对象可能被重复调用,每次先重置 head 和 pre。
  • 递归遍历完左子树后再改写当前节点的 left,避免破坏尚未访问的结构。
  • 头尾保持为空,不能套用循环双向链表的收尾逻辑。
  • 递归版仍占用树高级别的栈空间;要求常数空间时使用后面的右旋方案。

相似题目

题目 难度 关联与区别
426. 将二叉搜索树转化为排序的双向链表 中等 都按中序顺序连接前驱与后继;该题最终将首尾相连成环,本题必须令头的前驱和尾的后继为空。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/555651979704
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!