二叉搜索树(BST) = 二叉 + 搜索 + 树
二叉树模型
下面是一个简易的树杈子模型
可以看到,每个树杈最多有俩分支,还有的树杈没有分支,即每个树杈最多有不超过两个分支
把这样的树杈子结构倒过来,去掉树根,就变成了一种数据结构——二叉树,like下面这样↓
根据这种结构我们引出下面这些概念:
节点类:
- 分支与分支的交点称为树节点
- 没有分支相交的节点称为叶子节点
- 最顶上的节点称为根节点
- 上下相邻的节点称为父子节点(上面的是爹,下面的是儿子)
- 父节点的父节点称为祖先节点
- 有同一个爹的称为兄弟节点
子树类:
- 一个爹的左儿子,称为该爹的左子树
- 一个爹的右儿子,称为该爹的右子树
下图把各节点用圆圈表示出来,并注明了它在不同参考对象下,与其他节点的关系
二叉搜索树
二叉搜索树就是在二叉树的基础上,添加了可以搜索的功能
对于任意一个父节点 father,和它的左子节点 leftSon,右子节点 rightSon,左子树 leftTree,右子树 rightTree
如果满足 leftSon < father < rightSon,max( leftTree ) < father < min( rightTree ) 这俩条件
就称这颗二叉树为二叉搜索树
上面这棵树就是一颗二叉搜索树
他满足第一条:任意节点的 左节点值都小于它,右节点值都大于它
也满足第二条:任意节点的 左子树里的最大节点值小于它,右子树里的最小节点值大于它
其实二叉搜索树还有一个直观的特点,将每个节点向下投影,你会发现他们从左至右是升序的
二叉搜索树的用途
这种有序性造就了二叉搜索树的用途,如果每次搜索都从根节点出发,拿目标值跟当前节点比较
如果比当前节点小,往左边搜索
如果比当前节点大,往右边搜索
如果和当前节点相等,找到了!
这种搜索方式,不管往哪边走,最好的情况都是能每比较一次,就可以筛掉一半不符合条件的元素,相比线性搜索快很多
有利就有弊,二叉搜索树提高了搜索的速度,但是需要维护增加的元素,它不能像线性结构一样每次追加到头尾就行,他必须要保证新来的元素,要跟旧元素 “合群”。因此,它新增元素时需要花费更久的时间
二叉搜索树的实现
以下示例按照 K-V 键值对的方式构建二叉搜索树
1. 声明节点类
根据图示,我们的节点类应该有三部分,数据 + 左子节点 + 右子节点,和双向链表很像
public class BinarySearchTree<K extends Comparable<K>, V> {
private static class Node<K extends Comparable<K>, V>{
K key;
V value;
Node<K, V> left;
Node<K, V> right;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
这里要求 key 必须实现Comparable接口,因为要保证节点是可比较的(有序性)
2. 搭建BST结构
一颗二叉搜索树,它的入口节点就是根节点,每次搜索都要从根节点开始,所以我们将其作为属性保存到外部类中
public class BinarySearchTree<K extends Comparable<K>, V> {
private static class Node<K extends Comparable<K>, V>{
K key;
V value;
Node<K, V> left;
Node<K, V> right;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
private Node<K, V> root;
}
3. 增加一个节点
增加节点时同样需要从根节点入手,通过上面所述的两条要求,逐步向下找到合适的位置,将新节点安放过去
他满足第一条:任意节点的 左节点值都小于它,右节点值都大于它
也满足第二条:任意节点的 左子树里的最大节点值小于它,右子树里的最小节点值大于它
/**
* @return 如果 BST 中已存在给定的参数 key,则添加失败,返回 false
*/
public boolean put(K key, V value){
if(key == null){
throw new NullPointerException("传入的 key 不能为 null");
}
if(root == null){
root = new Node<>(key, value);
return true;
}
//临时变量记录父节点,为后续连接新叶子节点做备份跟进
Node<K, V> parent = null;
//用于迭代的当前节点
Node<K, V> current = root;
while (current != null) {
if(current.key.compareTo(key) > 0){
//如果当前节点的key大于给定的key,往左走
parent = current;
current = current.left;
}else if(current.key.compareTo(key) < 0){
//往右走
parent = current;
current = current.right;
}else{
//BST已有该key
return false;
}
}
//能正常退出循环,说明current已经为null,新节点作为叶子节点追加到BST的末端
//但是要追加到parent的left,还是right,还需要进一步判断
if(parent.key.compareTo(key) > 0){
//parent大于新节点,新节点添加到左边
parent.left = new Node<>(key, value);
}else{
parent.right = new Node<>(key, value);
}
return true;
}
4. 查找节点值
按key查找节点,本质就是对二叉搜索树有目标的遍历,还是按照相同的操作,让目标key(aimKey)和当前key(curKey)作比较
- 如果 curKey > aimKey,向左查找
- 如果 curKey < aimKey,向右查找
- 如果 curKey = aimKey,找到了
- 如果一直遍历到叶子节点都没能找到,返回null
//从 root 入手
public V get(K key) {
return get(root, key);
}
private V get(Node<K, V> cur, K aimKey) {
//遍历到尾,依然没找到 aimKey
if(cur == null){
return null;
}
if(cur.key.compareTo(aimKey) > 0){
//curKey > aimKey,往左走
return get(cur.left, aimKey);
}else if(cur.key.compareTo(aimKey) < 0){
//curKey < aimKey,往右走
return get(cur.right, aimKey);
}else{
//curKey = aimKey,找到了
return cur.value;
}
}
5. 删除节点
删除节点相对繁琐,如果是删除末端节点(叶子节点)还好,如果是非叶子节点,还要考虑被删节点之后的节点怎么办
在这里,我们还需要引出另外两个概念
- 前驱节点:对于节点A,它左子树中最大的那个节点B,B是A的前驱(但是B依然小于A)
- 后继节点:对于节点A,它右子树中最小的那个节点C,C是A的后继(但是C依然大于A)
无论前驱还是后继,他最多只有一个子节点
知道了前驱、后继有啥用
如果删除掉125,好像很轻松,直接令 150.left = null
就OK了,那是因为125没有后代节点(叶子节点)
如果删除150呢?我们需要保留150的后代节点,150只有一个直接的儿子,我们把150的儿子交给100保管就行了 100.right = 125
最复杂的来了,如果删除200呢?到底让100加冕成新root,还是报送300?可是二者都已经有了原来组建好的家庭,强拆会带来混乱
这时前驱、后继就是双方家庭选出的代表,他们是旧 root 的“左膀右臂”,他们的值最挨着 root 的值!这也就意味着,将前驱或后继提升为新root,对整个BST影响最小
妙哉~~
public V remove(K key) {
if(root == null || key == null){
return null;
}
//扫描目标
Node<K, V> parent = null;
Node<K, V> current = root;
while(current != null){
if(current.key.compareTo(key) > 0){
parent = current;
current = current.left;
}else if(current.key.compareTo(key) < 0) {
parent = current;
current = current.right;
}else{
//找到了!current就指向待删除的节点
break;
}
}
//如果不是break出来的
if(current == null) return null;
//current有两个直接儿子,把前驱或后继的值赋给当前节点,再删除前驱或后继
if(current.left != null && current.right != null){
//备份目标节点
Node<K, V> aim = current;
//找前驱(从左子树开始,一直往右走),记得时时更新parent
parent = current;
current = current.left;
while(current.right != null){
parent = current;
current = current.right;
}
//current已经指向了前驱
aim.key = current.key;
aim.value = current.value;
}
//走到这,current所指向的节点,最多只有一个子节点,把current删除
if(current == root){
root = root.left != null ? root.left : root.right;
}
//判断current是parent的左儿子还是右儿子
else if(current == parent.left){
//如果current有左儿子,就让parent.left指向左儿子,否则指向右儿子
parent.left = current.left != null ? current.left : current.right;
}else{
parent.right = current.left != null ? current.left : current.right;
}
return current.value;
}