散列表
使用散列的查找算法分为两步。第一步是用散列函数将被查找的键转化为数组的一个索引。理想情况下,不同的键都能转化为不同的索引值。当然,这只是理想情况,所以我们需要面对两个或者多个键都会散列到相同的索引值的情况。因此,散列查找的第二步就是一个处理碰撞冲突的过程,
散列表是算法在时间和空间上作出权衡的经典例子。
散列函数
如果我们有一个能够保存M 个键值对的数组,那么我们就需要一个能够将任意键转化为该数组范围内的索引([0,M -1 ]范围内的整数)的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键, 即对于任意键,0 到 M -1之间的每个整数都有相等的可能性与之对应(与键无关)。
散列函数和键的类型有关。严格地说,对于每种类型的键都我们都需要一个与之对应的散列函数。
正整数
将整数散列最常用方法是除留余数法。我们选择大小为素数M 的数组,对于任意正整数k,计算除以M的余数。这个函数的计算非常容易(在Java中为k%M ) 并能够有效地将键散布在0 到 M -1 的范围内。如果M 不是素数,我们可能无法利用键中包含的所有信息,这可能导致我们无法均匀地散列散列值。
浮点数
如果键是0 到 1之间的实数,我们可以将它乘以M 并四舍五入得到一个0至 M -1之间的索引值。尽管这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的作用更大,最低位对散 列的结果没有影响。修正这个问题的办法是将键表示为二进制数然后再使用除留余数法(Java就是这么做的)
字符串
除留余数法也可以处理较长的键 ,例如字符串,我们只需将它们当作大整数即可。Java的 ch a rA t()函数能够返回一个char值,即一个非负16位整数。如果R比任何字符的值都大,这种计算相当于将字符串当作一个N位 的 R进制值,将它除以M并取余。一种叫Horner方法的经典算法用N次乘法、加法和取余来计算一个字符串的散列值。只要R足够小,不造成溢出,那么结果就能够如我们所愿,落在0 至 M- 丨之内。使用一个较小的素数,例如31,可以保证字符串中的所有字符都能发挥作用。Java的 S trin g 的默认实现使用了一个类似的方法。
int hash = 0;
for (int i = 0; i < s.length(); i++) {
hash = (R * hash + s.charAt(i)) % M;
}
组合键
如果键的类型含有多个整型变量,我 们可 以 和 S trin g 类型一样将它们混合起来。例如, 假设被查找的键的类型是Date,其中含有几个整型的域:day ( 两个数字表示的日),month( 两个数字表示的月)和 year ( 4 个数字表示的年)。我们可以这样计算它的散列值:
int hash = (((day * R + month) % M) * R + year) % M
只要R足够小不造成溢出,也可以得到一个0 至之间的散列值 。在 这种情况下我们可以通过选择一个适当的M ,比如31,来省去括号内的%M计算。和字符串的散列算法一样,这个方法也能处理有任意多整型变量的类型。
Java 的约定
每种数据类型都需要相应的散列函数,于是Java令所有数据类型都继承了一个能够返回一个32位整数的 hashCode()方法。每一种数据类型的hashCode()方法都必须和equals() 方法一 致。也就是说,如果a.equals(b)返回true ,那么a.hashCode()的返回值必然和b.hashCode()的返回值相同。相反,如果两个对象的hashCode()方法的返回值不同,那么我们就知道这两个对象是不同的。但如果两个对象的hashCode()方法的返回值相同,这两个对象也有可能不同,我们还需要用equals()方法进行判断。请注意,这说明如果你要为自定义的数据类型定义散列函数,你需要同时重写hashCodeO和e quals()两个方法。
将 hashCodeO的返回值转化为一个数组索引
private int hash(Key x) {
return (x.hashCode() & 0x7fffffff) % M;
}
这段代码会将符号位屏蔽(将一个32位整数变为一个31位非负整数),然后用除留余数法计算它除以M的余数。在使用这样的代码时我们一般会将数组的大小M取为素数以充分利用原散列值的所有位。
基于拉链法的散列表
一个散列函数能够将键转化为数组索引。散列算法的第二步是碰撞处理,也就是处理两个或多个键的散列值相同的情况。一种直接的办法是将大小为M 的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对。这种方法被称为拉链法 , 因为发生冲突的元素都被存储在链表中。
拉链法的 一 种实现方法是使用原始的链表数据类型,来扩展SequentialSearchST。另一种更简单的方法(但效率稍低)是采用一般性的策略,为M个元素分别构建符号表来保存散列到这里的键。
public class SeparateChainingHashST<Key, Value> {
private int N; // 键值对总数
private int M; // 散列表的大小
private Sequent!alSearchST<Key, Value> [] st ; // 存放链表对象的数组
public SeparateChainingHashST() {
this (997);
}
public SeparateChainingHashST(int M) { // 创建M条链表
this.M = M;
st = (Sequent!alSearchST<Key, Value > []) new SequentialSearchST[M];
for ( int i = 0 ; i < M; i++)
st[i] = new SequentialSearchST() ;
}
private int hash(Key key) {
return (key * hashCode() & 0x7fffffff) % M;
}
public Value get(Key key) {
return (Value) st[hash(key)].get(key);
}
public void put(Key key, Value val) {
st[hash(key)].put(key, val);
}
public Iterable <Key > keys()
}
散列表的大小
在实现基于拉链法的散列表时,我们的目标是选择适当的数组大小M ,既不会因为空链表而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。而拉链法的一个好处就是这 并不是关键性的选择。如果存人的键多于预期,查找所需的时间只会比选择更大的数组稍长;如果少于预期,虽然有些空间浪费但查找会非常快。当内存不是很紧张时,可以选择一个足够 的M,使得查找需要的时间变为常数;当内存紧张时,选择尽量大的M 仍然能够将性能提高 M 倍。
删除操作
要删除一个键值对,先用散列值找到含有该键的SequentialSearchST对象,然后调用该对象的 delete() 方法 。这种重用已有代码的方式比重新实现链表的删除更好。
有序性相关的操作
散列最主要的目的在于均匀地将键散布开来,因此在计算散列后键的顺序信息就丢失了。如果你需要快速找到最大或者最小的键,或是查找某个范围内的键,散列表都不是合适的选择。因为这些操作的运行时间都将会是线性的。
基于拉链法的散列表的实现简单。在键的顺序并不重要的应用中,它可能是最快的(也是使用最广泛的)符号表实现。
基于线性探测法的散列表
实现散列表的另一种方式就是用大小为M的数组保存N个键值对,其中我们需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。
开放地址散列表中最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),我们直接检查散列表中的下一个位置(将索引值加1) 。这样的线性探测可能会产生三种结果:
- 命中,该位置的键和被查找的键相同;
- 未命中,键为空(该位置没有键);
- 继续查找,该位置的键和被查找的键不同。
我们用散列函数找到键在数组中的索引,检查其中的键和被查找的键是否相同。如果不同则继续查找(将索引增大,到达数组结尾时折回数组的开头),直到找到该键或者遇到一个空元素。
public class LinearProbingHashST<Key, Value> {
private int N; // 符号表中键值对的总数
private int M = 16; // 线性探测表的大小
private Key[] keys ; // 键
private Value[] vals; // 值
public LinearProbingHashST() {
keys = (Key[]) new Object[M];
val (Value[]) new Object[M];
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
private void resize()
public void put(Key key,Value val) {
if (N >= M / 2)
resize(2 * M);
int i;
for (int i = hash(key);keys[i] != null;i = (i + 1) % M)
if (keys[i].euqals(key)) {
vals[i] = val;
return;
}
keys[i] = key;
vals[i] = val;
N++;
}
public Value get(Key key) {
for (int i = hash(key);keys[i] != null; i = (i + 1) % M)
if (keys[i].equals(key))
return vals[i];
return null;
}
}
这段符号表的实现将键和值分别保存在两个数组(BinarySearchST类型)中,使用空(标记为null ) 来表示一簇键的结束。如果一个新键的散列值是一个空元素,那么就将它保存在那里;如果不是,我们就顺序查找一个空元素来保存它。要查找一个键,我们从它的散列值开始顺序查找,如果找到则命中,如果遇到空元素则未命中。
删除操作
如何从基于线性探测的散列表中删除一个 键?仔细想一想,你会发现直接将该键所在的位置设为null是不行的,因为这会使得在此位置之后的元素无法被查找。因此,我们需要将簇中被删除键的右侧的所有键重新插入散列表。
public void delete(Key key) {
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 keyToRedo = keys[i];
Value valToRedo = vals[i];
keys[i] = null ;
vals[i] = null;
N--;
put(keyToRedo, valToRedo);
i = ( i + 1) % M;
}
N--;
if (N > 0 && N == M/8)
resize(M/2);
}
和拉链法一样,开放地址类的散列表的性能也依赖于a = N/M的比值,但意义有所不同。我们将a称为散列表的使用率。对于基于拉链法的散列表,a 是每条链表的长度,因此一般大于1;对于基于线性探测的散列表,a 是表中已被占用的空间的比例,它是不可能大于1 的。
当散列表快满的时候查找所需的探测次数是巨大的(a越趋近于1, 由公式可知探测的次数也越来越大),但当使用率a 小于 1/2时探测的预计次数只在1.5到 2.5之间。
调整数组大小
private void resize(int cap) {
LinearProbingHashST<Key,Value> t;
t = new LinearProbingHashST<Key,Value>(cap);
for (int i = 0; i < M; i++) {
if (keys[i] != null)
t.put(keys[i],vals[i]);
keys = t.keys;
vals = t.vals;
M = t.M;
}
}
这段代码构造的散列表比原来大一倍, 因此a的值就会减半。
对于拉链法,如果你能准确地估计用例所需的散列表的大小N,调整数组的工作并不是必需的,只需要根据查找 耗时和(1+N/M)成正比来选取一个适当的M 即可。而对于线性探测法,调整数组的大小是必需的,因为当用例插人的键值对数量超过预期时它的查找时间不仅会变得非常长,还会在散列表被填满时进人无限循环。
总结
从表中显然可以知道,对于典型的应用程序,应该在散列表和二叉查找树之间进行选择。
相对二叉查找树,散列表的优点在于代码更简单,且查找时间最优(常数级别,只要键的数据类型是标准的或者简单到我们可以为它写出满足(或者近似满足)均匀性假设的高效散列函数即可)。二叉查找树相对于散列表的优点在于抽象结构更简单(不需要设计散列函数),红黑树可以保证最坏情况下的性能且它能够支持的操作更多(如排名、选择、排序和范围查找)。大多数程序员的第一选择都是散列表,在其他因素更重要时才会选择红黑树。