《算法》笔记 9 - 散列表
- 散列函数
- 基于拉链法的散列表
- 实现
- 性能
- 基于线性探测法的散列表
- 实现
- 性能
如果所有的键都是小整数,则可以用一个数组来作为无序的符号表,将键作为数组的索引,数组中对应的位置保存的值就是这个键对应的值。这样就可以快速访问任意的键了。散列表就是基于这种方法,但它能够处理更加复杂的数据类型。
使用散列的查找算法分为两步:第一步需要通过散列函数将键转换为数组的索引,理想情况下,根据索引就可以再获取键对应的值了,但实际上会存在多个键的散列值指向同一个索引的情况,这时就需要进行第二步:处理碰撞冲突。
散列函数
散列函数可以将键转化为数组的索引。如果数组的容量为M,则散列函数就应该能够将任意键转换为数组范围内的索引。合格的散列函数需要满足如下条件:
- 一致性,等价的键必然产生相等的散列值;
- 高效性,散列函数应该计算简便,开销很小
- 均匀性,能够均匀地散列所有的键
Java为每种数据类型都内置了hashCode方法,它的返回值是一个32位的整数。每一种数据类型的hashCode方法都与equals()方法一致,即如果a.equals(b),则a.hashCode()=b.hashCode(),但如果a.hashCode()=b.hashCode(),a不一定与b相等,因为会有碰撞问题,还需要用equals()方法进行判断。默认的hashCode方法会返回对象的内存地址,但这只适用于很少的情况,Java为很多常用的数据类型重写了hashCode()方法,比如String、Integer、Double、File等等。
在实现散列表时,需要将键映射为数组的索引,以下hash()方法是基于hashCode()的:
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
hashCode()的返回值是32位整数,将其和0x7fffffff进行&运算,可以屏蔽符号位,变为一个31位的非负整数,然后与数组容量m取余,可以保证散列值都落在数组的索引范围内。m为素数时,散列值会更均匀。
基于拉链法的散列表
实现
在散列函数将键转化为数组索引后,接下来要做的就是处理碰撞冲突。一种方法是将数组中的每个元素都指向一条链表,链表中的每个节点都存储了散列值为该元素的索引的键值对。这种方法便是拉链法。要从基于拉链法的散列表中查找一个元素,首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键。为了保证高效地查找,数组的容量M值应该足够大,这样得到的链表平均长度会比较短。
如下为基于拉链法的散列表的实现:
public class SeparateChainingHashST<Key, Value> {
private static final int INIT_CAPACITY = 4;
private int n; // count of k-v pairs
private int m; // size of hashtable
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();
}
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
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;
}
// double table size if average length of list >= 10
if (n >= 10 * m)
resize(2 * m);
int i = hash(key);
if (!st[i].contains(key))
n++;
st[i].put(key, val);
}
public void delete(Key key) {
if (key == null)
throw new IllegalArgumentException("argument to delete() is null");
int i = hash(key);
if (st[i].contains(key))
n--;
st[i].delete(key);
if (m > INIT_CAPACITY && size() < 2 * m)
resize(m / 2);
}
public void resize(int chains) {
SeparateChainingHashST<Key, Value> temp = new SeparateChainingHashST<Key, Value>(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;
}
public int size() {
return n;
}
}
数组的每个位置都指向一条可以顺序查找的链表,链表的平均长度为n/m,平均长度越短,查找的效率越高,但所需的内存空间却越大。此处通过resize方法将n/m的值(即链表的平均长度)控制在2~10之间。
public void put(Key key, Value val) {
...
// double table size if average length of list >= 10
if (n >= 10 * m)
resize(2 * m);
...
}
public void delete(Key key) {
...
if (m > INIT_CAPACITY && size() < 2 * m)
resize(m / 2);
}
性能
理想情况下,散列函数能够均匀并独立地将所有的键散布于0到M-1之间,虽然在实际应用中无法找到一个完全满足这一要求的散列函数,但通过实验也可以验证上述hash()方法得到的散布结果已经非常接近理想情况了,所以基于这一均匀散列假设分析得出的散列表的性能具有实际的参考意义。
基于均匀散列假设,链表的平均长度为n/m,那么其性能特点与无序链表一致:
- 未命中的查找和新插入元素时,都需要n/m次比较;
- 对于命中的查找,最坏情况为n/m次比较,在平均情况下,根据在之前对随机命中模式的分析,约需要与一半的元素进行比较。
基于线性探测法的散列表
实现
另一种实现散列表的方式是用大小为M的数组保存N个键值对,其中M>N,数组中的空位就可以用来解决碰撞问题。基于这种策略的的方法称为开放地址散列表。线性探测法是这类方法中最简单的一种。当出现碰撞时,就检查散列表的下一个位置。具体运行中,查找一个元素时,如果散列值指向的的键和被查找的键相同,则查找命中;指向的键为空,则未命中;如果指向的键与被查找的键不同,则继续与下一个位置比较。
public class LinerProbingHashST<Key, Value> {
private int n; // count of k-v pairs
private int m = 16; // size of hashtable
private Key[] keys;
private Value[] vals;
public LinerProbingHashST() {
this(16);
}
public LinerProbingHashST(int capaticy) {
keys = (Key[]) new Object[capaticy];
vals = (Value[]) new Object[capaticy];
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % m;
}
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 put(Key key, Value val) {
if (key == null)
throw new IllegalArgumentException("first argument to put() is null");
// double table size if average length of list >= 10
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] = val;
return;
}
}
keys[i] = key;
vals[i] = val;
n++;
}
public void resize(int capacity) {
LinerProbingHashST<Key, Value> st = new LinerProbingHashST<Key, Value>(capacity);
for (int i = 0; i < m; i++) {
if (keys[i] != null) {
st.put(keys[i], vals[i]);
}
keys = st.keys;
vals = st.vals;
m = st.m;
}
}
public int size() {
return n;
}
}
在探测数组时,使用i = (i + 1) % m来改变索引的值,可以保证索引不会超出数组范围,达到数组末尾时就会折回到开头的位置。
性能
在拉链法中,n/m表示链表的平均长度,一般大于1,但在线性探测法中,因为n必然小于等于m,所以n/m不会大于1。可以认为n/m是散列表的使用率。由于线性探测法通过探测某个位置是否为空来决定命中与否,所以使用率不允许达到1,否则就会出现无限循环。而且即便在使用率比较接近1时,散列表的性能已经变得非常差了。相关研究表明,在使用率小于0.5时,探测的次数约为1.5~2.5次。上面的实现中,在n>=m/2时,就会调用resize()将数组容量翻倍,以维持较低的使用率。
总结
可见不管是用哪种方式实现的散列表,都是在时间和空间之间做了权衡。如果没有内存限制,即使数据量特别大,也可以直接将键作为一个超大数组的索引,这样只需访问内存一次就可以完成查找;如果没有时间限制,也可以使用无序数组存储数据,然后顺序查找。而散列表则使用了适度的空间和时间并在这两个极端之间取得了一种平衡。通过调整散列表的参数就可以在空间和时间之间做出取舍。