一、二叉树BinaryTree
前言
没啥的,一定要记博客上,不然像上次那样把东西乱删哭死你
一、树结构基本介绍
到目前为止,我们所学的数据结构都为线性数据结构,元素之间是一对一的关系,但是在计算机科学中还存在非线性数据结构,其中最重要的就是树,抽象出来大概为这样(图解)
- 树是一种在层次结构中存储元素的数据结构,元素称为节点,线条称为路径,每个节点包含一个数据
- 树的顶部节点,比如上图的
1
,称为根节点 - 根节点有左右两个子节点(也可称为孩子),根节点可以叫做他们的父节点
- 来看看底部的节点,这几个节点,没有孩子节点,那么这些节点就称为叶子节点
- 还有更多关于树的专业术语,就先不探讨了,只有遇到时才好理清
我们可以发现,上图中,每个节点最多包含两个子节点,我们把这种树称为二叉树。
二、树的应用场景
应用于数据库,网站,图形用户界面等
- 表示分层数据
- 家庭中的人员关系树
- 磁盘上的文件文件夹
- 数据库中
- 数据库使用树索引,来快速查找数据
- 实现所有匹配
- 以Chrome浏览器为例,Chrome浏览器将你过去在网络中的搜索记录都存储在树中,只要你键入查询,它都会在树中进行匹配查找出类似记录
三、树分类
- Binary Tree:二叉树
- AVL Tree:AVL树
- Heaps:堆
- Tries树
- Graphs:图
1、二叉查找树 Binary Search Tree
一、二叉查找树基本介绍
下图是一个二叉查找树
- 这种线路类型的树,被称为二叉查找树,因为它能快速的查找树中的任何数据
- 任何节点的值始终会大于左子节点,并小于右子节点
- 同样,节点的左子节点可以归类为一棵左子树,左子树的值全都小于父节点(根节点),右子树也同理
二、二叉查找树的CRUD操作
如果想查询数字 1
:
- 首先判断根节点
7
,比1
大还是小,为小 - 则取左子节点
4
与之判断,比1
大还是小,为小 - 则取继续左子节点
1
继续判断,找到1
。
这棵树有7个节点,而我们只比较了3次就找到了目标节点,因为我们每次比较都舍弃了一半节点,这就是我们所说查询的对数时间复杂度【O(log n)】
如果想插入数字15
:
道理是一样的,复杂度的话
首先在查找上以对数时间运行,额外增加一个一个插入操作,以常数时间运行
这意味这插入数据,也以对数时间运行
删除节点也是如此,主要的就是做拼接操作
三、二叉查找树的时间复杂度
- lookup:O(log n)
- insert:O(log n)
- delete:O(log n)
二叉查找树提供了比链表和数组更良好性能,但是要注意的是:普通的二叉查找树如果使用不当,处理元素则会达到O(n)的效果
2、查找树CRUD实现
2.1、构建树
public class BinaryTree<Key extends Comparable,Value> {
private Node root;
private int count;
private class Node {
private Key key;
private Value value;
private Node leftChild;
private Node rightChild;
public Node(Key key,Value value) {
this.key = key;
this.value = value;
}
}
}
2.2、实现插入查找
普通循环实现插入查找:
// 往树插入参数中的内容
public void insert(Key key,Value value) {
// 根节点为空,直接添加
if (isEmpty()) {
root = new Node(key, value);
count++;
return;
}
Node parent = getParent(key);
int num = key.compareTo(parent.key);
if (num == 0) {
parent.value = value;
return;
}else if (num < 0) {
parent.leftChild = new Node(key, value);
} else {
parent.rightChild = new Node(key, value);
}
count++;
}
//----------------------------------------------------------------
// 根据key返回值
public Value find (Key key) {
// 根节点为空,直接添加
if (isEmpty()) {
throw new IllegalStateException("树为空异常");
}
Node parent = getParent(key);
if (parent.key == key)
return parent.value;
throw new IllegalArgumentException("不存在该节点");
}
//----------------------------------------------------------------
// 返回某个key的所在节点,当然该key可能不存在,则返回该key的父节点
private Node getParent (Key key) {
Node current = root;
// 首先在一个大循环中判断两个条件,直到判断到叶子节点
while (true) {
int num = key.compareTo(current.key);
// 返回空表示当前节点已存在
if (num == 0) {break;}
if (num < 0) {
if (current.leftChild == null) {
break;
}
current = current.leftChild;
}else {
if (current.rightChild == null) {
break;
}
current = current.rightChild;
}
}
return current;
}
递归实现插入查找:
// 调用递归实现添加元素
public void put (Key key, Value value) {
if (root == null) {
root = new Node(key,value);
count++;
return;
}
put(root, key, value);
}
private void put (Node t, Key key, Value value) {
Node keyParent = getKeyOrKeyParent(t, key);
Node node = new Node(key, value);
int num = key.compareTo(keyParent.key);
if (num < 0) {
keyParent.leftChild = node;
} else if (num > 0) {
keyParent.rightChild = node;
} else {
keyParent.value = value;
return;
}
count++;
}
//------------------------------------------------------------
// 调用递归实现查找元素
public Value get (Key key) {
return get (root,key);
}
private Value get (Node t,Key key) {
if (t == null)
throw new IllegalStateException("树为空");
Node x = getKeyOrKeyParent(t,key);
if (x.key == key)
return x.value;
else return null;
}
//-------------------------------------------------------------
// 递归实现传入一个Key,如果该Key存在,返回
// 不存在则返回该Key的父节点
private Node getKeyOrKeyParent (Node t,Key key) {
int num = key.compareTo(t.key);
if (num == 0) {
return t; // 该key存在
} else if (num < 0) {
if (t.leftChild == null) return t;
return getKeyOrKeyParent(t.leftChild,key);
} else {
if (t.rightChild == null) return t;
return getKeyOrKeyParent(t.rightChild,key);
}
}
2.3、实现删除
二叉树删除节点过程使用递归实现
- 这个有点小难度了,所以会讲的复杂详细点
- 首先要知道,删除某个节点之后,需要考虑两种情况
- 1.该节点的位置将被谁替代
- 2.如何拼接剩余节点
- 其次删除节点一共有三种情况
- 1.待删除节点下的左右孩子有为空
- 如果该节点的左子树为空,那么直接把右子树替换掉待删除节点的位置
- 同理
- 2.待删除节点的左右孩子都不为空
- 2.1.首先得到要替换 待删除节点位置 的值
- 例图:
- 如果我们要删除的节点为
3
, - 那么首先得到待删除节点的右子树
- 然后从该右子树一直遍历左子节点,最后一个节点值就可用来替代待删除节点,比如为
3.5
,就可作为1
和4
的父节点
- 然后从该右子树一直遍历左子节点,最后一个节点值就可用来替代待删除节点,比如为
- 2.2.然后就要断开连接
- 拼接的方案大同小异,就如上面只要得到,待替换节点
3.5
的父节点4
,取消绑定 - 但也会存在特殊情况,比如这颗右子树就没有左子节点,,待替换节点就为
4
则直接断开3
和4
的连接
- 拼接的方案大同小异,就如上面只要得到,待替换节点
- 2.3.然后引用待删除节点的左右子树
- 2.4.并覆盖该位置,这边我们通过递归实现,返回给调用者(父节点)就行
- 2.1.首先得到要替换 待删除节点位置 的值
- 3.待删除节点为根节点的话
- 可以从左子树找替换的值:找最大
- 也可以从右子树找替换的值:然后一直往左子节点找。使用这种方案就和上面一样
- 1.待删除节点下的左右孩子有为空
public void remove (Key key) {
// 执行删除该值
root = remove(root, key);
}
private Node remove(Node t, Key key) {
if (t == null) return t;
int num = key.compareTo(t.key);
if (num < 0) {
t.leftChild = remove(t.leftChild, key);
} else if (num > 0) {
t.rightChild = remove(t.rightChild, key);
} else {
count--;
// 第一种情况,待删除节点左右有为空
if (t.leftChild == null) return t.rightChild;
if (t.rightChild == null) return t.leftChild;
// 第二种情况,删除的节点有左右孩子
// 2.1.从待删除节点的右子树开始遍历到左子树最后一个节点,进行替换
Node current = t.rightChild;
while (current.leftChild != null) {
current = current.leftChild;
}
// 2.2.取消要作为替换节点的父引用绑定
Node temp = t.rightChild;
// 2.2.1.如果待替换节点的父节点就是t.rightChild,那么直接置为空
if (temp.leftChild == null) {
t.rightChild = null;
// 2.2.2.如果不为空,那么就找到待替换节点,进行取消
}else {
while (true) {
if (temp.leftChild == current) {
temp.leftChild = null;
break;
}
temp = temp.leftChild;
}
}
// 连接
current.leftChild = t.leftChild;
current.rightChild = t.rightChild;
// 覆盖
t = current;
}
return t;
}
3、实现查找最大最小键
/**
* 得到树中最小键
* @return 返回键
*/
public Key getMin() {
return min(root).key;
}
private Node min(Node t) {
// 根据二叉查找树定义,任何节点右子树一定 大于该节点 和 该节点的左子树
if (t.leftChild == null) return t;
return min(t.leftChild);
}
/**
* 得到树中最大键
* @return 返回键
*/
public Key getMax () {
return max(root).key;
}
private Node max (Node t) {
if (t.rightChild == null) return t;
return max(t.rightChild);
}
4、二叉树普通遍历
一、二叉树遍历介绍
遍历一个二叉树一共有三种方法
- 前序遍历:先访问根节点,再访问左子树,再访问右子树
- 中序遍历:先访问左子树,再访问根节点,最后访问右子树
- 这个最重要,遍历出来的数据都是有序的
- 后序遍历:先访问左子树,再访问右子树,最后访问根节点
各种遍历顺序大致图解:
4.1、前序遍历
理解以下这些遍历,只要精通递归过程就不是问题了
- 递去、归来
/**
* 前序遍历
* @return 返回一个队列Key
*/
public Queue<Key> preErgodic () {
Queue<Key> keys = new ArrayDeque<>();
preErgodic(root,keys);
return keys;
}
private void preErgodic (Node t,Queue<Key> queue) {
// 前序遍历是先得到根节点,再得到左子树,再得到右子树
if (t==null) return;
// 前序遍历首先一边递去左子树,一边添加节点
// 然后归来的时候,再依次添加右子树节点
// 当归来到第一次进入的根节点时,将会执行这里,(看下面注释)
queue.offer(t.key);
preErgodic(t.leftChild,queue);
preErgodic(t.rightChild, queue); // 这里,再次重复以上逻辑,也就是执行根节点的右子树
}
4.2、中序遍历
/**
* 中序遍历
*/
public Queue<Key> infixErgodic () {
Queue<Key> keys = new ArrayDeque<>();
infixErgodic(root,keys);
return keys;
}
private void infixErgodic (Node t,Queue<Key> queue) {
// 中序遍历: 左子树-->根-->右子树
if (t == null) return;
// 中序遍历首先递去左子树的叶子节点
// 回来时执行添加,回来途中如果还存在右子树,则再次递归判断
infixErgodic(t.leftChild,queue);
queue.offer(t.key);
infixErgodic(t.rightChild,queue);
}
4.2、后序遍历
public Queue<Key> suffixErgodic () {
ArrayDeque<Key> keys = new ArrayDeque<>();
suffixErgodic(root,keys);
return keys;
}
private void suffixErgodic (Node t,Queue<Key> queue) {
if (t == null) return;
// 后续遍历,先遍历左子树--->右子树--->根
suffixErgodic(t.leftChild,queue);
suffixErgodic(t.rightChild,queue);
queue.offer(t.key);
}
5、二叉树层序遍历
一、层序遍历介绍
所谓层序遍历,就是从树的第一层开始,依次获取该层上所有节点的值后,再向下一层遍历
如该图的遍历结果就为: EBGADFHC
二、代码实现
- 精髓:多出一个额外空间保存每层的节点
- 使用两个队列完成操作
- 一个队列一边弹出,一边判断,这个弹出的值是否有左右子节点,存在则添加到该队列后面
- 使用以上操作可保证每个节点的左右子节点都可以按顺序存放到另一个队列中
- 另一个队列接收弹出的值
// 层序遍历,使用循环
public Queue<Key> layerErgodic () {
// 判断每一层
ArrayDeque<Node> keys = new ArrayDeque<>();
ArrayDeque<Key> newKeys = new ArrayDeque<>();
// 一个队列一边保存每个层的节点,一边弹出,
// 弹出时再判断是否含有节点,继续保存
// 弹出的值添加到另一个队列
keys.offer(root);
while (true) {
// 弹出keys队列的每一个元素,并判断是否还有节点
Node node = keys.poll();
if (node.leftChild != null) {
keys.offer(node.leftChild);
}
if (node.rightChild != null) {
keys.offer(node.rightChild);
}
newKeys.offer(node.key);
if (keys.isEmpty()) {break;}
}
return newKeys;
}
其实呢!! 在我们普通遍历时采用的那几种方法,采用了深度优先遍历思想
而二叉树的层序遍历,采用了广度优先遍历思想
具体的这些玩意,等实际应用到再谈,比如图
6、二叉树的最大深度问题
一、树的最大深度
树的最大深度指的是从根节点到树的最底部那一层的深度
二、代码实现
- 1.核心逻辑就是:先遍历到叶子节点,返回时
- 如果此时左子节点不为空就为左子树深度+1,把结果再赋给调用者
- 右子树也同理
- 谁空的少,谁就加的多
- 2.然后让某个节点的左子树最大深度和右子树最大深度比较,决定谁给的值给这个父节点
- 3.最终判断根节点左子树和右子树最大的值,并加1
- 新学到的内容
- 递归存在局部变量时
- 虽然局部变量无法访问外界,但是我们可以通过return返回到外界方法接收
- 此时每个节点上都保存着当前状态下的最大深度值
/**
* 返回树的最大深度
* @return
*/
public int maxDepth () {
return maxDepth(root);
}
private int maxDepth (Node t) {
if (t == null) return 0;
int max;
int maxL = 0;
int maxR = 0;
// 核心逻辑就是得到左子树最大深度和右子树最大深度然后比较
maxL = maxDepth(t.leftChild);
maxR = maxDepth(t.rightChild);
max = maxL > maxR ? maxL + 1: maxR + 1;
return max; // 记得把max返回给某个方法,由于JVM栈中普通变量是无法访问外界的
}
7、递归
学习二叉树最重要的就是理解递归
- 知道遍历树时递归的一套流程是如何执行的
- 递去的时候树做了什么事
- 归来的时候树做了什么事
- 返回值到谁那了,如何应用
- 虽然来自于同一个方法,但是局部变量是属于自己的方法内,不会因为外界对这个遍历改变,导致自己也改变
当然针对二叉树功能实现,循环也是可以实现,但递归可是精髓
8、小结
等实际项目用到的时候再谈