前面我们了解二叉树查找,虽然二叉树的查找已经很快了,但是仍然需要O(logn)的时间复杂度。我们能不能更快呢?我们就想到了一个方法,如果我们能够把所有查找的键转化为一个唯一的数字,那么我们就可以采用数组的形式来存放键和值,那么存取的速度就会非常快了,时间复杂度是O(1)。
如果没有内存限制,我们可以做一个超大的数组来存放,那么所有查找都只需要一次访问就可以得到了。
但是显然,我们并没有这么大的内存来存放数组。所以我们只能选择一个折中的办法,就是采用散列表的形式。
散列表的实现需要解决以下两个问题:
1.散列函数
2.冲突解决
散列函数
散列函数需要满足什么条件呢?
1.一致性
2.易于计算
3.分布均匀
一致性
对于同一个键,使用散列函数计算得到的数组下标,每次计算都应该是一致的。
易于计算
使用散列函数计算数组的下标应该是高效的,否则就失去了散列表的特性。
分布均匀
使用散列表计算多个键得到的下标应该分布均匀,否则也会失去散列表的高效性。
因为键可能是各种各样类型的键,考虑到分布均匀的特征,我们应该将整个键映射成一个下标,而不是只使用键的一部分。例如:Date类型的键,应该充分考虑到day,month,year所有的因素。
幸运的是,对于java,它提供了hashCode()函数,对于大部分的数据类型,我们不必操心他们的散列函数。当然,对于我们自定义的数据类型,hashCode方法是需要重写的。
冲突解决
散列表允许冲突。因为我们并没有一个很大很大的数组,所以一个数组是存不下所有的键值的。所以就会出现多个键,映射到同一个数组下标中,这就造成了冲突。冲突的解决是一个重要的问题。
这里讨论两种冲突解决的办法:1.拉链法 2.线性探测法
拉链法
拉链法是将大小为M的数组中的每个元素指向一条链表,链表中每个节点都存储了散列值为当前下标的键值对。
这样,解决了冲突发生的时候,冲突的键值对存放的位置的问题。
因为发生冲突的元素都被存储在链表当中,这个方法的基本思想就是选择足够大的M,使得所有链表都尽可能的短,就能保证高效的查找了。
实现如下:
public class SeparateChainHashST {
private final static int DEFAULT_SIZE = 5;
private int M; // 数组大小
private BSTSearch[] array;
public SeparateChainHashST() {
this(DEFAULT_SIZE);
}
public SeparateChainHashST(int M) {
this.M = M;
array = new BSTSearch[M];
for (int i = 0; i < M; i++) {
array[i] = new BSTSearch();
}
}
private int hash(Comparable key) {
return (key.hashCode() & 0x7fffffff) % M;
}
public String get(Comparable key) {
return array[hash(key)].search(key);
}
public void put(Comparable key, String value) {
array[hash(key)].put(key, value);
}
public void delete(Comparable key) {
array[hash(key)].delete(key);
}
}
这里我们内部的链表使用之前写过的二叉树,这样我们的代码就可以复用了。对于使用二叉树作为链表还是单链表,这个问题其实并不重要,因为数组的大小应该是可变的,这样我们数组中的链表就不应该很长,使用二叉树还是单链表应该是差不多的。这里只是因为可以复用之前的代码比较方便而已。
值得留意的是,散列函数我们不能直接使用hashCode()函数,因为hashCode()返回值可能是负的,取余就是负的了。
线性探测法
使用大小为M的数组来保存N个键值对,其中M>N。我们需要使用数组中的空位来解决冲突,这一类方法成为开放地址散列表。
开放地址散列表中最简单的方法就是线性探测法。当发生冲突时(键的hash值已经被另一个不同的键占用了),我们直接检查散列表中的下一个位置。
这样就可能出现以下三种情况:
1.命中,键和被查找的键相同 2.未命中,该位置没有键 3.该位置的键和查找的键不同,需要继续查找
实现:
public class LinearProbeHashST {
private static final int DEFAULT_SIZE = 16;
private int N; // 当前键数目
private int M; // 数组大小
private Comparable[] keys;
private String[] values;
public LinearProbeHashST() {
this(DEFAULT_SIZE);
}
public LinearProbeHashST(int M) {
this.M = M;
N = 0;
keys = new Comparable[M];
values = new String[M];
}
private int hash(Comparable key) {
return (key.hashCode() & 0x0fffffff) % M;
}
public void put(Comparable key, String value) {
int keyPos = hash(key);
while (keys[keyPos] != null) {
if (keys[keyPos].equals(key)) {
values[keyPos] = value;
return;
}
keyPos = (keyPos + 1)%M;
}
keys[keyPos] = key;
values[keyPos] = value;
N++;
if (N > 0 && N >= M/2) resize(M*2);
}
public String get(Comparable key) {
int keyPos = hash(key);
while (keys[keyPos] != null) {
if (keys[keyPos].equals(key)) {
return values[keyPos];
}
keyPos = (keyPos + 1) % M;
}
return null;
}
public void delete(Comparable key) {
int keyPos = hash(key);
while (keys[keyPos] != null) {
if (keys[keyPos].equals(key)) {
keys[keyPos] = null;
values[keyPos] = null;
N--;
break;
}
keyPos = (keyPos + 1) % M;
}
int i = (keyPos + 1) % M;
while (keys[i] != null) {
Comparable tempKey = keys[i];
String tempValue = values[i];
keys[i] = null;
values[i] = null;
N--;
put(tempKey, tempValue);
i = (i + 1) % M;
}
if (N > 0 && N <= M/8) resize(M/2);
}
private void resize(int size) {
LinearProbeHashST t = new LinearProbeHashST(size);
for (int i = 0; i < M; i++) {
if (keys[i] != null) {
t.put(keys[i], values[i]);
}
}
keys = t.keys;
values = t.values;
M = t.M;
}
}
对于线性探测法,其中的get和put方法都很容易实现。只需要一个一个找下去就好了。而对于delete操作来说,相对来说就复杂一点了。当我们找到了键的时候,我们并不能简单的把键设为null,值也设为null,就结束了。因为我们是根据是否为null进行判断的,如果设为null了,我们查询到这里就终止了,那这个键之后的键就不会被查找到了。
所以我们需要将被删除键的右侧的所有的键都重新插入到散列表中。
我们插入的时候,只需要简单的找到位置插入就可以了。因为我们会维护数组的大小,不会让插入出现插入失败的情况。
散列表最大的好处就是读取速度非常快,只需要常数级别,时间复杂度为O(1)。
一个不是缺点的缺点,就是散列表的元素不是有序的,对于需要排序的应用来说,散列表就不是一个很好的选择了。