数据结构——哈希表

介绍

哈希表是一种数据结构,它的特点是新增、删除和修改的时间复杂度都是 O ( 1 ) O(1) O(1),而查询的时间复杂度根据数据的规模而定,也与底层的实现有关系。

概念

哈希

哈希是将数据通过某种方式转化为一个数字这个数字就是该数据在哈希数组中存储的下标

哈希碰撞

哈希碰撞指的是两个数据通过某种哈希的方式转化的数字相同,这会导致哈希数组的一个下标存储两个及以上的数据,会降低哈希表的查询效率

关于哈希表底层实现的一些讨论

参考javaHashTable的实现,哈希表的底层一般是数组+链表数组+红黑树的实现,当哈希表中链表太长并且数组的长度超过某个限制后就会出现树化的情况,就是将链表变成红黑树,这样可以提高查询效率,因为红黑树的查询比链表快。

本文哈希表的底层是使用数组+链表的方式实现的(因为红黑树太难了)。

实现

初次学哈希表应该很难理解其中的有些代码,可以去看看本文的末尾的问题及解答,如果还是不理解,可以将不理解的点发在评论区里。

/**
 * 哈希表
 */
public class HashTable {

    /**
     * 哈希表的节点类
     */
    private static class Entry {
        int hash; // 哈希码
        Object key; // 键
        Object value; // 值
        Entry next; // 下一个节点的指针

        public Entry(int hash, Object key, Object value) {
            this.hash = hash;
            this.key = key;
            this.value = value;
        }
    }

    /**
     * 储存数据的数组,大小是2的n次方
     */
    private Entry[] table = new Entry[16];

    /**
     * 储存的元素个数
     */
    private int size = 0;

    /**
     * 负载因子,当 存储元素的个数 与 数组的大小 的比值 超过了它就需要扩容
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 阈值,当 存储元素的个数 超过了它就需要扩容
     */
    private int threshold = (int) (LOAD_FACTOR * table.length);

    /**
     * 获取key的哈希码
     * @param key 键
     * @return 键对应的哈希码
     */
    private int hash(Object key) {
        if (key instanceof String k) {
            return k.hashCode();
        }
        return key.hashCode() & 0x7fffffff;
    }

    /**
     * 获取key在数组中的下标
     * @param key 键
     * @return 键在数组中对应的下标
     */
    private int getIndex(Object key) {
        return hash(key) & (table.length - 1);
    }

    /**
     * 根据key获取元素的值
     * @param key 键
     * @return 键对应的值,如果找不到,就返回null
     */
    public Object get(Object key) {
        // 1. 获取key在数组中对应的下标
        int index = getIndex(key);

        // 2. 在数组中这个下标的链表中查找
        Entry curr = table[index];
        while (curr != null) {
            // 3. 如果找到了,就返回它的值
            if (curr.key.equals(key)) {
                return curr.value;
            }
            curr = curr.next;
        }

        // 4. 如果找不到,就返回null
        return null;
    }

    /**
     * 新增或修改元素,如果key不重复,则是新增;否则就是修改
     * @param key 键
     * @param value 键对应的值
     */
    public void put(Object key, Object value) {
        // 1. 获取key在数组中对应的下标
        int index = getIndex(key);

        // 2. 如果有数组的index处为空,则直接添加;否则就沿着链表查找空位
        if (table[index] == null) {
            table[index] = new Entry(hash(key), key, value);
        } else {
            Entry curr = table[index];
            while (true) {
                // 2.1 有重复key则更新,更新时不需要让数量增加,退出本方法
                if (curr.hash == hash(key)) {
                    curr.value = value;
                    return;
                }

                // 2.2 如果找到空位,就退出循环
                if (curr.next == null) {
                    break;
                }
                curr = curr.next;
            }

            // 2.3 无重复key则新增
            curr.next = new Entry(hash(key), key, value);
        }

        // 3. 让数量增加,并检查是否需要扩容,如果需要,就扩容
        if (++size > threshold) {
            resize();
        }
    }

    /**
     * 根据key删除元素,并返回它的值
     * @param key 键
     * @return 键对应的值,如果找不到,就返回null
     */
    public Object remove(Object key) {
        // 1. 获取key在数组中对应的下标
        int index = getIndex(key);

        // 2. 沿链表查找这个key对应的元素
        Entry prev = null;
        Entry curr = table[index];
        while (curr != null) {
            // 2.1 如果找到了,就进行删除
            if (curr.key.equals(key)) {
                // 2.2 如果待删除元素是链表的首元素
                if (prev == null) {
                    // 2.3 则让链表指向它的下一个元素即可
                    table[index] = curr.next;
                } else {
                    // 2.4 否则就让 待删除元素的上一个元素 指向 它的下一个元素
                    prev.next = curr.next;
                }

                // 2.5 删除完毕后让数量减少
                size--;

                // 2.6 返回待删除元素的value
                return curr.value;
            }

            prev = curr;
            curr = curr.next;
        }

        // 3. 找不到这个key对应的元素,返回null
        return null;
    }

    /**
     * 扩容
     */
    private void resize() {
        // 1. 先构造出新的数组,容量为原来的二倍
        Entry[] newTable = new Entry[table.length << 1];

        // 2. 对原数组每个索引处挂的链表进行拆分,将其拆分成2个子链表
        for (int i = 0; i < table.length; i++) {
            // 2.1 获取链表头部
            Entry curr = table[i];

            // 2.2 如果链表头部为空,则不需要拆分
            if (curr == null) {
                continue;
            }

            // 2.3 将链表拆分成2个子链表
            Entry subList1Head = null; // 子链表1的头部
            Entry subList2Head = null; // 子链表2的头部
            Entry subList1 = null; // 子链表1的节点
            Entry subList2 = null; // 子链表2的节点
            while (curr != null) {
                if ((curr.hash & table.length) == 0) {
                    if (subList1 == null) {
                        subList1Head = curr;
                    } else {
                        subList1.next = curr;
                    }
                    subList1 = curr;
                } else {
                    if (subList2 == null) {
                        subList2Head = curr;
                    } else {
                        subList2.next = curr;
                    }
                    subList2 = curr;
                }
                curr = curr.next;
            }

            // 2.4 将(hash & table.length) == 0的放在newTable[i]处
            if (subList1 != null) {
                subList1.next = null; // 由于原先的节点可能指向别的节点,所以让它的next指向null
                newTable[i] = subList1Head;
            }

            // 2.5 将(hash & table.length) == 1的放在newTable[i + table.length]处
            // 注意此处的table.length是原先链表的长度
            if (subList2 != null) {
                subList2.next = null; // 由于原先的节点可能指向别的节点,所以让它的next指向null
                newTable[i + table.length] = subList2Head;
            }
        }

        // 3. 替换原先的数组
        table = newTable;

        // 4. 更新阈值
        threshold = (int) (LOAD_FACTOR * table.length);
    }

}

问题及解答

为什么哈希表数组的长度是2的n次方?

因为这样获取一个数据的下标更快,从而提高数据增删改查的效率。

一般来说,对于一个哈希表table和一个数据的哈希码hash,获取下标的方式为index = hash % table.length

但对于长度是2的n次方的数组table和一个数据的哈希码hashhash & (table.length - 1)操作正好能够得到这个数据在数组table中的下标。

这是位运算的知识,假如table.length是2的4次方(也就是16),则table.length的二进制是0001 0000,那么table.length - 1的二进制就是0000 1111。对于一个哈希码hash = 57(二进制为0011 1001),它按照取余hash % table.length获取的下标为9;它按照与运算hash & (table.length - 1)获取的下标的二进制为0000 1001,而这个二进制恰好就是9。

为什么需要负载因子?

负载因子的作用是防止哈希表的查询效率太低,因为数组长度越小,越可能发生哈希碰撞

一般在 存储元素的个数 / 数组长度 > 0.75时进行扩容,这样会将原先挂在相同索引处的元素分散到不同的索引处。

例如原先的数组长度为16,在数组索引为1的位置挂了三个元素ele1, ele2, ele3,其中ele1的哈希码hash = 17ele2的哈希码hash = 33ele3的哈希码hash = 49。在扩容之后,数组的长度为32,此时ele1的新索引为17,ele2的新索引为1,ele3的新索引为17。这就将数据分散开来,从而使得查询的效率更高。

有了负载因子之后为什么还要有阈值?

这个问题很简单,因为每次新增操作都需要检查是否需要扩容,而这个检查一般是size / table.length > LOAD_FACTOR,但是这样每次都需要计算一下,而size > threshold只需要在数组长度改变时(也就是扩容时)改变一次就行了,这样做可以减少时间的消耗。

为什么对于String类需要单独的hashCode()?

因为如果是Object的hashCode()方法,会导致只要字符串中有相同的字符,无论顺序如何,都会得到相同的哈希码,例如"name"和"aemn"使用Object的hashCode()方法得到的哈希码相同。但是如果是String的hashCode(),这两个元素的哈希码就不同了。

所以建议在使用自建的数据类型作为本文实现的哈希表的键key时,自己先重写equals()和hashCode()方法,然后在HashTable类中的hash方法中仿照String类加上自定义类的判断,这样就减少两个不同的对象有一模一样的哈希码的可能性了。

key.hashCode() & 0x7fffffff是干什么的?

key.hashCode()一般指的是java中Object对象的方法,而这个方法可能返回一个负值,众所周知,负值(int类型)的最高位(第32位)是1,而0x7fffffff的二进制是0111 1111 1111 1111 1111 1111 1111 1111,任何数和它进行与运算都会得到一个正数,也就是最高位为0。所以说key.hashCode() & 0x7fffffff保证返回的结果一定是正数。

在扩容时如何将一个链表拆分为两个子链表?

例如原先的数组长度为16,在数组索引为1的位置挂了五个元素ele1, ele2, ele3, ele4, ele5,其中ele1的哈希码hash = 17ele2的哈希码hash = 33ele3的哈希码hash = 49ele4的哈希码hash = 65ele5的哈希码hash = 81。在扩容之后,数组的长度为32,此时ele1的新索引为17,ele2的新索引为1,ele3的新索引为17,ele4的新索引为1,ele5的新索引为17。

由此可以发现一个规律:对于hash & table.length == 0的元素,它的新索引与原索引一样(注意:此处的table.length指的是原数组的长度),例如 e l e 2 ele2 ele20010 0001 & 0001 0000 == 0 e l e 4 ele4 ele40100 0001 & 0001 0000 == 0;然而对于hash & table.length == 1的元素,它的新索引等于原索引加上原数组的长度,即newIndex = lastIndex + table.length(注意:此处的table.length指的是原数组的长度),例如 e l e 1 ele1 ele10001 0001 & 0001 0000 == 1 e l e 3 ele3 ele30011 0001 & 0001 0000 == 1 e l e 5 ele5 ele50101 0001 & 0001 0000 == 1

  • 25
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值