算法学习 (门徒计划)2-1 二叉树(Binary-Tree)与经典问题 学习笔记

前言

4月1日,开课吧门徒计划算法课第四讲学习笔记。
本课讲二叉树。
课题为:
2-1 二叉树(Binary-Tree)与经典问题
(这是我第4次上课整理课堂笔记,我发现我做详细的整理能非常有效的帮助我学习知识,但是会消耗我过多的精力,如果各个节点都写的笼统,又不能帮助回忆,因此本次我尝试仅记录重点,并且为自认为有价值的知识点详细配图,但是总记录量要少一些)
(另一个原因是我最近比较忙碌,有必要提升效率)
(本课很重要,相当于思维结构的升维)

来自4月8日
(忙的一塌糊涂,希望今天能整理完毕发布)

总结本课核心理念:

  • 数据的存储结构本身不具备绝对的优劣区分,最重要的是根据实际情况选择最合适的结构进行使用。
  • 树结构是为了实现管理数据的从属关系和实现快速查询而设计的。
  • 学习树有利于提升思维的维度。
  • 设计方案时,时空复杂度有时不可兼得,需要根据具体情况选择合适的方式

树与二叉树

树家族是为了实现方便快捷的查找而存在的。

树的基本概念

  • 链表是一种特殊的树
  • 树是一种特殊的图

树的简单配图
(为树配图时,规范的写法是根节点在上,子节点在下,并且层对齐)
(下图至少是一个3叉树,因为图上的节点最多使用了3个指针域)

在这里插入图片描述

树的每一个子节点都是子树,而没有任何下属子树的节点,称为叶子节点
(因此树有明显的层次结构,是递归方案的常见学习类型和应用类型)

链表是特殊的树

链表的特性是每一个节点只能被一个节点指向,并且自生只指向一个节点,换而言之链表的数据结构中只存有一个指针域

链表如图:

在这里插入图片描述

而树的单元则是这样:

树单元配图,(下图配的是3叉树的单元,不同叉树的单元结构主要在于指针域的个数)

在这里插入图片描述

如上图,树的指针域不止为一个,可以为任意个。N个指针域,就是N叉树,而当指针域仅为一个时,就是链表
同样的,指针域可以指向地址或者为空,但是即便为空,指针域依然需要消耗空间,所以为了节约空间(综和性能考虑)通常会采用二叉树(在一定条件下,会平衡树结构的高度和宽度,二叉树虽然宽度低,但是高度相对会很高,因此树结构的枝干数目是根据实际情况而选择的)。

关于二叉树对于空间的节约下面会讲:N叉树变换二叉树的做法

树是特殊的图

(图还没学不详细讲)

这部分记录3点:

  • 树是有向图的一种
  • 度的概念和出度、入度
  • 当讨论树时,度的含义只为出度

关于图,分为有向图,和无向图,树是有向图的一种,也就是每一个节点都只记录下一个节点的指向

而对于图,有一个概念为:度(为什么叫度,不要研究,不值得)。

度所表达的意思是每两个节点间的联系。

关于入度,一个点被几个节点指向,就是有几个入度。
关于出度,一个点指向几个节点,就是有几个出度。

谈论树时,如果论述度,则定义只论述出度。

二叉树的基本概念

二叉树是一种最基础的树结构,存储结构及其算法都较为简单,因此二叉树显得特别重要。二叉树特点是每个结点最多只能有两棵子树,且有左右之分。

关于普通的二叉树,也就是仅符合二叉树基础规范的树结构没有深入学习的必要,接下来重点记录几个特殊的二叉树结构:

  • 完全二叉树
  • 完美二叉树
  • 满二叉树

完全二叉树

完全二叉树的定义为,除了最后一层,每一层都一定是满的,并且倒数第二层(子节点是最后一层)一定优先填充左侧的指针域,绝不存在某一个节点指针域有值,但是左侧某一个节点指针域为空的情况。

如图:在这里插入图片描述

上图就是一个完全二叉树,将这个二叉树删除节点6,依然是完全二叉树,但是如果删除节点4或者5,在保留节点6的时候,就不能称为完全二叉树,因为对于节点6左侧同一层的指针域出现为空的情况。

完全二叉树的意义

使用完全二叉树的意义在于可以采用连续空间进行存储
(可以使用数组进行存储)
对于任何一个节点:

计算其左孩子(左子树根节点)的方式为:
index_l = index*2
计算其右孩子(右子树根节点)的方式为:
index_r = index*2+1

起始根节点的坐标需要为1

使得可以在计算式和记录式之间切换
(关于两种方式的优劣性,始终是根据具体情况进行分析的计算式需要空间小,记录式获取目标快

完美二叉树

在了解完全二叉树的前题下,完美二叉树就是每一层都是满的,将完美二叉树最下一层从右侧减少若干叶子节点,就变成了了完全二叉树。

(可以理解为完美二叉树是最完美的完全二叉树,个人理解)

满二叉树

满二叉树是说每一个节点的两个指针域要么全部填充,要么都不填充,也就是不存在只有一个左孩子或者只有一个右孩子的情况。

(需要注意,完美二叉树必然是满二叉树因为每一层全满了,但是满二叉树没有对于层有要求,只对指针域有要求,因此满二叉树和完全二叉树和完美二叉树是有交集的集合关系,没有必要理解为包含关系)

二叉树的遍历

常用3种遍历:

  • 前序遍历:结果集排布顺序为,根,左,右
  • 中序遍历:结果集排布顺序为,左,根,右
  • 后序遍历:结果集排布顺序为,左,右,根

关于结果集:

遍历树时,子树的遍历方式和树一致,遍历的结果合并为结果集,
也就是左子树的结果就是左结果集,而左结果集内部的排布又是符合遍历的规则。

这些遍历只需要知道1点:

利用中序遍历和其他任何一种遍历方式都可以还原二叉树。

还原的方式常用递归的方式。首先获取根的位置,然后切分左右结果集,左右结果集,还原的最终结果就是左右子树,从而接上根。

N叉树变换二叉树的做法

对于配图里的树,至少是一个3叉数.

此时使用的指针域的数量为
7*3=21
而有效的指针域个数为6
因此浪费了
21-6 = 15
15个指针域

如果将其转换为二叉树则可以减少指针域的浪费,并且减少空间的损耗:
(转换二叉树的口诀为:左孩子,右兄弟。对于每一个节点,左支放这个节点的第一个左侧子节点,右支放这个节点的右侧第一个的节点)

转换后的结果(可见非常的丑)

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

因此,平衡树结构的宽度和深度,是要根据实际情况做出取舍的,本次只作为一个案例,启发一下思想。

关于二叉查找树

二叉查找树(二叉搜索树、二叉排序树、Binary Search Tree)是二叉树结构的一种常见应用形式,这个树具有以下特征:

  • 对于左子树,任何节点的值都小于根节点
  • 对于右子树,任何节点的值都大于根节点
  • 中序遍历的结果,必然是一个从小到大排列的数列

查找二叉树还有两个概念:

  • 前驱节点 : 左子树中,相对于根节点,最大的点
  • 后继节点 : 右子树中,相对于根节点,最小的点

在中序遍历中,这几个点是连续的。

关于树结构的深入理解(重点)

上面记载的都是简单的概念和衍生思维。

(现在没有精力做深入理解)
(记录课程时间戳1:17,后续如果看不懂自己的记录就重新回去看视频)

以下是自己总结的内容:

(我自认为的内容会用括号进行封装)

  • 树结构作为二维数据结构最简单的类型不但应用与各种场所而且值得深入学习。

  • 树结构的节点在数据角度理解为集合,而边表示关系,
    (因此树结构可以用较少的初始存储空间索引较大面积的信息族)

  • 因此树结构的子节点表示子集,并且这些子集互不相交。而所有的子集加起来等于父节点。
    (因此树集合对于子集关系的管理非常适合做查询相关的管理)

(从我个人角度来说,我想要用树结构做一些信息族的管理用于进行知识点的封装)

经典例题-二叉树的基本操作

(本次经典例题我只详细讲我感兴趣的题目,对于这部分我会试图准备多个方案去解决)

(而对于我不感兴趣的,我将只记录题目,不记录解法和解决思路,或者简单描述解决思路)

LeetCode-144. 二叉树的前序遍历

来源:LeetCode-144. 二叉树的前序遍历

给你二叉树的根节点 root ,返回它节点值的前序遍历。

(简单,用递归或迭代可以实现)

(但是参考答案时看到一个有趣的解法做一个学习和理解)

有趣的解法:Morris 遍历

作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/binary-tree-preorder-traversal/solution/er-cha-shu-de-qian-xu-bian-li-by-leetcode-solution/
来源:力扣(LeetCode)

Morris 遍历

思路与算法

有一种巧妙的方法可以在线性时间内,只占用常数空间来实现前序遍历。这种方法由 J. H. Morris 在 1979 年的论文「Traversing Binary Trees Simply and Cheaply」中首次提出,因此被称为 Morris 遍历。

Morris 遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其前序遍历规则总结如下:

  1. 新建临时节点,令该节点为 root;

  2. 如果当前节点的左子节点为空,将当前节点加入答案,并遍历当前节点的右子节点;

  3. 如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:

  • 如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点。然后将当前节点加入答案,并将前驱节点的右子节点更新为当前节点。当前节点更新为当前节点的左子节点。

  • 如果前驱节点的右子节点为当前节点,将它的右子节点重新设为空。当前节点更新为当前节点的右子节点。

  1. 重复步骤 2 和步骤 3,直到遍历结束。

这样我们利用 Morris 遍历的方法,前序遍历该二叉树,即可实现线性时间与常数空间的遍历。

(以上内容全部转载,下面记录我的理解)

首先这个方法中有一个名词叫做前驱节点,这个概念之前没有认识,做一个简单介绍:

前驱节点是对于左子树对于根节点时定义的概念,前驱节点的值小于根节点的值,是左子树中值最大的。
与之对应的有后继节点
后继节点是对于右子树对于根节点时定义的概念,后继节点的值大于根节点的值,是右子树中值最小的。

但是在本题中前驱节点指的是,左子树在垂直维度上,最接近根节点的节点,逻辑上就是寻找左子树的最右节点

现在假定有一个二叉树,我开始遍历,首先我获取了根节点,根据前序遍历的要求,我将依次输出左子树的遍历结果和右子树的遍历结果,对应这个方案,就是第二步。

第二步的执行使得分割了状态另左子树和根节点为一组,右子树为另一组。

第一组的遍历最终结果就是(根+左)结束后再去考虑右侧,这样层级嵌套的问题就被转换成了顺次进行的问题,这是很巧妙的思路。

在这种写法下右侧,右子树,不再是子树,从概念上变成了类似链表的状态,因为每一个环节将只继续考虑右侧,将唯一指向单一目标,这就是链表的结构。

因此这套解法真正的复杂度在于对于(根+左)的遍历。也就是第三步。

第三步的核心解法为利用前驱节点没有右子树的概念,使得可以强制成环,当寻找前驱节点时,寻找到自身节点时,意味着以这个节点为目标的树结构已经遍历完毕了左子树和根,剩余右子树需要遍历,而这个右子树进行遍历时,如果也出现成环,则意味着在更高一层的树结构中,完成了左子树的遍历,从而实现前序遍历。

第三步非常的难以理解:我以一个普通的二叉树进行举例

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

这个二叉树最终的遍历结果应该为:1245367
首先这个二叉树被切分成了两部分

根+左
    1
   / 
  2   
 /\  
4  5 

右
   3
  / \
 6   7

对于这个结构的切分,显然是合理的,所以焦点集中为前半段(根+左)

接下来对于(根+左)执行第三步

执行需要做一个准备,需要准备一个临时节点存储树的结构变化关系,也就是整体上上需要有2个节点进行存储树的存储,我称为原树和临时树(对应代码里的p1和p2)

p1 
 2   
 /\  
4  5 

p2
  5
   \
    1
   / \
  2   3
 /\   /\
4  5 6  7

.... 

这轮变化后先不要去关注p2成环的状态,这轮变化后输出了一个值:1

(根+左)变成了(左),也就是从这一刻起不必考虑右子树的事情,将左子树理解为目标进行输出,在第二步的时候讨论过,这套方案首先是切分右子树,然后讨论左侧的情况。现在就是切分左子树,输出根。

那么一个问题就摆在了眼前,怎么接续被切分的右子树。

这里就要关注成环的P2,后续将在某一个环节拆环,并且P1也是成环的,只是我没有写出来

再进行一次变化,需要关注的是,临时节点P2一直在变化

p1 

  4

p2
  4
   \
    2
   / \
  4   5
.... 

这一轮变化结束,输出节点为 2

(在描述时,我另P1不显示成环的状态,另P2显示成环的状态)

这一轮变化可以理解为进一步拆分出左子树的根,在这一步拆分结束后,左子树已经拆分到了尽头,应当进行遍历右子树了

p1 

    2
   / \
  4   5

p2
 null


于是再进行一次遍历,输出4,并且准备遍历4节点下方的右子树

在这轮遍历之前,首先尝试获取前驱节点,但是无法获取,因为已经成环,2节点左子树4的右子树是2节点,此时拆环,原本接续在4节点右子树的2节点被取消

成环的树
    2
   / \
  4   5
   \
    2
   / \
  4   5
   \
    2
   / \
  4   5

拆环
    2
   / \
  4   5

取右枝

	5

此时理解为左枝和根遍历完毕要准备遍历右枝了,而此时右枝的节点为5,

到这一步,输出的结果为:124

再回顾最初5节点接续的内容:

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

在之前的遍历流程中,依次在5节点和4节点下方接续了树,使得成环,而当前4节点下方的环已经解开了,那么5节点呢?

继续观察。

5节点没有左枝,因此理解为根进行输出,当前输出总结果为1245

而此时树的成环状态为:

成环的树
    1
   / \
  2   3
 /\   /\
4  5 6  7
   \
    1

因此对于1去获取前驱节点时获取到了自身,这个自身是在进行(根+左)切分(左)时赋值的,而现在观察到了这个成环的现象所以,解环并且取右枝,

拆环
    1
   / \
  2   3
 /\   /\
4  5 6  7

取右枝

    3
   / \
  6   7

到此为止完成了(根+左)完整的一次切分,剩余的为右子树,并且树结构成功还原。

此时输出的结果为1245,剩余需要遍历的为右子树。在整个过程中(目前为止),成环2次,因为这个二叉树有3层。

(理解了吗?我理解了)

总结

做一个总结:

这套解法是从纵向(斜向)切分二叉树,通过找前驱节点,然后为前驱节点接续当前节点使得下一次考虑前驱节点时,可以意识到这个节点时左子树的最后一个节点(这个左子树相对此时寻找前驱节点的根节点),用这种利用空余二叉树节点的方式来存放标志位,从而实现最小的空间复杂度。

利用的核心概念

  • 前驱节点没有右子树
  • 叶子节点没有左子树也没有右子树

示例代码

(只记录这个解法:Morris 遍历)

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<Integer>();
        if (root == null) {
            return res;
        }

        TreeNode p1 = root, p2 = null;

        //启动遍历条件
        while (p1 != null) {
            //考虑左子树和根节点的情况
            p2 = p1.left;
            //存在左子树时
            if (p2 != null) {
                //寻找前驱节点,离开条件:
                //要么右侧为空,要么右侧为当前的根节点
                while (p2.right != null && p2.right != p1) {
                    p2 = p2.right;
                }
                if (p2.right == null) {//前驱节点的右侧为空时
                    //输出当前节点
                    res.add(p1.val);
                    //并将当前节点接入前驱节点右枝(此时成环)
                    p2.right = p1;
                    //将当前节点置位左枝,
                    p1 = p1.left;
                    //开始讨论当前节点左子树的情况
                    continue;
                } else {
                    //此时拆环,此时根和左枝遍历完毕
                    p2.right = null;
                }
            } else {
                //左枝已经遍历完毕,接下来只讨论右子树
                res.add(p1.val);
            }
            //根+左遍历完毕,准备遍历右子树
            p1 = p1.right;
        }
        return res;
    }
}

LeetCode-589. N 叉树的前序遍历

来源:LeetCode-589. N 叉树的前序遍历

给定一个 N 叉树,返回其节点值的 前序遍历 。

N 叉树 在输入中按层序遍历进行序列化表示,每组子节点由空值 null 分隔。

要求使用迭代法。

(简单题,解法略,用栈来实现)

LeetCode-226. 翻转二叉树

来源:LeetCode-226. 翻转二叉树

翻转一棵二叉树。
翻转的概念为:

  • 调换每一个节点的左右子树

(简单题,解法略,用递归实现)

LeetCode-剑指 Offer 32 - II. 从上到下打印二叉树 II

来源:LeetCode-剑指 Offer 32 - II. 从上到下打印二叉树 II

从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。

解题思路

(虽然是简单题目,但是我一时间没想出来)

这道题目有要求是从上到下和从左到右,是一个明显有先后顺序的任务,因此可以使用队列来执行,这个队列内存储的元素应该为节点指针,和节点层数。

在遍历的过程中,入队和出队同批次进行,先将根入队。

之后循环进行出队动作和入队动作,出队时,将这个节点的左右子树入队。

出队的元素的节点层数加一时,打印的内容换行

(还是想出来了,很简单,不写代码了)

LeetCode-107. 二叉树的层序遍历 II

来源:LeetCode-107. 二叉树的层序遍历 II

给定一个二叉树,返回其节点值自底向上的层序遍历。 (即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)

(跟上一题有什么区别呢?只是改为从下到上而已,简单,略)

(想不出来就看看上一题,脑子不要僵住)

https://leetcode-cn.com/problems/binary-tree-zigzag-level-order-traversal/

LeetCode-103. 二叉树的锯齿形层序遍历

来源:LeetCode-103. 二叉树的锯齿形层序遍历

给定一个二叉树,返回其节点值的锯齿形层序遍历。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。

(本题需要用栈,用栈来实现交错遍历,2个栈,简单,略)

经典例题-二叉树的进阶操作

LeetCode-110. 平衡二叉树

来源:LeetCode-110. 平衡二叉树

给定一个二叉树,判断它是否是高度平衡的二叉树。

本题中,一棵高度平衡二叉树定义为:

一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。

示例:

   3
  / \
 9  20
    / \
   15  7

解题思路

本题可以用递归来解决,递归的循环体输入二叉树,返回这棵树的高度,当某一次左右子树返回的高度差大于1时,判断不平衡,其余若是能判断完毕则最终认为平衡。

(那么是否有有更好的思路呢)

(没有)

但是递归的方式也分为从上到下或者从下到上,写法的区别在于:

  • 从上到下的判断方式为先获得左右子树的高度,再对于根节点比较高度
  • 从下到上的判断方式为在获得左右子树时,将比较高度的动作加入递归循环体中

(我的思路为从下到上)

(判断从下到上还是从上到下关键在于看处理语句是放在递归调用的后面还是起前面,如果是先递归,处理返回值那就是从下到上,如果是先处理返回值再递归那就是从上到下)

示例代码

(以下为我的解法,速度还不错,但是内存用的有点多)
(这算是从上到下还是从下到上呢,我认为是从下到上)

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public boolean isBalanced(TreeNode root) {
        if(myIsBalanced(root,0)==null)
            return false;
        else
            return true;
    }


    public Integer myIsBalanced(TreeNode root,int height) {

        //树高度不增加
        if(root == null){
            return height;
        }

        height ++;
        
        Integer left_h;
        Integer right_h;

        left_h  = myIsBalanced(root.left,height);
        right_h = myIsBalanced(root.right,height);

        //传递不平衡的状态
        if(left_h == null||right_h == null){
            return null;
        }

        int min = Math.min(left_h,right_h);
        int max = Math.max(left_h,right_h);
        if(max>=min+2){
            //以空表示不平衡
            return null;
        }else{
            return max;
        }
    }
}

LeetCode-112. 路径总和

来源:LeetCode-112. 路径总和

给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。

叶子节点 是指没有子节点的节点。

提示:

树中节点的数目在范围 [0, 5000] 内
-1000 <= Node.val <= 1000
-1000 <= targetSum <= 1000

解题思路

需要注意的是,节点值可以为负数,因此需要计算每一条路径。
那么本题的核心思路就是如何遍历所有叶子节点,并且可以拥有遍历时的路径

这方面我根据之前层序打印树节点的方案:

题目来源:LeetCode-剑指 Offer 32 - II. 从上到下打印二叉树 II

这个解题时用的方案是遍历所有节点的,通过队列来实现,当某一个节点为叶子节点并且此时求和正好为目标时,判定成功,否则全部遍历完毕没有成功时,判定失败。

队列内存储的元素为当前节点指针域和当前节点路径和。

(这种遍历方案为优先广度搜索,代码略,改改就能用)

另一种方案为递归,每一次只考虑剩余求和期望,将这个期望交给递归循环体进行,因此准备两个入参,一个为树,一个为剩余期望

(这两种方式时间复杂度相同,但是空间复杂度递归的方案更胜一筹)

示例代码

(以下为递归的方案,队列的方案略写)

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public boolean hasPathSum(TreeNode root, int targetSum) {
        if(root == null){
            return false;
        }

        TreeNode left = root.left;
        TreeNode right = root.right;

        targetSum = targetSum-root.val;

        if(left == null && right ==null){
            if(targetSum == 0)
                return true;
            else 
                return false;
        }

        return hasPathSum(left,targetSum)||hasPathSum(right,targetSum);
    }
}

LeetCode-105. 从前序与中序遍历序列构造二叉树

来源:LeetCode-105. 从前序与中序遍历序列构造二叉树

根据一棵树的前序遍历与中序遍历构造二叉树。

注意:
你可以假设树中没有重复的元素。

解题思路

本题的核心思路在于对于前序遍历和中序遍历的了解。

前序遍历为:根、左、右
中序遍历为:左、根、右

因此可以迅速的进行遍历状态的切分,获取根节点和左右子树

综上,本题用递归来解题
(代码略)

LeetCode-222. 完全二叉树的节点个数

来源:LeetCode-222. 完全二叉树的节点个数

给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。

完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2h 个节点。

解题思路

本题的解法需要巧解,全部遍历肯定是不行的,一定要充分利用完全二叉树的性质

完全二叉树的性质为除了最后一层,其余层一定为满层,并且最后一层优先会填充左侧区域。

因此左子树的高度就是完全二叉树的高度

利用这一点根据左右子树的高度差可以快速定位最后一层的排布情况。

  • 如果高度相同,那么左子树必然为完美二叉树,需要明确的区域在于右子树。对于右子树而言,也是完全二叉树,可以递归进行判断。

  • 如果高度不相同,那么右子树必然为完美二叉树,需要明确的区域在于左子树。对于左子树而言,也是完全二叉树,可以递归进行判断。

如此就还需要知道2个信息:

  1. 左子树的高度计算方式
  2. 完美二叉树节点个数和层数的关系

(代码略)

简单示意图:

假设原始树为:
          1
        /   \
       /     \
      2       2
     / \     / \
    3   3   3   3
   / \  / 
  4  4  4

此时左子树高度高于右子树

则右子树为完美二叉树

假设原始树为:

           1
        /     \
       /       \
      2         2
     / \       / \
    3   3     3   3
   / \  / \   /\
  4  4  4 4  4  4

此时左子树高度等于右子树

则左子树为完美二叉树

LeetCode-剑指 Offer 54. 二叉搜索树的第k大节点

来源:LeetCode-剑指 Offer 54. 二叉搜索树的第k大节点

给定一棵二叉搜索树,请找出其中第k大的节点。

解题思路

关于二叉搜索树的概念,简单描述一下,二叉搜索树左子树的所有节点都小于根节点,右子树的所有节点都大于根节点。

因此中序遍历的二叉搜索树,将从小到大排列。

因此本题基础的解法为中序遍历,然后取第K位

(代码略,写法为二叉树求中序遍历)

这个基础上有一个性能更好的方案

那就是只进行右子树节点的统计,由于搜索树的特性,右子树所有节点都大于根节点的值,因此根据右子树节点个数可以快速定位第K大的数字时在左子树还是右子树或者是根节点。

这种方式同样是递归,但是在能满足条件时可以停下从而节约算力。

递归时传递2个参数,一个是子树的根,另一个是期望的K

而K的运算根据右子树的节点统计来规定,假定统计值为sum

  • 则当K大于(sum+1) 时,K减去(sum+1),并将搜索期望传递给左子树
  • 则当K等于(sum+1) 时,根节点就是期望目标传回即可
  • 则当K小于(sum+1) 时,理论上不会有这个判定,因为如果小于就继续搜索右子树,K保持不变

(代码略)

LeetCode-剑指 Offer 26. 树的子结构

来源:LeetCode-剑指 Offer 26. 树的子结构

输入两棵二叉树A和B,判断B是不是A的子结构。(约定空树不是任意一个树的子结构)

B是A的子结构, 即 A中有出现和B相同的结构和节点值。

例如:
给定的树 A:


     3
    / \
   4   5
  / \
 1   2

给定的树 B:

   4 
  /
 1

返回 true,因为 B 与 A 的一个子树拥有相同的结构和节点值。

解题思路

本题虽然是树,但是更多的是一种集合的包含关系,层次的进行判断,非常快速的就能想出一个基础方案:

遍历A树的节点,尝试在A树中找到B树的结构,这个方案需要准备两个核心函数,一个用于遍历树,一个用于判断,当前树是否包含目标树。

(虽然也不是特别好写代码,但是先想想有没有更好的方案,或者在这个方案上进行优化)

首先遍历树,是必须的,只要没有成功判断出包含关系,就必须进行树的遍历,因此能优化的部分只有遍历的方法和判断包含的方法了。

关于判断包含的方法,不同于本题整体的判断的条件,只需要改为从根节点开始是否能形成包含关系。

(没有优化空间了,但是感觉语言描述不够具体,所以我还是写一下代码)

示例代码

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public boolean isSubStructure(TreeNode A, TreeNode B) {

        //先排除B树为空的情况,但是可以允许B子树为空
        if(B == null)
            return false;
        if (A == null)
            return false;

        //判断
        if(myIsSubStructure(A,B))
            return true;
        
        //递归遍历
        return (isSubStructure(A.left,B)||
                isSubStructure(A.right,B));
    }

    public boolean myIsSubStructure(TreeNode A, TreeNode B) {

        //B子树可以空,空集合属于任何集合
        if(B == null)
            return true;

        //A不可为空,因为此时B不为空
        if (A == null)
            return false;
        
        //目前相同则继续递归判别
        if(A.val == B.val){
            //当B为叶子节点时,递归结束
            if(B.left == null&&
                B.right == null){
                    return true;
            }

            return (myIsSubStructure(A.left,B.left)&&
                myIsSubStructure(A.right,B.right));
        }else
            return false;

    }
}

LeetCode-968. 监控二叉树 (困难)

来源:LeetCode-968. 监控二叉树

给定一个二叉树,我们在树的节点上安装摄像头。

节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。

计算监控树的所有节点所需的最小摄像头数量。

示例图:

在这里插入图片描述

解题思路

(终于等到困难题,学习一下解法)

(我短时间内没做出来,学习两套优秀的解法)

(看不懂的可以只选一套自己喜欢的学习,我建议方案2)

方案1:动态规划

本题是一个规划问题,要求规划出满足监控树的最小摄像头数量。
以任何一个根节点root为例,如果期望其所在树被覆盖,从自身节点是否放置摄像头来讨论,有两种情况,放置或者不放置。

  • 放置时,左右子节点必然被覆盖,此时只需要确保孩子节点自身的子树被覆盖则满足全部覆盖的条件
  • 不放置时,左右子节点必须选出一个来放置摄像头,并且剩下的一个则重新作为独立的树加入讨论

(到这一步的讨论是考虑了这个根节点有可能有父节点放置摄像头的情况的,只是不分离出来节约摄像头,这个讨论的目的是明确覆盖树的规则)

根据讨论结果令每个节点root维护3种状态

  • a:覆盖整棵树所用的摄像头数目,在根节点放置摄像头的情况下,
  • b:覆盖整棵树所用的摄像头数目,根节点是否放置摄像头不确定
  • C:覆盖子树所需的摄像头数目, 不考虑根节点是否被覆盖

(这一步是为后续打基础,这3种状态不是独立存在的,a是一个用来定性的前提条件,而b和c则是用于动态讨论状态的中间条件,这3种状态被假设出来是为了建模讨论不同选择的影响)

根据这三种状态的定义,他们之间数量级关系必然为:a>=b>=c

(a和b都覆盖了整棵树,但是b的条件更自由,因此在可选的情况下,b有可能更小
(b和C都不考虑根节点,但是c的条件更自由,因此在可选的情况下,c有可能更小

那么对于根节点的左孩子和右孩子也有各自需要维护的3种状态分别为(la,lb,lc)和(ra,rb,rc)

此时再根据最初讨论的对于任何一个根节点期望树被覆盖的两种状态选择时,可以得到

a = lc + rc +1
b = min (a, min(la + rb , ra + lb))
c = min (a, lb + rb)

3个公式的理由为:

  • a是本身必然放置摄像头时,所需要的最小数目,由于本身必然放置摄像头所以左右孩子根节点必然不需要考虑放置摄像头,只需要考虑各自的子树被覆盖(c)。
  • b是在本身放置或者不放置中寻找最小值,如果不放置孩子节点必须放置(a),而另一个则是独立的考虑(b),
  • c是不考虑自身是否被覆盖情况下,子树被覆盖时(b)所用最少摄像头,同时由于a状态下包含了子树被覆盖的情况,所以在根放置和不放置中选一个最小值。

根据题意,最终求得的b的值就是期望覆盖这棵树的摄像头数目最小值。

(到这里就充分的利用完毕之前假设的3种状态,这种解法是我完全想不出来的,也是我期望未来的我能想出来的)

另外在这种讨论过程中,一个节点的孩子为空,则不能在该孩子处放置摄像头,则对于孩子的a状态的数目应该为一个极大数字,用于表示不可能的状态。

(到目前为止理解一下思路,通过虚构了3个变量,并且要求这3个变量都符合在自身定义下,使得当前节点的选择受到孩子节点状态的影响)

(因此虽然本题是个递归,但是是从下向上用返回值进行规划管理的递归)

示例代码

(动态规划)


class Solution {
    public int minCameraCover(TreeNode root) {
        int[] array = dfs(root);
        return array[1];
    }

    public int[] dfs(TreeNode root) {
        if (root == null) {
			//当为空时,
			//a为极大,b为0因为不需要覆盖,c为0因为不需要覆盖
            return new int[]{Integer.MAX_VALUE / 2, 0, 0};
        }
		
		//先获取左右子树的各自状态
        int[] leftArray = dfs(root.left);
        int[] rightArray = dfs(root.right);
        int[] array = new int[3];

		//再计算自身的3种状态
        array[0] = leftArray[2] + rightArray[2] + 1;
        array[1] = Math.min(array[0], Math.min(leftArray[0] + rightArray[1], rightArray[0] + leftArray[1]));
        array[2] = Math.min(array[0], leftArray[1] + rightArray[1]);
        return array;
    }
}


方案2:贪心算法

(相比起更具有宏观性的方案1,方案二就更有逻辑的具体性。适合学习)

方案二的核心理念就是,摄像头能放在父节点就一定尝试放在父节点

因此父节点选择是否放置摄像头就一定是根据子节点状态来选择的。
因此需要能够从下向上遍历,因此遍历所有节点的方式采用后序遍历

(很清楚的描述了遍历的方式,确定了循环的主基调)

另外还有一个问题就是如何传递状态使得父节点可以考虑是否放置摄像头呢

因此须有有一种方式传递这个状态,讨论可知有3种状态:

  • 编号0:无覆盖
  • 编号1:有摄像头
  • 编号2:被覆盖

并且定义空节点为被覆盖的一种,因为空节点不需要考虑覆盖关系并且不能放置摄像头所以当做被覆盖处理了。遇到空节点时递归结束返回状态:被覆盖。

至此递归的方式,递归的结束条件已经确立,还需要明确的就是根据回传的状态选择当前节点的状态

一共有4种状态讨论:

  1. 左右节点都被覆盖,则此时本身节点就处于了未覆盖的状态下,将这个状态返回,期望后续的节点能够完成对当前节点的覆盖
  2. 左右节点至少有一个未覆盖,则自身必须放置摄像头来完成覆盖,将这个状态返回
  3. 左右节点至少有一个摄像头,则自身必然被覆盖,将这个状态返回(需要注意的是这个状态需要排除掉左右节点存在未覆盖的可能性,所以判断的序列要靠后)
  4. 此时是根节点,但是依然未覆盖,因此需要自身来进行覆盖(相当于状态1的特殊情况)

(这个方案是我期望能设计出来的方案,但是我也没做到,我反思了很久得出我失败的结论,我缺少更为实际的讨论,进行了过多的猜想性思考,缺乏了实践和缺乏实践的勇气)

(所以最终我只能学习别人的思路,希望下一次我自己能想出来)

思路来源:

作者:carlsun-2
链接:https://leetcode-cn.com/problems/binary-tree-cameras/solution/968-jian-kong-er-cha-shu-di-gui-shang-de-zhuang-ta/
来源:力扣(LeetCode)

示例代码
/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */

class Solution {
    int res = 0;

    public int minCameraCover(TreeNode root) {
        int rootStatus = setStatus(root);
        //状态4 ,期望父节点失败,自身放置摄像头完成覆盖任务
        if(rootStatus == 0)
            res++;
        return res;
    }

    public int setStatus(TreeNode root) {
        if (root == null) {
            //空节点认为已经被覆盖
            return 2;
        }

		
		//先获取左右子树的各自状态
        int leftSet = setStatus(root.left);
        int rightSet = setStatus(root.right);

        //状态1,子节点都覆盖,此时贪心的期望自身的覆盖交给父节点进行
        if(leftSet == 2 && rightSet ==2 ){
            return 0;
        }

        //状态2子节点需要被覆盖,所以自身放置一个摄像头
        if(leftSet == 0 || rightSet == 0){
            res ++;
            return 1;
        }

        //状态3子节点有摄像头,所以自身被覆盖
        if(leftSet == 1 || rightSet == 1){
            return 2;
        }

        return -1;
    }
}

LeetCode-662. 二叉树最大宽度

来源:LeetCode-662. 二叉树最大宽度

给定一个二叉树,编写一个函数来获取这个树的最大宽度。树的宽度是所有层中的最大宽度。这个二叉树与满二叉树(full binary tree)结构相同,但一些节点为空。

每一层的宽度被定义为两个端点(该层最左和最右的非空节点,两端点间的null节点也计入长度)之间的长度。

示例1:

输入: 

          1
         / \
        3   2 
       /        
      5      

输出: 2
解释: 最大值出现在树的第 2 层,宽度为 2 (3,2)。

示例2:

输入: 

           1
         /   \
        3     2
       / \     \  
      5   3     9 

输出: 4


解题思路

需要注意的是:不是进行计算垂直层面的最大宽度,而是计算水平层面的最大宽度。如果本题是计算垂直层面,我感觉会有些许难度

本题还是需要遍历二叉树,根据题意需要水平的同层进行比较,因此我采用层序遍历的方案。

很简单,层序遍历的方式可以使用队列来实现,每一层进行比较,全部比较完毕后输出最大值。

那么本题的难度就在于怎么在层序遍历的过程找到宽度。

注意题干的一个关键语句:这个二叉树与满二叉树(full binary tree)结构相同,但一些节点为空

这句话本质上没有意义,如果一些节点为空了,就是不是满二叉树了,所以其实要想到填充节点,并且不只是填充节点更是要填充出一个特殊的形状。所以这句话是启发思想也是污染思路的话,这句话应该改为和完全二叉树结构相同,但是一些节点为空,或者是完美二叉树结构相同

关于完全二叉树,有一个重要性质就是可以用数组来保存,因此可以假设存在一个数组以完全二叉树的规则保存了这个二叉树,那么就可以通过同层的坐标来获取当前层的最大差值

(这段我想多了,只是一些联想而已,本质上,如果能想到下一层理论上可以拥有的节点数目是上一层的两倍,也行差不多意思)

代码就略了,但是回顾一下完全二叉树节点的计算式:

假设某一个节点的坐标为index,则:

  • 左孩子的坐标为:2*index
  • 右孩子的坐标为:2*index+1

需要注意的是,根节点坐标为1.

再回顾一下用队列实现层序遍历的方式:

先根节点入队,
然后出队一个节点,入队这个节点的左孩子和右孩子,持续循环直到队列为空。

(可以想到这题怎么解了吧)

结语

4.22 日是本次笔记真正完成的时间,虽然课程早就学习完毕了,但是我中途去忙了其他事情所以没有空继续发布内容。

并且由于算法题目部分在我当时的课堂笔记里没有学习,所以我也是花了几天重新回顾了一下。

本次我采用的学习策略为:

  • 先课前预习算法题目和基础概念,
  • 再和课上讲的内容做对比。专门的听自己期望的重点部分。(但是我依然没有尝试答疑的动作,因为我没有这个学习习惯,所以我依然主要是看录播学习。)
  • 最后根据录播整理课程笔记

另外本次学习用时其实比之前的短,并且也认为学习的足够扎实,课后的题目看一眼就知道该怎么做了,也更好的把握重点。


在我自身学习的过程中,我发现树结构其实是非常有学习价值的模型,并且有很多的变体,所以学完本课不是结束,而是开始,需要铭记。

(但是这部分开始得自学,就跟着当前这个课程进度是不行的,这个课只是入门课)

(但是这门入门课如果都跟不上,那还是放弃为好,希望我跟得上,你也一样)

下一节课的笔记内容我会尽快完成。┗( ▔, ▔ )┛

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值