首先保证这一篇分析查找算法的文章,气质与大部分搜索引擎搜索到的文章不同,主要体现在代码上面,会更加高级,会结合到很多之前研究过的内容,例如设计模式,泛型等。这也与我的上一篇面向程序员编程——精研排序算法不尽相同。
关键字:二分查找树,红黑树,散列表,哈希,索引,泛型,API设计,日志设计,测试设计,重构
查找是在大量的信息中寻找一个特定的信息元素,在计算机应用中,查找是常用的基本运算。
当今世纪,IT界最重要的词就是“数据!数据!数据!”,高效检索这些信息的能力是处理他们的重要前提。数据结构我们采用的是符号表,也叫索引和字典,算法就是下面将要研究的各种查找算法。
查找的数据结构
描述一张抽象的表格,我们会将value存入其中,然后按照指定的key来搜索并获取这些信息。符号表也叫索引,类似于书本后面列出的每个术语对应的页码(术语为key,页码为value),同时也被称为字典,类似于按照字母排序检索单词的释义(单词是key,发音释义是value)。
- 索引,数据库术语,我们在数据库中查找一张有大量记录的表时,
- 第一种方式是全表查询,取出每一条记录依次对比,这将耗费大量数据库系统资源,占用大量磁盘I/O操作;
- 第二种方式则是在表中建立索引(类似于存放指定数据到内存中),每次查询时,先到索引中检索匹配的索引值,也就是键key,找到以后直接取出对应的值value(rowid),快速取出表内记录。
- 数据库中关于索引也有展开的内容,这里不做详细介绍。(未来如果我遇到这方面的需求,抑或是我对数据库索引莫名提起了兴趣,我会新开一篇文章来研究。)
下文将要介绍到实现高效符号表的三种数据类型:
二分查找树、红黑树、散列表。
- 符号表是一种存储键值对的数据结构,我们将所有这些需要被检索的数据放在这个结构中。
符号表的键值对为key和value,也叫键和数据元素,大部分时候键指的都是主键(primary key),有的也包含次主键(secondary key)。
符号表支持两种操作:
操作 释义 插入(put) 将一组新的键值对存入表中 查找(get) 根据给定的键得到相应的值 符号表的分类
- 静态表:只做查找操作的符号表。即该符号表没有被修改增删等事务性操作,是静态的,不变的。
- 动态表:查找时插入不存在的数据元素,或从表中删除已经存在的元素。即操作过程中,该符号表可能是变化的,是动态的。
符号表的基础API设计
符号表[symbol table, 缩写ST],也有称为查找表[search table, 缩写ST],但是意思都是一样的,所以,之后下文出现的无论符号表、索引还是字典,指的都是同一个东西,不要混淆。
public class ST<Key, Value>
return | function | 释义 |
---|---|---|
. | ST() | 构造函数,创建一个索引 |
void | put(Key key, Value val) | 将键值存入表中(若值为空则删除键) |
Value | get(Key key) | 获取键对应的值(若key不存在返回null) |
void | remove(Key key) | 从表中强制删除键值对 |
boolean | containsKey(Key key) | 判断键在表中是否存在对应的值 |
boolean | isEmpty() | 判断表是否为空 |
int | size() | 获得表的键值对数量 |
Iterable | keys() | 获得表中所有键的集合(可迭代的) |
查找的算法分析
下面进入算法分析阶段,这次的研究我将一改以往简码的作风,我将遵循上面API的设计,完成一个在真实实践中也可用的API架构,因此除去算法本身,代码中也会有很多实用方法。
程序架构
利用这一次研究查找算法的机会,在研究具体算法内容之前,结合前面研究过的知识,我已经不满足于面向程序员编程——精研排序算法时的程序架构了(人总要学会慢慢长大...),这一次我要对系统进行重新架构。
第一版
首先建一个package search,然后按照上面的API创建一个ST基类:
package algorithms.search;
/**
* 符号表类
*
* @author Evsward
*
* @param <Key>
* @param <Value>
*/
public class ST<Key, Value> {
/**
* ST类并不去亲自实现SFunction接口
*
* @design “合成复用原则”
* @member 保存接口实现类的对象为符号表类的成员对象
*/
SFunction<Key, Value> sf;
/**
* 构造器创建符号表对象
*
* @param sf
* 指定接口的具体实现类(即各种查找算法)
*/
public ST(SFunction<Key, Value> sf) {
this.sf = sf;
}
/**
* 采用延时删除,将key的值置为null
*
* @param key
* 将要删除的key
*/
public void delete(Key key) {
sf.put(key, null);
}
/**
* 判断表内是否含有某key(value为null,key存在也算)
*
* @param key
* @return
*/
public boolean containsKey(Key key) {
@SuppressWarnings("rawtypes")
List list = (ArrayList) keySet();
list.contains(key);
return list.contains(key);
}
/**
* 判断表是否为空
*
* @return
*/
public boolean isEmpty() {
return sf.size() == 0;
}
public void put(Key key, Value val) {
sf.put(key, val);
}
public int size() {
return sf.size();
}
public Iterable<Key> keySet() {
return sf.keySet();
}
public Value get(Key key) {
return sf.get(key);
}
/**
* 即时删除(与延时删除相对应的) 直接删掉某key
*
* @param key
*/
public void remove(Key key) {
sf.remove(key);
}
}
- 针对ST类的几点说明:
- 泛型,ST为一个泛型类,它是类型泛化的,在使用时指定具体类型。对于泛型的内容,不了解的朋友可以转到“大师的小玩具——泛型精解”,查询“泛型类”相关的知识。
- 符号表的两种删除算法
- 延迟删除,也就是先将键对应的值置为空,然后在某个时候删除所有值为空的键。API中对应的是delete方法。
- 即时删除,立刻从表中删除指定的键值对。API中对应的是remove方法。
- 由于delete、containsKey和isEmpty方法均可不依赖SFunction的方法实现,因此他们不必放入SFunction接口中去。
- key不重复,我们遵循主键唯一的原则,不考虑次主键的情况。
- 不允许key为null,不允许值为null(delete后的结果允许值为null)。
- 既然要以key为条件去查找相应的值,就要做好等价性工作,也就是复写equals()方法。另外也可以考虑让key实现Comparable接口,除了有等价性以外,还可以比较大小。最后注意要使用不可变的数据类型作为key,以保证表的一致性。
下面,创建一个泛型接口SFunction<Key, Value>,用来定义查找算法必须要实现的方法,代码如下:
package algorithms.search;
/**
* 查找算法的泛型接口,定义必须要实现的方法
*
* @author Evsward
*
* @param <Key>
* @param <Value>
*/
public interface SFunction<Key, Value> {
public int size();// 获取表的长度
public Value get(Key key);// 查找某key的值
public void put(Key key, Value val);// 插入
public Iterable<Key> keySet();// 返回一个可迭代的表内所有key的集合
public void remove(Key key);// 强制删除一个key以及它的节点
}
- 针对SFunction接口的几点说明:
- 此接口为泛型接口,参数类型依然是泛化的,不了解的朋友可以转到“大师的小玩具——泛型精解”,查询“泛型接口”相关的知识。
- 此接口声明的方法为所有继承于ST类的子类必须实现的方法,每个子类有着自己不同的实现方式。
下面,我们再创建一个DemoSearch算法来实现SFunction接口。代码如下:
package algorithms.search;
/**
* 查找算法接口的实现类:Demo查找
*
* @notice 在实现一个泛型接口的时候,要指定参数的类型
* @author Evsward
*
*/
public class DemoSearch implements SFunction<String, String> {
@Override
public void put(String key, String val) {
// TODO Auto-generated method stub
}
@Override
public int size() {
// TODO Auto-generated method stub
return 0;
}
@Override
public void remove(Key key) {
// TODO Auto-generated method stub
}
@Override
public Iterable<String> keys() {
// TODO Auto-generated method stub
return null;
}
@Override
public String get(String key) {
return "demo-test";
}
}
(这段话已被丢弃)说明:实现类必须指定参数类型代替泛型,否则报错。
最后再创建一个客户端,用来调用search方法:
package algorithms.search;
public class Client {
public static void main(String[] args) {
ST st;
// error: Cannot instantiate the type SFunction
st = new ST<String, String>(new SFunction());
// warning: Type safety: The constructor ST(SFunction) belongs to the
// raw type ST. References to generic type ST<Key,Value> should be
// parameterized
st = new ST(new DemoSearch());
st.get("key");
}
}
分析:
- 接口本身只是方法的声明,无法创建一个接口的实例,但是它可以当做参数去传递,赋值的时候一定是它的实现类的实例。所以main方法中第二行代码报错,无法通过编译。
- 上面那种方式不可行,我们来传入具体的实现类的实例,然而创建时如果不指定具体参数类型,会有warning出来,但是其实在算法接口的实现类DemoSearch中已经指定具体参数类型了(class DemoSearch implements SFunction<String, String>),我们并不想在客户端调用的时候再次指定,这显得很麻烦,而且对于我们甄别其他的warning增加了困难。
@deprecated 第二版(第二版涉及实现泛型接口或者继承泛型基类需要指定具体参数类型的言论均被丢弃)
(这段话已被丢弃)由于泛型擦除的变通机制,我们无法继承一个未指定具体类型的泛型类或者实现一个未指定具体类型的泛型接口。(这段话已被丢弃) 虽然我最终也没有找到超越第一版的更好的版本,但是我们再一次加强了对java泛型的理解。所以,我们在第一版的基础之上,
在此约定,每个算法具体实现类的泛型均被指定为<String, String>。
然后将算法实现类配置到配置文件中
<sf>algorithms.search.DemoSearch</sf>
客户端代码改为:
package algorithms.search;
import tools.XMLUtil;
public class Client {
public static void main(String[] args) {
Object oSf = XMLUtil.getBean("sf");// 注入对象
@SuppressWarnings("unchecked")
ST<String, String> st = new ST<String, String>((SFunction<String, String>) oSf);
System.out.println(st);
System.out.println(st.get(""));
}
}
执行结果:
algorithms.search.ST@4e25154f
demo-test
之后的算法实现类只需要:
- 创建一个类,实现SFunction<String, String>接口,实现接口具体方法,填入自己算法内容。
- 修改config.xml中的类名为当前实现类,客户端代码不用改,可以直接执行测试结果。
关于数据结构:
我们发现上面的架构代码中并未出现具体的实现符号表的数据结构,例如数组、链表等。原因是每种算法依赖的数据结构不同,这些算法是直接操作于这些具体数据结构之上的(正如数据结构和算法水乳交融的关系),因此当有新的XXXSearch被创建的时候,它就会在类中引出自己需要的数据结构。上面被丢弃的第二版将泛型强制指定为<String, String>,这对系统架构中要求灵活的数据结构造成非常大的伤害,这也是第二版被丢弃的原因之一。
最终架构(后面还有被重构)
上面的“第二版”已被丢弃,文章将它留下的原因就是可以记录自己思考的过程,虽然没有结果,但是对设计模式,对泛型都加深了理解。那么最终架构是什么呢?由于泛型的特殊性,我们无法对其做多层继承设计,所以最终架构就是没有架构,每个算法都是一个独立的类,只能要求我自己在这些独立的算法类中去对比上面的API去依次实现。
顺序查找
又叫线性查找,是最基本的查找技术,遍历表中的每一条记录,根据关键字逐个比较,直到找到关键字相等的元素,如果遍历结束仍未找到,则代表不存在该关键字。
顺序查找不依赖表中的key是否有序,因此顺序查找中实现符号表的数据结构可以采用单链表结构,每个结点存储一个键值对,其中key是无序的。
- 码前准备:
- 我们要创建一个独立的实现顺序查找的符号表类SequentialSearchST。
- 在类中,要实现一个单链表结构。
- 操作单链表去具体实现基础API中的方法。
- 代码阶段:
package algorithms.search.support;
import java.util.ArrayList;
import java.util.List;
/**
* 已更换最新架构,请转到algorithms.search.STImpl;
*
* @notice 此类已过时,并含有bug,请对比学习。
* @author Evsward
*
* @param <Key>
* @param <Value>
*/
public class SequentialSearchSTOrphan<Key, Value> {
private Node first;
private class Node {
Key key;
Value val;
Node next;
public Node(Key key, Value val, Node next) {
this.key = key;
this.val = val;
this.next = next;
}
}
public void put(Key key, Value val) {
// 遍历链表
for (Node x = first; x != null; x = x.next) {
// 找到key则替换value
if (key.equals(x.key)) {
x.val = val;
return;
}
}
// 如果未找到key,则新增
first = new Node(key, val, first);
}
public int size() {
int count = 0;
for (Node x = first; x != null; x = x.next) {
count++;
}
return count;
}
/**
* 取出表中所有的键并存入可迭代的集合中
*
* @return 可以遍历的都是Iterable类型,这里用List
*/
public Iterable<Key> keySet() {
List<Key> list = new ArrayList<Key>();
for (Node x = first; x != null; x = x.next) {
list.add(x.key);
}
return list;
}
public Value get(Key key) {
// 遍历链表
for (Node x = first; x != null; x = x.next) {
// 找到key则替换value
if (key.equals(x.key)) {
return x.val;
}
}
return null;// 未找到则返回null
}
/**
* 永久删除
*
* @param key
*/
public void remove(Key key) {
if (containsKey(key)) {
if (key.equals(first.key)) {// 删除表头结点
first = first.next;
return;
}
// 遍历链表
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.next.key)) {
if (x.next.next == null) {// 删除表尾结点
x.next = null;
return;
}
x.next = x.next.next;// 删除表中结点
}
}
}
}
/**
* 下面的方法是固定的,不需要改动。
*/
/**
* 延迟删除
*
* @param key
*/
public void delete(Key key) {
if (containsKey(key)) {
put(key, null);
}
}
public boolean containsKey(Key key) {
return get(key) != null;
}
public boolean isEmpty() {
return size() == 0;
}
}
请仔细观察代码,我将其中的方法名改为与Map一致,这样可以将Map作为参照物,来测试我们新写的符号表算法,下面是客户端测试代码:
package algorithms.search.second;
import java.util.Random;
public class Client {
public static void main(String[] args) {
long start = System.currentTimeMillis();
SequentialSearchST<Integer, String> sst = new SequentialSearchST<Integer, String>();
// Map作为性能参照物
// Map<Integer, String> sst = new HashMap<Integer, String>();
if (sst.isEmpty()) {
sst.put(3, "fan");
System.out.println("sst.size() = " + sst.size());
}
if (!sst.containsKey(17)) {
sst.put(17, "lamp");
System.out.println("sst.size() = " + sst.size());
}
System.out.println("sst.get(20) = " + sst.get(20));
sst.put(20, "computer");
System.out.println("sst.get(20) = " + sst.get(20));
sst.remove(20);
System.out.println("sst.get(345) = " + sst.get(345));
Random rand = new Random();
String abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
for (int i = 0; i < 10000; i++) {
sst.put(rand.nextInt(), String.valueOf(abc.charAt(rand.nextInt(abc.length())))
+ String.valueOf(abc.charAt(rand.nextInt(abc.length()))));
}
sst.put(123, "gg");
sst.remove(3);
sst.remove(123);
sst.remove(17);
System.out.println("-----输出集合全部内容-----");
int a = 0;
for (int k : sst.keySet()) {
a++;
sst.get(k);
}
int keyR = sst.keySet().iterator().next();
System.out.println("next-key: " + keyR + " next-val: " + sst.get(keyR));
System.out.println("0-" + a + "..." + "sst.size() = " + sst.size());
long end = System.currentTimeMillis();
System.out.println("总耗时:" + (end - start) + "ms");
}
}
客户端测试写了很多行,覆盖了我们符号表中所有的方法,同时也有大量的数据操作来测试性能。先看一下刚写的SequentialSearchST的输出:
sst.size() = 1
sst.size() = 2
sst.get(20) = null
sst.get(20) = computer
sst.get(345) = null
-----输出集合全部内容-----
next-key: 1727216285 next-val: Pf
0-10000...sst.size() = 10000
总耗时:640ms
输出全部按预期正确,注意最后的总耗时是640ms。接下来是见证奇迹的时刻,我们将sst的对象类型改为Map,
Map<Integer, String> sst = new HashMap<Integer, String>();
再看一下输出情况:
sst.size() = 1
sst.size() = 2
sst.get(20) = null
sst.get(20) = computer
sst.get(345) = null
-----输出集合全部内容-----
next-key: 817524922 next-val: Hk
0-10000...sst.size() = 10000
总耗时:21ms
21ms VS 640ms !
这就是算法和数据结构的差异带来的性能差异。顺序查找因为有大量的遍历操作,并且它采用的单链表是一个内部类,每次要针对它进行操作,所以它的速度想想也不会太快。相较之下,jdk中的Map的性能提升不是一点半点,但是不用着急,后面会慢慢介绍到Map的实现方式,解答“为什么它这么快?”
二分查找
玩个游戏,我背着你写下一个100以内的整数,你如何用最快速的方式猜出这个数是几?这个游戏我想大家小时候都接触过,方法是先猜50,问结果比50大还是小,小的话再猜25,以此类推。这就是二分查找的主要思想。
@deprecated
(这段话已被丢弃)二分查找,也称折半查找,它属于有序表查找,所以前提必须是key有序,如果将数组的下标当做key的话,数组的结构天生就是key有序,因此实现符号表的数据结构采用数组。
上面被丢弃的原因是我没有想到如果key只是数组的下标的话,无形中就是强制约束了key的类型只能为整型,其他类型无法被作为key,这对我们程序的限制非常大,我们用了泛型就希望他们是类型泛化的,而不是被强制成一种类型不能变动。
因此,我们将采用两个数组,一个数组用来存放key,一个数组用来存放value,两个数组为平行结构,通过相等的下标关联,也即实现了key-value的关联关系。
- 码前准备:
- 创建一个实现二分查找的符号表类BinarySearchST。
- 在类中创建两个数组作为存储容器,一个存储key,一个存储value。
- 我们需要一个动态调整数组大小的方法。
- 操作数组去实现API中的基础方法。
- 代码阶段:
经过上面的分析,我们发现代码阶段的第一个难点其实在于动态调整数组大小,我们都知道数组的大小在创建时就被限定,无法改变其大小,这也是为什么实际工作中我们愿意使用List来替代数组的原因之一。经过查阅资料,找到了一个动态调整数组大小的下压栈。
动态调整数组栈
- 栈,首先栈的特性是LIFO,也叫下压栈,下推栈,把栈想象成一个奶瓶,无论它正放还是倒放,栈就是从瓶口往里挨个塞硬币,往外取的时候后进去的先取出来。注意top指针永远是在瓶口,永远指的是最新的元素(即下标最大的元素)的下一位,压入时按照元素下标顺序来讲,top的值是越来越大的,取出时top的值是越来越小的。关于下推栈,在大师的小玩具——泛型精解中搜索“下推栈”即可找到,当时我们是采用单链表泛型的方式实现的。
graph TB
subgraph top
el3-->el2
el2-->el1
end
这一次我们要实现数组的动态调整,因此采用泛型数组的方式实现下推栈。这里面要注意数组的大小一定要始终满足栈的空间需求,否则就会造成栈溢出,同时又要随时监控如果栈变小到一定程度,就要对数组进行减容操作,否则造成空间浪费。下面是动态调整数组栈:
package algorithms.search.second;
@SuppressWarnings("unchecked")
public class ResizeArrayStack<Item> {
/**
* 定义一个存放栈的数组
*
* @注意 该数组要始终保持空间充足以供栈的使用,否则会造成栈溢出。
*/
private Item[] target = (Item[]) new Object[1];
private int top = 0;// 栈顶指针,永远指向最新元素的下一位
// 判断栈是否为空
public boolean isEmpty() {
return top == 0;
}
// 返回栈的大小,如果插入一个元素,top就加1的话,当前top的值就是栈的大小。
public int size() {
return top;
}
/**
* 调整数组大小,以适应不断变化的栈。
*
* @supply 数组的大小不能预先设定过大,那会造成空间的浪费,影响程序性能
* @param max
*/
public void resize(int max) {
Item[] temp = (Item[]) new Object[max];
for (int i = 0; i < top; i++) {
temp[i] = target[i];
}
target = temp;
}
/**
* @step1 如果没有多余的空间,就给数组扩容
* @step2 空间充足,不断压入新元素
* @param i
*/
public void push(Item i) {
// 如果没有多余的空间,会将数组长度加倍,以支持栈充足的空间,栈永远不会溢出。
if (top == target.length)
resize(2 * target.length);// 扩充一倍
target[top++] = i;// 在top位置插入新元素,然后让top向上移动一位
}
/**
* 弹出一个元素,当弹出元素较多,数组空间有大约四分之三的空间空闲,则针对数组空间进行相应的减容
*
* @return
*/
public Item pop() {
Item item = target[--top];// top是栈顶指针,没有对应对象,需要减一位为最新对象
// 弹出的元素已被赋值给item,然而内存中弹出元素的位置还有值,但已不属于栈,需要手动置为null,以供GC收回。
target[top] = null;
// 如果栈空间仅为数组大小的四分之一,则给数组减容一半,这样可以始终保持数组空间使用率不低于四分之一。
if (top > 0 && top == target.length / 4)
resize(target.length / 2);
return item;
}
public static void main(String[] args) {
ResizeArrayStack<Client> clients = new ResizeArrayStack<Client>();
for (int i = 0; i < 5; i++) {
clients.push(new Client());
}
System.out.println("clients.size() = " + clients.size());
Client a = clients.pop();
System.out.println("clients.size() = " + clients.size());
a.testST();
}
}
输出
clients.size() = 5
clients.size() = 4
sst.size() = 1
sst.size() = 2
sst.get(20) = null
sst.get(20) = computer
sst.get(345) = null
-----输出集合全部内容-----
next-key: 1294486824 next-val: zV
0-10000...sst.size() = 10000
总耗时:21ms(客户端这还是Map呢,没改)
输出正确。
加入迭代
集合类数据元素的基本操作之一就是可以使用foreach语句迭代遍历并处理集合中的每个元素。加入迭代的方式就是实现Iterable接口,不了解Iterable接口与泛型联用的朋友可以转到“大师的小玩具——泛型精解”,查询“Iterable接口”相关的知识。下面对ResizeArrayStack作一下改造,加入迭代。
这里我不完整地粘贴出代码了,因为改动只是很小的部分。
package algorithms.search.second;
import java.util.Iterator;
@SuppressWarnings("unchecked")
public class ResizeArrayStack<Item> implements Iterable<Item> {
...
...
public static void main(String[] args) {
ResizeArrayStack<Client&g