• 二叉树的基础总结


    快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历

    简单分析一下他们的算法思想和代码框架:

    快速排序的逻辑是,若要对 nums[lo..hi] 进行排序,我们先找一个分界点 p,通过交换元素使得 nums[lo..p-1] 都小于等于 nums[p],且 nums[p+1..hi] 都大于 nums[p],然后递归地去 nums[lo..p-1]nums[p+1..hi] 中寻找新的分界点,最后整个数组就被排序了。

    快速排序的代码框架如下:

    void sort(int[] nums, int lo, int hi) {   
        /****** 前序遍历位置 ******/    
        // 通过交换元素构建分界点 p    
        int p = partition(nums, lo, hi);   
        /************************/
        sort(nums, lo, p - 1);    
        sort(nums, p + 1, hi);
    }
    

    先构造分界点,然后去左右子数组构造分界点,你看这不就是一个二叉树的前序遍历吗?

    再说说归并排序的逻辑,若要对 nums[lo..hi] 进行排序,我们先对 nums[lo..mid] 排序,再对 nums[mid+1..hi] 排序,最后把这两个有序的子数组合并,整个数组就排好序了。

    归并排序的代码框架如下:

    void sort(int[] nums, int lo, int hi) {    
        int mid = (lo + hi) / 2;    
        sort(nums, lo, mid);    
        sort(nums, mid + 1, hi);
        /****** 后序遍历位置 ******/    
        // 合并两个排好序的子数组    
        merge(nums, lo, mid, hi);    
        /************************/
    }
    

    先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?另外,这不就是传说中的分治算法嘛

    二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题

    写递归算法的秘诀

    写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要跳入递归的细节

    用一个具体的例子来说,比如说让你计算一棵二叉树共有几个节点:

    // 定义:count(root) 返回以 root 为根的树有多少节点
    int count(TreeNode root) {
        // base case
        if (root == null) return 0;
        // 自己加上子树的节点数就是整棵树的节点数
        return 1 + count(root.left) + count(root.right);
    }
    

    这个问题非常简单,大家应该都会写这段代码,root 本身就是一个节点,加上左右子树的节点数就是以 root 为根的树的节点总数。

    左右子树的节点数怎么算?其实就是计算根为 root.leftroot.right 两棵树的节点数呗,按照定义,递归调用 count 函数即可算出来。

    写树相关的算法,简单说就是,先搞清楚当前 root 节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。

    经典例题

    翻转二叉树

    输入一个二叉树根节点 root,让你把整棵树镜像翻转,比如输入的二叉树如下:

         4
       /   
      2     7
     /    / 
    1   3 6   9
    

    算法原地翻转二叉树,使得以 root 为根的树变成:

         4
       /   
      7     2
     /    / 
    9   6 3   1
    

    通过观察,我们发现只要把二叉树上的每一个节点的左右子节点进行交换,最后的结果就是完全翻转之后的二叉树

    可以直接写出解法代码:

    // 将整棵树的节点翻转
    TreeNode invertTree(TreeNode root) {
        // base case
        if (root == null) {
            return null;
        }
    /**** 前序遍历位置 ****/
    // root 节点需要交换它的左右子节点
    TreeNode tmp = root.left;
    root.left = root.right;
    root.right = tmp;
    
    // 让左右子节点继续翻转它们的子节点
    invertTree(root.left);
    invertTree(root.right);
    
    return root;
    }
    

    二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情

    填充二叉树节点的右侧指针

    image

    题目的意思就是把二叉树的每一层节点都用 next 指针连接起来:

    image

    可以模仿上一道题,写出如下代码:

    Node connect(Node root) {    
        if (root == null || root.left == null) {        
            return root;    
        }
        root.left.next = root.right;
        connect(root.left);    
        connect(root.right);
        return root;
    }
    

    这样其实有很大问题,再看看这张图:

    image

    节点 5 和节点 6 不属于同一个父节点,那么按照这段代码的逻辑,它俩就没办法被穿起来,这是不符合题意的。

    回想刚才说的,二叉树的问题难点在于,如何把题目的要求细化成每个节点需要做的事情,但是如果只依赖一个节点的话,肯定是没办法连接「跨父节点」的两个相邻节点的。

    我们的做法就是增加函数参数,一个节点做不到,我们就给他安排两个节点,「将每一层二叉树节点连接起来」可以细化成「将每两个相邻节点都连接起来」:

    // 主函数
    Node connect(Node root) {
        if (root == null) return null;
        connectTwoNode(root.left, root.right);
        return root;
    }
    
    // 辅助函数
    void connectTwoNode(Node node1, Node node2) {
        if (node1 == null || node2 == null) {
            return;
        }
        /**** 前序遍历位置 ****/
        // 将传入的两个节点连接
        node1.next = node2;
    
        // 连接相同父节点的两个子节点
        connectTwoNode(node1.left, node1.right);
        connectTwoNode(node2.left, node2.right);
        // 连接跨越父节点的两个子节点
        connectTwoNode(node1.right, node2.left);
    }
    

    将二叉树展开为链表

    image

    函数签名如下:

    void flatten(TreeNode root);
    

    我们尝试给出这个函数的定义:

    flatten 函数输入一个节点 root****,那么以 root 为根的二叉树就会被拉平为一条链表

    我们再梳理一下,如何按题目要求把一棵树拉平成一条链表?很简单,以下流程:

    1、将 root 的左子树和右子树拉平。

    2、将 root 的右子树接到左子树下方,然后将整个左子树作为右子树。

    image

    按照 flatten 函数的定义,对 root 的左右子树递归调用 flatten 函数即可:

    // 定义:将以 root 为根的树拉平为链表
    void flatten(TreeNode root) {    
        // base case    
        if (root == null) return;
        flatten(root.left);    
        flatten(root.right);
        /**** 后序遍历位置 ****/    
        // 1、左右子树已经被拉平成一条链表    
        TreeNode left = root.left;    
        TreeNode right = root.right;
        // 2、将左子树作为右子树    
        root.left = null;    
        root.right = left;
        // 3、将原先的右子树接到当前右子树的末端    
        TreeNode p = root;    
        while (p.right != null) {        
            p = p.right;    
        }    
        p.right = right;
    }
    

    写树的算法,关键思路如下:

    把题目的要求细化,搞清楚根节点应该做什么,然后剩下的事情抛给前/中/后序的遍历框架就行了,我们千万不要跳进递归的细节里,你的脑袋才能压几个栈呀。

    构造最大二叉树

    image

    先明确根节点做什么?对于构造二叉树的问题,根节点要做的就是把想办法把自己构造出来

    肯定要遍历数组把找到最大值 maxVal,把根节点 root 做出来,然后对 maxVal 左边的数组和右边的数组进行递归调用,作为 root 的左右子树。

    按照题目给出的例子,输入的数组为 [3,2,1,6,0,5],对于整棵树的根节点来说,其实在做这件事:

    TreeNode constructMaximumBinaryTree([3,2,1,6,0,5]) {
        // 找到数组中的最大值
        TreeNode root = new TreeNode(6);
        // 递归调用构造左右子树
        root.left = constructMaximumBinaryTree([3,2,1]);
        root.right = constructMaximumBinaryTree([0,5]);
        return root;
    }
    

    对于每个根节点,只需要找到当前 nums 中的最大值和对应的索引,然后递归调用左右数组构造左右子树即可

    明确了思路,我们可以重新写一个辅助函数 build,来控制 nums 的索引:

    /* 主函数 */
    TreeNode constructMaximumBinaryTree(int[] nums) {
        return build(nums, 0, nums.length - 1);
    }
    
    /* 将 nums[lo..hi] 构造成符合条件的树,返回根节点 */
    TreeNode build(int[] nums, int lo, int hi) {
        // base case
        if (lo > hi) {
            return null;
        }
    // 找到数组中的最大值和对应的索引
    int index = -1, maxVal = Integer.MIN_VALUE;
    for (int i = lo; i <= hi; i++) {
        if (maxVal < nums[i]) {
            index = i;
            maxVal = nums[i];
        }
    }
    
    TreeNode root = new TreeNode(maxVal);
    // 递归调用构造左右子树
    root.left = build(nums, lo, index - 1);
    root.right = build(nums, index + 1, hi);
    
    return root;
    }
    

    通过前序和中序遍历结果构造二叉树

    image

    ,直接来想思路,首先思考,根节点应该做什么。

    类似上一题,我们肯定要想办法确定根节点的值,把根节点做出来,然后递归构造左右子树即可

    找到根节点是很简单的,前序遍历的第一个值preorder[0]就是根节点的值,关键在于如何通过根节点的值,将preorderpostorder数组划分成两半,构造根节点的左右子树?

    换句话说,对于以下代码中的?部分应该填入什么:

    /* 主函数 */
    TreeNode buildTree(int[] preorder, int[] inorder) {
        return build(preorder, 0, preorder.length - 1,
                     inorder, 0, inorder.length - 1);
    }
    
    /* 
       若前序遍历数组为 preorder[preStart..preEnd],
       后续遍历数组为 postorder[postStart..postEnd],
       构造二叉树,返回该二叉树的根节点 
    */
    TreeNode build(int[] preorder, int preStart, int preEnd, 
                   int[] inorder, int inStart, int inEnd) {
        // root 节点对应的值就是前序遍历数组的第一个元素
        int rootVal = preorder[preStart];
        // rootVal 在中序遍历数组中的索引
        int index = 0;
        for (int i = inStart; i <= inEnd; i++) {
            if (inorder[i] == rootVal) {
                index = i;
                break;
            }
        }
    
        TreeNode root = new TreeNode(rootVal);
        // 递归构造左右子树
        root.left = build(preorder, ?, ?,
                          inorder, ?, ?);
    
        root.right = build(preorder, ?, ?,
                           inorder, ?, ?);
        return root;
    }
    

    对于代码中的rootValindex变量,就是下图这种情况:

    image

    对于左右子树对应的inorder数组的起始索引和终止索引比较容易确定:

    image

    root.left = build(preorder, ?, ?,
            inorder, inStart, index - 1);
    root.right = build(preorder, ?, ?,
            inorder, index + 1, inEnd);
    

    对于preorder数组呢?如何确定左右数组对应的起始索引和终止索引?

    这个可以通过左子树的节点数推导出来,假设左子树的节点数为leftSize,那么preorder数组上的索引情况是这样的:

    image

    看着这个图就可以把preorder对应的索引写进去了:

        int leftSize = index - inStart;
    
    root.left = build(preorder, preStart + 1, preStart + leftSize,
            inorder, inStart, index - 1);
    
            root.right = build(preorder, preStart + leftSize + 1, preEnd,
            inorder, index + 1, inEnd);
    

    再补一补 base case 即可写出解法代码:

    TreeNode build(int[] preorder, int preStart, int preEnd, 
                   int[] inorder, int inStart, int inEnd) {
    
        if (preStart > preEnd) {
            return null;
        }
    
        // root 节点对应的值就是前序遍历数组的第一个元素
        int rootVal = preorder[preStart];
        // rootVal 在中序遍历数组中的索引
        int index = 0;
        for (int i = inStart; i <= inEnd; i++) {
            if (inorder[i] == rootVal) {
                index = i;
                break;
            }
        }
    
        int leftSize = index - inStart;
    
        // 先构造出当前根节点
        TreeNode root = new TreeNode(rootVal);
        // 递归构造左右子树
        root.left = build(preorder, preStart + 1, preStart + leftSize,
                          inorder, inStart, index - 1);
    
        root.right = build(preorder, preStart + leftSize + 1, preEnd,
                           inorder, index + 1, inEnd);
        return root;
    }
    

    通过后序和中序遍历结果构造二叉树

    按照上述思路也可以写出来,只是索引位置和根节点位置判断发生了变化

    如何判断我们应该用前序还是中序还是后序遍历的框架

    根据题意,思考一个二叉树节点需要做什么,到底用什么遍历顺序就清楚了

    第 652 题「寻找重复子树」

    image

    举例来说,比如输入如下的二叉树:

    image

    节点 4 本身可以作为一棵子树,且二叉树中有多个节点 4:

    image

    还存在两棵以 2 为根的重复子树:

    image

    我们返回的List中就应该有两个TreeNode,值分别为 4 和 2(具体是哪个节点都无所谓)。

    这题咋做呢?还是老套路,先思考,对于某一个节点,它应该做什么

    比如说,你站在图中这个节点 2 上:

    image

    如果你想知道以自己为根的子树是不是重复的,是否应该被加入结果列表中,你需要知道什么信息?

    你需要知道以下两点

    1、以我为根的这棵二叉树(子树)长啥样

    2、以其他节点为根的子树都长啥样

    我如何才能知道以自己为根的二叉树长啥样

    其实看到这个问题,就可以判断本题要使用「后序遍历」框架来解决:

    void traverse(TreeNode root) {
        traverse(root.left);
        traverse(root.right);
        /* 解法代码的位置 */
    }
    

    我要知道以自己为根的子树长啥样,是不是得先知道我的左右子树长啥样,再加上自己,就构成了整棵子树的样子?

    明确了要用后序遍历,那应该怎么描述一棵二叉树的模样呢?二叉树的前序/中序/后序遍历结果可以描述二叉树的结构。

    我们可以通过拼接字符串的方式把二叉树序列化,看下代码:

    String traverse(TreeNode root) {
        // 对于空节点,可以用一个特殊字符表示
        if (root == null) {
            return "#";
        }
        // 将左右子树序列化成字符串
        String left = traverse(root.left);
        String right = traverse(root.right);
        /* 后序遍历代码位置 */
        // 左右子树加上自己,就是以自己为根的二叉树序列化结果
        String subTree = left + "," + right + "," + root.val;
        return subTree;
    }
    

    我们第一个问题就解决了,对于每个节点,递归函数中的subTree变量就可以描述以该节点为根的二叉树

    借助一个外部数据结构,让每个节点把自己子树的序列化结果存进去,这样,对于每个节点,不就可以知道有没有其他节点的子树和自己重复了么?

    利用HashMap,额外记录每棵子树的出现次数:

        // 记录所有子树以及出现的次数
        HashMap<String, Integer> memo = new HashMap<>();
        // 记录重复的子树根节点
        LinkedList<TreeNode> res = new LinkedList<>();
    
        /* 主函数 */
        List<TreeNode> findDuplicateSubtrees(TreeNode root) {
            traverse(root);
            return res;
        }
    
        /* 辅助函数 */
        String traverse(TreeNode root) {
            if (root == null) {
                return "#";
            }
    
            String left = traverse(root.left);
            String right = traverse(root.right);
    
            String subTree = left + "," + right+ "," + root.val;
    
            int freq = memo.getOrDefault(subTree, 0);
            // 多次重复也只会被加入结果集一次
            if (freq == 1) {
                res.add(root);
            }
            // 给子树对应的出现次数加一
            memo.put(subTree, freq + 1);
            return subTree;
        }
    

    这道题就完全解决了,主要还是要利用HashMap来存储每个节点的子树的序列化的字符串,这样方便查找是否有相同重复的

  • 相关阅读:
    「from CommonAnts」寻找 LCM
    P3380 二逼平衡树 [树状数组套可持久化主席树]
    [模板]二次剩余(无讲解)
    [校内训练19_09_10]sort
    [校内训练19_09_06]排序
    [校内训练19_09_06]直径
    [校内训练19_09_05]ca
    [校内训练19_09_02]不同的缩写
    [校内训练19_09_03]c Huge Counting
    [校内训练19_09_02]C
  • 原文地址:https://www.cnblogs.com/RealGang/p/14896940.html
Copyright © 2020-2023  润新知