二叉树的基础总结

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

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

快速排序的逻辑是,若要对 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来存储每个节点的子树的序列化的字符串,这样方便查找是否有相同重复的

posted @ 2021-06-17 19:33  RealGang  阅读(120)  评论(5编辑  收藏  举报