LeetCode 例题精讲 | 11 二叉树转化为链表:二叉树遍历中的相邻结点

点击关注上方“五分钟学算法”,

设为“置顶或星标”,第一时间送达干货。

转自面向大象编程,作者nettee

本期例题:

  • LeetCode 98. Validate Binary Search Tree 验证二叉搜索树(Medium)

  • LeetCode 426. Convert Binary Tree to Sorted Doubly Linked List 二叉树转化为链表(Medium)

本文将介绍二叉树问题中一个特殊的技巧:「在二叉树的前/中/后序遍历时对相邻结点进行操作」。这种方法不适用于大多数题目,但在一些特定的题目中使用这个技巧,能起到「秒杀」的效果。

还记得当年数据结构课上,老师对于二叉树的前序、中序、后序遍历的谆谆教诲吗?可能你一看到二叉树,前/中/后序三种遍历就在脑海中浮现出来。然而,当你开始在 LeetCode 上刷题,做了许多二叉树题目之后,就会发现,做这些题目似乎根本用不上什么前/中/后序遍历!

是的,求解二叉树问题最重要的思路是子问题思路,前面的几篇关于二叉树的文章一直在讨论的就是这种子问题思路。因为很多二叉树问题都是通过划分子问题,利用递归求解。对于这种子问题的思路来说,我们只是让左子树和右子树递归地完成计算任务,至于是左子树先计算还是右子树先计算,根本不重要,更不用说什么前/中/后序遍历了。

不过,二叉树的子问题思路并不是万能的。有些时候,用子问题来解题会比较麻烦。有时候题目具有特殊的性质,把前/中/后序遍历的思想掏出来会更加有效。本文就来讨论一下在什么时候适合用前/中/后序遍历的解题思路。

验证二叉搜索树:比较相邻结点

LeetCode 98. Validate Binary Search Tree(Medium)

给定一个二叉树,判断其是否是一个有效的二叉搜索树(BST)。二叉搜索树需要满足以下特征:

  • 结点的左子树只包含小于当前结点的值;

  • 结点的右子树只包含大于当前结点的值;

  • 所有左子树和右子树自身也是二叉搜索树。

验证二叉搜索树这道题,其实完全可以用子问题的思路来做。我们可以定义三个子问题:「二叉树是否为 BST」、「二叉树的最小值」、「二叉树的最大值」。(至于为什么是这三个子问题,其实是有固定的套路的,详见上一篇文章:二叉树问题太复杂?「三步走」方法解决它!)对于每个子树而言,如果根结点的值小于左子树的最大值、或者大于右子树的最小值,那么就不满足二叉搜索树的性质。题解代码如下所示:

// 注意:此代码不完整,仅用于展示思路
boolean res;

public boolean isValidBST(TreeNode root) {
    res = true;
    traverse(root);
    return res;
}

// 返回两个值
// 返回值0:二叉树的最小值
// 返回值1:二叉树的最大值
int[] traverse(TreeNode root) {
    if (root == null) {
        return ??; // 边界情况不好定义
    }

    int[] left = traverse(root.left);
    int[] right = traverse(root.right);
    if (root.val <= left[1] || root.val >= right[0]) {
        res = false;
    }
    return new int[]{left[0], right[1]};
}

这段代码中有一个空缺的地方:root == null 的时候,子问题如何返回?对于空子树而言,其最大值和最小值不存在,我们还需要使用特殊的 null 值来表示不存在最大值和最小值的情况,在代码中要多出很多条件判断,非常麻烦。

我们不妨换一种思路考虑这道题。二叉搜索树其实还有另一个性质:它的中序遍历序列一定是递增的!我们只需要两两比较中序遍历序列中相邻的两个数字即可。这不需要考虑各种子问题的特殊情况。

二叉搜索树的中序遍历序列一定是递增的

不过,这里虽然提到了中序遍历的「序列」,但并不需要用额外的空间保存这个序列。我们可以在遍历的过程中,用一个 prev 变量维护「上一个结点」,每次把当前结点的值和上一个结点的值进行比较。这样一个 prev 变量需要定义为全局变量,才能不受递归调用的影响。

最终,我们可以写出这样的题解代码:

TreeNode prev; // 全局变量:指向中序遍历的上一个结点
boolean valid;

public boolean isValidBST(TreeNode root) {
    valid = true;
    prev = null;
    traverse(root);
    return valid;
}

void traverse(TreeNode curr) {
    if (curr == null) {
        return;
    }

    traverse(curr.left);

    // 中序遍历的写法,把操作写在两个递归调用中间
    if (prev != null && prev.val >= curr.val) {
        // 如果中序遍历的相邻两个结点大小关系不对,则二叉搜索树不合法
        valid = false;
    }
    // 维护 prev 指针
    prev = curr;

    traverse(curr.right);
}

这段代码的思路要简单很多。我们在遍历到每个结点时都比较 prevcurr 结点的值。如果出现上一个结点的值大于当前结点的情况,就说明二叉搜索树不合法。

「相邻结点」遍历框架

我们可以在上面的题解代码中,抽出一个二叉树中序遍历的基本框架:

TreeNode prev; // prev 指向中序遍历的上一个结点

// curr 指向中序遍历的当前结点
void traverse(TreeNode curr) {
    if (curr == null) {
        return;
    }
    traverse(curr.left);
    if (prev != null) {
        // 在这里对 prev 和 curr 进行操作
        // ...
    }
    prev = curr; // 维护 prev 指针
    traverse(curr.right);
}

这段代码其实就是在二叉树中序遍历的基础上,增加了一个 prev 变量指向中序遍历的上一个结点,并在每次遍历的时候对 prevcurr 这一对结点进行操作。

还记得我们在本系列第一讲中的链表遍历框架吗?链表遍历框架是在链表遍历中维护一个 prev 指针指向前一个结点,而这里是在二叉树遍历中维护一个 prev 指针指向前一个结点。不同的是,二叉树的遍历需要做大量递归调用,所以需要把 prev 指针写成一个全局变量。

语言小贴士: 很多二叉树题目的代码都需要用到全局变量。不过在软件开发中,使用全局变量有不少弊端。除了用「真正的」全局变量,我们还可以用一些特殊的函数参数,达到和全局变量一样的效果。

在 C++ 中,可以使用引用类型的参数。引用类型的参数可以直接改变上层函数中变量的值,从而能够穿过一层层的递归函数。

int foo(TreeNode* root) {
    int res = 0;
    traverse(root, res);
    return res;
}

void traverse(TreeNode* root, int& res) {
    res = 1;
}

在 Java 中,可以使用长度为 1 的数组,因为数组元素的空间分配在堆上,与递归调用无关。

int foo(TreeNode root) {
    int[] res = new int[1];
    traverse(root, res);
    return res[0];
}

void traverse(TreeNode root, int[] res) {
    res[0] = 1;
}

每次对 prevcurr 这一对相邻结点进行操作,意味着什么呢?以中序遍历为例,我们可以想象有一根线按照中序遍历的顺序依次穿过了每一个结点:

想象一根线按照遍历顺序穿过每一个结点

这样,每次遍历的时候,prevcurr 都指向这根线上的相邻两个结点。「验证二叉搜索树」这道题,正是比较了这根线上所有的相邻结点。

这种对「相邻结点」进行操作的思路,就和子问题没有任何关系了。因为中序遍历相邻的两个结点,在二叉树上可能风马牛不相及,如上图中的 4 号和 5 号结点。也正是因为这个特点,这种方法可以不受递归的限制,把中序遍历中任何相邻的两个结点拉过来。在某些情况下,这种方法比子问题的方法更方便。

二叉树转链表:串联相邻结点

LeetCode 426. Convert Binary Tree to Sorted Doubly Linked List(Medium)

这道题是一道会员专享的题目。不过没关系,我们可以看中文站题库中的一道相同的题目:面试题36. 二叉搜索树与双向链表(Medium)

给定一棵二叉搜索树,将其转换成一个排序的循环双向链表。链表不使用新的结点,而是用二叉树的结点,调整其中指针的指向(left 指针表示链表的 prev 指针,right 指针表示链表的 next 指针)。

题目示例

需要注意的是,这道题虽然题干上说的是二叉搜索树,但并没有涉及到太多二叉搜索树的性质。这道题实际上就是让我们把一棵二叉树按中序遍历的顺序转化为链表

同样的,对于这道题目,我们可以先思考思考子问题的思路。我们可以让左右子树递归地构造出两段链表,再把根结点和这两段链表拼接起来。不过,拼接三段链表涉及到的指针操作很多,还需要考虑左右子树为空的情况。这种方法写成代码的话,会很麻烦。

用相邻结点的思路,能不能让解法更简单呢?我们再回顾一下这个用一根线穿过每一个结点的图,同样是中序遍历的顺序:

想象一根线按照遍历顺序穿过每一个结点

这些结点同时也是链表的结点,而这根线穿过的顺序正是我们所需要的链表的顺序。那么,我们只要把其中的相邻结点用指针链接起来,就得到了符合题意的链表。没错,思路就是这么简单。我们可以写出大概的伪代码:

void traverse(Node curr) {
    if (curr == null) {
        return;
    }
    // 中序遍历
    traverse(curr.left); // 遍历左子树
    list.append(curr); // (伪代码)将当前结点加入链表
    last = curr; // 更新 last 指针
    traverse(curr.right); // 遍历右子树
}

和任何链表问题一样,我们要检查有没有出现指针丢失的情况。我们发现,当根结点加入链表后,它的 leftright 指针都会被修改,这样我们就无法通过 root.right 找到其右子树了。解决指针丢失的方法也很简单,使用一个临时变量保存 root.leftroot.right 即可。

最终,我们得到的题解代码如下:

Node head; // 链表的头结点
Node last; // 二叉树遍历中的前一个结点,也是链表的尾结点

public Node treeToDoublyList(Node root) {
    head = null;
    last = null;
    traverse(root);
    // 将双向链表转为双向循环链表
    if (head != null) {
        head.left = last;
        last.right = head;
    }
    return head;
}

void traverse(Node curr) {
    if (curr == null) {
        return;
    }
    // 提前保存右子树指针
    Node right = curr.right;
    // 中序遍历
    traverse(curr.left);
    // 将当前结点加入链表
    curr.left = null;
    curr.right = null;
    if (head == null) {
        head = curr;
    } else {
        curr.left = last;
        last.right = curr;
    }
    last = curr; // 更新 last 指针
    traverse(right);
}

这段代码有两个需要注意的地方:

  • 在遍历二叉树的时候,我们是把结点串连成一个双向链表,但是题目要求返回的是双向循环链表,所以我们在最后把链表的头尾结点连接起来。

  • 实际上我们只需要提前保存右子树的指针,因为中序遍历会先遍历左子树,再处理当前结点。如果是前序遍历的话,左右子树的指针都需要保存。

总结

我将这种在二叉树遍历中关注相邻结点的方法称为二叉树的「迭代式遍历」。使用这种方法的时候,我们不关注二叉树的左右子树所对应的子问题,只关注在某个特定的遍历顺序(前/中/后序)时,处理遍历的两个相邻结点。这种方法并不适用于大多数题目,但对于特定的题目,这种方法却是快速解题的利器。

虽然本文的两道题使用的都是中序遍历序列,但这种方法对于前/中/后序遍历都适用,只需要调整「处理当前结点」和「递归调用两个子树」的顺序即可。

这里列出几个也用到该技巧的题目,各位小伙伴可以尝试练习一下:

  • LeetCode 114. Flatten Binary Tree to Linked List

  • LeetCode 116. Populating Next Right Pointers in Each Node

  • LeetCode 117. Populating Next Right Pointers in Each Node II

推荐阅读:一个我超喜欢的动态博客系统,五分钟即可部署上线!
作为计算机专业学生,最应该学习的课程前五位是什么?
以后有面试官问你「密码学」,你就把这篇文章扔给他
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值