¡Cómo implementar una buena tabla hash!

El artículo se publicó primero en la cuenta pública (Moon with Feiyu) y luego se sincronizó con el sitio web personal: xiaoflyfish.cn/

Búsqueda de WeChat: Flying Fish on the Moon , haz amigos, únete al grupo de intercambio de entrevistas

Creo que es bueno, espero que les guste, mírenlo, reenvíenlo para apoyar, ¡gracias!

imagen

prefacio

Suponga ahora que hay un documento muy largo, si desea contar cuántas veces aparece cada palabra en el documento, ¿qué debe hacer?

¡Es sencillo!

Podemos construir un HashMap con el tipo String como Clave y el tipo Int como Valor;

  • Recorra cada palabra en el documento word, encuentre wordel elemento cuya clave está en el y realice una operación de incremento automático en el valor relacionado.
  • Si el wordelemento de key= no existe en el HashMap, insertamos un (word,1)elemento para indicar una nueva adición.
  • De esta forma, cada conjunto de pares clave-valor representa el número correspondiente a una determinada palabra, al recorrer todo el documento podemos obtener el número de cada palabra.

Bajo la implementación simple, el ejemplo de código es el siguiente:

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

¿Cómo cuenta HashMap de manera eficiente el número correspondiente de palabras? ¡Lo estudiaremos paso a paso a continuación!

Primero, veamos si solo contamos el número de una determinada palabra.

Solo necesita abrir una variable, recorrer todas las palabras también y solo realizar una operación de incremento automático en esta variable cuando encuentre la misma palabra que la palabra de destino;

  • Cuando se completa el recorrido, podemos obtener el número de la palabra.
  • Podemos enumerar todas las palabras posibles, usar una variable para cada palabra para contar el número de ocurrencias, recorrer todas las palabras y determinar en qué variable se debe acumular la palabra actual.
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++;   
        }
    }
}
复制代码

Nota: Obviamente, hay dos grandes problemas con un código como este:

  1. La relación de mapeo entre palabras y contadores está escrita hasta la saciedad a través de un montón de if-else, y el mantenimiento es muy pobre;
  2. Se deben conocer todas las palabras posibles, y si se encuentra una palabra nueva, no hay forma de tratarla.

Optimización 1

Podemos abrir una matriz para mantener el contador.

El método específico es asignar un número a cada palabra y usar directamente el elemento de la matriz correspondiente al subíndice del número como su contador.

Podemos construir dos arreglos:

  • La primera matriz se usa para almacenar todas las palabras, el subíndice de la matriz es el número de palabra, lo llamamos matriz de diccionario;
  • 第二个数组用于存放每个单词对应的计数器,我们称之为计数数组。

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

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

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

Sin embargo, tomar el módulo de la potencia de dos es equivalente a interceptar directamente los bits inferiores del número. Cuando hay pocos elementos en el arreglo, es equivalente a usar solo la información de los bits inferiores del número y descartar la información. de los bits más altos, lo que puede aumentar el conflicto La probabilidad.

Por lo tanto, el código JDK introduce ^ h >>> 16una operación de bits de este tipo, que en realidad superpone la información de 16 bits de orden superior a la de 16 bits de orden inferior, de modo que podamos usar la información de orden superior al tomar el módulo.

¿Cómo lidiar con las colisiones de hash?

El método de cadena abierta se utiliza en JDK.

Cada ranura en la matriz integrada de la tabla hash almacena una lista vinculada, y el valor del nodo de la lista vinculada almacena el par clave-valor que debe almacenarse.

Si ocurre una colisión de hash, es decir, dos claves diferentes se asignan al mismo espacio en la matriz, colocamos el elemento directamente al final de la lista enlazada correspondiente al espacio.

En conclusión

La forma correcta de contar el número de palabras en una estructura de datos escrita a mano es:

De acuerdo con la longitud del texto completo, calcule aproximadamente cuántas palabras habrá, abra una matriz que sea varias veces su tamaño y luego diseñe una función hash razonable para asignar cada palabra a un subíndice de la matriz y use esta matriz. para contar estadísticas.

Por supuesto, en ingeniería real, no escribiremos una implementación de tabla hash para cada escenario por separado, ni tenemos que lidiar con escenarios de expansión complejos por nosotros mismos.

Supongo que te gusta

Origin juejin.im/post/7084560466436948005
Recomendado
Clasificación