散列表也是一种符号表,主要特征是可以将键通过散列函数映射为一个数组索引,然后利用这个数组索引就可以做很多东西。
散列函数
当我们输入一个对象,不论这是个什么东西,经过散列函数处理之后输出一个0到M-1的范围之内的整数。
对于散列函数有一些要求:
1. 相等的对象(使用equals()函数)的散列值是相同的
2.同样的散列值不同的两个对象不相等
3.在输出范围之内尽量均匀分布
但是哈希函数是和对象类型有关的,一般来说对于每种类型的键我们都需要与之对应的哈希函数。对于Java来说,每个Object对象都有一个hashCode()函数,但是它的默认实现是返回对象内存地址,所以是没有用处的,对于一些常见的类型比如,Integer,Double,String,File,URL,Java重写了hashCode(),这里我不管它具体怎么实现的,只需要用就好了,值得注意的是hashCode()返回的可能有负数。
一个hash函数的实现方式
private int hash(Key key){
return (key.hashCode() & 0x7fffffff)%M;
}
其中之所以要和0x7fffffff进行与运算就是要去掉符号位的影响,这样就不会有负数的问题了,然后就将结果对M取余数,一般这个M就是一个比较大的质数,之所以是质数,是因为这样可以将结果均匀地散列到0到M-1之间。对于自定义的对象,可以采用组合的方式得到自己的hash函数,比如对于Date类型,我们有
int hash = (((day*R+month)%M)*R+year)%M;
均匀性对于散列函数来说是很重要的,但是这里我们不仔细考虑,只是假设它能够均匀且独立地将所有的键散步到0和M-1之间
下面介绍两种实现散列表的方式,分别基于拉链发和线性探测法。
基于拉链法的散列表(SeparateChaining)
假设键的数目为N,数组大小为M,一般对于拉链法,N是大于M的。我们将某个键散列到0到M-1中的一个数,那么随着键的数目的增加,两个键之间一定会有重复的索引,这就发生了所谓的碰撞冲突,拉链法解决碰撞冲突的方法就是每个数组位置保存一个链表的引用,每个新加入的键先找到数组的位置,然后插入对应的链表。查找的时候同样的,先对要查找的键进行散列,然后到相应位置的链表中查找。对于拉链法,每个链表的平均长度为
N/M
,那么可以看出他比一个无序链表或者数组的性能提高了M倍。看着下面的图应该很好理解。
下面是相应的代码实现:
public class SeparateChainingHashST<Key, Value> {
private int N;//键的数量
private int M;//数组容量
private SequentialSearchST<Key, Value>[] st;
public SeparateChainingHashST(){
this(997);//数组容量为997
}
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<Key, Value>();
}
}
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 class SequentialSearchST<Key, Value> {
private Node first;
private class Node{
Key key;
Value val;
Node next;
public Node (Key key,Value val, Node next){
this.key = key;
this.val = val;
this.next = next;
}
}
public Value get(Key key){
for (Node x= first;x!=null;x=x.next){
if (key.equals(x.key))
return x.val;
}
return null;
}
public void put(Key key, Value val){
for (Node x= first;x!=null;x=x.next){
if (key.equals(x.key))
{x.val = val;return ;}
}
first = new Node(key, val, first);//new一个节点,它的next是first然后将first指向它。
}
}
因为每个链表的平均长度为 N/M 所以,在一张含有M条链表和N个键的散列表中,未命中查找和插入操作所需要的比较次数为~N/M
基于线性探测法的散列表(LinearProbing)
对于线性探测法,数组容量是大于键的数量的,并且在后面可以看到,数组不能太满,否则影响性能。主要思想是,我们维护两个数组,一个是键的数组,一个是值得数组,当我们将一个键散列到数组中的时候,如果当前位置是空的,那么就直接插入,如果已经有了元素,那么就往下一个位置插入,如果还是被占了,那就继续,直到找到一个空位置,然后再插入。查找的时候也是一样,根据键散列的位置我们去查找,如果当前位置的键和要查找的键不相同,那么就继续往后查找,要么找到,要么又碰到空的位置,那么此时就是查找未命中。看着下面的图,就能对这个过程有着清楚地了解。
删除
线性探测法的一个重要的操作是删除,但是删除不能仅仅将某个键置为null,因为这样如果它后面本来还有的键就可能因为这个null键而访问不到,我们的做法是将这个置为null之后直到下一个null键之间的数据重新加入散列表。代码见后面的delete()方法。
调整大小
对于线性探测甚至拉链法,我们都需要调整数组大小来保证性能。对于线性探测法,我们需要新建一个LinearProbingHashST()对象,只是新建对象的时候要扩大容量,然后把当前对象的数据重新put()进新的对象里面,最后把新对象的两个数组的引用传给当前数组。
下面是线性探测法的代码
public class LinearProbingHashST<Key, Value> {
private static final int INIT_CAPACITY = 4;
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;
}
private void resize(int capacity){
LinearProbingHashST<Key, Value> temp =new LinearProbingHashST<Key, Value>(capacity);
for(int i=0;i<m;i++){
if(keys[i] != null){
temp.put(keys[i], vals[i]);
}
}
keys = temp.keys;
vals = temp.vals;
m = temp.m;
}
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>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 Value get(Key key){
if (key == null) throw new IllegalArgumentException("first argument to put() 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 keyRedoKey = keys[i];
Value valReDoValue = vals[i];
keys[i] = null;
vals[i] = null;
n--;
put(keyRedoKey, valReDoValue);
i = (i+1)%m;
}
n--;
if (n>0 && n==m/8) resize(m/2);
assert check();
}
private boolean check(){
if (m<2*n){
System.err.println("Hash table size m = " + m + "; array size n = " + n);
return false;
}
for (int i=0; i<m;i++){
if (keys[i] ==null) continue;
else if (get(keys[i])!= vals[i]){
System.err.println("get[" + keys[i] + "] = " + get(keys[i]) + "; vals[i] = " + vals[i]);
return false;
}
}
return true;
}
public Iterable<Key> keys(){
Queue<K``
y> queue = new Queue<Key>();
for (int i=0;i<m;i++)
if (keys[i]!=null) queue.enqueue(keys[i]);
return queue;
}
}
分析总结
在一张大小为M并且含有
N=αM
个键的基于线性探测的三散列表中,如果散列是均匀的,命中和未命中的查找所需的次数分别为
可以看出当 α 约为0.5的时候,查找命中和未命中所需的次数分别为3/2和5/2,注意这是常数级别的,所以这就是线性探测法的优势,只要不涉及到有序性(因为插入的过程是没有顺序的),那么散列表无疑是最好的选择。即使采用拉链法,性能也能提高M倍。