Java-取模操作中的&和(length-1)、HashMap取模、HashMap扩容

1.背景

在哈希表相关的操作中,有一个典型的问题。

将n个元素放置到长度为k的数组中

现在,我们假定数组的长度为8,元素个数为10个。

1、2、3、4、5、6、7、8、9、10

如果我们拥有一个理想的哈希函数,可以将其中8个元素均匀的放置到数组的8个位置上。

不过由于有10个元素,所以必然会出现冲突的可能。

2.演示

2.1 取模:直接%

现在,我们模拟下上面的操作,选择怎么设计一个哈希函数。

由于值是已知的,很显然能想到一个办法,直接取模。

@Test void name2() { for (int i = 1; i < 11; i++) { log.info("k为: {},取模[8]: {}", i, i % 8); } }

运行结果

2023-06-17 16:56:34.381 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 1,取模[8]: 1 2023-06-17 16:56:34.382 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 2,取模[8]: 2 2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 3,取模[8]: 3 2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 4,取模[8]: 4 2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 5,取模[8]: 5 2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 6,取模[8]: 6 2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 7,取模[8]: 7 2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 8,取模[8]: 0 2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 9,取模[8]: 1 2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 10,取模[8]: 2

现在,给定的元素都能有序的放置到数组中。

2.2 hashCode()函数

上面的例子比较简单,我们换一下,假定给出的元素是。

sads、sadsa、wewq、sadd、hfhg、erter、tytr、cxfcx、dkfpdfs、dsfds

嗯,现在思路就乱了,没有任何规律呀?

所以,我们可以想到使用jdk自带的哈希函数,先来打印一下哈希值。

@Test void name4() { String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"}; Arrays.stream(array).forEach(e-> System.out.println(e.hashCode())); }

运行结果

3522397 109194404 3645992 3522382 3199613 96786516 3575747 95104710 1717009152 95879302

为了放置进去开始的8个数组,我们容易想到,还是取模这个老办法。

@Test void name4() { String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"}; Arrays.stream(array).forEach(e -> { int hashCode = e.hashCode(); log.info("k为: {},hashCode: {},取模[8]: {}", e, hashCode, hashCode % 8); }); }

运行结果

2023-06-17 17:06:12.389 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sads,hashCode: 3522397,取模[8]: 5 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadsa,hashCode: 109194404,取模[8]: 4 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: wewq,hashCode: 3645992,取模[8]: 0 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadd,hashCode: 3522382,取模[8]: 6 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: hfhg,hashCode: 3199613,取模[8]: 5 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: erter,hashCode: 96786516,取模[8]: 4 2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: tytr,hashCode: 3575747,取模[8]: 3 2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: cxfcx,hashCode: 95104710,取模[8]: 6 2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dkfpdfs,hashCode: 1717009152,取模[8]: 0 2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dsfds,hashCode: 95879302,取模[8]: 6

2.3 取模:& (arr.length - 1)

取模操作是一个常用的例子,翻看各种工程代码,我们会发现,取模,它并不是这样写的。

int mod = k % (arr.length);

而是

int mod = k & (arr.length - 1);

嗯,一个奇怪的写法,不妨先试试。

@Test void name4() { String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"}; Arrays.stream(array).forEach(e -> { int hashCode = e.hashCode(); log.info("k为: {},hashCode: {},取模[8]: {}", e, hashCode, hashCode & (8 - 1)); }); }

注意,我将hashCode % 8换成了hashCode & (8 - 1),再看下运行结果。

2023-06-17 17:10:16.230 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sads,hashCode: 3522397,取模[8]: 5 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadsa,hashCode: 109194404,取模[8]: 4 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: wewq,hashCode: 3645992,取模[8]: 0 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadd,hashCode: 3522382,取模[8]: 6 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: hfhg,hashCode: 3199613,取模[8]: 5 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: erter,hashCode: 96786516,取模[8]: 4 2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: tytr,hashCode: 3575747,取模[8]: 3 2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: cxfcx,hashCode: 95104710,取模[8]: 6 2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dkfpdfs,hashCode: 1717009152,取模[8]: 0 2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dsfds,hashCode: 95879302,取模[8]: 6

嗯,结果还是一样。

通过位运算进行快速取模

2.分析

2.1 原理

通过位运算进行快速取模,使用的前提是数组长度是2的幂次方

当数组的长度是2的幂次方时,arr.length - 1 的二进制表示中所有位都是1,例如:

  • 数组长度为4时,二进制表示为100,arr.length - 1 的二进制表示为011
  • 数组长度为8时,二进制表示为1000,arr.length - 1 的二进制表示为0111
  • 数组长度为16时,二进制表示为10000,arr.length - 1 的二进制表示为01111
  • 以此类推。

现在,看一下&操作的原理。位运算&是对元素进行且的操作,即串联

有假则假,全真为真。

0 & 0 = 0 0 & 1 = 0 1 & 0 = 0 1 & 1 = 1

我们再来看下上面的arr.length - 1,结果固定是0和n个1。

x & 0 = 0 x & 1 = x

取模是在干什么,就是找到那个余数。

当取余的时候,不管参与运算的数有多大,(arr.length-1)的前面都是0。

# 10的二进制 1010 # 7(arr.leng-1)的二进制 0111 # 10 & 7 (10) 1010 (7) 0111 = 0010 # 再用150(10010110)做个例子,结果为6 (150) 10010110 (7) 00000111 = 00000110

所以,当参与&运算时,前面的x位数字都可以忽略掉了(结果必定是0),我们实际要关心的只有(arr.length-1)的这个窗口。

由于(arr.length-1)后面几位都是111...,所以结果只会受输入的影响,值是什么结果就是什么即,前面提到的。

x & 1 = x

拿150举例,相当于把某倍数的arr.length-1(8)扔掉,即10010(144),扔掉,取出余数6。

2.2 总结

当数组(槽)长度是2n时,元素k的取模操作可替换为位运算以提高效率。

# 取模 m = k % arr.length # 可替换为 m = k & (arr.length-1)

简单抽取一个方法.

/** * 根据hashCode()计算数组中索引位置 * * @param key str * @param arr arr * @return idx */ private static int calculateIdx(String key, String[] arr) { // 2^n if (arr.length % 2 == 0) { return key.hashCode() & (arr.length - 1); } // !(2^n) return key.hashCode() % arr.length; }

3.扩展

3.1 HashMap的取模运算

HashMap 的取模公式为e.hash & (capacity - 1)

这里 capacity 是 HashMap 数组结构的大小,约定为 2 的 n 次幂,记为 capacity = 2n。
对于节点 e,它的哈希值用 e.hash 表示。

3.2 HashMap扩容时高低位链表迁移

为啥低位链表要用(e.hash & oldCap) == 0判断啊?

当链表节点被迁移到新的哈希桶时,核心逻辑是根据哈希值重新分配这些节点

我们拆成3部分看。

  • 部分1:定义两个链表,低位、高位。
  • 部分2:填充高低位链表的具体元素
  • 部分3:放置高低位链表到哈希桶的具体位置,低位不变还是原来的位置j,高位放到新位置j+oldCap

image-20240623190636422

首先,咱们为啥要区分高低位这么2个链表出来?

不妨看看我们的填充条件。

(e.hash & oldCap) == 0

我们要有几个基本的知识(还是先建议你去看下我的那篇文章)

  • &是串联:有0则0,全1为1。

  • &(length-1)实际上就是在比较最后的length-1,因为length-1前面的都是0,直接扔掉即可。

然后呢,我们看下面这个,以16到32为例。

16: 0001 0000 xx: xxxx xxxx &: 0000 0000

我们现在知道旧的16是0001 0000,也知道if中&的判断结果是0000 0000

那么xx的第5位(1那里)是不是必定是0?其他位我们都可以不管,反正这里肯定是0对不?

16: 0001 0000 xx: xxx0 xxxx

那我们再和新的32取模试下,32的二进制是0010 0000length-10001 1111

来,我们做&运算。

xx: xxx0 xxxx 31: 0001 1111

哎,关键点来了,后面四个xxxx咱们也不知道是不是1,但是咱们肯定知道结果是这样了。

xx: xxx0 xxxx 31: 0001 1111 &: 0000 xxxx

那既然我们都知道这一位必然为0了,是不是可以再看下15。

xx: xxx0 xxxx 15: 0000 1111 &: 0000 xxxx

悟到了没有,只要能&出0来,实际上就是跟旧的15没区别,它的哈希桶位置不会变。


__EOF__

本文作者羊37
本文链接https://www.cnblogs.com/yang37/p/17488009.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   羊37  阅读(169)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示