《算法4》散列表实现笔记

半城伤御伤魂 提交于 2020-03-09 00:19:39

一、散列函数

散列函数会将键转为数组的索引。我们的散列函数应该计算速度快且能够均匀分布所有的键,如对于大小为M的散列表,我们的散列函数应当能够让任意的key都能够转化为 0-到M-1 的整数,对于不同的键应该有不同的散列函数。Java中许多常用的类都重写了hashCode方法,以针对不同的数据类型使用不同的散列函数。

二、基于拉链法的散列表

散列算法理想的状态是将不同的key都转为不同的索引值,但这显然是不可能的,一定会产生冲突,因此我们就需要对冲突进行处理。
一种直接的方法是将大小为M的数组中的每个索引指向一条链表,链表中的每个节点都存储了散列值为该链表所在索引值的键值对,这种方法就是拉链法。

如下是基本数据结构:

public class SeparateChainingHashST<Key,Value> {
    /**
     * 键值对总数
     */
    private int N;
    /**
     * 散列表大小
     */
    private int M;

    private SequentialSearchST<Key,Value>[] st;

    public SeparateChainingHashST() {
        this(997);
    }
    public SeparateChainingHashST(int M) {
        this.M=M;
        st=new SequentialSearchST[M];
        for (int i = 0; i < M; i++) {
        	//数组中每个索引值都初始化一个链表
            st[i]=new SequentialSearchST<>();
        }
    }
}    

SequentialSearchST是前面顺序查找中实现的无序链表:

ublic class SequentialSearchST<Key, Value> {
    /**
     * 首节点
     */
    private Node first;
    private int size;


    private class Node {
        private Key key;
        private Value value;
        private Node next;

        public Node(Key key, Value value, Node next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    /**
     * 根据key查询对应的值,一个个往下遍历直到找到相等的key,返回对应的值,否则返回null
     * @param key
     * @return
     */
    public Value get(Key key) {
        for (Node x = first; x != null; x = x.next) {

            if (key.equals(x.key)) {
                return x.value;
            }
        }
        return null;
    }

    /**
     * 加入一个元素
     * @param key
     * @param value
     */
    public void put(Key key, Value value) {
        for (Node x = first; x != null; x = x.next) {
            //key已存在,更新对应的值
            if (key.equals(x.key)) {
                x.value = value;
                return;
            }
        }
        //key不存在,新添加一个节点
        first = new Node(key, value, first);
        size++;
    }
    public boolean isEmpty() {
        return size == 0;
    }

    private int size() {
        return size;
    }
    public boolean contains(Key key) {
        if (key == null) throw new IllegalArgumentException("argument to contains() is null");
        return get(key) != null;
    }

    /**
     * 删除key对应的节点
     * @param key
     */
    public void delete(Key key) {
        if (key == null) throw new IllegalArgumentException("argument to delete() is null");
        first = delete(first, key);
    }

    /**
     * 递归查找,直到找到相等的key,正常删除链表节点
     * @param x
     * @param key
     * @return
     */
    private Node delete(Node x, Key key) {
        if (x == null) return null;
        if (key.equals(x.key)) {
            size--;
            return x.next;
        }
        x.next = delete(x.next, key);
        return x;
    }
    public Iterable<Key> keys()  {
        Queue<Key> queue=new Queue<>();
        while (first!=null){
            queue.enqueue(first.key);
            first=first.next;
        }
        return queue;
    }
}

hash计算:
上面说了,java中为所有数据类型都重写了hashCode()方法,该方法返回一个32位比特的整数,但我们需要的是数组的索引,因此我们需要将默认的hashCode方法和除留余数法结合起来产生一个0到M-1的整数,又因为hashCode返回的值是带有符号位的,这样就算导致计算结果出现负数,因此我们需要通过0x7fffffff变为一个31位的非负整数,再使用除留余数法让其%M,M为一个较大的质数。

 private int hash(Key key){
        return (key.hashCode() & 0x7fffffff) % M;
    }

这样我们就可以对数据进行插入、删除和获取了:
插入实现:
如下,先计算key的哈希值,得到一个数组的索引,然后将该键值对插入到该索引对应的链表中。

 public void put(Key key,Value value){
        if (key==null){
            throw new NoSuchElementException("key为空");
        }
        if (value==null){
            delete(key);
        }
        //保证链表的长度在2到8之间
        if (N>=8*M){
            resize(M*2);
        }
        st[hash(key)].put(key,value);
    }

删除实现:
先计算key的哈希值,找到其所在的链表,如果该链表中有该key,其删除之。

 /**
     * 删除指定键值对
     * @param key
     */
    public void delete(Key key) {
        if (key == null) throw new IllegalArgumentException("argument to delete() is null");

        int i = hash(key);
        if (st[i].contains(key)){
            N--;
        }
        st[i].delete(key);
        //保证链表平均长度在2到8间 此为下界2
        if (N>0 && N<=M*2){
            resize(M/2);
        }

    }
 public boolean contains(Key key) {
        if (key == null) throw new IllegalArgumentException("key为空");
        return get(key) != null;
    }    

获取值:
计算key的哈希值, 返回对应链表中的value

public Value get(Key key){
        if (key == null) return null;

        return st[hash(key)].get(key);
    }

我们使用M条链表存储N个键,无论键在表中如何分布,其平均长度一定是 N/M
使用拉链法的一个好处是,如果存入的键多与预期,查找的时间只会比选择更大的数组长;如果低于预期,虽然会造成一点空间浪费,但查找很快。
因此内存足够时,可选择足够大的M,让查找使用变为常数;当内存紧张时,选择尽量大的M仍能够将性能提高M倍。

动态调整数组:
在删除后,如果平均长度N/M低于2,则让数组缩小 一倍:M/2;
在加入一个数据后,如果 N/M 高于8,则让数组增加一倍: M*2

 private void resize(int capacity){
        SeparateChainingHashST<Key,Value>hashST=new SeparateChainingHashST<>(capacity);
        for (int i = 0; i < M; i++) {
            for (Key key:st[i].keys()){
                if (key!=null){
                    hashST.put(key,st[i].get(key));
                }
            }
        }
        this.M=hashST.M;
        this.N=hashST.N;
        this.st=hashST.st;
    }

三、基于线性探测法的散列表

实现散列表的另一种方式是用大小为M的数组保存N个键值对(M>N),借助空位解决碰撞冲突,基于这种策略的所有方法都成为开放地址散列表。
开放地址散列表最简单的方法是线性探测法,即如果产生冲突(一个键的散列值已经被另一个不同的键占用),则直接检查散列表的下一个位置(索引+1),如果还是冲突,则一直向后探测,直到找到一个空位置,将该键值对插入进去。
数据结构如下:
这里使用一个Key[]保存键,一个Values[]保存key对应的值

public class LinearProbingHashST<Key, Value> {
    private Key[] keys;
    private Value[] values;

    /**
     * 键值对数
     */
    private int N;
    /**
     * 线性表大小
     */
    private int M;

    public LinearProbingHashST(int M) {
        this.M = M;
        keys = (Key[]) new Object[this.M];
        values = (Value[]) new Object[this.M];
    }
}    

插入操作:
我们需要计算待插入的键的hash值,然后判断当前索引是否被其他键占用,如果被占用的键也是待插入的键,则修改对应的值,否则一直遍历下去直到找到一个空位置。

    public void put(Key key, Value value) {
        if (key == null) throw new IllegalArgumentException("first argument to put() is null");
        if (value==null){
            delete(key);
        }
        //保证使用率 N/M 小于等于 1/2 ,当使用率趋近于1时,探测的次数会变得很大
        if (N>=M*2){
            resize(M*2);
        }
        int i;
        for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (keys[i].equals(key)) {
            	//待插入的key存在,修改对应的值并返回
                values[i] = value;
                return;
            }
        }
        //找到空位置,插入键值对
        keys[i] = key;
        values[i] = value;
        N++;
    }
     private int hash(Key key) {
        return (key.hashCode() & 0x7fffffff) % M;
    }

查询操作:
计算key的哈希值,如果当前位置冲突(被其他key占用),则继续向后遍历,遍历的结束条件是从hash(key)到下一个null位置前,如果存在,则返回对应的value,如果不存在则返回null

 public Value get(Key key) {
        for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
            if (keys[i].equals(key)) {
                return values[i];
            }
        }
        return null;
    }

对于如下一个散列表:

0   1   2   3    4    5   6    7    8     9    10   11    12     13    14    15
P   M            A    C   S    H 	L			E						R	  X
10  9			 8    4	  0    5    11          12                      3     7

A的散列值是4,H的散列值也是4,但因为在4冲突,所以插入H的时候被线性移动到了7位置,因此我们在查找H对应的值的时候,先从索引4开始,不匹配,顺次向下查找,如果在空位置9之前还没找到,说明不存在H,但因为H在9之前的7位置,索引返回对应的值5.
删除操作:
对于删除操作,我们不可以直接将对应的key置为null。
同样是上面的散列表,假如删除C,而H的散列值为4,在线性探测的过程中,会因为索引5为空,而“错误”的认为散列表中不存在H而返回null,但事实是H存在,并且在索引7中。

因此在删除给定key后,需要将key+1到下一个空位置前的所有键重新插入散列表,以避免上述错误的发生。

  public void delete(Key key) {
        if (!contains(key)) {
            return;
        }
        int i=hash(key);
        //线性探测找到待删除的key的索引
        while (!keys[i].equals(key)){
            i=(i+1)%M;
        }
        //置空
        keys[i]=null;
        values[i]=null;
        //将i+1的位置到下一个空位置前的所有key重新插入到散列表中
        i=(i+1)%M;
        while (keys[i]!=null){
            Key oldKey=keys[i];
            Value oldValue=values[i];
            keys[i]=null;
            values[i]=null;

            N--;
            put(oldKey,oldValue);
            i=(i+1)%M;
        }
        N--;
        if (N>0 && N <= M/8){
            resize(M/2);
        }
    }

α= N/M ,我们称α为散列表的使用率
在《算法4》中给出了如下结论:

在一张大小为M并含有N个键的基于线性探测的散列表中,如果我们的散列函数能够均匀并独立地将所有的键分布在0到M-1之间,则命中和未命中的查找所需的探测次数分别为:
1 / 2(1 + 1 / 1−α) and 1/2 ( 1 + 1 / (1−α) ^ 2)

即当散列表快满的时候,查找所需的探测次数是巨大的(α趋近于1),但当使用率α < 1/2时,探测的预计次数只在1.5到2.5之间,因此我们要保证 α的值不大于 1/2。
基于此我们要在
插入前和删除后对数组进行动态调整:

插入前判断:

        //保证使用率 N/M 不能超过1/2 ,当使用率趋近于1时,探测的次数会变得很大
        if (N>=M*2){
            resize(M*2);
        }

删除后判断:
保证使用的内存量和表中的键值对数量的比例总在一定范围内。

        //数组减小一半,如果N/M 为12.5% 或更少
		if (N>0 && N <= M/8){
            resize(M/2);
        }
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!