Comment implémenter une bonne table de hachage !

L'article a d'abord été publié sur le compte public (Moon with Feiyu), puis synchronisé sur le site personnel : xiaoflyfish.cn/

Recherche WeChat : Flying Fish on the Moon , faites-vous des amis, rejoignez le groupe d'échange d'interviews

Je pense que c'est bien, j'espère qu'il plaira, le regarder, le transmettre au soutien, merci !

image

avant-propos

Supposons maintenant qu'il y ait un très long document, si vous voulez compter combien de fois chaque mot du document apparaît dans le document, que devez-vous faire ?

C'est simple!

Nous pouvons construire un HashMap avec le type String comme clé et le type Int comme valeur ;

  • Parcourez chaque mot du document word, recherchez wordl'élément dont la clé se trouve dans la et effectuez une opération d'auto-incrémentation sur la valeur associée.
  • Si l' wordélément de la clé = n'existe pas dans le HashMap, nous insérons un (word,1)élément pour indiquer un nouvel ajout.
  • De cette manière, chaque ensemble de paires clé-valeur représente le nombre correspondant à un certain mot.Lorsque l'ensemble du document est parcouru, nous pouvons obtenir le numéro de chaque mot.

Sous l'implémentation simple, l'exemple de code est le suivant :

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);
    }
}
复制代码

Comment HashMap compte-t-il efficacement le nombre de mots correspondant ? Nous allons l'étudier étape par étape ci-dessous !

D'abord, voyons si nous ne comptons que le nombre d'un certain mot ?

Il vous suffit d'ouvrir une variable, de parcourir également tous les mots et d'effectuer uniquement une opération d'auto-incrémentation sur cette variable lorsqu'elle rencontre le même mot que le mot cible ;

  • Lorsque le parcours est terminé, nous pouvons obtenir le numéro du mot.
  • Nous pouvons lister tous les mots possibles, utiliser une variable pour chaque mot pour compter le nombre de ses occurrences, parcourir tous les mots et déterminer dans quelle variable le mot actuel doit être accumulé.
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++;   
        }
    }
}
复制代码

Remarque : Il y a évidemment deux gros problèmes avec un code comme celui-ci :

  1. La relation de mappage entre les mots et les compteurs est écrite à mort par un tas de if-else, et la maintenance est très médiocre ;
  2. Tous les mots possibles doivent être connus, et si un nouveau mot est rencontré, il n'y a aucun moyen de le traiter.

Optimisation 1

Nous pouvons ouvrir un tableau pour maintenir le compteur.

La méthode spécifique consiste à attribuer un numéro à chaque mot et à utiliser directement l'élément de tableau correspondant à l'indice du nombre comme compteur.

Nous pouvons construire deux tableaux :

  • Le premier tableau est utilisé pour stocker tous les mots, l'indice du tableau est le numéro du mot, nous l'appelons un tableau de dictionnaire ;
  • 第二个数组用于存放每个单词对应的计数器,我们称之为计数数组。

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

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

优化2

优化方式:

  • 一种是我们维护一个有序的数据结构,让比较和插入的过程更加高效,而不是需要遍历每一个元素判断逐一判断。
  • 另一种思路就是我们是否能寻找到一种直接基于字符串快速计算出编号的方式,并将这个编号映射到一个可以在O(1)时间内基于下标访问的数组中。

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

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

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)快速随机访问数组元素的能力

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

  • 比如对26进制的哈希值再进行一次对大质数取mod的运算,只有这样才能用比较有限的计数数组空间去表示整个哈希表。

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

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

如何实现

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

以JDK(JDK14)的HashMap为例:

  • 主要实现在 java.util 下的 HashMap 中,这是一个最简单的不考虑并发的、基于散列的Map实现。

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

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

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

hashcode方法

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

  • 当然数据类型不同,hashcode的计算方式是不一样的,但一定会保证的是两个一样的对象,对应的hashcode也是一样的;

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

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

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的实现:

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进制的想法本质上是相似的。

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的幂次大小的数组进行一次取模计算。

Cependant, prendre le modulo de la puissance de deux équivaut à intercepter directement les bits inférieurs du nombre.Lorsqu'il y a peu d'éléments dans le tableau, cela équivaut à n'utiliser que les informations des bits inférieurs du nombre et à écarter les informations des bits supérieurs, ce qui peut augmenter le conflit. La probabilité.

Par conséquent, le code JDK introduit ^ h >>> 16une telle opération sur les bits, qui superpose en fait les informations 16 bits de poids fort aux 16 bits de poids faible, de sorte que nous pouvons utiliser les informations de poids fort lors de la prise du modulo.

Comment gérer les collisions de hachage ?

La méthode de la chaîne ouverte est utilisée dans JDK.

Chaque emplacement du tableau intégré de la table de hachage stocke une liste chaînée, et la valeur du nœud de liste chaînée stocke la paire clé-valeur qui doit être stockée.

Si une collision de hachage se produit, c'est-à-dire que deux clés différentes sont mappées sur le même emplacement dans le tableau, nous plaçons l'élément directement à la fin de la liste chaînée correspondant à l'emplacement.

en conclusion

La manière correcte de compter le nombre de mots dans une structure de données manuscrite est :

En fonction de la longueur du texte intégral, estimez approximativement le nombre de mots qu'il y aura, ouvrez un tableau qui fait plusieurs fois sa taille, puis concevez une fonction de hachage raisonnable pour mapper chaque mot à un indice du tableau, et utilisez ce tableau pour compter les statistiques.

Bien sûr, dans l'ingénierie actuelle, nous n'écrirons pas une telle implémentation de table de hachage pour chaque scénario séparément, et nous n'aurons pas non plus à gérer nous-mêmes des scénarios d'expansion complexes.

Je suppose que tu aimes

Origine juejin.im/post/7084560466436948005
conseillé
Classement