《算法4》散列表

来源:互联网 发布:淘宝人生全本阅读 编辑:程序博客网 时间:2024/05/29 15:04

散列表也是一种符号表,主要特征是可以将键通过散列函数映射为一个数组索引,然后利用这个数组索引就可以做很多东西。

散列函数

当我们输入一个对象,不论这是个什么东西,经过散列函数处理之后输出一个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个键的基于线性探测的三散列表中,如果散列是均匀的,命中和未命中的查找所需的次数分别为

 12(1+11α)and12(1+1(1α)2)

可以看出当α约为0.5的时候,查找命中和未命中所需的次数分别为3/2和5/2,注意这是常数级别的,所以这就是线性探测法的优势,只要不涉及到有序性(因为插入的过程是没有顺序的),那么散列表无疑是最好的选择。即使采用拉链法,性能也能提高M倍。