算法学习 (门徒计划)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. 二叉树的前序遍历
给你二叉树的根节点 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 遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其前序遍历规则总结如下:
-
新建临时节点,令该节点为 root;
-
如果当前节点的左子节点为空,将当前节点加入答案,并遍历当前节点的右子节点;
-
如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:
-
如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点。然后将当前节点加入答案,并将前驱节点的右子节点更新为当前节点。当前节点更新为当前节点的左子节点。
-
如果前驱节点的右子节点为当前节点,将它的右子节点重新设为空。当前节点更新为当前节点的右子节点。
- 重复步骤 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<