查 找 (一)
现代计算机和网络使我们能够访问海量的信息。高效检索这些信息的能力是处理它们的重要前提。
二叉查找树
使用每个节点含有两个链接的二叉树来高效地实现符号表。
首先,我们需要定义一些术语。我们所使用的数据结构由节点组成,节点包含的链接可以为空(null)或者指向其他节点。在二叉树中,每个节点只能由一个父节点(只有一个例外,那就是根节点,它没有父节点),而且每个节点都只有左右两个链接,分别指向自己的左子节点和右子节点。尽管链接指向的是节点,但我们可以将每个链接看做指向了另一颗二叉树,而这棵树的根节点就是被指向的节点。因此我们可以将二叉树定义为一个空链接,或者是一个有左右两个链接的节点,每个链接都指向一棵子二叉树。在二叉查找树中,每个节点还包含了一个键和一个值,键之间也有顺序之分以支持高效的查找。
基本实现
public class BST {
private Node root;
private class Node {
private Integer key;
private Integer val;
private Node left, right;
private int N;
public Node(Integer key, Integer val, int N) {
this.key = key;
this.val = val;
this.N = N;
}
}
public int size() {
return size(root);
}
private int size(Node x) {
if (x == null) {
return 0;
}
return x.N;
}
// get,put,min,max,floor,ceiling,select,rank
// delete,deleteMin,deleteMax,keys
}
这段代码用二叉查找树实现了有序符号表的API,树由Node对象组成,每个对象都含有一对键值、两条链接和一个节点计数器N。每个Node对象都是一棵含有N个节点的子树的根节点,它的左链接指向一棵由小于该节点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键组成的二叉查找树。root变量指向二叉查找树的根节点Node对象。下面会陆续给出其他方法的实现。
上面的代码定义了二叉查找树(BST)的数据结构。
数据表示
我们嵌套定义了一个私有类来表示二叉查找树上的一个节点。每个节点都含有一个键、一个值、一条左链接、一条右链接和一个节点计数器。左链接指向一棵由小于该节点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键 组成的二叉查找树。变量N给出了以该节点为根的子树的节点总数。实现的私有方法size()会将空链接的值当作0,这样我们就能保证以下公式对于二叉树中的任意节点x总是成立。
size(x) = size(x.left) +size(x.right) +1
一棵二叉查找树代表了一组键(及其相应的值)的集合,而同一个集合可以用多颗不同的二叉查找树表示。如果我们将一棵二叉查找树的所有键投影到一条直线上,保证一个节点的左子树中的键出现在它的左边,右子树中的键出现在它的右边,那么我们一定可以得到一条有序的键列。
查找
一般来说,在符号表中查找一个键可能得到两种结果。如果含有该键的节点存在于表中,我们的查找就命中了,然后返回相应的值。否则查找未命中(并返回null)。
在二叉查找树中查找一个键的递归算法:如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则我们就(递归地)在适当的子树中继续查找。(较小查左子树,较大查右子树)
下面的代码中递归的get()方法完全实现了这段算法。代码会保证只有该节点所表示的子树才会含有和被查找的键相等的节点。和二分查找中每次迭代后查找的区间就会减少一半一样,在二叉查找树中,随着我们不断向下查找,当前节点所表示的子树的大小也会减小。当找到一个含有被查找的键的节点或者当前子树变为空时这个过程才会结束。
public Integer get(Integer key) {
return get(root, key);
}
private Integer get(Node x, Integer key) {
// 在以x为根节点的子树中查找并返回key所对应的值
// 如果找不到则返回null
if (x == null) {
return null;
}
int cmp = key.compareTo(x.key);
if (cmp < 0) {
return get(x.left, key);
} else if (cmp > 0) {
return get(x.right, key);
}
return x.val;
}
public void put(Integer key, Integer val) {
root = put(root, key, val);
}
private Node put(Node x, Integer key, Integer val) {
// 如果key存在与以x为根节点的子树中则更新它的值
// 否则将以key和val为键值对的新节点插入到该子树中
if (x == null) {
return new Node(key, val, 1);
}
int cmp = key.compareTo(x.key);
if (cmp < 0) {
x.left = put(x.left, key, val);
} else if (cmp > 0) {
x.right = put(x.right, key, val);
} else {
x.val = val;
}
x.N = size(x.left) + size(x.right) + 1;
return x;
}
这段代码实现了有序符号表API中的put()和get()方法
插入
查找的代码几乎和二分查找一样简单,这种简洁性是二叉查找树的重要特性之一。而二叉查找树的另一个更重要的特性就是插入的实现难度和查找差不多。当查找一个不存在于树的节点并结束于一条空链接时,我们需要做的就是将链接指向一个含有被查找的键的新节点。上面代码中的put()方法的实现逻辑和递归查找很类似:如果树是空的,就返回一个含有该键值对的新节点;如果被查找的键小于根节点的键,我们会继续在左子树中插入该键,否则在右子树中插入该键。
有序性相关的方法与删除操作
最大键和最小键
如果根节点的左链接为空,那么一棵二叉查找树中最小的键就是根节点;如果左链接非空,那么树中的最小键就是左子树中的最小键。简单的循环也能等价的实现这段描述,单位了保持一致性我们使用了递归。我们可以让递归调用返回键key而非节点对象Node,但我们后面还会用到这方法来找出含有最小键的节点。找出最大键的方法也是类似的,只是变为查找右子树而已。
public Integer min() {
return min(root).key;
}
private Node min(Node x) {
if (x.left == null) {
return x;
}
return min(x.left);
}
public Integer max() {
return max(root).key;
}
private Node max(Node x) {
if (x.right == null) {
return x;
}
return max(x.right);
}
向上取整和向下取整
如果给定的键key小于二叉查找树的根节点的键,那么小于等于key的最大键floor(key)一定在根节点的左子树中;如果给定的key大于二叉查找树的根节点,那么只有当根节点右子树中存在小于等于key的节点时,小于等于key的最大键才会出现在右子树中,否则根节点就是小于等于key的最大键。这段描述说明了floor()方法的递归实现,同时也递推地证明了它能够计算出预期的结果。将左变为右(同时小于变成大于)就能得到ceiling()的算法。
public Integer floor(Integer key) {
Node x = floor(root, key);
if (x == null) {
return null;
}
return x.key;
}
private Node floor(Node x, Integer key) {
if (x == null) {
return null;
}
int cmp = key.compareTo(x.key);
if (cmp == 0) {
return x;
}
if (cmp < 0) {
return floor(x.left, key);
}
Node t = floor(x.right, key);
if (t != null) {
return t;
}
return x;
}
public Integer ceiling(Integer key) {
Node x = ceiling(root, key);
if (x == null) {
return null;
}
return x.key;
}
private Node ceiling(Node x, Integer key) {
if (x == null) {
return null;
}
int cmp = key.compareTo(x.key);
if (cmp == 0) {
return x;
}
if (cmp > 0) {
return ceiling(x.right, key);
}
Node t = ceiling(x.left, key);
if (t != null) {
return t;
}
return x;
}
选择操作
二叉查找树中的选择操作和基于切分的数组选择操作类似。我们在二叉查找树的没你个节点中维护的子树节点计数器变量N就是用来支持此操作的。
假设我们向找到排名为k的键(即树中正好由k个小于它的键)。如果左子树中的节点数t大于k,那么我们就继续(递归地)在左子树中查找排名为k的键;如果t=k,那么我们就返回根节点中的键;如果t小于k,我们就(递归地)在右子树中查找排名为(k-t-1)的键。
public Integer select(int k) {
return select(root, k).key;
}
private Node select(Node x, int k) {
if (x == null) {
return null;
}
int t = size(x.left);
if (t > k) {
return select(x.left, k);
} else if (t < k) {
return select(x.right, k - t - 1);
} else {
return x;
}
}
排名
rank()是select()的逆方法,它会返回给定键的排名。它的实现和select()类似:如果给定的键和根节点的键相等,我们返回左子树中的节点总数t;如果给定的键小于根节点,我们会烦恼会该键在左子树中的排名(递归计算);如果给定的键大于根节点,我们会返回t+1(根节点)加上它在右子树中的排名(递归计算)。
public int rank(Integer key) {
return rank(key, root);
}
private int rank(Integer key, Node x) {
if (x == null) {
return 0;
}
int cmp = key.compareTo(x.key);
if (cmp < 0) {
return rank(key, x.left);
} else if (cmp > 0) {
return 1 + size(x.left) + rank(key, x.right);
} else {
return size(x.left);
}
}
删除最大键和删除最小键
二叉查找树中最难实现的方法就是delete()方法,即从符号表中删除一个键值对。作为热身运动,我们先考虑deleteMin()方法(删除最小键所对应的键值对),和put()方法一样,我们的递归方法接受一个指向节点的链接,并返回一个指向节点的链接。这样我们就能方便地改变树的结构,将返回的链接赋给作为参数的链接。对于deleteMin(),我们要不断的深入根节点的左子树中直至遇到一个空链接,然后将指向该节点的链接指向该节点的右子树(只需要在递归调用中返回它的右链接即可)。此时已经没有任何链接指向要删除的节点,因此它会被垃圾收集器清理掉。deleteMax()方法的实现完全相似。
public void deleteMin() {
root = deleteMin(root);
}
private Node deleteMin(Node x) {
if (x.left == null) {
return x.right;
}
x.left = deleteMin(x.left);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
public void deleteMax() {
root = deleteMax(root);
}
private Node deleteMax(Node x) {
if (x.right == null) {
return x.left;
}
x.right = deleteMax(x.right);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
删除操作
我们可以用类似的方法删除任意只有一个子节点(或者没有子节点)的节点,但应该怎样删除一个拥有两个子节点的节点呢?删除之后我们要处理两颗子树,但被删除节点的父节点只有一条空出来的链接。T.Hibbard在1962年提出了解决这个难题的第一个方法,在删除节点x后用它的后继节点填补它的位置。因为x有一个右子节点,因此它的后继节点就是其右子树中的最小节点。这样的替换仍然能够保证树的有序性,因此x.key和它的后继节点的键之间不存在其他的键。我们能够用4个简单的步骤完成将x替换为它的后继节点的任务:
- 将指向即将被删除的节点的链接保存为t
- 将x指向它的后继节点min(t.right)
- 将x的右链接(原本指向一棵所有节点都大于x.left的二叉查找树)指向deleteMin(t.right),也就是在删除后所有节点仍然都大于x.key的子二叉查找树
- 将x的左链接(本为空)设为t.left(其下所有的键都小于被删除的节点和它的后继节点)
在递归调用后我们会修正被删除的节点的父节点的链接,并将由此节点到根节点的路径上的所有节点的计数器减一。尽管这个方法能够正确地删除一个节点,它的一个缺陷是可能会在某些实际应用中产生性能问题。
public void delete(Integer key) {
root = delete(root, key);
}
private Node delete(Node x, Integer key) {
if (x == null) {
return null;
}
int cmp = key.compareTo(x.key);
if (cmp < 0) {
x.left = delete(x.left, key);
} else if (cmp > 0) {
x.right = delete(x.right, key);
} else {
if (x.left == null) {
return x.right;
}
if (x.right == null) {
return x.left;
}
Node t = x;
x = min(t.right);
x.right = deleteMin(t.right);
x.left = t.left;
}
x.N = size(x.left) + size(x.right) + 1;
return x;
}
范围查找
要实现能够返回给定范围内键的keys()方法,我们首先需要一个遍历二叉查找树的基本方法,叫做中序遍历。先打印处根节点的左子树中的所有键(根据二叉查找树的定义它们应该都小于根节点的键),然后打印根节点的键,最后打印出根节点的右子树中的所有键(根据二叉查找树的定义它们应该都大于根节点的键)。
private void print(Node x) {
if (x == null) {
return;
}
print(x.left);
System.out.println(x.key);
print(x.right);
}
为了实现接受两个参数并能够将给定范围内的键返回给用例的keys()方法,我们可以修改一下这段代码,将所有落在给定范围以内的键加入一个队列并跳过那些不可能含有所查找键的子树。
public Iterable<Integer> keys() {
return keys(min(), max());
}
public Iterable<Integer> keys(Integer lo, Integer hi) {
Queue<Integer> queue = new ArrayDeque<>();
keys(root, queue, lo, hi);
return queue;
}
private void keys(Node x, Queue<Integer> queue, Integer lo, Integer hi) {
if (x == null) {
return;
}
int cmplo = lo.compareTo(x.key);
int cmphi = hi.compareTo(x.key);
if (cmplo < 0) {
keys(x.left, queue, lo, hi);
}
if (cmplo <= 0 && cmphi >= 0) {
queue.add(x.key);
}
if (cmphi > 0) {
keys(x.right, queue, lo, hi);
}
}
为了确保以给定节点为跟的子树中所有在指定范围之内的键加入队列,我们会(递归地)查找根节点的左子树,然后查找根节点,然后(递归地)查找根节点的右子树。