1 无序链表的顺序查找
1.1 无序链表实现
符号表中使用的数据结构的一个简单选择就是链表,每个节点存储一个键值对。
- get(K key):方法实现就是遍历链表,equals()方法比较节点中键的相等性。匹配成功,返回节点中对应的值;否则返回null;
- put(K key, V value):方法也是遍历链表,equals()方法比较表中每个节点的键的相等性。匹配成功,用新值更新节点中对应的旧值;否则就构建新节点插入链表头。
具体实现如下代码1-1所示:
public class SequentialSearchST<K, V> extends AbstractST<K, V>{
/**
* 头结点
*/
private Node<K, V> first;
/**
* 符号表键值对数量
*/
private int size;
private class Node<K, V> {
K key;
V val;
Node<K, V> next;
public Node(K key, V val, Node<K, V> next) {
this.key = key;
this.val = val;
this.next = next;
}
}
@Override
public void put(K key, V value) {
for (Node<K, V> x = first; x != null; x = x.next) {
if (x.key.equals(key)) {
x.val = value;
return;
}
}
first = new Node<>(key, value, first);
size++;
}
@Override
public V get(K key) {
for (Node<K, V> x = first; x != null; x = x.next) {
if (x.key.equals(key)) {
return x.val;
}
}
return null;
}
@Override
public V delete(K key) {
Node<K, V> pre = null;
Node<K, V> p = null;
V oldVal = null;
for (Node<K, V> x = first; p == null && x != null; x = x.next) {
if (x.key.equals(key)) {
if (pre == null) {
p = first;
first = first.next;
} else {
p = x;
pre.next = x.next;
}
oldVal = p.val;
p.val = null;
p.next = null;
size--;
}
pre = x;
}
return oldVal;
}
@Override
public int size() {
return size;
}
@Override
public Iterable<K> keys() {
Set<K> keySet = new HashSet<>();
for (Node<K, V> x = first; x != null; x = x.next) {
keySet.add(x.key);
}
return keySet;
}
@Override
public String toString() {
if (size == 0) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append('{');
for (Node<K, V> x = first; x != null; x = x.next) {
sb.append(x.key + "=" + x.val);
if (x.next != null) {
sb.append(", ");
}
}
sb.append("}");
return sb.toString();
}
}
我们把之前ST做成接口,代码如下1-2所示:
/**
* 符号表接口
* @author Administrator
* @version 1.0
* @description
* @date 2022-11-11 20:16
*/
public interface ST<K, V> {
/**
* 键值对存入符号表
* @param key 键
* @param value 值
*/
void put(K key, V value);
/**
* 获取键key对应的值
* @param key 键
* @return 值
*/
V get(K key);
/**
* 删除键对应的键值对
* @param key 键
*/
V delete(K key);
/**
* 键是否包含在符号表中
* @param key 键
* @return {@code true}如果键在符号表中;否则返回{@code false}
*/
default boolean contains(K key) {
return get(key) != null;
}
/**
* 符号表是否为空
* @return {@code true}符号表为空;否则返回{@code false}
*/
default boolean isEmpty() {
return size() == 0;
}
/**
* 符号表中键值对的数量
* @return 键值对的数量
*/
int size();
/**
* 键的迭代器
* @return 键的迭代器
*/
Iterable<K> keys();
}
SequentialSearchST类的父类保留,只有个空参构造。
-
测试下基本功能,测试结果:
-
public class STTest { public static void main(String[] args) { ST<String, Integer> st = new SequentialSearchST<>(); st.put("11", 4234); st.put("21", 2342); st.put("31", 132); st.put("41", 41); System.out.println(st); System.out.println(st.get("11")); System.out.println(st.get("51")); System.out.println(st.size()); System.out.println(st.delete("31")); System.out.println(st.contains("41")); System.out.println(st); } }
测试结果:
{41=41, 31=132, 21=2342, 11=4234} 4234 null 4 132 true {41=41, 21=2342, 11=4234}
1.2 分析
命题A:在含有N对键值对的基于(无序)链表的符号表中,未命中查找和插入都需要N次比较。命中的查找在最坏情况下需要N次比较。特别地,向一个空表中插入N个不同的键需要 ∼ N 2 2 \sim\frac{N^2}{2} ∼2N2次比较。
推论:向一个空表中插入N个不同的键需要 ∼ N 2 2 \sim\frac{N^2}{2} ∼2N2次比较。
查找一个已经存在的键并不需要N次比较。一种度量方法就是查找表中的每个键,并将总时间除以N。我们将它称为随机命中。尽管我们实现的符号表查找模式不是随机的,很容易得出随机命中所需的平均比较次数为 ∼ N 2 \sim\frac{N}{2} ∼2N。
上述分析证明我们实现的基于链表的实现以及顺序查找是非常低效的,无法满足FrequencyCounter处理庞大输入问题的需求。比较的总次数和查找的总次数与插入次数的乘积成正比。
2 有序数组中的二分查找
完整的有序符号表API实现如下代码2-1所示:
2.1 实现
import java.util.Arrays;
import java.util.HashSet;
import java.util.NoSuchElementException;
import java.util.Set;
/**
* @author Administrator
* @version 1.0
* @description
* @date 2022-11-12 19:33
*/
public class BinarySearchST<K extends Comparable<K>, V> extends AbstractSortedST<K, V>{
private K[] keys;
private V[] vals;
private int size;
private int capacity;
public BinarySearchST(int initCapacity) {
if (initCapacity < 0) {
throw new IllegalArgumentException("Illegal Capacity: "+
capacity);
}
this.capacity = initCapacity;
keys = (K[]) new Comparable[capacity];
vals = (V[]) new Object[capacity];
}
@Override
public K min() {
checkEmpty();
return keys[0];
}
@Override
public K max() {
checkEmpty();
return keys[size - 1];
}
private void checkEmpty() {
if (size == 0) {
throw new NoSuchElementException("元素不存在");
}
}
@Override
public K floor(K key) {
int i = rank(key);
if (i == 0 && keys[0].compareTo(key) != 0) {
return null;
}
return keys[i];
}
@Override
public K ceiling(K key) {
int i = rank(key);
if (i == size) {
return null;
}
return keys[i];
}
@Override
public int rank(K key) {
int lo = 0, hi = size - 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;
}
@Override
public K select(int k) {
checkIndex(k - 1);
return keys[k];
}
@Override
public V delete(int index) {
checkIndex(index);
V oldVal = vals[index];
for (int i = index; i < size; i++) {
keys[i] = keys[i + 1];
vals[i] = vals[i + 1];
}
size--;
return oldVal;
}
private void checkIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("索引越界");
}
}
@Override
public V deleteMin() {
return delete(0);
}
@Override
public V deleteMax() {
return delete(size - 1);
}
@Override
public V delete(K key) {
int k = rank(key);
V oldVal = null;
if (k < size && keys[k].compareTo(key) == 0) {
// 删除
oldVal = delete(k);
}
return oldVal;
}
@Override
public int size(K lo, K hi) {
int l = rank(lo);
int h = rank(hi);
if (h < l) {
throw new IllegalArgumentException("范围上限小于范围下限");
}
return contains(hi) ? h - l + 1: h - l;
}
@Override
public void put(K key, V value) {
int k = rank(key);
if (k < size && keys[k].compareTo(key) == 0) {
vals[k] = value;
return;
}
if (size == capacity) {
throw new StackOverflowError("数组溢出");
}
for (int i = size - 1; i >= k; i--) {
keys[i + 1] = keys[i];
vals[i + 1] = vals[i];
}
keys[k] = key;
vals[k] = value;
size++;
}
@Override
public V get(K key) {
int i = rank(key);
if (i < size && keys[i].compareTo(key) == 0) {
return vals[i];
}
return null;
}
@Override
public int size() {
return size;
}
@Override
public Iterable<K> keys(K lo, K hi) {
Set<K> keySet = new HashSet<>();
int h = rank(hi);
for (int i = rank(lo); i < h; i++) {
keySet.add(keys[i]);
}
if (contains(hi)) {
keySet.add(keys[h]);
}
return keySet;
}
@Override
public Iterable<K> keys() {
return new HashSet<>(Arrays.asList(keys));
}
@Override
public String toString() {
if (size == 0) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append('{');
for (int i = 0; i < size; i++) {
sb.append(keys[i]).append("=").append(vals[i]);
if (i != size - 1) {
sb.append(", ");
}
}
sb.append("}");
return sb.toString();
}
}
继承结构同无线链表一样,这样不在赘述,完整代码在文章最后仓库中。
2.2 分析
使用一对平行数组,一个Comparable类型的数组存存放键,put方法实现键的有序性;另外一个数组存储值。然后利用数组索引来高效实现get()和其他操作
实现的核心是rank()方法,它返回表中小于给定键的键的数量。
对于put()方法,如果给定的键存在于表中,那么就通过rank()方法可以精确定位索引,完成值的替换;否则键就不存在与表中,要插入的位置就是rank()方法返回的索引k。索引从[k,size-1]键值对数组同时完成整体的数据后移一位以腾出索引k处的位置存储新的键值对。
因为底层用数组实现,关于数组扩容或缩容问题可以参考ArrayList或其他基于数组实现的数据结构,这里暂时不做实现。
-
注意事项
- 索引越界问题
- 空表取值问题
- 满表存值问题,即有序数组表扩容问题
3 对二分查找的分析
命题B:在N个键的有序数组中进行二分查找最多需要( lg N + 1 \lg N +1 lgN+1)次比较(无论成功与否)
证明:可以参考排序分析
令 C ( N ) C(N) C(N)为在大小N的符号表中查找一个键所需要进行的比较次数。显然 C ( 0 ) = C ( 1 ) = 0 C(0)=C(1)=0 C(0)=C(1)=0,且对于 N > 0 N>0 N>0我们可以写出一个和递归方法对于的归纳关系式:
C ( N ) ≤ C ( ⌊ N 2 ⌋ ) + 1 C(N)\le C(\lfloor\frac{N}{2}\rfloor)+1 C(N)≤C(⌊2N⌋)+1
无论查找是在左侧还是右侧,子数组的大小都不会超过 ⌊ N 2 ⌋ \lfloor\frac{N}{2}\rfloor ⌊2N⌋,我们需要一次比较来检查中间元素和别查找元素是否相等,并决定是继续查找左侧还是右侧的子数组。当N为2的幂减1时( N = 2 n − 1 N=2^n-1 N=2n−1),这种递推很容易。首先因为 ⌊ N 2 ⌋ = 2 n − 1 − 1 \lfloor\frac{N}{2}\rfloor=2^{n-1}-1 ⌊2N⌋=2n−1−1,所有:
C ( 2 n − 1 ) = C ( 2 n − 1 ) + 1 C(2^n-1)=C(2^{n-1})+1 C(2n−1)=C(2n−1)+1
用这个公式代换上述不等式有:
C ( 2 n − 1 ) = C ( 2 n − 2 ) + 1 + 1 C(2^n-1)=C(2^{n-2})+1+1 C(2n−1)=C(2n−2)+1+1
上述代换重复执行n-2次,得
C ( 2 n − 1 ) = C ( 2 0 ) + n C(2^n-1)=C(2^0)+n C(2n−1)=C(20)+n
最后结果:
C ( N ) = C ( 2 n ) ≤ n + 1 < lg N + 1 C(N)=C(2^{n})\le n+1\lt \lg N + 1 C(N)=C(2n)≤n+1<lgN+1
对于一般的N,确定结论更加复杂,单不难通过以上论证推广得到。二分查找所需时间必然在对数范围内。
上述实现中get(),ceiling(),floor()等只调用了一次rank(),2个参数的size调用了2次rank(),因此上述证明可以保证这些操作所需的时间都是对数级别。
尽量能够保证查找所需的时间是对数级别的,BinarySearchST仍然无法支持我们用类似FrequencyCounter的程序来处理大量问题,因为put()方法太慢了。二分查找减少了比较的次数但无法减少运行所需时间:在键随机排列情况下,构造一个基于有序数组的符号表所需要访问数组的次数是数组长度的平方级别(实际情况下键的排列虽然不是随机的,但仍然很好地符合这个模型。BinarySearchST的操作的成本表如下表3-1所示:
方法 | 运行所需时间的 的增长量级 |
---|---|
put() | 插入 lg N + N \lg N + N lgN+N ;更新 N |
get() | lg N \lg N lgN |
delete(int) | N |
delete(K) | lg N + N \lg N + N lgN+N |
contains() | lg N \lg N lgN |
size() | 1 |
size(K,K) | lg N \lg N lgN |
min() | 1 |
max() | 1 |
floor() | lg N \lg N lgN |
ceiling() | lg N \lg N lgN |
rank() | lg N \lg N lgN |
select() | 1 |
deleteMin() | N |
deleteMax() | 1 |
命题C:向大小为N的有序数组中插入一个新的元素在最坏的情况下需要访问 ∼ 2 N \sim 2N ∼2N次数组,因此向一个空符号表中插入N个元素在最坏的情况下需要访问 ∼ ( N 2 + N lg N ) \sim (N^2+N\lg N) ∼(N2+NlgN)
证明:rank()+数组访问次数,即$ (\lg 1 + 2\cdot1)+(\lg2+2\cdot2)+…+(\lg N + 2\cdot N)= lg ( 1 ⋅ 2 ⋯ N ) + 2 ( 1 + 2 + ⋯ + N ) = lg N ! + N ( N + 1 ) \lg(1\cdot2\cdots N) + 2(1+2+\cdots+N)=\lg N!+N(N+1) lg(1⋅2⋯N)+2(1+2+⋯+N)=lgN!+N(N+1)\sim (N^2+N\lg N)$
4 总结
下面看下符号表的初始实现的性能特点,如下表4-1所示:
算法(数据结构) | 最坏情况下的成本 (N次插入后) | 平均情况下的成本 (N次随机插入后) | 算法高效的支持有序 性相关的操作 | ||
查找 | 插入 | 查找 | 插入 | ||
顺序查找(无序链表) | N | N | N/2 | N | 否 |
二分查找(有序数组) | lgN | N+lgN | lgN | N+lgN | 是 |
现在的问题是我们能否找到同时保证查找和插入都是对数级别的算法和数据结构。答案是可以。如果实现目标呢?要支持高效的插入操作,我们似乎需要一种链式结构,但链表无法使用二分查找法。为了将二分查找和链表灵活性结合起来,我们需要更加复杂的数据结构。能够同时拥有两者的就是二叉查找树,后面我们继续学习相关的知识。
5 后记
如果小伙伴什么问题或者指教,欢迎交流。
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/algorithm
参考:
[1][美]Robert Sedgewich,[美]Kevin Wayne著;谢路云译.算法:第4版[M].北京:人民邮电出版社,2012.10