树是一种数据结构,树的相关基础知识的和体系介绍可以查看百度百科关于树的介绍或者文章最后的文献索引,这里不再详述。
我们会陆续讲解二叉查找树,2-3查找树,红黑树,B树,B+树和B*树。
二叉查找树(Binary Search Tree),简称BST(又称二叉搜索树),是二叉树的一种,其中每个节点的值大于左子树的值(如果左子树存在)而小于右子树的值(如果右子树存在)。
相对更简单的理解和实现使用递归方式,但是如果数据量大的话递归方式就不适用了,所有呢这里实现不采用递归方式。如果想要了解递归实现的话,可以查看文章末尾算法第4版或者视频。
API同之前有序符号表的API一致,可以查看之前的文章基础-符号表(一)-数据结构和算法(Java)
树节点代码如下:
/**
* 内部节点类
* @param
* @param
*/
static class Node<K, V> {
/**
* 键
*/
K key;
/**
* 值
*/
V value;
/**
* 左子树
*/
Node<K, V> left;
/**
* 右子树
*/
Node<K, V> right;
/**
* 该节点为根的树中节点总数
*/
int size;
Node(K key, V value, Node<K, V> left, Node<K, V> right, int size) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
this.size = 1;
}
Node(K key, V value) {
this(key, value, null, null, 1);
}
}
查找一个键有两种结果,如果包含键的节点存在与表中,我们的查找就命中,返回相应的值。否则未命中,返回null。
如果表为空,直接返回null
根节点root赋值给当前节点变量cur,只要当前节点不为null,循环执行一下操作
比较当前节点的键和目标键key的大小
如果目标键小于当前节点的键,说明目标节点在当前节点的左子树中,把当前节点的左子节点cur.left赋值给cur,即当前节点的左子节点变为了下次循环的当前节点
如果目标键大于当前节点的键,说明目标节点在当前节点的右子树中,把当前节点的右子节点cur.right赋值给cur,即当前节点的右子节点变为了下次循环的当前节点
如果目标键等于当前节点的键,说明命中目标节点,直接返回当前节点的值。
如果循环结束即查找到某个空节点,仍然为命中目标,说明该键不在表中,返回null。
代码2.2.2-1如下:
/**
* 获取key对应的值
* @param key 键
* @return 指定键对应的值
*/
@Override
public V get(K key) {
return get(root ,key);
}
/**
* 从根结点开始查找键对应的值
* @param root 根结点
* @param key 指定的键
* @return 键对应的值
*/
private V get(Node<K,V> root, K key) {
if (size == 0) {
return null;
}
Node<K, V> cur = root;
while (cur != null) {
if (key.compareTo(cur.key) < 0) {
cur = cur.left;
} else if (key.compareTo(cur.key) > 0) {
cur = cur.right;
} else {
return cur.value;
}
}
return null;
}
实现代码2.2.3-1如下:
/**
* 插入键值对,如果key存在则替换旧值;如果不存在,在合适位置插入新的键值对
* @param key 键
* @param value 值
*/
@Override
public void put(K key, V value) {
// 判断表是否为空
if (root == null) {
root = new Node<>(key, value);
return;
}
// 父节点
Node<K, V> f = null;
// 当前节点
Node<K, V> cur = root;
// 新插入节点是否为左节点:true-左节点,false-右节点
boolean left = true;
// 查找key是否在表中
while (cur != null) {
cur.size++;
if (key.compareTo(cur.key) < 0) {
// 给定的键小于当前节点的键,继续在左子树查找
f = cur;
cur = cur.left;
left = true;
} else if (key.compareTo(cur.key) > 0) {
// 给定的键大于当前节点的键,继续在右子树查找
f = cur;
cur = cur.right;
left = false;
} else {
// 给定的键等于当前节点的键,新值替换旧值
cur.value = value;
return;
}
}
// key不在表中,插入新节点
Node<K, V> newNode = new Node<>(key, value);
if (left) {
f.left = newNode;
} else {
f.right = newNode;
}
}
算法分析:代码给的注释如果明了可跳过以下解释
命中节点,未命中,插入为左子树,未命中,插入为右子树如下2.2.3-2所示:
使用二叉查找树的算法的运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序。在最好的情况下,一棵含义N个节点的树是完全平衡的,叶子结点和根节点的距离都为 ∼ lg N \sim\lg N ∼lgN。在最坏情况下,搜索路径上可能有N个节点。
我们假设键的分布是(均匀)随机的,或者说它们的插入顺序是随机的。二叉查找树和快速排序几乎一样,树的根节点就是快速排序中的第一个切分元素(左侧的键都比它小,右侧的键都比它大),而这对于所有的子树同样适用。
命题C:在由N个随机键构造的二者查找树中,查找命中平均所需的比较次数为 ∼ ln N ( 约 为 1.39 lg N ) \sim\ln N(约为1.39\lg N) ∼lnN(约为1.39lgN)
证明:暂时不证明,因为本人没研究明白呢
二叉查找树得以广泛应用的一个重要的原因就是它能够保持键的有序性,因此它可以做为实现有序符号表API中的众多方法的基础。这使得符号表的用例不仅能够通过键还能同键的相对顺序来访问键值对。
非递归实现代码如下4.1-1所示:
/**
* 获取最小键
* @return 最小键
*/
@Override
public K min() {
if (isEmpty()) {
return null;
}
Node<K, V> f = null;
Node<K, V> cur = root;
while (cur != null) {
f = cur;
cur = cur.left;
}
return f.key;
}
/**
* 获取最大键
* @return 最大键
*/
@Override
public K max() {
if (isEmpty()) {
return null;
}
Node<K, V> f = null;
Node<K, V> cur = root;
while (cur != null) {
f = cur;
cur = cur.right;
}
return f.key;
}
如果目标键在树中,向上取整和向下取整都是目标键本身;否则,向下取整就是取小于给定值的键的最大键,即寻找目标节点的前驱节点。向上取整就是取大于给定键的最小键,即寻找目标节点的后继节点。
关于前驱和后继节点不了解的自己去搜索一下。
算法:
逻辑就是如果目标key小于当前节点的key,那么小于等于key最大键一定(如果有)出现在当前节点的左子树;
如果目标key大于当前节点的key,那么只有当前节点右子树中有小于等于目标key的节点存在时,小于等于目标key的最大键才会出现在当前节点的右子树中,否则当前节点就是小于等于目标key的最大键。
代码4.2.1如下:
/**
* 向下取整,获取小于给定键的最大键
* @param key 目标键
* @return 小于给定键的最大键
*/
@Override
public K floor(K key) {
if (isEmpty()) {
return null;
}
Node<K, V> cur = root;
Node<K, V> prev = null;
while (cur != null) {
if (key.compareTo(cur.key) < 0) {
cur = cur.left;
} else if (key.compareTo(cur.key) > 0) {
prev = cur;
cur = cur.right;
} else {
return cur.key;
}
}
return prev == null ? null: prev.key;
}
直接给代码,可对比向下取整,代码。4.2.2-1如下:
/**
* 向上取整,获取大于给定键的最小值
* @param key 目标键
* @return 大于给定键的最小值
*/
@Override
public K ceiling(K key) {
if (isEmpty()) {
return null;
}
Node<K, V> cur = root;
Node<K, V> next = null;
while (cur != null) {
if (key.compareTo(cur.key) < 0) {
next = cur;
cur = cur.left;
} else if (key.compareTo(cur.key) > 0) {
cur = cur.right;
} else {
return cur.key;
}
}
return next == null ? null: next.key;
}
代码4.3.1如下:
/**
* 返回排序为k的键
* @param k 排名
* @return 排序为k的键
*/
@Override
public K select(int k) {
if (isEmpty()) {
return null;
}
int i = k;
Node cur = root;
while (cur != null) {
int l /**
* 返回排序为k的键
* @param k 排名
* @return 排序为k的键
*/
@Override
public K select(int k) {
if (isEmpty()) {
return null;
}
int n = k;
Node cur = root;
while (cur != null) {
int nl = cur.left == null ? 0: cur.left.size;
if (nl > n) {
cur = cur.left;
} else if (nl < n) {
n = n - nl - 1;
cur = cur.right;
} else {
return cur.key;
}
}
return null;
}= cur.left == null ? 0: cur.left.size;
if (l > i) {
cur = cur.left;
} else if (l < i) {
i = i - l - 1;
cur = cur.right;
} else {
return cur.key;
}
}
return null;
}
排序为k,因为键有序,就是我们说的索引,返回索引为k的节点对应的键,算法如下:
代码如下:
/**
* 小于key的键的数量
* @param key 目标key
* @return 小于key的键的数量
*/
@Override
public int rank(K key) {
if (isEmpty()) {
return 0;
}
Node<K, V> cur = root;
int n = 0;
while (cur != null) {
if (key.compareTo(cur.key) < 0) {
cur = cur.left;
} else if (key.compareTo(cur.key) > 0) {
n += 1 + (cur.left == null ? 0: cur.left.size);
cur = cur.right;
} else {
break;
}
}
return n;
}
算法如下:
以删除最小键为例,算法逻辑
代码如下:
/**
* 删除最小的键对应的节点
* @return 要删除的最小键对应的值
*/
@Override
public V deleteMin() {
if (isEmpty()) {
// 树为空
return null;
}
// 祖父节点
Node<K, V> s = null;
// 父节点
Node<K, V> f = null;
// 当前节点
Node<K, V> cur = root;
while (cur != null) {
s = f;
f = cur;
cur = cur.left;
}
if (s == null) {
// 根节点为最小键
root = root.right;
} else if (f.right == null){
s.left = null;
} else {
s.left = f.right;
}
V oldVal = f.value;
// 清空最小键节点,等待GC
f.key = null;
f.value = null;
f.right = null;
return oldVal;
}
树为空和根节点为最小键的情况,这里不再画示意图。最小键节点左连接肯定是null,下面给出有链接为null和不为空的示意图:
删除最大键参考删除最小键,不再详述,代码4.5-2如下:
/**
* 删除最大键对应的键值对
* @return 最大键对应的值
*/
@Override
public V deleteMax() {
if (isEmpty()) {
// 树为空
return null;
}
// 祖父节点
Node<K, V> s = null;
// 父节点
Node<K, V> f = null;
// 当前节点
Node<K, V> cur = root;
while (cur != null) {
s = f;
f = cur;
cur = cur.right;
}
if (s == null) {
// 根节点为最小键
root = root.left;
} else if (f.left == null){
s.right = null;
} else {
s.right = f.left;
}
V oldVal = f.value;
// 清空最小键节点,等待GC
f.key = null;
f.value = null;
f.left = null;
return oldVal;
}
算法逻辑:
目标key节点存在于树中,且在子树有后继或者前驱,最终效果是用其后继或者前驱的键值对替换了目标节点的键值对,然后删除相应的后继或者前驱节点。所以我们的删除操作采用这样方式,而不是先删除目标节点在对树做调整。
代码4.6-1如下:
/**
* 删除键对应的键值对
* @param key 键
* @return 要删除键对应的值
*/
@Override
public V delete(K key) {
if (root == null) {
return null;
}
// 父节点
Node<K, V> xp = null;
// 当前节点
Node<K, V> x = root;
// 路径上的节点
Stack<Node<K, V>> minus = new Stack<>();
while (x != null) {
minus.push(x);
if (key.compareTo(x.key) < 0) {
xp = x;
x = x.left;
} else if (key.compareTo(x.key) > 0) {
xp = x;
x = x.right;
} else {
if (x.right != null) {
// 查找后继节点
Node<K, V> xrp = x;
Node<K, V> xr = x.right;
while (xr != null) {
minus.push(xr);
xp = xrp;
xrp = xr;
xr = xr.left;
}
// 交换内容
K xk = x.key;
V xv = x.value;
x.key = xrp.key;
x.value = xrp.value;
xrp.key = xk;
xrp.value =xv;
x = xrp;
} else if (x.left != null) {
// 查找前驱节点
Node<K, V> xlp = x;
Node<K, V> xl = x.left;
while (xl != null) {
minus.push(xl);
xp = xlp;
xlp = xl;
xl = xl.right;
}
// 交换内容
K xk = x.key;
V xv = x.value;
x.key = xlp.key;
x.value = xlp.value;
xlp.key = xk;
xlp.value =xv;
x = xlp;
}
// 删除目标节点
if (xp != null) {
if (x.left != null) {
if (x == xp.left) {
xp.left = x.left;
} else {
xp.right = x.left;
}
} else if (x.right != null) {
if (x == xp.left) {
xp.left = x.right;
} else {
xp.right = x.right;
}
} else{
if (x == xp.left) {
xp.left = null;
} else {
xp.right = null;
}
}
} else {
// 树只有一个根节点,删除的节点也是根节点
root = null;
}
// 记录key对应的值,清空目标节点,等待GC
V oldVal = x.value;
x.key = null;
x.value = null;
x.left = null;
x.right = null;
// 路径上所有节点计数-1
for (Node<K, V> m : minus) {
m.size--;
}
return oldVal;
}
}
return null;
}
后面涉及树的遍历问题,我们会单独写一篇讲解,讲解玩树的遍历问题后,在讲解后续方法。