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没区别,它的哈希桶位置不会变。

posted @ 2023-06-17 18:09  羊37  阅读(73)  评论(1编辑  收藏  举报