文章目录
- 前言
- 1. 树形结构
- 1.1 什么是树
- 1.2 名词概念
- 1.3 树的表现形式
- 2. 二叉树
- 2.1 概念
- 2.2 两种特殊的二叉树
- 2.3 二叉树的性质
- 3. 二叉树的存储结构
- 3.1 顺序存储
- 3.2 链式存储
- 4. 二叉树的遍历
- 4.1 前序遍历
- 4.2 中序遍历
- 4.3 后序遍历
- 4.4 层序遍历
- 5. 遍历的代码实现
- 5.1 递归实现
- 5.2 非递归实现(了解)
- 结语
前言
在我第一次听到二叉树这个词的时候,脑海中就想起来下面的这个名场面,汤姆的“裤裆劈树”🤣🤣🤣
开个玩笑,在我们编程世界中,二叉树是一种特殊的 “树”,而要认识二叉树,我们有得先认识 “树” 是什么玩意
1. 树形结构
1.1 什么是树
树跟我们前面学到的数据结构都不一样,它为一种非线性的数据结构,是由 n(n>=0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树(根朝上,而叶朝下)
- 根节点:没有前驱节点
- 除了根节点,其余节点都可以被分成互不相交的子集合,而每个子集合又是一棵和树相似的子树。每棵子树的根节点都只有一个前驱节点,可以有零个或多个后继节点
- 树是通过递归定义的
要注意:如果子树之间有交集,那就不能算是树了
1.2 名词概念
- 节点(node):包含一个数据元素以及若干指向子树分支的信息
- 节点的度:一个节点含有的子树的个数。上图中:C的度为2
- 树的度:在一棵树中,最大的节点的度即为树的节点。上图中:树的度为2
- 叶子节点:也叫做终端节点,度为零的节点。
- 分支节点:也叫做非终端节点,度不为零的节点
- 父节点:也叫做双亲节点。上图中:A为B的父节点
- 子节点:也叫做孩子节点。上图中:D为B的子节点
- 根节点:在一棵树中,没有双亲节点的节点
- 节点的层次:根节点算作第1层,依次往下为2层、3层……
- 树的高度或深度:树中节点的最大层次。(深度是相对节点位置的,而深度的最大值就等于树的高度)上图中:树的高度为4
- 兄弟节点:具有相同父节点的节点互称为兄弟节点。上图中:B和C是兄弟节点
- 堂兄弟节点:双亲在同一层的节点互为堂兄弟节点;上图中:D和E是堂兄弟节点
- 节点的祖先:从根到该节点所经分支上的所有节点。上图中:A是所有节点的祖先
1.3 树的表现形式
之前我们在学习线性表的时候,是定义了个节点Node类,类内部定义了 val 值和 next 指向下一个节点的地址。在二叉树这里,我们也是类似的表示形式。树可以用孩子表示法、孩子双亲表示法、孩子兄弟表示法等等,我们在这里就用孩子表示法
class TreeNode {public char val; //节点中存储的数据public TreeNode left; //左孩子public TreeNode right; //右孩子
}
2. 二叉树
2.1 概念
一棵二叉树是一个有限的节点集合,该集合为:
- 要么为空
- 要么是有一个根节点加上左右两棵称为左子树和右子树的二叉树组成
上图就是典型的二叉树,我们可以看出:
- 二叉树不存在度大于2的节点
- 二叉树是一棵有序树,它的子树有左右之分,次序不能颠倒
二叉树的五种基本形态
三种特殊的形态
接下来我们将重点讲解满二叉树和完全二叉树
2.2 两种特殊的二叉树
满二叉树:如果每层的节点数都达到最大值,则这棵二叉树就是满二叉树。即如果二叉树的节点总数为 2*K - 1(K为树的层数),那它就是满二叉树
完全二叉树:除了最后一层,所有层的节点都被完全填满,而且最后一层的节点尽可能地集中在左侧。这样的二叉树就是完全二叉树(我们也可以这样理解:所有的叶子节点都在最后一层或者倒数第二层,且最后一层叶子节点在左边连续,倒数第二层在右边连续)
要注意:满二叉树就是一种特殊的完全二叉树
2.3 二叉树的性质
- 假设根节点的层数为第1层,那么一棵非空二叉树第i层上最多有 2 i − 1 2^{i-1} 2i−1(i>0)个节点
- 证明:因为二叉树的度最大为2,假设每一层节点的度都为2。那么第1层就有1个节点,第2层就有2个节点,第3层就有4个节点,根据等比数列的规律,我们可以算出第i层上最多有 2 i − 1 2^{i-1} 2i−1(i>0)个节点
- 假设根节点的深度为1,那么深度为K的二叉树最大节点数为 2 K − 1 2^K-1 2K−1(K>=0)
- 证明:假设每一层节点的度都为2。那么第1层就有1个节点,第2层就有2个节点,第3层就有4个节点……根据等比数列的求和公式可以得到深度为K的二叉树最大节点数为 2 K − 1 2^K-1 2K−1(K>=0)
- 对于任何一棵二叉树,用 n 0 n_0 n0表示叶子节点数,用 n 2 n_2 n2来表示度为2的节点数,则有 n 0 n_0 n0 = n 2 n_2 n2 + 1
- 证明: n 1 n_1 n1表示度为1的节点数,又因为一棵N个节点的数有N-1条边,所以我们可以等出两条等式①N = n 0 n_0 n0 + n 1 n_1 n1 + n 2 n_2 n2 ②N = n 0 n_0 n0*0 + n 1 n_1 n1*1 + n 2 n_2 n2*2; 联立可得 n 0 n_0 n0 = n 2 n_2 n2 + 1
- 假设根节点的层数为第1层,那么有n个节点的完全二叉树的深度为 log 2 ( n + 1 ) \log_2{(n+1)} log2(n+1) 的向上取整
证明:因为深度为K的满二叉树的节点数n一定小于等于 2 K − 1 2^K-1 2K−1(用性质2可得),那么倒推可以等到 K = log 2 ( n + 1 ) \log_2{(n+1)} log2(n+1) ,向上取整指的是 - 对于完全二叉树,如果我们从上到下、从左往右编号,则编号为 i 的节点,则其左孩子编号就为 2i,右孩子编号就为 2i+1;其双亲节点编号为 i/2(i = 1时为根节点,无双亲节点)
3. 二叉树的存储结构
二叉树有两种存储结构:顺序存储和链式存储
3.1 顺序存储
二叉树的顺序存储结构跟线性表十分相似,就是使用一维数组来存储二叉树中的节点,而数组的下标表示的就是该节点的存储位置:
该树各节点在数组中的形式:(为表示方便,此处起始点记为1)
我们上面展示的树为完全二叉树,它刚好可以填满整个数组,不会造成存储空间的浪费
而当二叉树不是完全二叉树时:(D、F表示不存在的节点)
其存储结构如下,^ 表示该位置没有节点,我们可以发现,此时浪费了两个存储空间
由此我们可以得到一个结论:顺序存储结构适用于完全二叉树,非完全二叉树使用顺序存储则会造成浪费存储空间。因此对于二叉树,我们更习惯于使用链式存储结构
3.2 链式存储
我们可以将节点设计成两个域:数据域和指针域,数据域用来存放具体数据,指针域则存放父节点或者子节点的地址。下面我们使用孩子表示法进行演示
通过一个一个的节点引用起来就是链式存储,常见的表示方式为二叉链表,如图
4. 二叉树的遍历
二叉树的遍历指的是从根节点出发,按照某种约定依次对树中的每个节点仅作一次访问。遍历是二叉树上最重要的操作之一,是二叉树上进行其他运算的基础。二叉树一共有四种遍历方式:
- 前序遍历(NLR):又称为先序遍历,先访问根节点 → \rightarrow → 根的左子树 → \rightarrow → 根的右子树
- 中序遍历(LNR):先访问根的左子树 → \rightarrow → 根节点 → \rightarrow → 根的右子树
- 后序遍历(LRN):先访问根的左子树 → \rightarrow → 根的右子树 → \rightarrow → 根节点
- 层序遍历:从根节点从上往下逐层遍历,在同一层时,按从左到右的顺序对节点逐个访问。与上面提到的顺序存储结构相类似
N:Node(根节点) L:Left(左子树) R:Right(右子树)
4.1 前序遍历
规则:首先访问根节点,然后递归地进行左子树的前序遍历,最后递归地进行右子树的前序遍历
此处要重点理解递归的含义:我们知道,二叉树是递归定义的,即每棵子树都可以看成是一棵二叉树。所以在遍历的时候,需要不断对新的子树严格按照前序遍历的规则来执行
具体的遍历步骤如下:
- 访问根节点:首先访问当前节点,也就是根节点
- 遍历左子树:然后,对根节点的左子节点进行前序遍历。如果左子节点存在,重复上述步骤,即先访问左子节点,然后递归地遍历其左子树,接着遍历其右子树
- 遍历右子树:最后,对根节点的右子节点进行前序遍历。同样,如果右子节点存在,重复上述步骤
由此我们可以得到前序遍历的结果为:
A B D H E I C F J G
4.2 中序遍历
首先递归地进行左子树的中序遍历,然后访问根节点,最后递归地进行右子树的中序遍历
具体的遍历步骤如下:
- 遍历左子树:首先,对根节点的左子节点进行中序遍历。如果左子树存在,那么按照同样的规则,先遍历左子树的左子树,然后访问左子树的根节点,最后遍历左子树的右子树
- 访问根节点:在左子树的遍历完成后,访问当前节点,也就是根节点
- 遍历右子树:最后,对根节点的右子节点进行中序遍历。如果右子树存在,重复上述步骤,即先遍历右子树的左子树,然后访问右子树的根节点,最后遍历右子树的右子树
由此我们可以得到中序遍历的结果为:
H D B I E A F J C G
4.3 后序遍历
首先递归地进行左子树的后序遍历,然后递归地进行右子树的后序遍历,最后访问根节点
具体的遍历步骤如下:
- 遍历左子树:首先,对根节点的左子节点进行后序遍历。如果左子树存在,那么按照同样的规则,先遍历左子树的左子树,然后遍历左子树的右子树,最后访问左子树的根节点
- 遍历右子树:在左子树的遍历完成后,对根节点的右子节点进行后序遍历。如果右子树存在,重复上述步骤,即先遍历右子树的左子树,然后遍历右子树的右子树,最后访问右子树的根节点
- 访问根节点:在左子树和右子树的遍历都完成后,访问当前节点,也就是根节点
由此我们可以得到后序遍历的结果为:
H D I E B J F C G A
4.4 层序遍历
从根节点从上往下逐层遍历,在同一层,按从左到右的顺序对节点逐个访问
层序遍历的结果为:
A B C D E F G H I J
通过上面的例子我们也知道了二叉树的遍历是怎么一回事。实际上,我们也可以根据遍历的结果来创建出一棵二叉树:
前序遍历 + 中序遍历、 后序遍历 + 中序遍历
前序遍历的第一个节点就是根节点;后序遍历的最后一个节点也是根节点。在知道根节点后,根据中序遍历的我们就能得知左子树和右子树,最后根据递归的规律我们就能够反推出一棵二叉树
但是如果只知道前序遍历和后序遍历则是无法反推出一棵二叉树
5. 遍历的代码实现
5.1 递归实现
因为二叉树是由递归定义的,所以我们最常使用递归来实现二叉树的遍历
首先,我们要先定义好节点:(此处使用孩子表示法)
class TreeNode {public char val; //节点中存储的数据public TreeNode left; //左孩子public TreeNode right; //右孩子public TreeNode(char val) {this.val = val;}}
前序遍历:
//前序遍历(根左右)public void preOrder(TreeNode root) {if (root == null) {return;}System.out.print(root.val + " ");//根preOrder(root.left);//左preOrder(root.right);//右}
中序遍历:
//中序遍历(左根右)public void inOrder(TreeNode root) {if (root == null) {return;}inOrder(root.left);//左System.out.print(root.val + " ");//根inOrder(root.right);//右}
后序遍历:
//后序遍历(左右根)public void postOrder(TreeNode root) {if (root == null) {return;}postOrder(root.left);//左postOrder(root.right);//右System.out.print(root.val + " ");//根}
层序遍历:(此处我们需要借助队列)
从根节点从上往下逐层遍历,在同一层,按从左到右的顺序对节点逐个访问
//层序遍历public void levelOrder(TreeNode root) {Queue<TreeNode> queue = new LinkedList<>();if (root == null) {return;}queue.offer(root);while (!queue.isEmpty()) {TreeNode cur = queue.poll();System.out.print(cur.val + " ");if (cur.left != null) {queue.offer(cur.left);}if (cur.right != null) {queue.offer(cur.right);}}System.out.println();}
设计思路:队列有先进先出的特性。
- 首先我们得判断根节点是否为 null,为 null 就直接返回,说明是一棵空树,不为 null 就入队
- 接着就是以队列是否为空来作为 while 循环条件,不为空就一直循环。循环内部我们让根节点出队,创建一个 cur 来接收根节点,接下来打印 cur 的值
- 然后就判断 cur 的左右是否为null(一定要先左再右),不为 null 就入队。接着继续循环上面操作,cur 接收出队的节点,打印 cur 的值
- 最后队列为空,循环停止,层序遍历完成
5.2 非递归实现(了解)
非递归实现遍历需要借助栈,它有先进后出的特性
//前序遍历(非递归)public void preOrderNot(TreeNode root) {Stack<TreeNode> stack = new Stack<>();TreeNode cur = root;while (cur != null || !stack.empty()) {while (cur != null) {stack.push(cur);System.out.print(cur.val + " ");cur = cur.left;}TreeNode top = stack.pop();cur = top.right;}}//中序遍历(非递归)public void inOrderNot(TreeNode root) {Stack<TreeNode> stack = new Stack<>();TreeNode cur = root;while (cur != null || !stack.empty()) {while (cur != null) {stack.push(cur);cur = cur.left;}TreeNode top = stack.pop();System.out.print(top.val + " ");cur = top.right;}}//后序遍历(非递归)public void postOrderNot(TreeNode root) {Stack<TreeNode> stack = new Stack<>();TreeNode cur = root;TreeNode prev = null;while (cur != null || !stack.isEmpty()) {while (cur != null) {stack.push(cur);cur = cur.left;}TreeNode top = stack.peek();if (top.right == null || top.right == prev ) {stack.pop();System.out.print(top.val+" ");prev = top;} else {cur = top.right;}}}
通过对比代码数量我们也可以看出非递归实现二叉树的遍历十分麻烦,因此该方法了解即可
结语
二叉树的相关知识十分重要,关于四种遍历的递归思路一定要熟记。下一篇博客我会详细介绍二叉树的经典题型,如 ”相同的二叉树‘ “翻转二叉树”……掌握了这些经典题型能让我们更加深刻的认识二叉树
希望大家能喜欢这篇文章,有总结不到位的地方还请多多谅解,若有出现纰漏,希望大佬们看到错误之后能够在私信或评论区指正,博主会及时改正,共同进步!