文章目录
1 概述
定义:符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应值。
符号表将一个键和值关联起来,形参一个键值对。通过将一个键值对插入符号表并希望之后能够从符号表的所有键值对中按照键直接找到相应的值。
查找在大多数应用程序中都至关重要,许多编程环境因此将符号表实现为高级的抽象数据结构。
下面给出一些符号表的典型应用场景,如下表1-1所示:
应用 | 查找的目的 | 键 | 值 |
---|---|---|---|
字典 | 找出单词的释义 | 单词 | 单词释义 |
账户管理 | 处理交易 | 账户号码 | 交易详情 |
网络搜索 | 找到相关的网页 | 关键字 | 网页列表 |
2 API
如下表2-1 为一种简单的泛型符号表API:public classST<k,V>
类型 | 名称和参数 | 描述 |
---|---|---|
ST() | 构造器,创建一张符号表 | |
void | put(K key, V value) | 将键值对存入表中 |
V | get(K key) | 获取键对应的值 |
void | delete(K key) | 从表中删除对应的键值对 |
boolean | contains(K key) | 键是否在符号表中 |
boolean | isEmpty() | 表是否为空 |
int | size() | 表中键值对数量 |
Iterable<K> | keys() | 表中所有键的集合 |
在查看用例代码之前,为了保证代码的一致、简洁和实用,我们要先说明具体实现中的几个设计决策。
2.1 泛型
对于符号表,我们明确指定键和值的类型区分它们的不同角色。
2.2 重复的键
我们所有的实现都遵循一下原则:
- 每个键只对应一个值(键不重复,一一对应)。
- 当用例代码向表中存入的键值对和表中已有的键冲突时,对应的新值替换旧值。
2.3 空(null)键
键不能为空。如果使用空键,抛出异常。
2.4 空(null)值
我们规定值也不为空。这个规定产生了2结果:
- 通过get()方法是否返回空值,判断键是否在表中
- 通过把put的第二个参数(值)设置为空来实现删除。
2.5 删除操作
在符号表中,删除的实现可以有两种方法:
- 延时删除:把键对应的值置空,在某个时候统一清除空值的键值对
- 即时删除:立即从表中删除指定的键值对。
在put()的实现的开头有一句代码:
if(val == null){delete(key);return;}
来保证符号表中任何键的值不为空。
2.6 便捷方法
默认实现:
boolean contains(k Key) { return get(key) != null;}
boolean isEmpty() { return size() == 0;}
2.7 迭代
通过keys方法返回Iterable对象以方便用例遍历所有的键。
2.8 键的等价性
要确定一个键是否存在于符号表中,首先要确立对象等价性的概念。在Java是通过equals方法返回true来判断两个对象是否相等,方法继承自Object,默认是比较对象的内存地址值。JDK中很多类都重写了这个方法,如果是自定义类型的键,需要自己重写这个方法。
3 有序符号表
典型的应用程序中,键都是Comparable的对象,因此可以使用a.compareTo(b)来比较a和b两个键。许多符号表的实现利用Comparable接口带来的键的有序性可以扩展原有的API。于是,对于Comparable的键,我们实现如下表3-1的API:public class ST<K extends Comparable<k>, v>
类型 | 名称和参数 | 描述 |
---|---|---|
ST() | 构造器,创建一张符号表 | |
void | put(K key, V value) | 将键值对存入表中 |
V | get(K key) | 获取键对应的值 |
void | delete(K key) | 从表中删除对应的键值对 |
boolean | contains(K key) | 键是否在符号表中 |
boolean | isEmpty() | 表是否为空 |
int | size() | 表中键值对数量 |
K | min() | 最小的键 |
K | max() | 最大的键 |
K | floor(K key) | 小于等于key的最大键 |
K | ceiling(K key) | 大于等于key的最小键 |
int | rank(K key) | 小于key的键的数量 |
K | select(int k) | 排名为k的键 |
void | deleteMin() | 删除最小的键 |
void | deleteMax() | 删除最大的键 |
int | size(K lo, K hi) | [lo,hi]直接键的数量 |
Iterable<K> | keys(K lo, K hi) | [lo,hi]之间的所有键,已排序 |
Iterable<K> | keys() | 表中所有键的集合,已排序 |
下面见到类中的声明中含有泛型变量K extends Comparable<K>,那么说明实现了上述API。
3.1 最大键和最小键
对于一组有序的键,很常见的操作就是查询其中的最大键和最小键。在有序符号表中,我们也有方法删除最大键值对和最小键值对。
3.2 向下取整和向上取整
对于给定的键,向下取整和向上取整操作有时很有用,来自于实时的取整函数。
3.3 排名和选择
检验一个键是否插入了合适位置的基本操作是rank()和select()。对于0~size-1的所有i都有i==rank(select(key)),且所有的键满足key=selct(rank(key))。
3.4 范围查找
给定范围内有多少键?是那些?在很多应用能够回答这些问题并接受2个参数的size()和keys()方法都很有用。
3.5 例外情况
当一个方法需要返回一个键但表中却没有合适的键可以返回时,我们约定抛出一个异常(另外一种合理的方法是返回空)。
3.6 便捷方法
我们约定所有有序符号表API的时候都含有如下表3.6-1所示的方法:
方法 | 默认实现 |
---|---|
void deleteMin() | delete(min()); |
void deleteMax() | delete(max()) |
int size(K lo, K hi) | if(hi.compareTo(lo)<0) return 0; else if(contains(hi)) reutrn rank(hi)-rank(lo)+1; else return rank(hi) - rank(lo); |
Iterable<K> keys() | return keys(min(),max()); |
3.7 (在谈)键的等价性
Java的一条最佳实践就是维护所有Comparable类型中compareTo()方法和equals()放大一致性。在Comparable类型键的符号表中,我们使用compareTo()方法来比较两个键。Java为许多检测作为键的类型提供了标准的compareTo()方法实现,如果是自定义类型的键,需要自己提供compareTo的实现。
3.8 成本模型
无论我们是使用equals()方法还是使用compareTo()方法,我们使用比较一词来表示将符号表条目和一个被查找的键进行比较操作。在大多数符号表的实现中,这个操作都出现在内循环。在少数的例外中,我们则会统计数组的访问次数。
查找的成本模型。 在学习符号表的实现时,我们会统计比较的次数(等价性测试或是键的相互比较)。在内循环不进行比较(极少)的情况下,我们会统计数组的访问次数。
4 用例举例
在学习实现之前,我们先看下如何使用,这里考察2个用例:一个用来跟踪算法在小规模输入下的行为测试和另外一个用来寻找更高效实现的性能测试用例。
4.1 行为测试用例
这里我们主要测试符号表用例的键、值及输出,如果键相同,后者键对应的值覆盖前者。设计键为单个的字符串,值为对应的序号。示例如下代码4.1-1所示:
public class MethodsTest {
public static void main(String[] args) {
ST<String, Integer> st;
st = new ST<>();
for (int i = 0; !StdIn.isEmpty(); i++) {
String key = StdIn.readString();
st.put(key, i);
}
for (String s : st.keys()) {
StdOut.println(s + " " + st.get(s));
}
}
}
输入输出:
S E A R C H E X A M P E
A 8
C 4
E 11
H 5
M 9
P 10
R 3
S 0
X 7
这是有序符号表的实现用例;无序符号表键的顺序不固定,具体看实现,但是对应的值和有序符号表应该是一致的;
4.2 性能测试用例
测试类FrequencyCounter用例从标准输入中得到一列字符串并记录每个(长度至少达到指定阈值)字符串的次数,然后遍历所有键并找出频率最高的键。测试数据为官网2段文字:《双城记》前5行,《双城记》全文,leizig1M.txt,测试如下代码4.2-1所示:
public class FrequencyCounter {
public static void main(String[] args) {
int minLen = Integer.parseInt(args[0]);
ST<String, Integer> st = new ST<>();
String filename = args[1];
try(BufferedReader fr =new BufferedReader(new FileReader(new File(filename)))) {
String word = null;
while ((word = fr.readLine()) != null) {
// 用符号表统计频率
// 忽略长度小于阈值的单词
String[] words = word.split("\\s");
for (String s : words) {
if (s.length() < minLen) {
continue;
}
// 单词作为键,如果第一次出现,值设置为1;其他单词对应的值加1
if (!st.contains(s)) {
st.put(s, 1);
} else {
st.put(s, st.get(s) + 1);
}
}
}
String maxKey = null;
int max = 0;
for (String key : st.keys()) {
Integer count = st.get(key);
if (count > max) {
maxKey = key;
max = count;
}
}
StdOut.println(maxKey + " " + max);
}catch (IOException e) {
e.printStackTrace();
}
}
}
研究符号表处理大型文本的性能要考虑两个方面的因素:首先,每个单词单词都会被作为键搜索,因此处理性能和单词总量必然有关;其次,输入的每个单词都会存入单词表(重复的单词增加计数),因此不同的单词的总数也是相关的。
没有一个高效的符号表作为基础是无法使用FrequencyCounter这样的程序来处理大型问题的。许多符号表应用都有以下共性:
- 混合使用查找和插入操作
- 大量的键
- 查找操作比插入操作多
- 虽然不可预测,单查找和插入操作的使用模式并非随机。
后面我们继续讲解2种基础符号表的实现及分析。
5 后记
如果小伙伴什么问题或者指教,欢迎交流。
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/algorithm
参考:
[1][美]Robert Sedgewich,[美]Kevin Wayne著;谢路云译.算法:第4版[M].北京:人民邮电出版社,2012.10