一、基本定义
二叉搜索树(Binary Search Tree)又称二叉查找树,是一种在查找与插入时都具备高效率的数据结构,具有以下特点:
- 任意节点的左子树的节点值小于当前该节点的节点值;
- 任意节点的右子树的节点值小于当前该节点的节点值;
- 任意节点的左右子树也必须是二叉查找树;
- 没有键值相等的节点。
二、代码实现
1. 基本实现
我们首先来定义一个基本的二叉搜索树,首先定义一个root
根节点。然后定义一个Node私有类以表示数中的每个节点,Node中定义Key与Value,Key需要实现Comparable接口以便后面插入删除节点时做比较,value存储每个节点中要存储的值,然后需要定义两个指向左右节点的指针。最后增加一个节点计数器,用来表示以此结点为根的子树中的结点总数。
public class BinarySearchTree<Key extends Comparable<Key>, Value> {
private Node root;
private class Node {
private Key key;
private Value value;
private Node left, right; // 左右子节点
private int N; // 结点计数器,表示以该结点为根节点的子树的结点总数
public Node(Key key, Value value, int N) {
this.key = key;
this.value = value;
this.N = N;
}
// 返回数中节点的总数
public int size() {
return size(root);
}
// 返回以node结点为根节点的子树的节点总数
private int size(Node node) {
if (node == null) {
return 0;
} else {
return node.N;
}
}
}
}
2. 查找元素
查找元素的实现比较简单,这里采用递归算法。
从根节点开始,如果树是空的,则返回null表示未命中;如果被查找的键和当前根节点相等,查找命中;如果没有命中就继续根据比较的值决定在左子树还是右子树中继续查找。具体表示为:如果比较返回的结果小于0,代表要查找的节点的key小于当前判断节点的key,那么根据二叉树的特性(左子树小于当前节点),继续在左子树中查找,反之则在右子树中查找,代码实现如下:
// 查找元素
public Value get(Key key) {
// 从根节点开始按key查找
return get(root, key);
}
private Value get(Node node, Key key) {
if (node == null) {
return null;
}
// 和当前节点比较
int cmp = key.compareTo(node.key);
if (cmp < 0) {
// 递归在左子树查找
return get(node.left, key);
} else if (cmp > 0) {
// 递归在右子树查找
return get(node.right, key);
} else {
// 命中
return node.value;
}
}
3. 插入元素
插入元素的实现也很简单,利用递归算法,与查找方法类似:从根结点开始判断,如果树是空的,就返回一个含有该键值对的新结点;如果被查找的键小于根结点的键,就在其左子树中插入该键,否则在右子树插入该键,代码实现如下:
public void put(Key key, Value value) {
root = put(root, key, value);
}
private Node put(Node node, Key key, Value value) {
if (node == null) {
return new Node(key, value, 1); // 新建一个节点为根
}
int cmp = key.compareTo(node.key);
if (cmp < 0) {
// 在node的左子树插入
node.left = put(node.left, key, value);
} else if (cmp > 0) {
// 在node的右子树插入
node.right = put(node.right, key, value);
} else {
// 当前键已经存在,则更新当前键对应的值
node.value = value;
}
// 插入后更新以node为根节点的所有子树的所有节点的总数
node.N = size(node.left) + size(node.left) + 1;
return node;
}
4. 获取最大值/最小值对应的键
获取最大值对应的键与获取最小值对应的键基本一致。如果根结点的左子节点为空,那么该根结点就是最小的键;如果根节点的左子节点不为空,那么一直沿着左链接深入,直到遇到某个结点没有左子结点了,那么此时该结点的键就是最小的。最大值的键的获取是镜像的,实现过程差不多,代码如下:
// 递归实现min
public Key min() {
return min(root).key;
}
// 递归实现max
public Key max() {
return max(root).key;
}
private Node min(Node node) {
if (node.left == null) {
return node;
} else {
return min(node.left);
}
}
private Node max(Node node) {
if (node.right == null) {
return node;
} else {
return max(node.right);
}
}
5. 向上/向下取key
floor(Key key)
返回所有小于或等于key的键中的最大值;ceiling(Key key)
返回所有大于或等于key的键中的最小值。
floor方法:如果key等于根结点的键那么直接返回根结点的键;如果key小于根结点,则小于等于key的最大键一定在根结点的左子树中;如果key大于根结点,那么必须当右子树中存在小于等于key的结点时,小于等于key的键才存在于右子树中,若不存在则小于等于key的键就是根结点本身。
这两个方法是镜像的,理解了floor就将能顺理成章写出ceiling。
// 向下取整
public Key floor(Key key) {
Node node = floor(root, key);
if (node == null) {
return null;
} else {
return node.key;
}
}
private Node floor(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
if (cmp == 0) {
// 与根节点相等,直接返回根节点
return node;
} else if (cmp < 0) {
// 比根节点小,则满足向下取整的节点在左子树中
return floor(node.left, key);
} else {
// 比根节点大,若在右子树中就返回右子树相应结点,否则就是根节点本身
Node temp = floor(node.right, key);
if (temp != null) {
return temp;
} else {
return node;
}
}
}
// 向上取整
public Key ceiling(Key key) {
Node node = ceiling(root, key);
if (node == null) {
return null;
} else {
return node.key;
}
}
private Node ceiling(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
// 和根结点相等直接返回根结点
if (cmp == 0) {
return node;
// 比根结点大,肯定在右子树中
} else if (cmp > 0) {
return ceiling(node.right, key);
// 比根结点小,若在左子树中就返回左子树相应结点,否则就是根结点本身
} else {
Node temp = ceiling(node.left, key);
if (temp != null) {
return temp;
} else {
return node;
}
}
}
6. 选择与排名
select(k)
:假设我们想知道排名为k的键是什么(即树中正好有k个键小于它)。如果左子树中的结点数t大于k,那么继续递归地在左子树中查找排名为k的键;如果t等于k,就返回根结点的键(根结点的左子树结点总数刚好就是根结点的排名),如果t小于k,得在右子树递归地查找排名为k - t -1的键(因为左子树结点个数为t,加上根结点1,共t + 1个,而k - t - 1+ t + 1 = k)依然能保证查找到的是排名为k的键。
rank(Key key)
:此方法可返回给定键的排名。是select方法的逆方法。如果给定键和根结点的键相同,就返回左子树的结点数(根结点左子树的结点数刚好是根结点的排名);如果给定的键小于根结点,递归运算返回该键在左子树中的排名;如果给定的键大于根结点,返回t + 1 + 该键在右子树中的排名
(t + 1是根结点的左子树及根结点,所以三者加起来才是该键的正确排名)
// 返回二叉搜索树中第k个元素的key
public Key select(int k) {
if (k < 0 || k >= size()) {
throw new IllegalArgumentException("argument to select() is invalid" + k);
}
return select(root, k).key;
}
private Node select(Node node, int k) {
if (node == null) {
return null;
}
int t = size(node.left);
if (t > k) {
// 左子树节点大于k,继续在左子树查找
return select(node.left, k);
} else if (t < k) {
// 左子树节点树小于k,则继续在右子树查找
return select(node.right, k - t - 1);
} else {
// 左子树的节点数刚好等于k,命中,排名为k的就是这个根节点
return node;
}
}
public int rank(Key key) {
return rank(root, key);
}
private int rank(Node node, Key key) {
if (node == null) {
return 0;
}
int cmp = key.compareTo(node.key);
if (cmp < 0) {
// 比根结点小,应该在左子树中继续查找
return rank(node.left, key);
} else if (cmp > 0) {
// 比根结点大,应该在右子树中查找,算排名时加上左子树和根结点的结点总和
return 1 + size(node.left) + rank(node.right, key);
} else {
// 和根结点相等,找到,排名就是其左子树结点总数
return size(node.left);
}
}
7. 删除元素
7.1 删除最大值/最小值
先看简单的情况,删除最小最大键,其实思路和查找最小最大键类似。也是不断深入左子树,直到某个结点没有左子结点,现在要做的就是删除该结点,比如该结点为x,其父结点为t,有t.lelf == x。只要使x的右结点(不管是不是空)成为t的新的左结点即可,也就是t.left = x.right
,原左结点会被垃圾回收,达到删除的目的。删除最小键的操作轨迹如下图左边所示。
删除最大键是删除最小键的镜像问题,就不赘述了,代码如下所示:
public void deleteMin() {
root = deleteMin(root);
}
private Node deleteMin(Node node) {
if (node.left == null) {
return node.right;
}
// 其实就是node.left = node.left.right
node.left = deleteMin(node.left);
node.N = size(node.left) + size(node.right) + 1;
return node;
}
public void deleteMax() {
root = deleteMax(root);
}
private Node deleteMax(Node node) {
if (node.right == null) {
return node.left;
}
node.right = deleteMax(node.right);
node.N = size(node.left) + size(node.right) + 1;
return node;
}
7.2 删除任意元素
如果要删除的键只有一个子结点或者没有子结点,可以按照上述方法删除,但是如果要删除的结点既有左子结点又有右子结点呢?删除后将要同时处理两棵子树,但是被删除结点的父结点只会空出一条链接出来。换个角度想想,二叉查找树的中序遍历序列就是有序键的集合,所以删除了该结点,可以用该结点的后继或者前驱结点取代它。这里我们打算用后继结点取代被删除结点的位置。具体步骤如下
- 如果被删除的结点只有一个子结点或者没有子结点,比如被删除结点为x,其父结点为t。若x没有左结点则
t.left = x.right
,或者x没有右结点则t.right = x.left
。 - 如果被删除的结点有左右子结点。先将被删除的结点保存为t,其右子结点为t.right,然后找到右子树中的最小结点,该结点就是被删除结点t的后继结点,设为x。t和m之间再无其他键,所以m取代t的位置后,剔除m后的t的右子树中所有结点仍然大于m,所以只需让m的右子树连接剔除m后的t的右子树,m的左子树连接t的左子树即可。
代码如下所示:
public void delete(Key key) {
root = delete(root, key);
}
private Node delete(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
if (cmp > 0) {
// key 大于当前节点,往当前节点的左子树查找
node.right = delete(node.right, key);
} else if (cmp < 0) {
// key 小于当前节点,往当前节点的右子树查找
node.left = delete(node.right, key);
} else {
// 命中给定的key
// 如果根节点只有一个子结点或者没有子结点,按照删除最小/最大键的做法即可
if (node.left == null) {
return node.right;
}
if (node.right == null) {
return node.left;
}
// 根节点的两个孩子节点都不为空
// 要删除的节点用temp保存
Node t = node;
// t的后继结点取代t的位置
node = min(t.right);
node.right = deleteMin(t.right);
node.left = t.left;
}
node.N = size(node.left) + size(node.right) + 1;
return node;
}
8. 范围查找
要查找某个范围内的所有键,首先需要一个遍历二叉树所有结点的方法,我们很难容易发现二叉查找树的中序遍历序列就是有序键的集合。所以得到如下思路中序遍历二叉查找树,如果该键落在范围内,加入到集合中。当然如果某个根结点的键小于该范围的最小值,其左子树肯定也不会在范围内;同样某个结点的键大于该范围的最大值,其右子树肯定也不会在范围内。这两种情况都无需递归遍历了,直接跳过。所以为了减少比较操作,在递归遍历前加上判断条件。代码如下所示:
public Set<Key> keys() {
return keys(min(), max());
}
public Set<Key> keys(Key low, Key high) {
Set<Key> set = new LinkedHashSet();
keys(root, set, low, high);
return set;
}
private void keys(Node node, Set<Key> set, Key low, Key high) {
if (node == null) {
return;
}
int cmplow = low.compareTo(node.key);
int cmphigh = high.compareTo(node.key);
if (cmplow < 0) {
// 当前节点比low大,左子树中可能还有结点落在范围内的,所以应该遍历左子树
keys(node.left, set, low, high);
}
if (cmplow <= 0 && cmphigh >= 0) {
// 在区间[low, high]之间的加入队列
set.add(node.key);
}
if (cmphigh > 0) {
// 当前节点比high小,右子树中可能还有结点落在范围内,所以应该遍历右子树
keys(node.right, set, low, high);
}
}
三、二叉搜索树的缺陷
按上面实现的二叉搜索树来看,二叉搜索树的实现对于查找来说提不错,但是有时候会出现以下极端情况:
问题显而易见,二叉搜索树的性能取决于树的形状,像上面这种极端的情况,树几乎退化为链表,在最坏的情况下,查找的时间复杂度也将变为O(n)。即在最坏情况下,二叉查找树的查找和插入效率很低。为了解决这个问题,引出了平衡二叉树(AVL),以及后面还有红黑树等多个变种。
《算法》第四版