[二叉树的基础总结]

261 阅读10分钟

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

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

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

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

 
    /****** 前序遍历位置 ******/    
    // 通过交换元素构建分界点 p    
      
    /************************/

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

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

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


    /****** 后序遍历位置 ******/    
    // 合并两个排好序的子数组    
    
    /************************/
}

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

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

写递归算法的秘诀

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

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

// 定义:count(root) 返回以 root 为根的树有多少节点

    // base case
   
    // 自己加上子树的节点数就是整棵树的节点数
 

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

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

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

经典例题

翻转二叉树

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

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

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

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

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

可以直接写出解法代码:

// 将整棵树的节点翻转

    // base case
  
/**** 前序遍历位置 ****/
// 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 不属于同一个父节点,那么按照这段代码的逻辑,它俩就没办法被穿起来,这是不符合题意的。

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

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

// 主函数


// 辅助函数

    /**** 前序遍历位置 ****/
    // 将传入的两个节点连接
    

    // 连接相同父节点的两个子节点
    
    // 连接跨越父节点的两个子节点

将二叉树展开为链表

image

函数签名如下:

void flatten(TreeNode root);

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

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

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

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

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

image

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

// 定义:将以 root 为根的树拉平为链表

    /**** 后序遍历位置 ****/    
    // 1、左右子树已经被拉平成一条链表    
   
    // 2、将左子树作为右子树    
    
    // 3、将原先的右子树接到当前右子树的末端    
 

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

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

构造最大二叉树

image

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

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

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


    // 找到数组中的最大值
   
    // 递归调用构造左右子树

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

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

/* 主函数 */


/* 将 nums[lo..hi] 构造成符合条件的树,返回根节点 */

    // base case
 
// 找到数组中的最大值和对应的索引



// 递归调用构造左右子树

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

image

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

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

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

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

/* 主函数 */


/* 
   若前序遍历数组为 preorder[preStart..preEnd],
   后续遍历数组为 postorder[postStart..postEnd],
   构造二叉树,返回该二叉树的根节点 
*/

    // root 节点对应的值就是前序遍历数组的第一个元素
 
    // rootVal 在中序遍历数组中的索引
 

   
    // 递归构造左右子树

对于代码中的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 即可写出解法代码:



    // root 节点对应的值就是前序遍历数组的第一个元素
  
    // rootVal 在中序遍历数组中的索引
  

 

    // 先构造出当前根节点
   
    // 递归构造左右子树
    

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

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

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

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

第 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);
    /* 解法代码的位置 */
}

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

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

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


    // 对于空节点,可以用一个特殊字符表示
  
    // 将左右子树序列化成字符串
  
    /* 后序遍历代码位置 */
    // 左右子树加上自己,就是以自己为根的二叉树序列化结果
  

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

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

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

    // 记录所有子树以及出现的次数
   
    // 记录重复的子树根节点
  

    /* 主函数 */
  

    /* 辅助函数 */
  

       

       
        // 多次重复也只会被加入结果集一次
     
        // 给子树对应的出现次数加一
   

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

原文:二叉树的基础总结 - RealGang - 博客园 (cnblogs.com)

分类: LeetCode