《算法4》散列表

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

散列函数

当我们输入一个对象,不论这是个什么东西,经过散列函数处理之后输出一个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倍。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值