算法4 第三章《查找》1——符号表与二叉查找树
符号表:一张抽象的表格,将各种信息存储在其中,然后按照指定的键来搜索并获取这些信息,键和值的具体意义取决于不同的应用,符号表最主要的目的就是将一个键和一个值联系起来。
符号表也可以被称为字典。
符号表是一种存储键值对的数据结构,支持两种操作:
插入(put)
,即将一组新的键值对存入表中;
查找(get)
,即根据给定的键得到相应的值。
1.符号表的API
用应用程序编程接口(API)来精确地定义这些操作,为数据类型的实现和用例提供一份“契约”。
public class | ST<Key, Value> | |
---|---|---|
ST() | 创建一张符号表 | |
void | put(Key key, Value val) | 将键值对存入表中(若值为空则将键key 从表中删除) |
Value | get(Key key) | 获取键key 对应的值(若键key 不存在则返回null) |
void | delete(Key key) | 从表中删去键key(及其对应的值) |
boolean | contains(Key key) | 键key 在表中是否有对应的值 |
boolean | isEmpty() | 表是否为空 |
int | size() | 表中的键值对数量 |
Iterable | keys() | 表中的所有键的集合 |
为了方便用例处理表中的所有键值,我们有时会在API 的第一行加上implements Iterable<Key>
这句话, 强制所有实现都必须包含iterator()
方法来返回一个实现了hasNext()
和next()
方法的迭代。
在Java 中,按照约定所有的对象都继承了一个equals()
方法,而自定义的键则需要重写equals()
方法。
我们的所有实现都遵循以下规则:
- 每个键只对应着一个值(表中不允许存在重复的键);
- 当用例代码向表中存入的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值。
- 键不能为空,值不能为空。
2.有序符号表的API
典型的应用程序中,键都是Comparable
的对象,因此可以使用a.compareTo(b)
来比较a 和b 两个键。许多符号表的实现都利用了Comparable 接口带来的键的有序性来更好地实现put()
和get()
方法。更重要的是在这些实现中,符号表都会保持键的有序并大大扩展它的API,根据键的相对位置定义更多实用的操作。
对于Comparable
的键,我们实现了下表所示的API。
public class | ST<Key extends Comparable, Value> | |
---|---|---|
ST() | 创建一张符号表 | |
void | put(Key key, Value val) | 将键值对存入表中(若值为空则将键key 从表中删除) |
Value | get(Key key) | 获取键key 对应的值(若键key 不存在则返回null) |
void | delete(Key key) | 从表中删去键key(及其对应的值) |
boolean | contains(Key key) | 键key 在表中是否有对应的值 |
boolean | isEmpty() | 表是否为空 |
int | size() | 表中的键值对数量 |
Iterable | keys() | 表中的所有键的集合 |
上面的API和符号表的API一致 | ||
Key | min() | 最小的键 |
Key | max() | 最大的键 |
Key | floor(Key key) | 小于等于key 的最大键 |
Key | ceiling(Key key) | 大于等于key 的最小键 |
int | rank(Key key) | 小于key 的键的数量 |
Key | select(int k) | 排名为k 的键 |
void | deleteMin() | 删除最小的键 |
void | deleteMax() | 删除最大的键 |
int | size(Key lo, Key hi) | [lo…hi] 之间键的数量 |
Iterable | keys(Key lo, Key hi) | [lo…hi] 之间的所有键,已排序 |
向下取整(floor)操作:找出小于等于该键的最大键;
向上取整(ceiling)操作:找出大于等于该键的最小键,这两个操作有时很有用。
Java 的一条最佳实践就是维护所有Comparable 类型中compareTo()
方法和equals()
方法的一致性。也就是说,任何一种Comparable 类型的两个值a 和b 都要保证(a.compareTo(b)==0)
和a.equals(b)
的返回值相同。为了避免任何潜在的二义性,我们只会使compareTo()
方法来比较两个键,即我们用布尔表达式a.compareTo(b)==0
来表示“a 和b 相等吗?”
3.无序链表中的顺序查找
顺序查找:在查找中我们一个一个地顺序遍历符号表中的所有键并使用equals()
方法来寻找与被查找的键匹配的键。
public class SequentialSearchST<Key, Value>
{
private Node first; // 链表首结点
private class Node
{ // 链表节点的定义,我们使用了一个私有内部Node类来在链表中保存键和值
Key key;
Value value;
Node next
public Node(Key key, Value val, Node next)
{
this.key = key;
this.value = val;
this.next = next;
}
}
public Value get(Key key)
{ // 查找给定的键key,返回相应的值val
for(Node x = first, x != null, x = x.next)
if(key.equals(x.key))
return x.val; // 命中
return null; // 未命中
}
public void put(Key key, Value val)
{ // 查找给定的键,找到则更新其值,否则在表中新建结点
for(Node x = first, x != null, x = x.next)
if(key.equals(x.key))
{
x.val = val;
return; // 命中,更新
}
first = new Node(key, val, first); // 未命中,新建结点
}
}
基于链表的实现以及顺序查找是非常低效的。
4.有序数组中的二分查找
有序符号表使用的数据结构是一对平行的数组,一个存储键一个存储值。
rank()方法
这份实现的核心是rank()
方法,它返回表中小于给定键的键的数量。
这里我们采用基于有序数组的二分查找(迭代)
一般情况下二分查找都比顺序查找快得多,它也是众多实际应用程序的最佳选择。
public int rank(Key key)
{
int lo = 0, hi = N-1;
while (lo <= hi)
{
int mid = lo + (hi - lo) / 2;
int cmp = key.compareTo(keys[mid]);
if (cmp < 0) hi = mid - 1;
else if (cmp > 0) lo = mid + 1;
else return mid;
}
return lo;
}
get()方法
对于get()
方法,只要给定的键存在于表中,rank()
方法就能够精确地告诉我们在哪里能够找到它(如果找不到,那它肯定就不在表中了)。
public Value get(Key key)
{ // 查找给定的键key,返回相应的值val
if (isEmpty()) return null;
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) return vals[i]; // 命中就返回
else return null; // 未命中就返回null
}
put()方法
对于put()
方法,只要给定的键存在于表中,rank()
方法就能够精确地告诉我们到哪里去更新它的值,以及当键不在表中时将键存储到表的何处。我们将所有更大的键向后移动一格来腾出位置(从后向前移动)并将给定的键值对分别插入到各自数组中的合适位置。
public void put(Key key, Value val)
{ // 查找键,找到则更新值,否则创建新的元素
int i = rank(key); // 精确地告诉我们到哪里去更新它的值
if (i < N && keys[i].compareTo(key) == 0)
{ vals[i] = val; return; } // 命中,更新,然后返回
for (int j = N; j > i; j--)
{ keys[j] = keys[j-1]; vals[j] = vals[j-1]; } // 未命中,将所有更大的键向后移动一格来腾出位置
keys[i] = key; vals[i] = val; // 将给定的键值对分别插入到各自数组中的合适位置
N++;
}
5.二叉查找树
在《查找》这一章中,一共学习六种符号表的实现。
为了将二分查找的效率和链表的灵活性结合起来,我们需要更加复杂的数据结构。能够同时拥有两者的就是二叉查找树。 具体来说,就是使用每个结点含有两个链接(链表中每个结点只有一个链接)的二叉查找树来高效地实现符号表。
定义:一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable
的键(以及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。
基本实现
算法3.3 基于二叉查找树的符号表
public class BST<Key extends Comparable<Key>, Value>
{
private Node root; // 二叉查找树的根节点
private class Node
{ // 我们嵌套定义了一个私有类来表示二叉查找树上的一个结点。每个结点都含有
// 一个键、一个值、一条左链接、一条右链接和一个结点计数器
private Key key; // 键
private Value val; // 值
private Node left, right; // 指向子树的链接
private int N; // 以该结点为根的子树中的结点总数
public Node(Key key, Value 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;
// 会将空链接的值当做0,这样能保证对于二叉树中的任意节点x总是成立:
// size(x) = size(x.left) + size(x.right) + 1
else
return x.N;
}
public Value get(Key key)
{
return get(root, key);
}
private Value get(Node x, Key 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);
else
return x.val;
}
pubic void put(Key key, Value val)
{ // 查找key,找到则更新它的值,否则为它创建一个新的结点
root = put(root, key, val)
}
private Node put(Node x, Key key, Value 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;
}
}
有序性相关的方法与删除操作
算法3.3(续2) 二叉查找树中max()、min()、floor()、ceiling() 方法的实现
public Key min()
{
return min(root).key;
}
private Node min(Node x)
{
if(x.left == null)
return x;
return min(x.left);
}
public Key floor(Key key)
{
Node x = floor(root, key);
if(x == null)
return null;
return x.key;
}
private Node floor(Node x, Key key)
{ // 小于等于key 的最大键
if(x == null)
return null;
int cmp = key.compareTo(x.key);
if(cmp == 0)
return x;
if(cmp < 0)
// 如果给定的键key 小于二叉查找树的根结点的键,那么小于等于key的最大键floor(key)
// 一定在根结点的左子树中;
return floor(x.left, key);
// 如果给定的键key 大于二叉查找树的根结点,那么只有当根结点右子树中存在小于等于key 的结点时,
// 小于等于key 的最大键才会出现在右子树中,否则根结点就是小于等于key的最大键。
Node t = floor(x.right, key);
if(t != null)
return t;
else
return x;
}
max() 和ceiling() 的实现分别与min() 和floor() 方法基本相同,只是将代码中的left 和right(以及>和<)调换而已。
算法3.3(续3) 二叉查找树中select() 和rank() 方法的实现
public Key select(int k)
{
return select(root, k).key;
}
private Node select(Node x, int k)
{ // 返回排名为k的结点
if(x == null)
return null;
int t = size(x.left);
// size() 方法用来统计每个结点以下的子结点总数, 代码见算法3.3
if(t > k)
return select(x.left, k);
else if(t < k)
return select(x.right, k-t-1);
else
return x;
}
public int rank(Key key)
{
return rank(key, root);
}
private int rank(Key key, Node x)
{ // 返回以x为根结点的子树中小于x.key的键的数量
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);
}
删除最大键和删除最小键、删除操作
对于deleteMin()
,我们要不断深入根结点的左子树中直至遇见一个空链接,然后将指向该结点的链接指向该结点的右子树(只需要在递归调用中返回它的右链接即可)。此时已经没有任何链接指向要被删除的结点,因此它会被垃圾收集器清理掉。我们给出的标准递归代码在删除结点后会正确地设置它的父结点的链接并更新它到根结点的路径上的所有结点的计数器的值。
我们可以用类似的方式删除任意只有一个子结点(或者没有子结点)的结点,但应该怎样删除一个拥有两个子结点的结点呢?删除之后我们要处理两棵子树,但被删除结点的父结点只有一条空出来的链接。方法是:在删除结点x 后用它的后继结点填补它的位置。因为x 有一个右子结点,因此它的后继结点就是其右子树中的最小结点。这样的替换仍然能够保证树的有序性,因为x.key
和它的后继结点的键之间不存在其他的键。步骤如下:
- 将指向即将被删除的结点的链接保存为
t
; - 将
x
指向它的后继结点min(t.right)
; - 将
x
的右链接(原本指向一棵所有结点都大于x.key
的二叉查找树)指向deleteMin(t.right)
,也就是在删除后所有结点仍然都大于x.key
的子二叉查找树; - 将
x
的左链接(本为空)设为t.left
(其下所有的键都小于被删除的结点和它的后继
结点)。
算法3.3(续4) 二叉查找树的delete() 方法的实现
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 delete(Key key)
{
root = delete(root, key);
}
private Node delete(Node x, Key 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.right == null)
return x.left;
if(x.left == null)
return x.right;
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;
}
算法3.3(续5) 二叉查找树的范围查找操作
public Iterable<Key> keys()
{ return keys(min(), max()); }
public Iterable<Key> keys(Key lo, Key hi)
{ // 返回[lo..hi] 之间的所有键,已排序
// 将所有落在给定范围以内的键加入一个队列Queue 并跳过那些不可能含有所查找键的子树
Queue<Key> queue = new Queue<Key>();
keys(root, queue, lo, hi);
return queue;
}
private void keys(Node x, Queue<Key> queue, Key lo, Key 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.enqueue(x.key);
if (cmphi > 0) keys(x.right, queue, lo, hi);
}