树是一种非线性数据结构,这种数据结构要比线性数据结构复杂的多,因此分为三篇博客进行讲解:
第一篇:树的基本概念及常用操作的Java实现(二叉树为例)
第二篇:二叉查找树
第三篇:红黑树
本文目录:
第一篇:树的基本概念及常用操作
1、基本概念
1.1 什么是树
我们前面学习的数组、链表等都是线性结构,它们的特点是:一个节点至多只有一个头结点,至多只有一个尾结点,彼此连接起来形成一条线性的结构,如下图所示。
而树结构,是非线性的典型例子,不再是一对一,变成了一对多(图可以是多对多),如下图所示:
根据上面树的图片,我们可以大致总结下树的特点:
1、图中的树就像我们实际生活中的树倒立过来一样,最顶部的节点就是树根,即根节点(root节点);
2、每棵树至多只有一个根节点,如果树不为空,也至少有一个根节点;
3、根节点下面可以有多个子节点,但是每个子节点只能有一个父节点,同时每个子节点下面又可以有多个它的子节点;
4、具有同一个节点的节点称为兄弟节点;
5、没有子节点的节点成为叶子节点(leaf);
1.2 树的相关术语
关于树,有几个比较重要的术语需要掌握:高度(Height)、深度(Depth)、层(Level)。它们的定义如下:
1、节点的高度 = 节点到叶子节点的最长路径(边数);
2、节点的深度 = 根节点到这个节点所经历的边的个数;
3、节点的层数 = 节点的深度 + 1;
4、树的高度 = 根节点的高度。
这几个概念比较容易混淆,可以利用下面这张图片帮助记忆。
可以从生活中这个词的含义进行理解:
“高度”:这个概念就是从下往上的度量,比如我们说一栋80层楼的高度,都是从地面开始的。只不过树的高度是把0作为起点而已;
“深度”:生活中我们往往说水深多少米,很明显是从上往下度量的,所以树也是从根节点开始度量的,并且计数起点也是0.可以看出来高度和深度正好是相反的两个方向开始对树进行度量的;
“层数”:和深度的计算比较类似,只不过计数起点从1开始,即根节点位于第一层。
除此之外树还有很多其他概念,比如:
节点的度:一个节点直接含有的子树个数(即有几个分支),叫做节点的度。
树的度:即一棵树中最大节点的度,哪个节点上的子节点最多,它的度就是树的度。
2、二叉树(Binary Tree)
2.1 二叉树的基本概念
树的结构多种多样,我们最常用的是二叉树,所以这里就用二叉树对树这种数据结构进行讲解了。
二叉树:是有限个节点的集合,这个集合可以是空集,也可以是一个根节点和至多两个子二叉树组成的集合,其中一颗树叫做根的左子树,另一棵叫做根的右子树。
从二叉树的定义可以看出来它是一个递归的定义,必须注意到左、右子树的概念,比如下图中的两棵不同的二叉树。
下面对二叉树中比较特殊的两种树结构进行说明:满二叉树和完全二叉树。
上图所示的3个树型结构都是二叉树,但是2号和3号比较特殊。
其中2号的二叉树中,叶子节点全都在最底层,除了叶子节点外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树。其准确定位为:
满二叉树定义:如果一棵树的高度为k,且拥有(2^k) - 1个节点,则称之为满二叉树。即每个节点要么有两个子节点,要么没有子节点。
其中3号的二叉树中,叶子节点都在最底层,并且最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树。要成为一棵完全二叉树,需要满足以下要求:
1、所有叶子节点都出现在k或者k-1层,而且从1到k-1层必须达到最大节点数;
2、第k层可以不是满的,但是第k层的所有节点必须集中在最左边。
即:叶子节点都必须在最后一层或者倒数第二层,而且必须在左边。任何一个节点不可能有右子树而没有左子树。
2.2 二叉树的存储方式
要想存储一棵二叉树,我们有两种办法:一种是基于指针或者引用的二叉链式存储法,另一种是基于数组的顺序存储法。
2.2.1 链式存储法
如下图所示,每个节点有三个字段,其中一个存储数据,另外两个指向左右节点的指针。我们只要只要拎着根节点,就可以通过左右节点的指针,把整棵树都串起来。大部分的二叉树代码都是通过链式存储法这种方法实现的。
链式存储法实现二叉树的Java代码:
package com.zju.tree;
/**
* @author 作者 pcwl
* @date 创建时间:2018年11月16日 下午12:25:26
* @version 1.0
* @comment 链表法实现二叉树
*/
public class BinaryTree_LinkedList {
private int data; // 二叉树的数据
private BinaryTree_LinkedList leftChildNode; // 二叉树的左子节点
private BinaryTree_LinkedList rightChildNode; // 二叉树的右子节点
public BinaryTree_LinkedList(int data, BinaryTree_LinkedList leftChildNode, BinaryTree_LinkedList rightChildNode){
data = this.data;
leftChildNode = this.leftChildNode;
rightChildNode = this.rightChildNode;
}
public int getData(){
return data;
}
public void setData(int data){
this.data = data;
}
public BinaryTree_LinkedList getLeftChildNode(){
return this.leftChildNode;
}
public void setLeftChildNode(BinaryTree_LinkedList leftChildNode){
this.leftChildNode = leftChildNode;
}
public BinaryTree_LinkedList getRightChildNode(){
return this.rightChildNode;
}
public void setRightChildNode(BinaryTree_LinkedList rightChildNode){
this.rightChildNode = rightChildNode;
}
}
2.2.2 数组的顺序存储法
如下图所示,我们把根节点存储在下标i=1的位置,那么左子节点存储在下标2*i = 2的位置上,右子节点存储在(2*i)+1=3的位置上。依次类推,B节点的左子节点存储在2*i=2*2=4的位置上,右子节点存储在(2*i)+1=5的位置上。
如果节点X存储在数组下标为i的位置,下标为2*i的位置存储的就是左子节点,下标为(2*i)+1的位置存储的就是右子节点。反过来,下标为i/2的位置存储的就是它的父节点。通过这种方式,我们只要知道根节点的存储位置(一般情况下,为了方便计算子节点,根节点会存储在下标为1的位置上),这样就可以通过下标计算,把整棵树串起来。
但是,可以发现上面的例子是一棵完全二叉树,所以仅仅“浪费”了一个下标为0的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。比如下面这个例子:
这个例子中的二叉树是一棵非完全二叉树,所以就浪费了数组中很多的存储空间。
因此,如果要存储的二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储那样需要有额外的左右子节点的指针开销。其实推排序用的就是一棵完全二叉查找树,最常用的存储方式就是数组。
数组的顺序存储法实现二叉树的Java代码:
/**
* @author 作者 pcwl
* @date 创建时间:2018年11月16日 下午1:57:03
* @version 1.0
* @comment 二叉树的数组实现
*/
public class BinaryTree_Array {
// 二叉树的数组实现,保存的不是左右子树的引用,而是数组的下标,通过下标就能找到它们之间的对应关系了
private int parentNode;
private int leftChildNode;
private int rightChildNode;
public int getParentNode() {
return parentNode;
}
public void setParentNode(int parentNode) {
this.parentNode = parentNode;
}
public int getLeftChildNode() {
return leftChildNode;
}
public void setLeftChildNode(int leftChildNode) {
this.leftChildNode = leftChildNode;
}
public int getRightChildNode() {
return rightChildNode;
}
public void setRightChildNode(int rightChildNode) {
this.rightChildNode = rightChildNode;
}
}
2.3 二叉树的遍历 ---- 面试常问
对于二叉树常用的操作,面试中问的比较多的就是二叉树的遍历,因此这里单独把它拿出来讲,后面再讲其他二叉树的操作。
二叉树的遍历常用的方法有三种:前序遍历、中序遍历和后序遍历。【实际上是按照当前节点的打印顺序来命名的】
1、前序遍历:对于树中的任意节点,先打印这个节点,然后再打印它的左子树,最后打印它的右子树;
2、中序遍历:对于树中的任意节点,先打印它的左子树,然后再打印它本身,最后打印它的右子树;
3、后序遍历:对于树中的任意节点,先打印它的左子树,然后打印它的右子树,最后打印这个节点本身。
从上图中可以看出来,二叉树的前、中、后序遍历都是一个递归的过程。比如:前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。
写递归代码的关键,就是看能不能写出递推公式,而递推公式的关键就是:如果要解决问题A,就假设子问题B和C已经解决,然后再来看看如何利用B和C来解决A。前、中、后序遍历的递推公式如下所示:
前序遍历的递推公式:
preOrder(r) = print r -> preOrder(r -> left) -> preOrder(r -> right)中序遍历的递推公式:
inOrder(r) = inOrder(r -> left) -> print r -> inOrder(r -> right)后序遍历的递推公式:
postOrder(r) = postOrder(r -> left) -> postOrder(r -> right) -> print r
下面看它们的具体Java代码实现:
2.3.1 前序遍历
1、先访问根节点;
2、再遍历左子树;
3、再遍历右子树;
4、退出。
// 遍历二叉树:前序遍历
public void iteratorFirstOrder(BinaryTree_LinkedList node){
if(node == null){
return;
}
System.out.println(node); // 打印root节点
iteratorFirstOrder(node.getLeftChildNode()); // 遍历左子树
iteratorFirstOrder(node.getRightChildNode()); // 遍历右子树
}
2.3.2 中序遍历
1、先遍历左子树;
2、再访问根节点;
3、再遍历右子树;
4、退出。
// 遍历二叉树:中序遍历
public void iteratorMediumOrder(BinaryTree_LinkedList node){
if(node == null){
return;
}
iteratorMediumOrder(node.getLeftChildNode()); // 遍历左子树
System.out.println(node); // 访问根节点
iteratorMediumOrder(node.getRightChildNode()); // 遍历右子树
}
2.3.3 后序遍历
1、先遍历左子树;
2、再遍历右子树;
3、访问根节点;
4、退出。
// 遍历二叉树:后序遍历
public void iteratorLastOrder(BinaryTree_LinkedList node){
if(node == null){
return;
}
iteratorLastOrder(node.getLeftChildNode()); // 遍历左子树
iteratorLastOrder(node.getRightChildNode()); // 遍历右子树
System.out.println(node); // 访问根节点
}
2.4 二叉树的其他操作
2.4.1 二叉树的创建
上面已经讲过链表和数组实现二叉树了。下面对二叉树的其他操作用基于链表实现的二叉树进行讲解。
public class BinaryTree_LinkedList {
private BinaryTree_LinkedList root; // 根节点
private int data; // 二叉树的数据
private BinaryTree_LinkedList leftChildNode; // 二叉树的左子节点
private BinaryTree_LinkedList rightChildNode; // 二叉树的右子节点
public BinaryTree_LinkedList(int data, BinaryTree_LinkedList leftChildNode, BinaryTree_LinkedList rightChildNode){
data = this.data;
leftChildNode = this.leftChildNode;
rightChildNode = this.rightChildNode;
}
public int getData(){
return data;
}
public void setData(int data){
this.data = data;
}
public BinaryTree_LinkedList getLeftChildNode(){
return this.leftChildNode;
}
public void setLeftChildNode(BinaryTree_LinkedList leftChildNode){
this.leftChildNode = leftChildNode;
}
public BinaryTree_LinkedList getRightChildNode(){
return this.rightChildNode;
}
public void setRightChildNode(BinaryTree_LinkedList rightChildNode){
this.rightChildNode = rightChildNode;
}
public BinaryTree_LinkedList getRoot() {
return root;
}
public void setRoot(BinaryTree_LinkedList root) {
this.root = root;
}
}
2.4.2 二叉树的添加元素
需要考虑:添加到左子树还是右子树;
// 添加元素:左子树
public void insertAsLeftChildNode(BinaryTree_LinkedList node) {
if (root == null) {
root = node; // 如果当前根节点为空,就将此节点当作根节点
}
root.setLeftChildNode(node);
}
// 添加元素:左子树
public void insertAsRighttChildNode(BinaryTree_LinkedList node) {
if (root == null) {
root = node; // 如果当前根节点为空,就将此节点当作根节点
}
root.setRightChildNode(node);
}
2.4.3 二叉树删除元素
删除哪个元素,就将它设置为null。
// 删除元素
public void deleteNode(BinaryTree_LinkedList node){
node = null;
}
2.4.4 清空二叉树
只需要将删除根节点即可,即将根节点设置为null。
// 清空二叉树
public void clearBinaryTree(){
root = null;
}
2.4.5 获得二叉树的高度
即获得根节点的度,需要递归左右子树,找出根节点最大的度,即为二叉树的高度。
// 获取树的高度,即为获取根节点的度
public int getTreeHeight(){
return getNodeHeight(root);
}
// 获取指定节点的度
public int getNodeHeight(BinaryTree_LinkedList node){
if(node == null){
return 0;
}
int leftChildHeight = getNodeHeight(node.getLeftChildNode()); // 获取当前节点左子树的度
int rightChildHeight = getNodeHeight(node.getRightChildNode()); // 获取当前节点右子树的度
int max = Math.max(leftChildHeight, rightChildHeight);
return max + 1; // 加上当前节点自己
}
2.4.6 获取二叉树的节点总数
遍历所有子树,再相加。
// 获取所有节点总数
public int getSize(){
return getChildSize(root);
}
// 获取指定节点的字节点个数
public int getChildSize(BinaryTree_LinkedList node){
if(node == null){
return 0;
}
int leftChildSize = getChildSize(node.getLeftChildNode());
int rightChildSize = getChildSize(node.getRightChildNode());
return leftChildSize + rightChildSize + 1; // 加上指定节点自己
}
2.6.7 获得某个节点的父节点
我们在构造树时,用的是左右子树表示节点,没有含有父节点的引用。因此,如果要想获取指定节点的父节点,那么需要从顶向下遍历各个子树,若该子树的根节点的孩子就是目标节点,则返回该节点,否则遍历它的左右子树。
说明:以下代码实现未考虑数据重复的情况。
// 获取指定节点的父节点
public BinaryTree_LinkedList getParentNode(BinaryTree_LinkedList node){
// 如果树为空或者这个节点就是根节点,则它没有父节点
if(root == null || root == node){
return null;
}else{
return getParent(root, node);
}
}
// 递归对比,节点的孩子节点与指定节点是否一致
public BinaryTree_LinkedList getParent(BinaryTree_LinkedList subTreeNode, BinaryTree_LinkedList node){
// 如果子树为空,则没有父节点
if(subTreeNode == null){
return null;
}
// 该节点的左右子节点与目标节点一致
if(subTreeNode.getLeftChildNode() == node || subTreeNode.getRightChildNode() == node){
return subTreeNode;
}
// 需要遍历subTreeNode的左右子树
BinaryTree_LinkedList parent;
// 从左子树查找
if((parent = getParent(subTreeNode.getLeftChildNode(), node)) != null){
return parent;
}else{
// 从右子树查找
return getParent(subTreeNode.getRightChildNode(), node);
}
}
说明:翻转二叉树、对称二叉树的内容来自于:https://mp.weixin.qq.com/s/ONKJyusGCIE2ctwT9uLv9g
2.6.8 翻转二叉树
对于一棵二叉树,翻转它的左右子树,如下图所示:
下面我们来分析下,二叉树的翻转过程:
1、首先我们要对根节点进行判空处理,在根节点不为空的情况下存在左右子树(即使左右子树为空),然后交换左右子树;
2、把根节点的左子树当成左子树的根节点,对当前根节点进行判空处理,不为空时交换左右子树;
第三次翻转时,11节点没有子节点,所以不用进行交换了。
3、把根节点的右子树当成右子树的根节点,对当前根节点进行判空处理,不为空时交换左右子树。
4、重复步骤2和3,最后二叉树变为原来的镜像结构,如图1所示。
// 二叉树的翻转
public BinaryTree_LinkedList reverseTree(BinaryTree_LinkedList root){
// 1、首先我们要对根节点进行判空处理,在根节点不为空的情况下存在左右子树(即使左右子树为空),然后交换左右子树;
if(root == null){
return null;
}
// 2、把根节点的左子树当成左子树的根节点,对当前根节点进行判空处理,不为空时交换左右子树;
if(root.leftChildNode != null){
reverseTree(root.leftChildNode);
}
// 3、把根节点的右子树当成右子树的根节点,对当前根节点进行判空处理,不为空时交换左右子树。
if(root.rightChildNode != null){
reverseTree(root.rightChildNode);
}
BinaryTree_LinkedList temp = root.leftChildNode;
root.leftChildNode = root.rightChildNode;
root.rightChildNode = root.leftChildNode;
return root;
}
2.6.9 判断两个二叉树是否完全对称
一棵左右完全对称的二叉树如下图所示:
分析过程如下:
1、先比较根节点的左右子节点;
2、将左子树根节点的左子节点和右子树根节点的右子节点进行比较; ---- 左的左和右的右进行比较
3、将左子树根节点的右子节点和右子树根节点的左子节点进行比较; ---- 左的右和右的左进行比较
4、重复以上过程......
// 判断两个二叉树是否对称
public boolean isSymmetrical(BinaryTree_LinkedList root1, BinaryTree_LinkedList root2){
if(root1 == null && root2 == null){
return true;
}
if(root1 == null || root2 == null){
return false;
}
if(root1.data != root2.data){
return false;
}
// 递归
return (isSymmetrical(root1.leftChildNode, root2.rightChildNode) && isSymmetrical(root1.rightChildNode, root2.leftChildNode));
}
2.6.10 二叉树的宽度
二叉树的深度上面已经介绍过了,下面介绍怎么求二叉树的宽度:
二叉树的宽度:具有最多节点数的层中包含的节点数。比如:下图所示的二叉树的宽度就是4,即第3层的节点数。
求解思路:使用队列,层次遍历二叉树。在上一层遍历完成后,下一层的所有节点放到队列中,此时队列的元素个数就是下一层的宽度。依次类推,依次遍历每一层即可求出二叉树的最大宽度。
// 求解二叉树的最大宽度
public int getWidth(BinaryTree_LinkedList root){
if(root == null){
return 0;
}
Queue<BinaryTree_LinkedList> queue = new ArrayDeque<BinaryTree_LinkedList>();
int maxWidth = 1; // 最大宽度
queue.add(root); // 入队
while(true){
int len = queue.size(); // 当前层节点的个数
if(len == 0) break;
// 如果当前层还有节点
while(len > 0){
BinaryTree_LinkedList node = queue.poll();
len--;
if(node.leftChildNode != null) queue.add(leftChildNode); // 下一层节点入队
if(node.rightChildNode != null) queue.add(rightChildNode); // 下一层节点入队
}
maxWidth = Math.max(maxWidth, queue.size()); // 取当前层的宽度和下一层宽度较大的
}
return maxWidth;
}
2.6.11 重建二叉树 ---- 面试题
参考:https://blog.csdn.net/snow_7/article/details/51822366
参考:https://blog.csdn.net/jsqfengbao/article/details/47088947
输入某二叉树的前序和中序遍历的结果,请重建出该二叉树。假设输入的前序遍历和中序遍历都不含重复的数字。
根据前序遍历数组,可以得到树的根节点,根据得到的根节点,去中序数组中找到相应的根节点,因为中序的遍历顺序是左-->根节点-->右,可以得到根节点的左子树和右子树的中序遍历数组,这样树的左子树中的节点个数和右子树中的节点个数就确定了。在前序数组中,左子树的前序和右子树的前序也可以确定了。
参数说明:前序遍历数组,子树在前序数组中的开始位置,结束位置;
中序遍历数组,子树在中序数组中的开始位置,结束位置。
可以根据根节点的前序和中序数组得根节点子树的前序和中序数组,所以需要使用递归。
public class BuildTree {
public TreeNode reConstructBinaryTree(int[] pre, int[] in) {
// 如果先序或者中序数组有一个为空的话,就无法建树,返回为空
if (pre == null || in == null || pre.length != in.length)
return null;
else {
return reBulidTree(pre, 0, pre.length - 1, in, 0, in.length - 1);
}
}
private TreeNode reBulidTree(int[] pre, int startPre, int endPre, int[] in, int startIn, int endIn) {
if (startPre > endPre || startIn > endIn)// 先对传的参数进行检查判断
return null;
int root = pre[startPre]; // 数组的开始位置的元素是根元素
int locateRoot = locate(root, in, startIn, endIn); // 得到根节点在中序数组中的位置,左子树的中序和右子树的中序以根节点位置为界
if (locateRoot == -1) return null; // 在中序数组中没有找到跟节点,则返回空
TreeNode treeRoot = new TreeNode(root);// 创建树根节点
treeRoot.left = reBulidTree(pre, startPre + 1, startPre + locateRoot - startIn, in, startIn, locateRoot - 1);// 递归构建左子树
treeRoot.right = reBulidTree(pre, startPre + locateRoot - startIn + 1, endPre, in, locateRoot + 1, endIn);// 递归构建右子树
return treeRoot;
}
// 找到根节点在中序数组中的位置,根节点之前的是左子树的中序数组,根节点之后的是右子树的中序数组
private int locate(int root, int[] in, int startIn, int endIn) {
for (int i = startIn; i < endIn; i++) {
if (root == in[i])
return i;
}
return -1;
}
}
推荐及参考:
1、《数据结构与算法之美》之树篇:https://time.geekbang.org/column/article/67856
2、重温数据结构:https://blog.csdn.net/u011240877/article/details/53193877
3、二叉树的基本操作:https://mp.weixin.qq.com/s/ONKJyusGCIE2ctwT9uLv9g
4、红黑树深入剖析:https://zhuanlan.zhihu.com/p/24367771