数据结构与算法 二叉树
1. 为什么需要树这种数据结构?
1) 数组存储方式的分析
- 优点:
- 通过 下标 方式访问元素,速度快
- 对于 有序数组,还可以使用二分查找提高检索速度
- 缺点:如果无序数组要检索具体某个值,或插入值(按一定顺序)会整体移动,并且数组的大小固定,如果该数组已经满了但又想插入数据,那就必须对该数组进行扩容,效率较低,如下的示意图
2) 链表存储方式的分析
- 优点:在一定程度上对数组存储方式有优化
例如:插入一个数值节点,只需要将插入节点,链接到链表中即可,同理,删除效率也很好
- 缺点:检索效率较低
需要从头结点开始遍历查找。
简单说:
- 数组访问快,增删慢
- 链表增删快,访问慢
所以就出现了 树 这种数据结构。如下图就是二叉树模型
3) 树存储数据方式分析
提供数据 存储 、读取 效率。
例如:利用 二叉排序树(Binary Sort Tree),既可以保证数据的检索速度,同时也可以保证数据的插入、删除、修改 的速度
如图所示:
- 插入时,小的数在 左节点、大的数在 右节点
- 查找时:根据插入事的特性,基本上就类似折半查找了,每次都过滤一半的节点
- 删除时:只需要移动相邻的节点的引用
树 的常用术语
- 节点:每一个圆圈表示一个节点,也称节点对象
- 根节点:最上面,最顶部的那个节点,也就是一棵树的入口
- 父节点:有子节点的节点
- 子节点:看图
- 兄弟节点:具有相同父节点的节点互称为兄弟节点
- 叶子节点:没有子节点的节点
- 非终端节点或分支节点:度不为零的节点
- 节点的度:一个节点含有的子树的个数称为该节点的度
- 树的度:一棵树中,最大的节点度称为树的度
- 节点的权:可以简单的理解为节点值
有时候也用 路径 来表示
- 路径:从 root 节点找到该节点的路线
- 层:看图
- 子树:有子节点的父子两层就可以称为是一个子树
- 树的高度:最大层数
- 森林:多棵子树构成森林
2. 二叉树的概念
二叉树(Binary tree)是树形结构的一个重要类型。许多实际问题抽象出来的数据结构往往是二叉树形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。二叉树特点是每个结点最多只能有两棵子树,且有左右之分 。
二叉树是n个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成,是有序树。当集合为空时,称该二叉树为空二叉树。在二叉树中,一个元素也称作一个结点 (节点)。
定义:
二叉树(binary tree)是指树中节点的度不大于2的有序树,它是一种最简单且最重要的树。二叉树的递归定义为:二叉树是一棵空树,或者是一棵由一个根节点和两棵互不相交的,分别称作根的左子树和右子树组成的非空树;左子树和右子树又同样都是二叉树。
- 二叉树的子节点分为 左节点 和 右节点
- 如果该二叉树的所有 叶子节点 都在 最后一层,并且 节点总数 = 2n-1 (n 为层数),则我们称为 满二叉树
- 如果该二叉树的所有叶子节点都在最 后一层或倒数第二层,而且 最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称为 完全二叉树
详细点的解析:
一棵深度为k且有
个结点的二叉树称为满二叉树。
根据二叉树的性质2, 满二叉树每一层的结点个数都达到了最大值, 即满二叉树的第i层上有
个结点 (i≥1) 。
如果对满二叉树的结点进行编号, 约定编号从根结点起, 自上而下, 自左而右。则深度为k的, 有n个结点的二叉树, 当且仅当其每一个结点都与深度为k的满二叉树中编号从1至n的结点一一对应时, 称之为完全二叉树。 [2]
从满二叉树和完全二叉树的定义可以看出, 满二叉树是完全二叉树的特殊形态, 即如果一棵二叉树是满二叉树, 则它必定是完全二叉树。
完全二叉树的特点:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。需要注意的是,满二叉树肯定是完全二叉树,而完全二叉树不一定是满二叉树。
3. 二叉树的遍历
有三种:
- 前序遍历:先输出父节点,再遍历左子树(递归)和右子树(递归)
- 中序遍历:先遍历左子树(递归),再输出父节点,再遍历右子树(递归)
- 后序遍历:先遍历左子树(递归),再遍历右子树(递归),最后输出父节点
看上述粗体部分:前中后说的就是父节点的输出时机。
注意理清楚描述中的递归,这关乎着你如何找到下一个输出节点
关于递归,请看 数据结构与算法——初谈递归
二叉树遍历思路分析
- 前序遍历:
- 先输出当前节点(初始节点是 root 节点)
- 如果左子节点不为空,则递归继续前序遍历
- 如果右子节点不为空,则递归继续前序遍历
上图的输出顺序为:1、2、3、4
- 中序遍历:
- 如果当前节点的左子节点不为空,则递归中序遍历
- 输出当前节点
- 如果当前节点的右子节点不为空,则递归中序
上图的输出顺序为:2、1、4、3
- 后序遍历:
- 如果左子节点不为空,则递归继续前序遍历
- 如果右子节点不为空,则递归继续前序遍历
- 输出当前节点
上图的输出顺序为:2、4、3、1
如果不理解,就结合下面的代码进行理解。
二叉树遍历代码实现
注意这里 this 的含义。
/** * 二叉树测试 */ public class BinaryTreeTest { // 先编写二叉树节点 class HeroNode { public int id; public String name; public HeroNode left; public HeroNode right; public HeroNode(int id, String name) { this.id = id; this.name = name; } @Override public String toString() { return "HeroNode{" + "id=" + id + ", name='" + name + '\'' + '}'; } /** * 前序遍历 */ public void preOrder() { // 1. 先输出当前节点 System.out.println(this); // 2. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.preOrder(); } // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.preOrder(); } } /** * 中序遍历 */ public void infixOrder() { // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.infixOrder(); } // 2. 先输出当前节点 System.out.println(this); // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.infixOrder(); } } /** * 后序遍历 */ public void postOrder() { // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.postOrder(); } // 2. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.postOrder(); } // 3. 先输出当前节点 System.out.println(this); } } // 编写 二叉树 类 class BinaryTree { public HeroNode root;//树根 /** * 前序遍历 */ public void preOrder() { //判断二叉树是否为空 if (root == null) { System.out.println("二叉树为空"); return; } root.preOrder(); } /** * 中序遍历 */ public void infixOrder() { if (root == null) { System.out.println("二叉树为空"); return; } root.infixOrder(); } /** * 后续遍历 */ public void postOrder() { if (root == null) { System.out.println("二叉树为空"); return; } root.postOrder(); } } /** * 前、中、后 遍历测试 */ @Test public void fun1() { // 手动创建节点与构建二叉树 HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n2 = new HeroNode(2, "无用"); HeroNode n3 = new HeroNode(3, "卢俊"); HeroNode n4 = new HeroNode(4, "林冲"); n1.left = n2; n1.right = n3; n3.right = n4; BinaryTree binaryTree = new BinaryTree(); binaryTree.root = n1; System.out.println("\n 前序遍历:"); binaryTree.preOrder(); System.out.println("\n 中序遍历:"); binaryTree.infixOrder(); System.out.println("\n 后序遍历:"); binaryTree.postOrder(); } }
测试输出
前序遍历: HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=4, name='林冲'} 中序遍历: HeroNode{id=2, name='无用'} HeroNode{id=1, name='宋江'} HeroNode{id=3, name='卢俊'} HeroNode{id=4, name='林冲'} 后序遍历: HeroNode{id=2, name='无用'} HeroNode{id=4, name='林冲'} HeroNode{id=3, name='卢俊'} HeroNode{id=1, name='宋江'}
思考一下:
如上图,给 卢俊义 增加一个节点 关胜,写出他的前、中、后序的打印顺序:
注意:上面这个新增的节点,并不是按照顺序增加的,这里考的知识点是 前、中、后序的遍历规则
- 前序:1、2、3、5、4
- 中序:2、1、5、3,4
- 后序:2、5、4、3、1
那么下面通过程序来检测答案是否正确:
/** * 考题:给卢俊新增一个 left 节点,然后打印前、中、后 遍历顺序 */ @Test public void fun2() { // 创建节点与构建二叉树 HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n2 = new HeroNode(2, "无用"); HeroNode n3 = new HeroNode(3, "卢俊"); HeroNode n4 = new HeroNode(4, "林冲"); HeroNode n5 = new HeroNode(5, "关胜"); n1.left = n2; n1.right = n3; n3.right = n4; n3.left = n5; BinaryTree binaryTree = new BinaryTree(); binaryTree.root = n1; System.out.println("\n 前序遍历:"); binaryTree.preOrder(); System.out.println("\n 中序遍历:"); binaryTree.infixOrder(); System.out.println("\n 后序遍历:"); binaryTree.postOrder(); }
输出信息
前序遍历: HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 中序遍历: HeroNode{id=2, name='无用'} HeroNode{id=1, name='宋江'} HeroNode{id=5, name='关胜'} HeroNode{id=3, name='卢俊'} HeroNode{id=4, name='林冲'} 后序遍历: HeroNode{id=2, name='无用'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} HeroNode{id=3, name='卢俊'} HeroNode{id=1, name='宋江'}
可以看到,后序是最容易弄错的规则,所以在后续上,一定要多多 debug,多多思考 ,看下他的调用轨迹。
4. 二叉树的查找
要求:
- 编写前、中、后序查找方法(和上面的遍历类似,只是添加了一些东西,注意观察,细看代码,思想是一样的)
- 并分别使用三种查找方式,查找 id=5 的节点
- 并分析各种查找方式,分别比较了多少次
由于二叉树的查找是遍历查找,所以就简单了,前面遍历规则已经写过了,改写成查找即可
/** * 二叉树测试 */ public class BinaryTreeTest { // 先编写二叉树节点 class HeroNode { public int id; public String name; public HeroNode left; public HeroNode right; public HeroNode(int id, String name) { this.id = id; this.name = name; } @Override public String toString() { return "HeroNode{" + "id=" + id + ", name='" + name + '\'' + '}'; } /** * 前序遍历 */ public void preOrder() { // 1. 先输出当前节点 System.out.println(this); // 2. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.preOrder(); } // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.preOrder(); } } /** * 中序遍历 */ public void infixOrder() { // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.infixOrder(); } // 2. 先输出当前节点 System.out.println(this); // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.infixOrder(); } } /** * 后序遍历 */ public void postOrder() { // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { this.left.postOrder(); } // 2. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { this.right.postOrder(); } // 3. 先输出当前节点 System.out.println(this); } /** * 前序查找 */ public HeroNode preOrderSearch(int id) { System.out.println(" 进入前序遍历"); // 用来统计查找了几次 // 1. 判断当前节点是否相等,如果相等,则返回 if (this.id == id) { return this; } // 2. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { HeroNode result = this.left.preOrderSearch(id); if (result != null) { return result; } } // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { HeroNode result = this.right.preOrderSearch(id); if (result != null) { return result; } } return null; } /** * 中序查找 */ public HeroNode infixOrderSearch(int id) { // System.out.println(" 进入中序遍历"); // 用来统计查找了几次,不能在这里打印,这里打印是进入了方法几次,看的是比较了几次 // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { HeroNode result = this.left.infixOrderSearch(id); if (result != null) { return result; } } System.out.println(" 进入中序遍历");// 用来统计查找了几次 // 2. 如果相等,则返回 if (this.id == id) { return this; } // 3. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { HeroNode result = this.right.infixOrderSearch(id); if (result != null) { return result; } } return null; } /** * 后序查找 */ public HeroNode postOrderSearch(int id) { // System.out.println(" 进入后序遍历"); // 用来统计查找了几次,不能在这里打印,这里打印是进入了方法几次,看的是比较了几次 // 1. 如果左子节点不为空,则递归继续前序遍历 if (this.left != null) { HeroNode result = this.left.postOrderSearch(id); if (result != null) { return result; } } // 2. 如果右子节点不为空,则递归继续前序遍历 if (this.right != null) { HeroNode result = this.right.postOrderSearch(id); if (result != null) { return result; } } System.out.println(" 进入后序遍历");// 用来统计查找了几次 // 3. 如果相等,则返回 if (this.id == id) { return this; } return null; } } // 编写 二叉树 类 class BinaryTree { public HeroNode root; /** * 前序遍历 */ public void preOrder() { if (root == null) { System.out.println("二叉树为空"); return; } root.preOrder(); } /** * 中序遍历 */ public void infixOrder() { if (root == null) { System.out.println("二叉树为空"); return; } root.infixOrder(); } /** * 后续遍历 */ public void postOrder() { if (root == null) { System.out.println("二叉树为空"); return; } root.postOrder(); } /** * 前序查找 */ public HeroNode preOrderSearch(int id) { //判断树是否为空 if (root == null) { System.out.println("二叉树为空"); return null; } return root.preOrderSearch(id); } /** * 中序查找 */ public HeroNode infixOrderSearch(int id) { if (root == null) { System.out.println("二叉树为空"); return null; } return root.infixOrderSearch(id); } /** * 后序查找 */ public HeroNode postOrderSearch(int id) { if (root == null) { System.out.println("二叉树为空"); return null; } return root.postOrderSearch(id); } } /** * 查找 id=5 的前、中、后序测试 */ @Test public void fun3() { // 创建节点与构建二叉树 HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n2 = new HeroNode(2, "无用"); HeroNode n3 = new HeroNode(3, "卢俊"); HeroNode n4 = new HeroNode(4, "林冲"); HeroNode n5 = new HeroNode(5, "关胜"); n1.left = n2; n1.right = n3; n3.right = n4; n3.left = n5; BinaryTree binaryTree = new BinaryTree(); binaryTree.root = n1; System.out.println("找到测试:"); int id = 5; System.out.println("\n前序遍历查找 id=" + id); System.out.println(binaryTree.preOrderSearch(id)); System.out.println("\n中序遍历查找 id=" + id); System.out.println(binaryTree.infixOrderSearch(id)); System.out.println("\n后序遍历查找 id=" + id); System.out.println(binaryTree.postOrderSearch(id)); System.out.println("找不到测试:"); id = 15; System.out.println("\n前序遍历查找 id=" + id); System.out.println(binaryTree.preOrderSearch(id)); System.out.println("\n中序遍历查找 id=" + id); System.out.println(binaryTree.infixOrderSearch(id)); System.out.println("\n后序遍历查找 id=" + id); System.out.println(binaryTree.postOrderSearch(id)); } }
测试输出
找到测试: 前序遍历查找 id=5 # 共查找 4 次 进入前序遍历 进入前序遍历 进入前序遍历 进入前序遍历 HeroNode{id=5, name='关胜'} 中序遍历查找 id=5 # 共查找 3 次 进入中序遍历 进入中序遍历 进入中序遍历 HeroNode{id=5, name='关胜'} 后序遍历查找 id=5 # 共查找 2 次 进入后序遍历 进入后序遍历 HeroNode{id=5, name='关胜'} 找不到测试: 前序遍历查找 id=15 进入前序遍历 进入前序遍历 进入前序遍历 进入前序遍历 进入前序遍历 null 中序遍历查找 id=15 进入中序遍历 进入中序遍历 进入中序遍历 进入中序遍历 进入中序遍历 null 后序遍历查找 id=15 进入后序遍历 进入后序遍历 进入后序遍历 进入后序遍历 进入后序遍历 null
可以看出:
- 找到的次数和 查找的顺序 有关,而查找顺序就是于遍历方式有关
- 找不到的次数则是相当于都遍历完成,所以是相等的次数
5. 二叉树的删除
要求:
- 如果删除的节点是 叶子节点,则删除该节点
- 如果删除的节点是非叶子节点,则删除该子树
测试:删除 5 号叶子节点和 3 号子树。
说明:目前的二叉树不是规则的,如果不删除子树,则需要考虑哪一个节点会被上提作为父节点。这个后续讲解排序二叉树时再来实现。先实现简单的
思路分析:
- 由于我们的二叉树是单向的
- 所以我们判定一个节点是否可以删除,是判断它的 子节点 是否可删除,否则则没法回到父节点删除了,因为要判断被删除的节点满足前面的两点要求(因为链表的关系)
- 当前节点的 左子节点 不为空,并且左子节点就是要删除的节点,则 this.left = null,并且返回(结束递归删除)
- 当前节点的 右子节点 不为空,并且右子节点就是要删除的节点,则 this.right = null,并且返回(结束递归删除)
如果前面都没有删除,则继续递归,直到找到并删除,或者是找不到对应的节点,输出没有该节点即可。上面的要求是 2 点,实际上是,找到符合条件的节点则直接删除(因为不考虑是否有子树)
// BinaryTree 新增删除的方法 /** * 删除节点 * * @param id * @return */ public HeroNode delete(int id) { if (root == null) { System.out.println("树为空"); return null; } HeroNode target = null;//保存要删除的目标节点 // 如果 root 节点就是要删除的节点,则直接置空 if (root.id == id) { target = root; root = null; } else { target = this.root.delete(id); } return target; }
// HeroNode 中新增删除的方法 /** * 删除节点,思路,先看左右,看完再递归,具体看代码 * @param id * @return 如果删除成功,则返回删除的节点 */ public HeroNode delete(int id) { // 判断左子节点是否是要删除的节点 HeroNode target = null; if (this.left != null && this.left.id == id) { target = this.left; this.left = null; return target; } if (this.right != null && this.right.id == id) { target = this.right; this.right = null; return target; } // 尝试左递归 if (this.left != null) { target = this.left.delete(id); if (target != null) { return target; } } // 尝试右递归 if (this.right != null) { target = this.right.delete(id); if (target != null) { return target; } } return null; }
删除方法测试用例
/** * 构建当前这个树 * * @return */ private BinaryTree buildBinaryTree() { HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n2 = new HeroNode(2, "无用"); HeroNode n3 = new HeroNode(3, "卢俊"); HeroNode n4 = new HeroNode(4, "林冲"); HeroNode n5 = new HeroNode(5, "关胜"); n1.left = n2; n1.right = n3; n3.right = n4; n3.left = n5; BinaryTree binaryTree = new BinaryTree(); binaryTree.root = n1; return binaryTree; } /** * 不考虑子节点的删除 */ @Test public void delete() { System.out.println("\n删除 3 号节点"); delete3(); System.out.println("\n删除 5 号节点"); delete5(); System.out.println("\n删除一个不存在的节点"); deleteFail(); System.out.println("\n删除 root 节点"); deleteRoot(); } @Test public void delete3() { // 创建节点与构建二叉树 BinaryTree binaryTree = buildBinaryTree(); binaryTree.preOrder(); // 删除 3 号节点 HeroNode target = binaryTree.delete(3); String msg = (target == null ? "删除失败,未找到" : "删除成功:" + target.toString()); System.out.println(msg); binaryTree.preOrder(); } @Test public void delete5() { // 创建节点与构建二叉树 BinaryTree binaryTree = buildBinaryTree(); binaryTree.preOrder(); // 删除 5 号节点 HeroNode target = binaryTree.delete(5); String msg = (target == null ? "删除失败,未找到" : "删除成功:" + target.toString()); System.out.println(msg); binaryTree.preOrder(); } /** * 删除一个不存在的节点 */ @Test public void deleteFail() { // 创建节点与构建二叉树 BinaryTree binaryTree = buildBinaryTree(); binaryTree.preOrder(); // 删除 5 号节点 HeroNode target = binaryTree.delete(9); String msg = (target == null ? "删除失败,未找到" : "删除成功:" + target.toString()); System.out.println(msg); binaryTree.preOrder(); } /** * 删除 root 节点 */ @Test public void deleteRoot() { // 创建节点与构建二叉树 BinaryTree binaryTree = buildBinaryTree(); binaryTree.preOrder(); // 删除 1 号节点 HeroNode target = binaryTree.delete(1); String msg = (target == null ? "删除失败,未找到" : "删除成功:" + target.toString()); System.out.println(msg); binaryTree.preOrder(); }
分别输出信息如下
删除 3 号节点 HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 删除成功:HeroNode{id=3, name='卢俊'} HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} 删除 5 号节点 HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 删除成功:HeroNode{id=5, name='关胜'} HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=4, name='林冲'} 删除一个不存在的节点 HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 删除失败,未找到 HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 删除 root 节点 HeroNode{id=1, name='宋江'} HeroNode{id=2, name='无用'} HeroNode{id=3, name='卢俊'} HeroNode{id=5, name='关胜'} HeroNode{id=4, name='林冲'} 删除成功:HeroNode{id=1, name='宋江'} 二叉树为空
6. 顺序存储二叉树
基本说明-概念
从数据存储来看,数组存储 方式和 树 的存储方式可以 相互转换。即使数组可以转换成树,树也可以转换成数组。如下示意图
上图阅读说明:
- 圆圈顶部的数字对应了数组中的索引
- 圆圈内部的值对应的数数组元素的值
现在有两个要求:
- 上图的二叉树的节点,要求以数组的方式来存储 arr=[1,2,3,4,5,6,7]
- 要求在遍历数组 arr 时,仍然可以以 前序、中序、后序的方式遍历
特点(思路)
想要 实现上面的两个要求,需要知道顺序存储二叉树的特点:
- 顺序二叉树 通常只考虑 完全二叉树(完全二叉树上面有解释)
- 第 n 个元素的 左子节点 为 2*n+1
- 第 n 个元素的 右子节点 为 2*n+2
- 第 n 个元素的 父节点 为 (n-1)/2
注:n 表示二叉树中的第几个元素(按 0 开始编号)
比如:
- 元素 2 的左子节点为:2 * 1 + 1 = 3,对比上图去查看,的确是 3
- 元素 2 的右子节点为:2 * 1 + 2 = 4,也 就是元素 5
- 元素 3 的左子节点为:2 * 2 + 1 = 5,也就是元素 6
- 元素 3 的父节点为: (2-1)/2= 1/2 = 0,也就是根节点 1
搞懂特点规律,下面进行代码实现。
前序遍历
使用如上的知识点,进行前序遍历,需求:将数组 arr=[1,2,3,4,5,6,7],以二叉树前序遍历的方式进行遍历,遍历结果为 1、2、4、5、3、6
前序遍历概念(上面有讲):
- 先输出当前节点(初始节点是 root 节点)
- 如果左子节点不为空,则递归继续前序遍历
- 如果右子节点不为空,则递归继续前序遍历
/** * 顺序存储二叉树 */ public class ArrBinaryTreeTest { /** * 前序遍历测试 */ @Test public void preOrder() { int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7}; ArrBinaryTree tree = new ArrBinaryTree(arr); tree.preOrder(0); // 1,2,4,5,3,6,7 } } class ArrBinaryTree { int[] arr; public ArrBinaryTree(int[] arr) { this.arr = arr; } /** * 前序遍历 * * @param index 就是知识点中的 n,从哪一个节点开始遍历 */ public void preOrder(int index) { /* 1. 顺序二叉树 通常只考虑 **完成二叉树** 2. 第 n 个元素的 **左子节点** 为 `2*n+1` 3. 第 n 个元素的 **右子节点** 为 `2*n+2` 4. 第 n 个元素的 **父节点** 为 `(n-1)/2` */ if (arr == null || arr.length == 0) { System.out.println("数组为空,不能前序遍历二叉树"); return; } // 1. 先输出当前节点(初始节点是 root 节点) System.out.println(arr[index]); // 2. 如果左子节点不为空,则递归继续前序遍历 int left = 2 * index + 1; if (left < arr.length) { preOrder(left); } // 3. 如果右子节点不为空,则递归继续前序遍历 int right = 2 * index + 2; if (right < arr.length) { preOrder(right); } } }
测试输出
1 2 4 5 3 6 7
中序、后序遍历
/** * 中序遍历:先遍历左子树,再输出父节点,再遍历右子树 * * @param index */ public void infixOrder(int index) { if (arr == null || arr.length == 0) { System.out.println("数组为空,不能前序遍历二叉树"); return; } int left = 2 * index + 1; if (left < arr.length) { infixOrder(left); } System.out.println(arr[index]); int right = 2 * index + 2; if (right < arr.length) { infixOrder(right); } } /** * 后序遍历:先遍历左子树,再遍历右子树,最后输出父节点 * * @param index */ public void postOrder(int index) { if (arr == null || arr.length == 0) { System.out.println("数组为空,不能前序遍历二叉树"); return; } int left = 2 * index + 1; if (left < arr.length) { postOrder(left); } int right = 2 * index + 2; if (right < arr.length) { postOrder(right); } System.out.println(arr[index]); }
测试代码
/** * 中序遍历测试 */ @Test public void infixOrder() { int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7}; ArrBinaryTree tree = new ArrBinaryTree(arr); tree.infixOrder(0); // 4,2,5,1,6,3,7 } /** * 后序遍历测试 */ @Test public void postOrder() { int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7}; ArrBinaryTree tree = new ArrBinaryTree(arr); tree.postOrder(0); // 4,5,2,6,7,3,1 }
应用案例
学会了顺序存储二叉树,那么它可以用来做什么呢?
八大排序算法中的 堆排序,就会使用到顺序存储二叉树。
7. 线索化二叉树
为什么要线索化二叉树?
看如下问题:将数列 {1,3,6,8,10,14} 构成一颗二叉树
可以看到上图的二叉树为一颗 完全二叉树。对他进行分析,可以发现如下的一些问题:
- 当对上面的二叉树进行中序遍历时,数列为 8,3,10,1,14,6
- 但是 6,8,10,14 这几个节点的左右指针,并没有完全用上
如果希望充分利用各个节点的左右指针,让各个节点可以 指向自己的前后节点,这个时候就可以使用 线索化二叉树 了
介绍
n 个节点的二叉树链表中含有 n + 1 个空指针域,他的推导公式为 2n-(n-1) = n + 1。
利用二叉树链表中的空指针域,存放指向该节点在 某种遍历次序下(前序,中序,后序)的 前驱节点 和 后继节点的指针,这种附加的指针称为「线索」
- 前驱:一个节点的前一个节点
- 后继:一个节点的后一个节点
如下图,在中序遍历中,下图的中序遍历为 8,3,10,1,14,6,那么 8 的后继节点就为 3,8 没有前驱节点,10 的前驱节点是 3,10 的后继节点是 1 (主要是看遍历出来的数列的顺序来判定)
这种加上了线索的二叉树链表称为 线索链表(一般的二叉树本来就是用链表实现的),相应的二叉树称为 线索二叉树(Threaded BinaryTree)。根据线索性质的不同,线索二叉树可分为:前、中、后序线索二叉树。
思路分析
将上图的二叉树,进行 中序线索二叉树,中序遍历的数列为 8,3,10,1,14,6。
那么以上图为例,线索化二叉树后的样子如下图
- 8 的后继节点为 3
- 3 由于 左右节点都有元素,不能线索化
- 10 的前驱节点为 3,后继节点为 1
- 1 不能线索化
- 14 的前驱节点为 1,后继节点为 6
- 6 有左节点,不能线索化
注意:当线索化二叉树后,那么一个 Node 节点的 left 和 right 属性,就有如下情况:
- left 指向的是 左子树,也可能是指向 前驱节点
例如:节点 1 left 节点指向的是左子树,节点 10 的 left 指向的就是前驱节点
- right 指向的是 右子树,也可能是指向 后继节点
例如:节点 3 的 right 指向的是右子树,节点 10 的 right 指向的是后继节点
代码实现
下面的代码,有几个地方需要注意:
- HeroNode 就是一个 简单的二叉树节点,不同的是多了两个 type 属性:
- leftType:左节点的类型:0:左子树,1:前驱节点
- rightType:右节点的类型:0:右子树,1:后继节点
为什么需要?上面原理讲解了,left 或则 right 会有两种身份,需要一个额外 的属性来指明
- threadeNodes:线索化二叉树
是将一棵二叉树,进行线索化标记。只是将可以线索化的节点进行赋值。
/** * 线索化二叉树 */ public class ThreadedBinaryTreeTest { class HeroNode { public int id; public String name; public HeroNode left; public HeroNode right; /** * 左节点的类型:0:左子树,1:前驱节点 */ public int leftType; /** * 右节点的类型:0:右子树,1:后继节点 */ public int rightType; public HeroNode(int id, String name) { this.id = id; this.name = name; } @Override public String toString() { return "HeroNode{" + "id=" + id + ", name='" + name + '\'' + '}'; } } class ThreadedBinaryTree { public HeroNode root; public HeroNode pre; // 保留上一个节点 /** * 线索化二叉树:以 中序的方式线索化 */ public void threadeNodes() { // 从 root 开始遍历,然后 线索化 this.threadeNodes(root); } private void threadeNodes(HeroNode node) { if (node == null) { return; } // 中序遍历顺序:先左、自己、再右 threadeNodes(node.left); // 难点就是在这里,如何线索化自己 // 当自己的 left 节点为空,则设置为前驱节点 if (node.left == null) { node.left = pre; node.leftType = 1; } // 因为要设置后继节点,只有回到自己(node)的后继节点的时候,才能把自己设置为前一个的后继节点 !!这里自己好好意会一下 // 当前一个节点的 right 为空时,则需要自己(node)是后继节点 if (pre != null && pre.right == null) { pre.right = node; pre.rightType = 1; } // 数列: 1,3,6,8,10,14 // 中序: 8,3,10,1,14,6 // 这里最好结合上面图示的二叉树来看,容易理解 // 因为中序遍历,先遍历左边,所以 8 是第一个输出的节点 // 当 node = 8 时,pre 还没有被赋值过,则为空。这是正确的,因为 8 就是第一个节点 // 当 8 处理完成之后,处理 3 时 // 当 node = 3 时,pre 被赋值为 8 了。 pre = node; //关键!!!! threadeNodes(node.right); } } @Test public void threadeNodesTest() { HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n3 = new HeroNode(3, "无用"); HeroNode n6 = new HeroNode(6, "卢俊"); HeroNode n8 = new HeroNode(8, "林冲2"); HeroNode n10 = new HeroNode(10, "林冲3"); HeroNode n14 = new HeroNode(14, "林冲4"); n1.left = n3; n1.right = n6; n3.left = n8; n3.right = n10; n6.left= n14; ThreadedBinaryTree tree = new ThreadedBinaryTree(); tree.root = n1; tree.threadeNodes(); // 验证: HeroNode left = n10.left; HeroNode right = n10.right; System.out.println("10 号节点的前驱节点:" + left.id); System.out.println("10 号节点的后继节点:" + right.id); } }
测试输出
10 号节点的前驱节点:3 10 号节点的后继节点:1
如果看代码注释看不明白的话 ,现在来解释:
- 线索化的时候,就是要按照 中序遍历 的顺序,去找可以线索化的节点
中序遍历顺序:先左、自己、再右
我们主要的代码是在 自己这一块
- 确定前一个节点 pre
这个 pre 很难理解,对照下图进行理解
// 数列: 1,3,6,8,10,14 // 中序: 8,3,10,1,14,6 // 因为中序遍历,先遍历左边,所以 8 是第一个输出的节点 // 当 node = 8 时,pre 还没有被赋值过,则为空。这是正确的,因为 8 就是第一个节点 // 当 8 处理完成之后,处理 3 时 // 当 node = 3 时,pre 被赋值为 8 了。
- 设置前驱节点
难点的讲解在于 pre,解决了这里就简单了
如果当 node = 8 时,pre 还是 null,因为 8 就是中序的第一个节点。因此 8 没有前驱
如果当 node = 3 时,pre = 8,那么 3 是不符合线索化要求的,因为 8 是 3 的 left
- 设置后继节点
接上面的逻辑。
如果当 node = 8 时,本来 该给 8 设置他的后继节点,但是此时根本就获取不到节点 3,因为节点是单向的。
这里就得利用前一个节点 pre,
当 node=3 时,pre = 8,这时就可以为节点 8 处理它的后继节点了,因为根据中序的顺序,左、自己、后。那么自己(node)一定是前一个的后继。只要前一个的 right 为 null,就符合线索化了
上述最难的 3 个点说明,请对照上图看,先看一遍代码,再看说明。然后去 debug 你就了解了。
遍历线索化二叉树
结合图示来看思路说明最直观
对于原来的中序遍历来说,无法使用了,因为左右节点再也不为空了。这里直接利用线索化节点提供的线索,找到他的后继节点遍历,思路如下:
- 首先找到它的第一个节点,并打印它
中序遍历,先左,所以一直往左找,直到 left 为 null 时,则是第一个节点
- 然后看它的 right节点是否为线索化节点,是的话则打印它
因为:如果 right 是一个线索化节点,也就是 right 是当前节点的 后继节点,可以直接打印。
这里判断是否为线索化节点就得用到新添加的那两个属性了,
- leftType:左节点的类型:0:左子树,1:前驱节点
- rightType:右节点的类型:0:右子树,1:后继节点
- right 如果是一个普通节点,那么就直接处理它的右侧节点
因为:按照中序遍历顺序,左、自己、右,这里就理所当然是右了
看描述索然无味,结合下面的代码来看,就比较清楚了
/** * 遍历线索化二叉树 */ public void threadedList() { // 前面线索化使用的是中序,这里也同样要用中序的方式 // 但是不适合使用之前那种递归了 HeroNode node = root; while (node != null) { // 中序:左、自己、右 // 数列: 1,3,6,8,10,14 // 中序: 8,3,10,1,14,6 // 那么先找到左边的第一个线索化节点,也就是 8. 对照图示理解,比较容易 while (node.leftType == 0) { node = node.left; } // 找到这个线索化节点之后,打印它 System.out.println(node); // 如果该节点右子节点也是线索化节点,则打印它 while (node.rightType == 1) { node = node.right; System.out.println(node); } //否则 // 到达这里,就说明遇到的不是一个 线索化节点了 // 而且,按中序的顺序来看:这里应该处理右侧了 node = node.right; } }
测试
/** * 线索化遍历测试 */ @Test public void threadedListTest() { // 1,3,6,8,10,14 HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n3 = new HeroNode(3, "无用"); HeroNode n6 = new HeroNode(6, "卢俊"); HeroNode n8 = new HeroNode(8, "林冲2"); HeroNode n10 = new HeroNode(10, "林冲3"); HeroNode n14 = new HeroNode(14, "林冲4"); n1.left = n3; n1.right = n6; n3.left = n8; n3.right = n10; n6.left = n14; ThreadedBinaryTree tree = new ThreadedBinaryTree(); tree.root = n1; tree.threadeNodes(); tree.threadedList(); // 8,3,10,1,14,6 }
输出信息
HeroNode{id=8, name='林冲2'} HeroNode{id=3, name='无用'} HeroNode{id=10, name='林冲3'} HeroNode{id=1, name='宋江'} HeroNode{id=14, name='林冲4'} HeroNode{id=6, name='卢俊'}
前序线索化
public void preOrderThreadeNodes() { preOrderThreadeNodes(root); } /** * 前序线索化二叉树 */ public void preOrderThreadeNodes(HeroNode node) { // 前序:自己、左(递归)、右(递归) // 数列: 1,3,6,8,10,14 // 前序: 1,3,8,10,6,14 if (node == null) { return; } System.out.println(node); // 当自己的 left 节点为空,则可以线索化 if (node.left == null) { node.left = pre; node.leftType = 1; } // 当前一个节点 right 为空,则可以把自己设置为前一个节点的后继节点 if (pre != null && pre.right == null) { pre.right = node; pre.rightType = 1; } // 因为是前序,因此 pre 保存的是自己 // 到下一个节点的时候,下一个节点如果是线索化节点 ,才能将自己作为它的前驱节点 pre = node; // 那么继续往左,查找符合可以线索化的节点 // 因为先处理的自己,如果 left == null,就已经线索化了 // 再往左的时候,就不能直接进入了 // 需要判定,如果不是线索化节点,再进入 // 比如:当前节点 8,前驱 left 被设置为了 3 // 这里节点 8 的 left 就为 1 了,就不能继续递归,否则又回到了节点 3 上 // 导致死循环了。 if (node.leftType == 0) { preOrderThreadeNodes(node.left); } if (node.rightType == 0) { preOrderThreadeNodes(node.right); } }
这里代码相对于中序线索化来说,难点在于:什么时候该继续往左查找,什么时候该继续往右查找。
测试
/** * 前序线索化 */ @Test public void preOrderThreadedNodesTest() { // 1,3,6,8,10,14 HeroNode n1 = new HeroNode(1, "宋江"); HeroNode n3 = new HeroNode(3, "无用"); HeroNode n6 = new HeroNode(6, "卢俊"); HeroNode n8 = new HeroNode(8, "林冲2"); HeroNode n10 = new HeroNode(10, "林冲3"); HeroNode n14 = new HeroNode(14, "林冲4"); n1.left = n3; n1.right = n6; n3.left = n8; n3.right = n10; n6.left = n14; ThreadedBinaryTree tree = new ThreadedBinaryTree(); tree.root = n1; tree.preOrderThreadeNodes(); // 验证: 前序顺序: 1,3,8,10,6,14 HeroNode left = n10.left; HeroNode right = n10.right; System.out.println("10 号节点的前驱节点:" + left.id); // 8 System.out.println("10 号节点的后继节点:" + right.id); // 6 left = n6.left; right = n6.right; System.out.println("6 号节点的前驱节点:" + left.id); // 14, 普通节点 System.out.println("6 号节点的后继节点:" + right.id); // 14,线索化节点 }
输出
HeroNode{id=1, name='宋江'} HeroNode{id=3, name='无用'} HeroNode{id=8, name='林冲2'} HeroNode{id=10, name='林冲3'} HeroNode{id=6, name='卢俊'} HeroNode{id=14, name='林冲4'} 10 号节点的前驱节点:8 10 号节点的后继节点:6 6 号节点的前驱节点:14 注意:这里不是前驱,而是正常的一个left节点 6 号节点的后继节点:14
可以看到,我们线索化二叉树的时候,是按照前序的顺序 1,3,8,10,6,14 的顺序遍历查找处理的。处理之后的 6 号节点两个都是一样的,但是 left 是正常的节点 14,right 是线索化节点 14,不明白可以结合下图想一下
前序线索化遍历
前序线索化遍历,还是要记住它的特点是:自己、左(递归)、右(递归),那么遍历思路如下:
- 先打印自己
- 再左递归打印
- 直到遇到一个节点有 right 且是后继节点,则直接跳转到该后继节点,继续打印
- 如果遇到的是一个普通节点,则打印该普通节点,完成一轮循环,进入到下一轮,从第 1 步开始
/** * 前序线索化二叉树遍历 */ public void preOrderThreadeList() { HeroNode node = root; // 最后一个节点无后继节点,就会退出了 // 前序:自己、左(递归)、右(递归) while (node != null) { // 先打印自己 System.out.println(node); while (node.leftType == 0) { node = node.left; System.out.println(node); } while (node.rightType == 1) { node = node.right; System.out.println(node); } node = node.right; } }
测试代码
@Test public void preOrderThreadeListTest() { ThreadedBinaryTree tree = buildTree(); tree.preOrderThreadeNodes(); System.out.println("前序线索化遍历"); tree.preOrderThreadeList(); // 1,3,8,10,6,14 }
测试输出
HeroNode{id=1, name='宋江'} HeroNode{id=3, name='无用'} HeroNode{id=8, name='林冲2'} HeroNode{id=10, name='林冲3'} HeroNode{id=6, name='卢俊'} HeroNode{id=14, name='林冲4'} 前序线索化遍历 HeroNode{id=1, name='宋江'} HeroNode{id=3, name='无用'} HeroNode{id=8, name='林冲2'} HeroNode{id=10, name='林冲3'} HeroNode{id=6, name='卢俊'} HeroNode{id=14, name='林冲4'}
总结
还有一个后序线索化,这里不写了,从前序、中序获取到几个重要的点:
- 线索化时:
- 根据不同的「序」,如何进行遍历的同时,处理线索化节点
对于中序来说:
- 先递归到最左节点
- 开始线索化
- 再递归到最右节点
它的顺序:先左(递归)、自己(node)、再右(递归)
对于前序来说:
- 开始线索化
- 一直往左递归
- 一直往右递归
它的顺序:自己(node)、左(递归)、右(递归)
- 根据不同的「序」,考虑如何跳过或进入下一个节点,因为要考虑前驱和后继
「序」:前、中、后序
- 中序:由于它的顺序,第一个线索化节点,就是他的顺序的第一个节点,不用管接下来遇到的节点是否已经线索化过了,这是由于它天然的顺序,已经线索化过的节点,不会在下一次处理
- 前序:由于它的顺序,第一个顺序输出的节点,并不是第一个线索化节点。所以它需要对他的 左右节点进行类型判定,是普通节点的话,再按:自己、左、右的顺序进行左、右进行递归,因为下一次出现的节点有可能是已经线索化过的节点,如果不进行判定,就会导致又回到了已经遍历过的节点。就会导致死循环了。这里可以结合上图思考一下。
- 根据不同的「序」,如何进行遍历的同时,处理线索化节点
- 遍历线索化时:基本上和线索化时的「序」一起去考虑,何时该进行输出?什么时候遇到后继节点时,跳转到后继节点处理。最重要的一点是:遍历时,不用考虑前驱节点,之后考虑何时通过后继节点进行跳转输出(看遍历代码就懂)。