《算法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倍。
个人觉得是我见过的最简单易懂的算法入门书籍。 以前搜刮过几本算法竞赛书,但是难度终归太大【好吧,其实是自己太懒了】。 略翻过教材,大多数水校的教材,大家懂的。好一点的也是那本国内的经典,不是说它写的不好,只是没有这一本好。 本书Java实现,配有大量的图解,没有一句难懂的话,而且全都是模块化实现。 讲的都是实用算法,没有那些高大上听着名字就让人感到很害怕的东西,个人觉得比CLRS实用性要强,更加适合入门的学习。 大一,推荐这本书入门 【有C语言基础即可,自己去搜索下如何用Java写出Hello World就没有问题】 大二,推荐这本书从头到尾好好读一遍,做下上千道的课后习题 【后面的有点小难度,但是难度不大值得一做,听起来很多的样子,用心去做,相信很快就可以做完的】。 大三,推荐这本书,重新温习已知算法,为找工作,考研做准备。 【可以试着自己在纸上全部实现一遍】 大四,依旧推荐这本书,没事重温经典,当手册来查也不错。 Sedgwick 红黑树的发现者,Donald E.Knuth 的得意门生,对各种算法都有比较深入的研究,他的书,我想不会太差。 也许对于数据结构的学习涉及的内容比较少,没有动态规划,图论也只是讲了很基础的东西,字符串中KMP弄的过于复杂(对比于acm)。但是瑕不掩瑜,对于绝大部分内容真的讲的超级清楚,完美的图解,就像单步调试一样,也许是一本不需要智商就能看懂的算法书(习题应该略有难度,还没有做,打算上Princeton的公开课时同步跟进)。至少这是一本让我这个算法渣渣看了爱不释手,怦然心动的书。 完美学习资源: 官方主页:http://algs4.cs.princeton.edu/home/ Coursera公开课:https://www.coursera.org/course/algs4partI (听说已经开课两期了,最近即将开课的时间是2014/09/05号那期,希望有兴趣的同学一起来学习)。 MOOC平台(笔记、讨论等): http://mooc.guokr.com/course/404/Algorithms--Part-I/ http://mooc.guokr.com/course/403/Algorithms--Part-II/ 不得不吐槽,他的lecture比他的书好,他本人讲的课更是一绝。 互补课程: 斯福坦的Algorithms: Design and Analysis, http://mooc.guokr.com/course/157/Algorithms--Design-and-Analysis--Part-1/ 快毕业了才接触到豆瓣和MOOC,看到很多经典的书籍都是推荐大学一二年级的学生看,每每想到自己却连书皮都没有摸过,就深感惭愧。 我们都老的太快,却聪明得太迟。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值