【重学数据结构】哈希表 Hash

目录

哈希表是怎么来的?

哈希表的数据结构

哈希函数的设计

那此时你可能有疑问,为什么不能直接使用hashcode()?

[为什么 HashMap 的数组长度要取 2 的整数幂?](#为什么 HashMap 的数组长度要取 2 的整数幂?)

一张图说明计算哈希下标索引全流程

手写实现简单的哈希散列

哈希碰撞

拉链寻址

常见问题

介绍一下散列表

为什么使用散列表

拉链寻址和开放寻址的区别

对应的Java源码中,对于哈希索引冲突提供了什么样的解决方案


哈希表是怎么来的?

哈希表的存在是为了解决能通过O(1)时间复杂度直接索引到指定元素。

这是什么意思呢?

我们使用数组存放元素,都是按照顺序存放的,当需要获取某个元素的时候,则需要对数组进行遍历比较a[i]与key的值是否相等,直到相等才返回索引i,时间复杂度是On;

在有序表中查找时,我们经常使用的是二分查找,通过比较key与a[i]的大小来折半查找,直到相等时才返回索引i,最终通过索引找到我们要找的元素,时间复杂度是O(logn)。

所以我们有了一种想法,可以不经过比较,直接通过计算key得到我们要的结果,这就有了哈希表。

哈希表的数据结构

我们通过对一个 Key 值计算它的哈希并与长度为2的n次幂的数组减一做与运算,计算出槽位对应的索引,将数据存放到索引下。那么这样就解决了当获取指定数据时,只需要根据存放时计算索引ID的方式再计算一次,就可以把槽位上对应的数据获取处理,以此达到时间复杂度为O(1)的情况,如图所示

哈希散列虽然解决了获取元素的时间复杂度问题,但大多数时候这只是理想情况。因为随着元素的增多,很可能发生哈希冲突,或者哈希值波动不大导致索引计算相同,也就是一个索引位置出现多个元素情况。如图所示;

当赵六和小明都映射到下标索引为02的位置时就发生了冲突,情况就糟糕了,因为不能满足O(1)的时间复杂度获取元素的诉求了。

那么此时就出现了一系列解决方案,包括;HashMap 中的拉链寻址 + 红黑树、扰动函数、负载因子、ThreadLocal 的开放寻址、合并散列等各类数据结构设计。让元素在发生哈希冲突时,也可以存放到新的槽位,并尽可能保证索引的时间复杂度小于O(n)

哈希函数的设计

HashMap的哈希函数是先通过 hashCode() 获取到key的哈希值,哈希值是一个32位的int类型的数值,然后再将哈希值右移16位(高位),然后与哈希值本身异或,达到高位与低位混合的效果,得到一个更加散列的低 16 位的 Hash 值,增大随机性减少碰撞。

java 复制代码
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

那此时你可能有疑问,为什么不能直接使用hashcode()?

首先,hashCode的取值范围是[-2^31, 2^31-1],与 int 的取值范围一致,也就是[-2147483648, 2147483647],有将近40亿的长度,不可能把数组初始化得这么大,内存也放不下。

所以,我们要将hashCode的值进行扰动计算,先通过 hashCode() 获取到key的哈希值,哈希值是一个32位的int类型的数值,然后再将哈希值右移16位(高位),相当于把高位移到了低位,然后与哈希值本身异或,达到高位与低位混合的效果,得到一个更加散列的低 16 位的 Hash 值,增大随机性减少碰撞。

为什么 HashMap 的数组长度要取 2 的整数幂?

因为这样(数组长度 - 1)正好相当于一个 "低位掩码"。 与 操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度 16 为例,16-1=15。2进制表示是 0000 0000 0000 0000 0000 0000 0000 1111 。和某个散列值做与 操作如下,结果就是截取了最低的四位值。

除了以上的方便哈希取余的好处外,第二个好处是为了再扩容时,利用扩容后的大小也是2的倍数,将已经产生hash碰撞 的元素完美的转移到新的table中去。

一张图说明计算哈希下标索引全流程

手写实现简单的哈希散列

哈希碰撞

java 复制代码
/**
 * 不完美哈希
 */
public class HashMap01<K, V> implements Map<K, V>{
    private Logger logger = LoggerFactory.getLogger(HashMap01.class);

    private final Object[] table = new Object[8];

    @Override
    public void put(K key, V value) {
        table[key.hashCode() % table.length] = value;
    }

    @Override
    public V get(K key) {
        return (V) table[key.hashCode() % table.length];
    }
}

HashMap01 的实现只是通过哈希计算出的下标,散列存放到固定的数组内。那么这样当发生元素下标碰撞时,原有的元素就会被新的元素替换掉。

测试程序

java 复制代码
public class Test {
    private static final Logger logger = LoggerFactory.getLogger(Test.class);
    public static void main(String[] args) {
            Map<String, String> map = new HashMap01<>();
            map.put("01", "小明");
            map.put("02", "小刚");
            logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

            // 下标碰撞
            map.put("09", "小红");
            map.put("12", "小娟");
            logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

    }
}

通过测试结果可以看到,碰撞前 map.get("01") 的值是小红,两次下标索引碰撞后存放的值则是小娟

这也就是使用哈希散列必须解决的一个问题,无论是在已知元素数量的情况下,通过扩容数组长度解决,还是把碰撞的元素通过链表存放,都是可以的

拉链寻址

java 复制代码
public class HashMap02BySeparateChaining<K, V> implements Map<K, V> {
    private Logger logger = LoggerFactory.getLogger(HashMap02BySeparateChaining.class);
    private final LinkedList<Node<K, V>>[] tab = new LinkedList[8];
    @Override
    public void put(K key, V value) {
        int idx = key.hashCode() & tab.length - 1;
        if (tab[idx] == null) {
            tab[idx] = new LinkedList<>();
            tab[idx].add(new Node<K, V>(key, value));
        } else {
            tab[idx].add(new Node<K, V>(key, value));
        }
    }

    @Override
    public V get(K key) {
        int idx = key.hashCode() & (tab.length - 1);
        for (Node<K, V> kvNode : tab[idx]) {
            if (key.equals(kvNode.getKey())) {
                return kvNode.value;
            }
        }
        return null;
    }

    static class Node<K, V> {
        K key;
        V value;
        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }

        public K getKey() {
            return key;
        }

        public void setKey(K key) {
            this.key = key;
        }

        public V getValue() {
            return value;
        }

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

因为元素在存放到哈希桶上时,可能发生下标索引膨胀,所以这里我们把每一个元素都设定成一个 Node 节点,这些节点通过 LinkedList 链表关联,当然你也可以通过 Node 节点构建出链表 next 元素即可。

测试程序

java 复制代码
public class Test {
    private static final Logger logger = LoggerFactory.getLogger(Test.class);
    public static void main(String[] args) {
            Map<String, String> map = new HashMap02BySeparateChaining<>();
            map.put("01", "小明");
            map.put("02", "小刚");
            logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

            // 下标碰撞
            map.put("09", "小红");
            map.put("12", "小娟");
            logger.info("碰撞前 key:{} value:{}", "01", map.get("01"));

    }
}

此时第一次和第二次获取01位置的元素没有被替代。因为此时的元素是被存放到链表上了。

常见问题

介绍一下散列表

散列表是将键(key)通过哈希函数计算出一个整数哈希值,然后通过对数组长度取模,得到要给数组下标,从而实现快速存储和查找 以此达到时间复杂度O(1)的情况

为什么使用散列表

为了实现高效的查找、插入和删除操作

通过哈希函数将key映射为数组的一个索引位置,查询的时候只需要再次计算哈希值并取模,就能直接定位到对应的位置,从而实现接近O(1)

拉链寻址和开放寻址的区别

区别在于冲突元素的存放方式不同

拉链寻址:当发生哈希冲突时,就将新元素插入到该位置的链表中

开放寻址:当发生哈希冲突时,根据探测去寻找下一个空闲位置

对应的Java源码中,对于哈希索引冲突提供了什么样的解决方案

使用的拉链寻址的方案

如果我的内容对你有帮助,请辛苦动动您的手指为我点赞,评论,收藏。感谢大家!!

相关推荐
overFitBrain21 分钟前
数据结构-5(二叉树)
开发语言·数据结构·python
孟柯coding23 分钟前
常见排序算法
数据结构·算法·排序算法
Point28 分钟前
[LeetCode] 最长连续序列
前端·javascript·算法
是阿建吖!34 分钟前
【优选算法】链表
数据结构·算法·链表
kev_gogo36 分钟前
关于回归决策树CART生成算法中的最优化算法详解
算法·决策树·回归
屁股割了还要学1 小时前
【C语言进阶】一篇文章教会你文件的读写
c语言·开发语言·数据结构·c++·学习·青少年编程
叫我:松哥1 小时前
优秀案例:基于python django的智能家居销售数据采集和分析系统设计与实现,使用混合推荐算法和LSTM算法情感分析
爬虫·python·算法·django·lstm·智能家居·推荐算法
chenyy23333 小时前
2025.7.25动态规划再复习总结
算法·动态规划
yzx9910133 小时前
JS与Go:编程语言双星的碰撞与共生
java·数据结构·游戏·小程序·ffmpeg
爱和冰阔落3 小时前
【数据结构】长幼有序:树、二叉树、堆与TOP-K问题的层次解析(含源码)
c语言·数据结构·算法