自己动手实现java数据结构哈希表

Posted 小熊餐馆

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了自己动手实现java数据结构哈希表相关的知识,希望对你有一定的参考价值。

1.哈希表介绍

  前面我们已经介绍了许多类型的数据结构。在想要查询容器内特定元素时,有序向量使得我们能使用二分查找法进行精确的查询((O(logN)对数复杂度,很高效)。
  可人类总是不知满足,依然在寻求一种更高效的特定元素查询的数据结构,哈希表/散列表(hash table)就应运而生啦。哈希表在特定元素的插入,删除和查询时都能够达到O(1)常数的时间复杂度,十分高效。

1.1 哈希算法

  哈希算法的定义:把任意长度的输入通过哈希算法转换映射为固定长度的输出,所得到的输出被称为哈希值(hashCode = hash(input))。哈希映射是一种多对一的关系,即多个不同的输入有可能对应着一个相同的哈希值输出;也意味着,哈希映射是不可逆,无法还原的。

  举个例子:我们有一个好朋友叫熊大,大家都叫他老熊。可以理解为是一个hash算法:对于一个人名,我们一般称呼为"老" + 姓氏(单姓) (hash(熊大) = 老熊)。同时,我们还有一个好朋友叫熊二,我们也叫他老熊(hash(熊二) = 老熊)。当熊大和熊二两个好朋友同时和我们聚会时,都称呼他们为老熊就不太合适啦,因为这时出现了hash冲突。老熊这个称呼同时对应了多个人,多个不同的输入对应了相同的哈希值输出。

  java在Object这一最高层对象中实现了hashCode方法,并允许子类重写更适应自身,冲突概率更低的hashCode方法。

1.2 哈希表实现的基本思路

  哈希表存储的是key-value键值对结构的数据,其基础是一个数组。

  由于采用hash算法会出现hash冲突,一个数组下标对应了多个元素。常见的解决hash冲突的方法有:开放地址法、重新哈希法、拉链法等等,我们的哈希表实现采用的是拉链法解决hash冲突。

  采用拉链法的哈希表将内部数组的每一个元素视为一个插槽(slot)或者桶(bucket),并将数据存放在键值对节点(EntryNode)中。EntryNode除了存放key和value,还维护着一个next节点的引用。为了解决hash冲突,单个插槽内的多个EntryNode构成一个简单的单向链表,插槽指向链表的头部节点,新的数据将会插入当前链表的尾部。

  key值不同但映射的hash值相同的元素在哈希表的同一个插槽中以链表的形式共存。

  

1.3 哈希表的负载因子(loadFactor):

  哈希表在查询数据时通过直接计算数据hash值对应的插槽,迅速获取到key值对应的数据,进行非常高效的数据查询。

  但依然存在一个问题:虽然设计良好的hash函数可以尽可能的降低hash冲突的概率,但hash冲突还是不可避免的。当发生频繁的哈希冲突时,对应的插槽内可能会存放较多的元素,导致插槽内的链表数据过多。而链表的查询效率是非常低的,在极端情况下,甚至会出现所有元素都映射存放在同一个插槽内,此时的哈希表退化成了一个链表,查询效率急剧降低。

  一般的,哈希表存储的数据量一定时,内部数组的大小和数组插槽指向的链表长度成反比。换句话说,总数据量一定,内部数组的容量越大(插槽越多),平均下来桶链表的长度也就越小,查询效率越高。

  同等数据量下,哈希表内部数组容量越大,查询效率越高,但同时空间占用也越高,这本质上是一个空间换时间的取舍。

  哈希表允许用户在初始化时指定负载因子(loadFactor):负载因子代表着存储的总数据量内部数组大小比值。插入新数据时,判断哈希表当前的存储量和内部数组的比值是否超过了负载因子。当比值超过了负载因子时,哈希表认为内部过于拥挤,查询效率太低,会触发一次扩容的rehash操作。rehash会对内部数组扩容,将存储的元素重新进行hash映射,使得哈希表始终保持一个合适的查询效率。

  通过指定自定义的负载因子,用户可以控制哈希表在空间和时间上取舍的程度,使哈希表能更有效地适应用户的使用场景。

  指定的负载因子越大,哈希表越拥挤(负载高,紧凑),查询效率越低,空间效率越高。

  指定的负载因子越小,哈希表越稀疏(负载小,松散),查询效率越高,空间效率越低。

2.哈希表ADT接口

  和之前介绍的链表不同,我们在哈希表的ADT接口中暴露出了哈希表内部实现的EntryNode键值对节点。通过暴露出去的public方法,用户在使用哈希表时,可以获得内部的键值对节点,灵活的访问其中的key、value数据(但没有暴露setKey方法,不允许用户自己设置key值)。

public interface Map <K,V>{
    /**
     * 存入键值对
     * @param key   key值
     * @param value value
     * @return 被覆盖的的value值
     */
    V put(K key,V value);

    /**
     * 移除键值对
     * @param key   key值
     * @return 被删除的value的值
     */
    V remove(K key);

    /**
     * 获取key对应的value值
     * @param key   key值
     * @return      对应的value值
     */
    V get(K key);

    /**
     * 是否包含当前key值
     * @param key   key值
     * @return      true:包含 false:不包含
     */
    boolean containsKey(K key);

    /**
     * 是否包含当前value值
     * @param value   value值
     * @return        true:包含 false:不包含
     */
    boolean containsValue(V value);

    /**
     * 获得当前map存储的键值对数量
     * @return 键值对数量
     * */
    int size();

    /**
     * 当前map是否为空
     * @return  true:为空 false:不为空
     */
    boolean isEmpty();

    /**
     * 清空当前map
     */
    void clear();

    /**
     * 获得迭代器
     * @return 迭代器对象
     */
    Iterator<EntryNode<K,V>> iterator();

    /**
     * 键值对节点 内部类
     * */
    class EntryNode<K,V>{
        final K key;
        V value;
        EntryNode<K,V> next;

        EntryNode(K key, V value) {
            this.key = key;
            this.value = value;
        }

        boolean keyIsEquals(K key){
            if(this.key == key){
                return true;
            }

            if(key == null){
                //:::如果走到这步,this.key不等于null,不匹配
                return false;
            }else{
                return key.equals(this.key);
            }
        }

        EntryNode<K, V> getNext() {
            return next;
        }

        void setNext(EntryNode<K, V> next) {
            this.next = next;
        }

        public K getKey() {
            return key;
        }

        public V getValue() {
            return value;
        }

        public void setValue(V value) {
            this.value = value;
        }

        @Override
        public String toString() {
            return key + "=" + value;
        }
    }
}

3.哈希表实现细节

3.1 哈希表基本属性:      

public class HashMap<K,V> implements Map<K,V>{

    /**
     * 内部数组
     * */
    private EntryNode<K,V>[] elements;

    /**
     * 当前哈希表的大小
     * */
    private int size;

    /**
     * 负载因子
     * */
    private float loadFactor;

    /**
     * 默认的哈希表容量
     * */
    private final static int DEFAULT_CAPACITY = 16;

    /**
     * 扩容翻倍的基数
     * */
    private final static int REHASH_BASE = 2;

    /**
     * 默认的负载因子
     * */
    private final static float DEFAULT_LOAD_FACTOR = 0.75f;

    //========================================构造方法===================================================
    /**
     * 默认构造方法
     * */
    @SuppressWarnings("unchecked")
    public HashMap() {
        this.size = 0;
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        elements = new EntryNode[DEFAULT_CAPACITY];
    }

    /**
     * 指定初始容量的构造方法
     * @param capacity 指定的初始容量
     * */
    @SuppressWarnings("unchecked")
    public HashMap(int capacity) {
        this.size = 0;
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        elements = new EntryNode[capacity];
    }

    /**
     * 指定初始容量和负载因子的构造方法
     * @param capacity 指定的初始容量
     * @param loadFactor 指定的负载因子
     * */
    @SuppressWarnings("unchecked")
    public HashMap(int capacity,int loadFactor) {
        this.size = 0;
        this.loadFactor = loadFactor;
        elements = new EntryNode[capacity];
    }
}

3.2 通过hash值获取对应插槽下标:

  获取hash的方法仅和数据自身有关,不受到哈希表存储数据量的影响。

  因此getIndex方法的时间复杂度为O(1)

   /**
     * 通过key的hashCode获得对应的内部数组下标
     * @param key 传入的键值key
     * @return 对应的内部数组下标
     * */
    private int getIndex(K key){
        return getIndex(key,this.elements);
    }

    /**
     * 通过key的hashCode获得对应的内部数组插槽slot下标
     * @param key 传入的键值key
     * @param elements 内部数组
     * @return 对应的内部数组下标
     * */
    private int getIndex(K key,EntryNode<K,V>[] elements){
        if(key == null){
            //::: null 默认存储在第0个桶内
            return 0;
        }else{
            int hashCode = key.hashCode();

            //:::通过 高位和低位的异或运算,获得最终的hash映射,减少碰撞的几率
            int finalHashCode = hashCode ^ (hashCode >>> 16);
            return (elements.length-1) & finalHashCode;
        }
    }

3.3 链表查询方法:

  当出现hash冲突时,会在对应插槽处生成一个单链表。我们需要提供一个方便的单链表查询方法,将增删改查接口的部分公用逻辑抽象出来,简化代码的复杂度。

  值得注意的是:在判断Key值是否相等时使用的是EntryNode.keyIsEquals方法,内部最终是通过equals方法进行比较的。也就是说,判断key值是否相等和其它数据结构一样,依然是由equals方法决定的。hashCode方法的作用仅仅是使我们能够更快的定位到所映射的插槽处,加快查询效率。

  思考一下,为什么要求在重写equals方法的同时,也应该重写hashCode方法?

   /**
     * 获得目标节点的前一个节点
     * @param currentNode 当前桶链表节点
     * @param key         对应的key
     * @return  返回当前桶链表中"匹配key的目标节点"的"前一个节点"
     *          注意:当桶链表中不存在匹配节点时,返回桶链表的最后一个节点
     * */
    private EntryNode<K,V> getTargetPreviousEntryNode(EntryNode<K,V> currentNode,K key){
        //:::不匹配
        EntryNode<K,V> nextNode = currentNode.next;
        //:::遍历当前桶后面的所有节点
        while(nextNode != null){
            //:::如果下一个节点的key匹配
            if(nextNode.keyIsEquals(key)){
                return currentNode;
            }else{
                //:::不断指向下一个节点
                currentNode = nextNode;
                nextNode = nextNode.next;
            }
        }
        //:::到达了桶链表的末尾,返回最后一个节点
        return currentNode;
    }

3.4 增删改查接口:

  哈希表的增删改查接口都是通过hash值直接计算出对应的插槽下标(getIndex方法),然后遍历插槽内的桶链表进行进一步的精确查询(getTargetPreviousEntryNode方法)。在负载因子位于正常范围内时(一般小于1),桶链表的平均长度非常短,可以认为单个桶链表的遍历查询时间复杂度为(O(1))

  因此哈希表的增删改查接口时间复杂度都是O(1)

    @Override
    public V put(K key, V value) {
        if(needReHash()){
            reHash();
        }

        //:::获得对应的内部数组下标
        int index = getIndex(key);
        //:::获得对应桶内的第一个节点
        EntryNode<K,V> firstEntryNode = this.elements[index];

        //:::如果当前桶内不存在任何节点
        if(firstEntryNode == null){
            //:::创建一个新的节点
            this.elements[index] = new EntryNode<>(key,value);
            //:::创建了新节点,size加1
            this.size++;
            return null;
        }

        if(firstEntryNode.keyIsEquals(key)){
            //:::当前第一个节点的key与之匹配
            V oldValue = firstEntryNode.value;
            firstEntryNode.value = value;
            return oldValue;
        }else{
            //:::不匹配

            //:::获得匹配的目标节点的前一个节点
            EntryNode<K,V> targetPreviousNode = getTargetPreviousEntryNode(firstEntryNode,key);
            //:::获得匹配的目标节点
            EntryNode<K,V> targetNode = targetPreviousNode.next;
            if(targetNode != null){
                //:::更新value的值
                V oldValue = targetNode.value;
                targetNode.value = value;
                return oldValue;
            }else{
                //:::在桶链表的末尾 新增一个节点
                targetPreviousNode.next = new EntryNode<>(key,value);
                //:::创建了新节点,size加1
                this.size++;
                return null;
            }
        }
    }

    @Override
    public V remove(K key) {
        //:::获得对应的内部数组下标
        int index = getIndex(key);
        //:::获得对应桶内的第一个节点
        EntryNode<K,V> firstEntryNode = this.elements[index];

        //:::如果当前桶内不存在任何节点
        if(firstEntryNode == null){
            return null;
        }
        if(firstEntryNode.keyIsEquals(key)){
            //:::当前第一个节点的key与之匹配

            //:::将桶链表的第一个节点指向后一个节点(兼容next为null的情况)
            this.elements[index] = firstEntryNode.next;
            //:::移除了一个节点 size减一
            this.size--;
            //:::返回之前的value值
            return firstEntryNode.value;
        }else{
            //:::不匹配

            //:::获得匹配的目标节点的前一个节点
            EntryNode<K,V> targetPreviousNode = getTargetPreviousEntryNode(firstEntryNode,key);
            //:::获得匹配的目标节点
            EntryNode<K,V> targetNode = targetPreviousNode.next;

            if(targetNode != null){
                //:::将"前一个节点的next" 指向 "目标节点的next" ---> 相当于将目标节点从桶链表移除
                targetPreviousNode.next = targetNode.next;
                //:::移除了一个节点 size减一
                this.size--;
                return targetNode.value;
            }else{
                //:::如果目标节点为空,说明key并不存在于哈希表中
                return null;
            }
        }
    }

    @Override
    public V get(K key) {
        //:::获得对应的内部数组下标
        int index = getIndex(key);
        //:::获得对应桶内的第一个节点
        EntryNode<K,V> firstEntryNode = this.elements[index];

        //:::如果当前桶内不存在任何节点
        if(firstEntryNode == null){
            return null;
        }
        if(firstEntryNode.keyIsEquals(key)){
            //:::当前第一个节点的key与之匹配
            return firstEntryNode.value;
        }else{
            //:::获得匹配的目标节点的前一个节点
            EntryNode<K,V> targetPreviousNode = getTargetPreviousEntryNode(firstEntryNode,key);
            //:::获得匹配的目标节点
            EntryNode<K,V> targetNode = targetPreviousNode.next;

            if(targetNode != null){
                return targetNode.value;
            }else{
                //:::如果目标节点为空,说明key并不存在于哈希表中
                return null;
            }
        }
    }

3.5 扩容rehash操作:

  前面提到,当插入数据时发现哈希表过于拥挤,超过了负载因子指定的值时,会触发一次rehash扩容操作。

  扩容时,我们的内部数组扩容了2倍,所以对于每一个插槽内的元素在rehash时存在两种可能:

    1.依然映射到当前下标插槽处

    2.映射到高位下标处(当前下标 + 扩容前内部数组长度大小)

  注意观察0,4,8三个元素节点,在扩容前(对4取模)都位于下标0插槽;扩容后,数组容量翻倍(对8取模),存在两种情况,0,8两个元素哈希值依然映射在下标0插槽(低位插槽),而元素4则被映射到了下标4插槽(高位插槽)(当前下标(0) + 扩容前内部数组长度大小(4))。

  通过遍历每个插槽,将内部元素按顺序进行rehash,得到扩容两倍后的哈希表(数据保留了之前的顺序,即先插入的节点依然位于桶链表靠前的位置)。

  和向量扩容一样,虽然rehash操作的时间复杂度为O(n)。但是由于只在插入时偶尔的被触发,总体上看,rehash操作的时间复杂度为O(1)

哈希表扩容前:

哈希表扩容后:

 

  /**
     * 哈希表扩容
     * */
    @SuppressWarnings("unchecked")
    private void reHash(){
        //:::扩容两倍
        EntryNode<K,V>[] newElements = new EntryNode[this.elements.length * REHASH_BASE];

        //:::遍历所有的插槽
        for (int i=0; i<this.elements.length; i++) {
            //:::为单个插槽内的元素 rehash
            reHashSlot(i,newElements);
        }

        //:::内部数组 ---> 扩容之后的新数组
        this.elements = newElements;
    }

    /**
     * 单个插槽内的数据进行rehash
     * */
    private void reHashSlot(int index,EntryNode<K, V>[] newElements){
        //:::获得当前插槽第一个元素
        EntryNode<K, V> currentEntryNode = this.elements[index];
        if(currentEntryNode == null){
            //:::当前插槽为空,直接返回
            return;
        }

        //:::低位桶链表 头部节点、尾部节点
        EntryNode<K, V> lowListHead = null;
        EntryNode<K, V> lowListTail = null;
        //:::高位桶链表 头部节点、尾部节点
        EntryNode<K, V> highListHead = null;
        EntryNode<K, V> highListTail = null;

        while(currentEntryNode != null){
            //:::获得当前节点 在新数组中映射的插槽下标
            int entryNodeIndex = getIndex(currentEntryNode.key,newElements);
            //:::是否和当前插槽下标相等
            if(entryNodeIndex == index){
                //:::和当前插槽下标相等
                if(lowListHead == null){
                    //:::初始化低位链表
                    lowListHead = currentEntryNode;
                    lowListTail = currentEntryNode;
                }else{
                    //:::在低位链表尾部拓展新的节点
                    lowListTail.next = currentEntryNode;
                    lowListTail = lowListTail.next;
                }
            }else{
                //:::和当前插槽下标不相等
                if(highListHead == null){
                    //:::初始化高位链表
                    highListHead = currentEntryNode;
                    highListTail = currentEntryNode;
                }else{
                    //:::在高位链表尾部拓展新的节点
                    highListTail.next = currentEntryNode;
                    highListTail = highListTail.next;
                }
            }
            //:::指向当前插槽的下一个节点
            currentEntryNode = currentEntryNode.next;
        }

        //:::新扩容elements(index)插槽 存放lowList
        newElements[index] = lowListHead;
        //:::lowList末尾截断
        if(lowListTail != null){
            lowListTail.next = null;
        }

        //:::新扩容elements(index + this.elements.length)插槽 存放highList
        newElements[index + this.elements.length] = highListHead;
        //:::highList末尾截断
        if(highListTail != null){
            highListTail.next = null;
        }
    }

    /**
     * 判断是否需要 扩容
     * */
    private boolean needReHash(){
        return ((this.size / this.elements.length) > this.loadFactor);
    }

3.6 其它接口实现:

    @Override
    public boolean containsKey(K key) {
        V value = get(key);
        return (value != null);
    }

    @Override
    public boolean containsValue(V value) {
        //:::遍历全部桶链表
        for (EntryNode<K, V> element : this.elements) {
            //:::获得当前桶链表第一个节点
            EntryNode<K, V> entryNode = element;

            //:::遍历当前桶链表
            while (entryNode != null) {
                //:::如果value匹配
                if (entryNode.value.equals(value)) {
                    //:::返回true
                    return true;
                } else {
                    //:::不匹配,指向下一个节点
                    entryNode = entryNode.next;
                }
            }
        }
        //:::所有的节点都遍历了,没有匹配的value
        return false;
    }

    @Override
    public int size() {
        return this.size;
    }

    @Override
    public boolean isEmpty() {
        return (this.size == 0);
    }

    @Override
    public void clear() {
        //:::遍历内部数组,将所有桶链表全部清空
        for(int i=0; i<this.elements.length; i++){
            this.elements[i] = null;
        }

        //:::size设置为0
        this.size = 0;
    }

    @Override
    public Iterator<EntryNode<K,V>> iterator() {
        return new Itr();
    }

    @Override
    public String toString() {
        Iterator<EntryNode<K,V>> iterator = this.iterator();

        //:::空容器
        if(!iterator.hasNext()){
            return "[]";
        }

        //:::容器起始使用"["
        StringBuilder s = new StringBuilder("[");

        //:::反复迭代
        while(true){
            //:::获得迭代的当前元素
            EntryNode<K,V> data = iterator.next();

            //:::判断当前元素是否是最后一个元素
            if(!iterator.hasNext()){
                //:::是最后一个元素,用"]"收尾
                s.append(data).append("]");
                //:::返回 拼接完毕的字符串
                return s.toString();
            }else{
                //:::不是最后一个元素
                //:::使用", "分割,拼接到后面
                s.append(data).append(", ");
            }
        }
    }

4.哈希表迭代器

  1. 由于哈希表中数据分布不是连续的,所以在迭代器的初始化过程中必须先跳转到第一个非空数据节点,以避免无效的迭代。

  2. 当迭代器的下标到达当前插槽链表的末尾时,迭代器下标需要跳转到靠后插槽的第一个非空数据节点。

  /**
     * 哈希表 迭代器实现
     */
    private class Itr implements Iterator<EntryNode<K,V>> {
        /**
         * 迭代器 当前节点
         * */
        private EntryNode<K,V> currentNode;

        /**
         * 迭代器 下一个节点
         * */
        private EntryNode<K,V> nextNode;

        /**
         * 迭代器 当前内部数组的下标
         * */
        private int currentIndex;

        /**
         * 默认构造方法
         * */
        private Itr(){
            //:::如果当前哈希表为空,直接返回
            if(HashMap.this.isEmpty()){
                return;
            }
            //:::在构造方法中,将迭代器下标移动到第一个有效的节点上

            //:::遍历内部数组,找到第一个不为空的数组插槽slot
            for(int i=0; i<HashMap.this.elements.length; i++){
                //:::设置当前index
                this.currentIndex = i;

                EntryNode<K,V> firstEntryNode = HashMap.this.elements[i];
                //:::找到了第一个不为空的插槽slot
                if(firstEntryNode != null){
                    //:::nextNode = 当前插槽第一个节点
                    this.nextNode = firstEntryNode;

                    //:::构造方法立即结束
                    return;
                }
            }
        }

        @Override
        public boolean hasNext() {
            return (this.nextNode != null);
        }

        @Override
        public EntryNode<K,V> next() {
            this.currentNode = this.nextNode;
            //:::暂存需要返回的节点
            EntryNode<K,V> needReturn = this.nextNode;

            //:::nextNode指向自己的next
            this.nextNode = this.nextNode.next;
            //以上是关于自己动手实现java数据结构哈希表的主要内容,如果未能解决你的问题,请参考以下文章

自己动手实现java数据结构 链表

自己动手写编译器:符号表及其实现

[DataStructure]哈希表二叉树及多叉树 Java 代码实现

[DataStructure]非线性数据结构之哈希表二叉树及多叉树 Java 代码实现

Java 数据结构 & 算法宁可累死自己, 也要卷死别人 9 哈希表原理

Java 数据结构 & 算法宁可累死自己, 也要卷死别人 9 哈希表原理