《算法》笔记 7 - 符号表、顺序查找、二分查找
符号表API有序符号表成本模型无序链表中的顺序查找实现性能有序数组中的二分查找实现性能现代计算机和网络使人们能够访问海量的信息,而且各种计算设备正在源源不断地生成新的信息,高效检索这些信息的能力就成了处理它们的重要前提。接下来学习几种经典的查找算法。符号表符号表指的是一张用于存储信息的抽象的表格,主要目的就是将一个键和一个值联系起来,可以将一个键值对插...
- 符号表
- API
- 有序符号表
- 成本模型
- 无序链表中的顺序查找
- 实现
- 性能
- 有序数组中的二分查找
- 实现
- 性能
现代计算机和网络使人们能够访问海量的信息,而且各种计算设备正在源源不断地生成新的信息,高效检索这些信息的能力就成了处理它们的重要前提。接下来学习几种经典的查找算法。
符号表
符号表指的是一张用于存储信息的抽象的表格,主要目的就是将一个键和一个值联系起来,可以将一个键值对插入到符号表,也可以从符号表的所有键值对中按照键直接找到对应的值,符号表也被称为字典。
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<Key> keys() //表中所有键的集合
}
设计的符号表将会遵循以下规则:符号表的实现使用了泛型;每个键只能对应一个值,如果向表中存入的键值对已经在表中存在,则用新的值覆盖旧值,所以表中不存在重复的键;也不允许存入空或空值,只有在表中找不到键对应的值时,get方法会返回null。
有序符号表
符号表根据其中键是否有序分为有序符号表和无序符号表,有序符号表中基于键的有序性可以实现更多有用的操作。
Key floor(Key key) //获取小于等于key的最大键
Key ceiling(Key key) //获取大于等于Key的最小键
int rank(Key key) //获取小于Key的键的数量
Key select(int k) //获取排名为k的键
Key max() //获取最大的键
Key min() //获取最小的键
获取最大、最小键的操作使得有序符号表具有了与优先队列类似的功能,不同的是优先队列中可以存在重复的键但符号表不行。
rank和select方法可以用来检验一个新的键是否插入到合适的位置。对于0到size()-1中的所有i都有i=rank(select(i)),且key=select(rank(key))。floor和ceiling类似于对实数的向下取整、向上取整操作。
成本模型
符号表中插入、查找都需要将一个值与符号表中的键进行比较,在学习符号表的实现时,会统计比较的次数来分析一种实现的成本,如果一种实现的比较次数很少,便考虑其访问数据结构的次数。
无序链表中的顺序查找
可以使用链表作为符号表的简单实现,每个结点存储一个键值对。get()方法会遍历链表,将要查找的键依次与链表中的结点比较,匹配成功就返回结点的值,否则返回null;put()方法也会遍历链表,如果找到匹配的结点,就用要插入的值覆盖匹配结点的值,否则就在链表头部添加一个新的结点。
实现
public class SequentialSearchST<Key, Value> {
private Node first;
private int n;
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 Value get(Key key) {
if (key == null)
throw new IllegalArgumentException("argument to get() is null");
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 value) {
if (key == null)
throw new IllegalArgumentException("first argument to put() is null");
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key)) {
x.val=value;
return;
}
}
first = new Node(key, value, first);
n++;
}
...
}
性能
对于一个含有N个键值对的基于无序链表的符号表来说:
未命中的查找,需要N次比较,因为要遍历并比较链表中的所有键;
插入操作,如果待插入的元素没在符号表中,也需要N次比较;
对于命中的查找,最坏情况为N次比较,但平均情况下,命中的查找不需要这么多次比较。可以通过计算查找表中每个键的总次数,将其除以N来估算一次命中查找的平均比较次数,这种方法假设对符号表中每个键进行查找的可能性都相同,也称为随机命中。虽然实际应用中,不可能做到完全随机,但也基本吻合。
在随机命中模式下,查找的总次数=第一个键的比较次数+第二个键的比较次数+…+第N个键的比较次数=1+2+…+N=N*(N+1)/2;平均比较次数=(N+1)/2。相当于与一半的元素进行比较。
向空表中插入N个不同的键时,每次插入都需与已经插入的所有键比较,所以也需要1+2+…+N=N*(N+1)/2次比较。增长数量级为平方级别。
有序数组中的二分查找
基于有序数组的符号表,这里使用的数据结构是一对平行的数组,一个存储键,一个存储值;也可以用一个由键值对构成的数据来实现。
实现
public class BinarySearchST<Key extends Comparable<Key>, Value> {
private Key[] keys;
private Value[] vals;
private int N;
public BinarySearchST(int capacity) {
keys = (Key[]) new Comparable[capacity];
vals = (Value[]) new Object[capacity];
}
public Value get(Key key) {
if (isEmpty())
return null;
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) {
return vals[i];
} else {
return null;
}
}
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++;
}
public int rank(Key key) {
// return rankRecursion(key, 0, N - 1);
return rankIteration(key, 0, N - 1);
}
public int rankRecursion(Key key, int lo, int hi) {
// if (hi <= lo)
if (hi < lo)
return lo;
int mid = lo + (hi - lo) / 2;
int cmp = key.compareTo(keys[mid]);
if (cmp < 0) {
return rankRecursion(key, lo, mid - 1);
} else if (cmp > 0) {
return rankRecursion(key, mid + 1, hi);
} else {
return mid;
}
}
public int rankIteration(Key key, int lo, int hi) {
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;
}
public Iterable<Key> keys() {
return keys(keys[0], keys[N - 1]);
}
public Iterable<Key> keys(Key lo, Key hi) {
if (lo == null)
throw new IllegalArgumentException("first argument to keys() is null");
if (hi == null)
throw new IllegalArgumentException("second argument to keys() is null");
Queue<Key> queue = new Queue<Key>();
if (lo.compareTo(hi) > 0)
return queue;
for (int i = rank(lo, false); i < rank(hi, false); i++)
queue.enqueue(keys[i]);
if (get(hi) != null)
queue.enqueue(keys[rank(hi, false)]);
return queue;
}
}
这份实现的核心是rank方法,它返回表中小于给定键的键的数量。对于get方法,只要给定的键存在于表中,根据rank方法就可以知道去哪儿找到它;对于put方法,如果给定的键存在于表中,根据rank方法就可以知道去哪儿更新键对应的值,如果键不在表中,根据rank方法也可以知道应该将新的键值插入什么位置。插入的时候,会先将所有更大的键向后移动一格来腾出位置。
由于使用了有序数组,rank方法可以通过二分查找快速地找到键的位置。在查找时,先将被查找的键和子数组的中间键比较,如果被查找的键小于中间键,就在左子数组中继续查找,如果大于中间键,就在右子数组中继续查找,否则中间键就是被命中的键。
rankRecursion和rankIteration分别用递归和迭代实现了这种算法。put方法在插入键值对时,由于使用了rank方法拿到待插入键的排序位置,可以保证符号表一直是有序的。
性能
在N个键的有序数组中进行二分查找时,先找到1个中间元素,然后在剩下的(N-1)/2个元素中继续二分查找,由此可得比较次数的关系式:
C(N)<=C((N-1)/2)+1,其中1为与中间元素的1次比较;
于是C(N)<=C(N/2)+1;
而且有C(0)=0,C(1)=1;
假设N的个数刚好为2的幂,即N=2^n, n=LgN;
则:C(2n)<=C(2(n-1))+1;
继续迭代直到n=0,可得:C(2n)<=C(20)+n;
将N=2^n, n=LgN代入上式可得:
C(N)<=1+LgN。
即N的个数刚好为2的幂时,最多需要LgN+1次比较;
推广到一般的情况,可知查找的增长数量级为对数级别。
查找操作的性能在对数级别,是非常快的,但插入操作的性能如何呢?
在最坏的情况下,插入的位置在数组的最开头,所以插入前需要将所有的元素向后移动一格,键、值各一个数组,共需要2N次数组访问,插入前调用rank方法进行了LgN次比较,LgN相比2N较小可忽略,所以插入操作需要访问数组~2N次;
向一个空符号表中插入N个元素,在最坏的情况下,每次都要插入数组的开头,共需2N(N-1)/2,约为~N^2次数组访问,所以插入操作的增长数量级为线性的,构建一个有序符号表则需要平方级别的时间,线性、平方级别的算法无法用于解决大规模的问题。
更多推荐
所有评论(0)