如果所有的键都是小整数,我们可以用一个数组来实现无序的符号表,将键作为数组的索引而数组中键i处存储的就是它对应的值。这样我们就可以快速访问任意的键。我们需要用算术操作将复杂类型的键转化为数组的索引来访问数组中的键值对。
使用散列的查找算法分为两步:
第一步是用散列函数将被查找的键转化为数组的一个索引。理想情况下,不同的键都能转化为不同的索引值。
第二步就是一个处理碰撞冲突的过程,例如拉链法和线性探测法。
散列表是算法在时间和空间上作出权衡的经典例子。如果没有内存限制,我们可以直接将键作为(可能是一个超大的)数组的索引,那么所有查找操作只需要访问内存一次即可完成。另一方面,如果没有时间限制,我们可以使用无序数组并进行顺序查找。
而散列表则使用了适度的空间和时间并在这两个极端之间找到了一种平衡。使用散列表,你可以实现在一般应用中拥有常数级别的查找和插入操作的符号表。
散列函数
如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键,即对于任意键,0到M-1之间的每个整数都有相等的可能性与之对应。
散列函数和键的类型有关。严格地说,对于每种类型的键我们都需要一个与之对应的散列函数。
正整数
将整数散列最常用方法是除留余数法。我们选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数。这个函数的计算非常容易并能够有效地将键散布在0到M-1的范围内。如果M不是素数,我们可能无法利用键中包含的所有信息,这可能导致我们无法均匀地散列散列值。
浮点数
如果键是0到1之间的实数,我们可以将它乘以M并四舍五入得到一个0至M-1之间的索引值,尽管这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的作用更大,最低位对散列的结果没有影响。修正这个问题的办法是将键表示为二进制然后再使用除留余数法。
字符串
除留余数法也可以处理类型为字符串的键:
int hash = 0;
for (int i = 0; i < s.length(); i++)
hash = (R * hash + s.charAt(i)) % M;
如果R比任何字符的值都大,这种计算相当于将字符串当作一个N位的R进制,将它除以M并取余。一种叫Horner方法的经典算法用N次乘法、加法和取余来计算一个字符串的散列值。只要R足够小,不造成溢出,那么结果就能够如我们所愿,落在0至M-1之内。
组合键
如果键的类型含有多个整型变量,我们可以和String类型一样将它们混合起来。假设被查找的键的类型是Date,其中含有几个整型的域:day,month和year。我们可以这样计算它的散列值:
int hash = (((day * R + month) % M) * R + year) % M;
hashCode()
每种数据类型都需要相应的散列函数,于是java令所有数据类型都继承了一个能够返回一个32比特整数的hashCode()方法。
每一种数据类型的hashCode()方法都必须和equals()方法一致。也就是说,如果a.equals(b)返回true,那么a.hashCode()的返回值必然和b.hashCode()的返回值相同。相反,如果两个对象的hashCode()方法的返回值不同,那么我们就知道这两个对象是不同的。但如果两个对象的hashCode()方法的返回值相同,这两个对象也有可能不同,我们还需要用equals()方法进行判断。请注意,这说明如果你要为自定义的数据类型定义散列函数,你需要同时重写hashCode()和equals()两个方法。
将hashCode()的返回值转化为一个数组索引
因为我们需要的是数组的索引而不是一个32位的整数,我们在实现中会将默认的hashCode()方法和除留余数法结合起来产生一个0至M-1的整数,方法如下:
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
这段代码会将符号位屏蔽,然后用除留余数法计算它除以M的余数。在使用这样的代码时我们一般会将数组的大小M取为素数以充分利用原散列值的所有位。
总的来说,要为一个数据类型实现一个优秀的散列方法需要满足三个条件:
- 一致性——等价的键必然产生相等的散列值;
- 高效性——计算简便;
- 均匀性——均匀地散列所有的键。
设计同时满足这三个条件的散列函数是专家们的事。有了各种内置函数,java程序员在使用散列时只需要调用hashCode()方法即可。
均匀散列假设
我们使用的散列函数能够均匀并独立地将所有的键散布于0到M-1之间。
基于拉链法的散列表
一个散列函数能够将键转化为数组索引。散列算法的第二步是碰撞处理,也就是处理两个或多个键的散列值相同的情况。一种直接的办法是将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对。这种方法被称为拉链法,因为发生冲突的元素都被存储在链表中。这个方法的基本思想就是选择足够大的M,使得所有链表都尽可能短以保证高效的查找。查找分两步:首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键。
代码
其中SequentialSearchST的实现参考博客。
package section3_1;
import java.util.ArrayList;
import java.util.List;
public class SeparateChainingHashST<Key, Value> {
private static final int INIT_CAPACITY = 4;
private int m; // hash table size
private int n; // number of key-value pairs
private SequentialSearchST<Key,Value>[] st;
public SeparateChainingHashST() {
this(INIT_CAPACITY);
}
public SeparateChainingHashST(int m) {
this.m = m;
st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[m];
for (int i = 0;i < m;i++) {
st[i] = new SequentialSearchST<>();
}
}
public void resize(int chains) {
SeparateChainingHashST<Key,Value> temp = new SeparateChainingHashST<>(chains);
for (int i = 0;i < m;i++) {
for (Key key:st[i].keys()) {
temp.put(key,st[i].get(key));
}
}
this.m = temp.m;
this.n = temp.n;
this.st = temp.st;
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
public int size() {
return n;
}
public boolean isEmpty() {
return size() == 0;
}
public boolean contains(Key key) {
if (key == null) throw new IllegalArgumentException("argument to contains() is null");
return get(key) != null;
}
public Value get(Key key) {
if (key == null) throw new IllegalArgumentException("argument to get() is null");
return (Value) st[hash(key)].get(key);
}
public void put(Key key, Value val) {
if (key == null) throw new IllegalArgumentException("first argument to put() is null");
if (val == null) {
delete(key);
return;
}
if (n >= 10*m) resize(2*m);
if (!st[hash(key)].contains(key)) n++;
st[hash(key)].put(key, val);
}
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException("argument to delete() is null");
if (st[hash(key)].contains(key)) n--;
st[hash(key)].delete(key);
if (m > INIT_CAPACITY && n <= 2*m) resize(m/2);
}
public Iterable<Key> keys() {
List<Key> list = new ArrayList<>();
for (int i = 0;i < m;i++) {
for (Key key:st[i].keys()) {
list.add(key);
}
}
return list;
}
public static void main(String[] args) {
SeparateChainingHashST<String,Integer> hst = new SeparateChainingHashST<>();
String[] words = new String[]{
"it","was","the","best","of","times","it","was","the","worst","of","times",
"it","was","the","age","of","wisdom","it","was","the","age","of","foolishness",
"it","was","the","epoch","of","belief","it","was","the","epoch","of","incredulity",
"it","was","the","season","of","light","it","was","the","season","of","darkness",
"it","was","the","spring","of","hope","it","was","the","winter","of","despair"
};
int idx = 0;
while (idx < words.length) {
String word = words[idx];
if (!hst.contains(word)) {
hst.put(word,1);
} else {
hst.put(word,hst.get(word)+1);
}
idx++;
}
int cnt = 0;
for (String w:hst.keys()) {
System.out.println(w);
cnt++;
}
System.out.println(cnt);
}
}
性能分析
在一张含有M条链表和N个键的散列表中,(在均匀散列假设成立的前提下)任意一条链表中的键的数量均在N/M的常数因子范围内的概率无限趋向于1。
在一张含有M条链表和N个键的散列表中,未命中查找和插入操作所需的比较次数为~N/M。
散列表的大小
在实现基于拉链法的散列表时,我们的目标是选择适当的数组大小M,既不会因为空链表而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。而拉链法的一个好处就是这并不是关键性的选择。如果存入的键多于预期,查找所需的时间只会比选择更大的数组稍长;如果少于预期,虽然有些空间浪费但查找会非常快。当内存不是很紧张时,可以选择一个足够大的M,使得查找需要的时间变为常数;当内存紧张时,选择尽量大的M仍然能够将性能提高M倍。
有序性相关的操作
散列最主要的目的在于均匀地将键散布开来,因此在计算散列后键的顺序信息就丢失了。如果你需要快速找到最大或者最小的键,或是查找某个范围内的键,散列表都不是合适的选择,因为这些操作的运行时间都将会是线性的。
基于拉链法的散列表的实现简单。在键的顺序并不重要的应用中,它可能是最快的(也是使用最广泛的)符号表实现。当使用java的内置数据类型作为键,或是在使用含有经过完善测试的hashCode()方法的自定义类型作为键时,基于拉链法的散列表能够提供快速而方便的查找和插入操作。
基于线性探测法的散列表
实现散列表的另一种方式就是用大小为M的数组保存N个键值对,其中M>N。我们需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。
开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),我们直接检查散列表中的下一个位置(将索引值加1)。这样的线性探测可能会产生三种结果:
- 命中,该位置的键和被查找的键相同;
- 未命中,键为空(该位置没有键);
- 继续查找,该位置的键和被查找的键不同。
我们用散列函数找到键在数组中的索引,检查其中的键和被查找的键是否相同,如果不同则继续查找(将索引增大,到达数组结尾时折回数组的开头),直到找到该键或者遇到一个空元素,如图所示。
开放地址类的散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表的空元素。这些空元素可以作为查找结束的标志。
代码
package section3_1;
import java.util.ArrayList;
import java.util.List;
public class LinearProbingHashST<Key, Value> {
private static final int INIT_CAPACITY = 30;
private int n;
private int m;
private Key[] keys;
private Value[] vals;
public LinearProbingHashST() {
this(INIT_CAPACITY);
}
public LinearProbingHashST(int capacity) {
m = capacity;
n = 0;
keys = (Key[]) new Object[m];
vals = (Value[]) new Object[m];
}
public int size() {
return n;
}
public boolean isEmpty() {
return size() == 0;
}
public boolean contains(Key key) {
if (key == null) throw new IllegalArgumentException("argument to contains() is null");
return get(key) != null;
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
public void resize(int capacity) {
LinearProbingHashST<Key,Value> temp = new LinearProbingHashST<>(capacity);
for (int i = 0;i < m;i++) {
if (keys[i] != null) {
temp.put(keys[i],vals[i]);
}
}
this.m = temp.m;
this.keys = temp.keys;
this.vals = temp.vals;
}
public void put(Key key, Value value) {
if (key == null) throw new IllegalArgumentException("first argument to put() is null");
if (value == null) {
delete(key);
return;
}
if (n >= m/2) resize(2*m);
int i;
for (i = hash(key);keys[i] != null;i = (i + 1) % m) {
if (keys[i].equals(key)) {
vals[i] = value;
return;
}
}
keys[i] = key;
vals[i] = value;
n++;
}
public Value get(Key key) {
if (key == null) throw new IllegalArgumentException("argument to get() is null");
for (int i = hash(key);keys[i] != null;i = (i + 1) % m) {
if (keys[i].equals(key)) {
return vals[i];
}
}
return null;
}
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException("argument to delete() is null");
if (!contains(key)) return;
int i = hash(key);
while (!key.equals(keys[i])) i = (i + 1) % m;
keys[i] = null;
vals[i] = null;
i = (i + 1) % m;
while (keys[i] != null) {
Key keyToRehash = keys[i];
Value valToRehash = vals[i];
keys[i] = null;
vals[i] = null;
n--;
put(keyToRehash,valToRehash);
i = (i + 1) % m;
}
n--;
if (n > 0 && n <= m/8) resize(m/2);
}
public Iterable<Key> keys() {
List<Key> list = new ArrayList<>();
for (int i = 0;i < m;i++) {
if (keys[i] != null) {
list.add(keys[i]);
}
}
return list;
}
public static void main(String[] args) {
LinearProbingHashST<String,Integer> hst = new LinearProbingHashST<>();
String[] strings = new String[]{
"S","E","A","R","C","H","E","X","A","M","P","L","E"
};
Integer[] integers = new Integer[]{
0,1,2,3,4,5,6,7,8,9,10,11,12
};
int idx = 0;
while (idx < strings.length) {
hst.put(strings[idx],integers[idx]);
idx++;
}
for (String s : hst.keys()) {
System.out.println(s + " " + hst.get(s));
}
}
}
删除操作
仔细想一想,你会发现直接将该键所在的位置设为null是不行的,因为这会使得在此位置之后的元素无法被查找。因此,我们需要将簇中被删除键的右侧的所有键重新插入散列表。
和拉链法一样,开放地址类的散列表的性能也依赖于α=N/M的比值,但意义有所不同。我们将α称为散列表的使用率。对于基于拉链法的散列表,α是每条链表的长度,因此一般大于1;对于基于线性探测的散列表,α是表中已被占用的空间的比例,它是不可能大于1的。事实上,在使用线性探测法时我们不允许α达到1(散列表被占满),因为此时未命中的查找会导致无限循环。为了保证性能,我们会动态调整数组大小来保证使用率在1/8到1/2之间。
键簇
线性探测的平均成本取决于元素在插入数组后聚集成的一组连续的条目,也叫做键簇。显然,短小的键簇才能保证较高的效率。随着插入的键越来越多,这个要求很难满足,较长的键簇会越来越多。另外,因为数组的每个位置都有相同的可能性被插入一个新键,长键簇更长的可能性比短键簇更大,因为新键的散列值无论落在簇中的任何位置都会使簇的长度加1。
性能分析
在一张大小为M并含有N=αM个键的基于线性探测的散列表中,基于均匀散列假设,命中和未命中的查找所需的探测次数分别为:
~1/2(1+1/(1-α))和~1/2(1+1/(1-α)^2)
可以看出当散列表快满的时候查找所需的探测次数是巨大的(α越趋近于1,由公式可知探测的次数也越来越大),但当使用率α小于1/2时探测的预计次数只在1.5到2.5之间。下面,我们为此来考虑动态调整散列表数组的大小。
调整数组大小
在使用线性探测法时,我们要保证散列表的使用率永远都不会超过1/2。
对于拉链法,如果你能准确地估计用例所需散列表的大小N,调整数组的工作并不是必需的,只需要根据查找耗时和(1+N/M)成正比来选取一个适当的M即可。而对于线性探测法,调整数组的大小是必需的,因为当用例插入的键值对数量超过预期时它的查找时间不仅会变得非常长,还会在散列表被填满时进入无限循环。