java
主页 > 软件编程 > java >

在Java中实现一个散列表的教程

2022-04-21 | 秩名 | 点击:

假设现在有一篇很长的文档,如果希望统计文档中每个单词在文档中出现了多少次,应该怎么做呢?

很简单!

我们可以建一个HashMap,以String类型为Key,Int类型为Value;

简单实现下,代码示例如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

import java.util.HashMap;

import java.util.Map;

public class Test {

    public static void main(String[] args) {

        Map map = new HashMap<>();

        String doc = "yue ban fei yu";

        String[] words = doc.split(" ");

        for (String s : words) {

            if (!map.containsKey(s)) {

                map.put(s, 1);

            } else {

                map.put(s, map.get(s) + 1);

            }

        }

        System.out.println(map);

    }

}

那HashMap是怎么做到高效统计单词对应数量的?我们下面会逐步来研究一下!

首先我们先来看看如果只统计某一个单词的数量?

只需要开一个变量,同样遍历所有单词,遇到和目标单词一样的,才对这个变量进行自增操作;

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

import java.util.HashMap;

import java.util.Map;

public class Main {

    public static void main(String[] args) {

        int[] cnt = new int[20000];

        String doc = "a b c d";

        String[] words = doc.split(" ");

        int a = 0;

        int b = 0;

        int c = 0;

        int d = 0;

        for (String s : words) {

           if (s == "a") a++;

           if (s == "b") b++;

           if (s == "c") c++;

           if (s == "d") d++;  

        }

    }

}

注意:这样的代码显然有两个很大的问题:

优化1

我们可以开一个数组去维护计数器。

具体做法就是,给每个单词编个号,直接用编号对应下标的数组元素作为它的计数器就好啦。

我们可以建立两个数组:

每遇到一个新的单词,都遍历一遍字典数组,如果没有出现过,我们就将当前单词插入到字典数组结尾。

这样做,整体的时间复杂度较高,还是不行。

优化2

优化方式:

以单词为例,英文单词的每个字母只可能是 a-z。

我们用0表示a、1表示b,以此类推,用25表示z,然后将一个单词看成一个26进制的数字即可。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

import java.util.HashMap;

import java.util.Map;

public class Main {

    public static void main(String[] args) {

        int[] cnt = new int[20000];

        String doc = "a b c d";

        String[] words = doc.split(" ");

        for (String s : words) {

            int tmp = 0;

            for (char c: s.toCharArray()) {

                tmp *= 26;

                tmp += (c - 'a');

            }

            cnt[tmp]++;

        }

        String target = "a";

        int hash = 0;

        for (char c: target.toCharArray()) {

            hash *= 26;

            hash += c - 'a';

        }

        System.out.println(cnt[hash]);

    }

}

这样我们统计N个单词出现数量的时候,整体只需要O(N)的复杂度,相比于原来的需要遍历字典的做法就明显高效的多。

这其实就是散列的思想了。

优化3

使用散列!

散列函数的本质,就是将一个更大且可能不连续空间(比如所有的单词),映射到一个空间有限的数组里,从而借用数组基于下标O(1)快速随机访问数组元素的能力。

但设计一个合理的散列函数是一个非常难的事情。

取了mod之后,我们很快就会发现,现在可能出现一种情况,把两个不同的单词用26进制表示并取模之后,得到的值很可能是一样的。

这个问题被称之为哈希碰撞。

如何实现

最后我们考虑一下散列函数到底需要怎么设计。

以JDK(JDK14)的HashMap为例:

找到其中用于计算哈希值的hash方法:

1

2

3

4

static final int hash(Object key) {

    int h;

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

可以发现就是对key.hashCode()进行了一次特别的位运算。

hashcode方法

在Java中每个对象生成时都会产生一个对应的hashcode。

所以在比较两个对象是否相等时,我们可以先比较hashcode是否一致,如果不一致,就不需要继续调用equals,大大降低了比较对象相等的代价。

我们就一起来看看JDK中对String类型的hashcode是怎么计算的,我们进入 java.lang 包查看String类型的实现:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

public int hashCode() {

    // The hash or hashIsZero fields are subject to a benign data race,

    // making it crucial to ensure that any observable result of the

    // calculation in this method stays correct under any possible read of

    // these fields. Necessary restrictions to allow this to be correct

    // without explicit memory fences or similar concurrency primitives is

    // that we can ever only write to one of these two fields for a given

    // String instance, and that the computation is idempotent and derived

    // from immutable state

    int h = hash;

    if (h == 0 && !hashIsZero) {

        h = isLatin1() ? StringLatin1.hashCode(value)

                       : StringUTF16.hashCode(value);

        if (h == 0) {

            hashIsZero = true;

        } else {

            hash = h;

        }

    }

    return h;

}

Latin和UTF16是两种字符串的编码格式,实现思路其实差不多,我们来看看StringUTF16中hashcode的实现:

1

2

3

4

5

6

7

8

public static int hashCode(byte[] value) {

    int h = 0;

    int length = value.length >> 1;

    for (int i = 0; i < length; i++) {

        h = 31 * h + getChar(value, i);

    }

    return h;

}

其实就是对字符串逐位按照下面的方式进行计算,和展开成26进制的想法本质上是相似的。

1

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

为什么选择了31?

首先在各种哈希计算中,我们比较倾向使用奇素数进行乘法运算,而不是用偶数。

因为用偶数,尤其是2的幂次,进行乘法,相当于直接对原来的数据进行移位运算;这样溢出的时候,部分位的信息就完全丢失了,可能增加哈希冲突的概率。

为什么选择了31这个奇怪的数,这是因为计算机在进行移位运算要比普通乘法运算快得多,而31*i可以直接转化为(i << 5)- i ,这是一个性能比较好的乘法计算方式,现代的编译器都可以推理并自动完成相关的优化。

具体可以参考《Effective Java》中的相关章节。

h>>>16

我们现在来看 ^ h >>> 16 又是一个什么样的作用呢?

它的意思是就是将h右移16位并进行异或操作,为什么要这么做呢?

因为那个hash值计算出来这么大,那怎么把它连续地映射到一个小一点的连续数组空间呢?

所以需要取模,我们需要将hash值对数组的大小进行一次取模。

我们需要对2的幂次大小的数组进行一次取模计算。

但对二的幂次取模相当于直接截取数字比较低的若干位,这在数组元素较少的时候,相当于只使用了数字比较低位的信息,而放弃了高位的信息,可能会增加冲突的概率。

所以,JDK的代码引入了^ h >>> 16 这样的位运算,其实就是把高16位的信息叠加到了低16位,这样我们在取模的时候就可以用到高位的信息了。

如何处理哈希冲突呢?

JDK中采用的是开链法。

哈希表内置数组中的每个槽位,存储的是一个链表,链表节点的值存放的就是需要存储的键值对。

如果碰到哈希冲突,也就是两个不同的key映射到了数组中的同一个槽位,我们就将该元素直接放到槽位对应链表的尾部。

总结

手写数据结构统计单词的数量正确的思路就是:

根据全文长度大概预估一下会有多少个单词,开一个数倍于它的数组,再设计一个合理的hash函数,把每个单词映射到数组的某个下标,用这个数组计数统计就好啦。

当然在实际工程中,我们不会为每个场景都单独写一个这样的散列表实现,也不用自己去处理复杂的扩容场景。

原文链接:https://juejin.cn/post/7084560466436948005
相关文章
最新更新