Estructura de datos # Tabla hash

Conceptos básicos de HashTabble

¿Qué es una función hash?

La complejidad del tiempo de consulta y modificación de la matriz es O (1). Si existe una relación de mapeo entre los atributos del objeto, puede utilizar las ventajas de la matriz para convertir la "clave" en el índice de la matriz. es lo que hace la función hash.

Pensamientos causados ​​​​al convertir las "claves" de la vida en índices

Si hay 30 estudiantes en una clase, el número de estudiantes es del 1 al 30. En este momento, el número de estudiante menos uno se puede utilizar como índice de la matriz para almacenar con éxito la información de 30 estudiantes. Este método de conversión de "clave" a índice es relativamente simple.

Sin embargo, en la mayoría de los casos, los datos que procesamos son relativamente complejos. Por ejemplo, si estamos interesados ​​en la información de los residentes, la identificación única del residente puede ser el número de identificación (18 dígitos) porque el número en la tarjeta de identificación es demasiado grande y excede el límite de enteros. No podemos usar este número directamente como índice de la matriz. De hecho, también es un número muy grande. Incluso si usamos este número como índice de la matriz, debemos solicitar un espacio enorme. , y la memoria no utilizada de 17 bits o menos causará problemas extremos: un gran desperdicio.

Es más, algunos identificadores únicos no tienen relación directa con los números, los más comunes son las cadenas. Sigamos usando la información del estudiante como una castaña, si usamos el nombre del estudiante como la "clave" para identificar la información del estudiante. En este momento, la "clave" es una cadena ¿ Cómo diseñar una función hash para convertir la cadena en un número? Este es el primer tema que debemos considerar al diseñar una tabla hash ~

El índice obtenido por la función hash diseñada con el número del estudiante como índice es único. El rango del índice es lo suficientemente pequeño y es conveniente usar matrices para el almacenamiento, pero para más tipos de datos como cadenas, fechas, números de punto flotante, etc. ., es difícil para nosotros garantizar que cada "clave" A corresponda a diferentes índices mediante la conversión de la función hash que diseñamos. Es decir, dos claves diferentes generan el mismo índice después de ser convertidas por la función hash que diseñamos, lo llamamos "conflicto hash", este es también el segundo problema que debemos resolver al diseñar la tabla hash ~

Pensando en el tiempo y el espacio

Las tablas hash encarnan plenamente la idea clásica en el campo del diseño de algoritmos: intercambiar espacio por tiempo. Como ejemplo de la tarjeta de identificación anterior, si podemos solicitar un espacio grande de 18 nueves, entonces la complejidad temporal de la consulta de información del usuario es O (1). Suponiendo una situación extrema, solo podemos solicitar 1 espacio de matriz, entonces todos los datos generarán conflictos de hash cuando se conviertan en índices. En este momento, si usamos una estructura de datos como una lista vinculada para almacenar datos, la consulta también será O(n ) complejidad del tiempo.

Las anteriores son dos situaciones extremas. Una es que el espacio es muy grande y el consumo de tiempo es muy pequeño. La otra es que el espacio es pequeño y el tiempo es relativamente grande. La tabla hash es un equilibrio entre tiempo y espacio ~

Diseño de función hash

El diseño de la función hash sigue el punto de referencia.
  • Consistencia: si a==b entonces hash(a)==hash(b)
  • Eficiencia: El cálculo es eficiente y sencillo.
  • Uniformidad: La distribución del índice obtenida es aproximadamente uniforme, mejor
Entonces, ¿cómo diseñar una función hash?

Esto debe analizarse en función de problemas específicos, porque el diseño de funciones hash tiene muchas prácticas especiales en muchos campos especiales. Este artículo utiliza java int integer como índice para diseñar una función hash:

  • Los enteros positivos de rango pequeño se pueden usar directamente como índices de matriz, y los enteros negativos de rango pequeño pueden considerar desplazamientos de intervalo. Por ejemplo, para los números en el intervalo [-100,100], todos los números negativos se pueden asignar a [100,200].

  • Para números enteros de gran rango, como el tipo Long, un enfoque común es utilizar el método del módulo y el resto. Por ejemplo, el número de identificación es un número de 18 dígitos, entonces, ¿cómo cambiarlo a un entero int más pequeño? En este momento, puede tomar los siguientes 4 dígitos y usar el método del módulo para obtener los últimos dígitos. Sin embargo, normalmente el módulo de un número primo es útil para resolver la distribución desigual de índices y utilizar mejor toda la información digital de los números enteros grandes. Detrás de esto se verifica mediante una gran cantidad de teorías matemáticas. No necesitamos profundizar, pero podemos verificar su uniformidad con las castañas, y la probabilidad de conflicto de hash es pequeña:

un conjunto de números Elija 4 para números no primos Elige 7 como número primo
10 2 3
20 0 6
30 2 2
40 0 4
50 2 1
  • ¿Cómo diseñar una función hash para cadenas? De hecho, las cadenas también se pueden tratar como números enteros grandes: cada carácter se puede tratar como un número y 26 letras se pueden tratar como un número hexadecimal. como:

100 en decimal se puede escribir como 1 * 10 2 + 0 * 10 1 + 0 * 10 0

De la misma manera, las cadenas son similares. Por ejemplo, la palabra "código" se puede escribir como c * 26 3 + o * 26 2 + d * 26 1 + e * 26 0 c, o, d, e. Los números definidos en hexadecimal son: Can. En este momento se diseña la función hash:

hash(código) = (c * 26 3 + o * 26 2 + d * 26 1 + e * 26 0 )% M donde M es un número primo.

Código Hash en Java

Java proporciona un método hashCode para facilitarnos la obtención del valor hash de una clase. Para las clases existentes, puede obtenerlo directamente mediante el método hashCode. Para clases personalizadas, puede anular el método hashCode para obtenerlo.

/**
 * Create by SunnyDay on 2022/05/06 17:55
 */
public class Student {
    
    
    private int age;
    private String name;
    private String sex;

    // 主要用于计算hash值
    @Override
    public int hashCode() {
    
    
        int M = 31;
        int hash = 0;
        hash = hash * M + age;
        hash = hash * M + name.hashCode();
        hash = hash * M + sex.hashCode();
        return hash;
    }
    // hash 冲突时可利用这个判断对象是否相等
    @Override
    public boolean equals(Object obj) {
    
    
        if (this == obj) return true;
        if (null == obj) return false;
        if (obj.getClass() != this.getClass()) return false;

        Student another = (Student) obj;
        return this.age == another.age && 
                this.name.equals(another.name) && 
                this.sex.equals(another.sex);
    }
}

Sin embargo, el valor devuelto por el método hashCode de Java es un valor int de 32 bits, que es un número entero con signo, lo que significa que este valor puede ser un número negativo. Para convertir un número negativo en un índice en una matriz, debemos hacerlo en nuestra propia tabla hash. De hecho, el diseño del código hash de Java también es relativamente razonable, porque cuando diseñamos una tabla hash, generalmente necesitamos modular un número primo, y este número primo suele ser del tamaño de una tabla hash. Sin una tabla hash no podemos obtener números primos. Por tanto, el índice no se puede obtener directamente al definir la clase. Esta es la consideración de diseño de Java hashCode.

Implementación de HashTab

Primero piense en cómo diseñar HashTab, necesitamos resolver dos problemas:

Diseño de función hash

Aquí puede obtener un valor hash a través del método hashCode de Java, pero este valor puede ser un número negativo y debemos manejarlo manualmente. En este momento, el valor del índice en HashTab se puede diseñar en función de la capacidad de la matriz.

  • Primero, obtenga un valor hash mediante el método hashCode de Java.
  • En segundo lugar, realice un procesamiento no negativo en el valor hash (el método hashCode de Java devuelve un número entero que puede ser negativo)
  • Finalmente, el resultado es módulo para obtener un valor distribuido uniformemente (normalmente módulo de un número primo)
Resolución de conflictos hash

Incluso si los números primos se eligen bien en la operación de módulo, habrá casos de conflictos de hash. En este caso, los conflictos de hash deben resolverse. La solución más utilizada es el método de dirección de lista vinculada.

Insertar descripción de la imagen aquí

Primera edición: implementación básica

Antes de Java8, cada posición en HashMap correspondía a una lista vinculada, pero a partir de Java8, cuando el conflicto Hash alcanza un cierto nivel, la lista vinculada se convertirá en un árbol rojo-negro.

La capa inferior del método de dirección de la lista vinculada no necesariamente requiere que escribamos un nodo de la lista vinculada para implementarlo nosotros mismos, porque la capa inferior de TreeMap es una implementación de árbol rojo-negro. Entonces podemos usarlo y escribir una versión ~

/**
 * Create by SunnyDay on 2022/05/06 14:23
 * custom hashTable base on TreeMap.
 */
public class MyHashTable<K, V> {
    
    
    private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
    private int M;//capacity 
    private int size;

    public MyHashTable(int M) {
    
    
        this.M = M;
        this.size = 0;
        hashTable = new TreeMap[M];
        for (int i = 0; i < M; i++) {
    
    
            hashTable[i] = new TreeMap<>();
        }
    }

    /**
     * default constructor,default capacity is 97.
     */
    public MyHashTable() {
    
    
        this(97);
    }

    /**
     * calculate index
     */
    private int hash(K key) {
    
    
        return (key.hashCode() & 0x7fffffff) % M;
    }

    public int getSize() {
    
    
        return size;
    }

    /**
     * add element.
     */
    public void add(K key, V value) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        if (map.containsKey(key)) {
    
    
            map.put(key, value);
        } else {
    
    
            map.put(key, value);
            size++;
        }

    }

    /**
     * delete element.
     */
    public V remove(K key) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        V element = null;
        if (map.containsKey(key)) {
    
    
            element = map.remove(key);
            size--;
        }
        return element;
    }

    /**
     * Detect whether the target element exists.
     */
    public boolean containKey(K key) {
    
    
        return hashTable[hash(key)].containsKey(key);
    }

    /**
     * query the target element.
     */
    public V get(K key) {
    
    
        return hashTable[hash(key)].get(key);
    }
}

Análisis de complejidad del tiempo: hay M direcciones en total, si hay N elementos.

Si se implementa utilizando una lista enlazada ordinaria, cada dirección tiene una complejidad temporal promedio de O(N/M) y una complejidad temporal en el peor de los casos de O(N).

Sin embargo, lo anterior se implementa usando TreeMap. La complejidad de tiempo promedio de cada dirección como un árbol equilibrado es O (log (N/M)), y la complejidad de tiempo en el peor de los casos es O (logN).

Segunda edición: procesamiento espacial dinámico de matrices

Como se mencionó anteriormente, la complejidad temporal de HashTab es de nivel O (1), y parece que la complejidad temporal está relacionada con la cantidad de elementos en la matriz. Se puede ver que existe una relación entre M y N. M es un valor fijo de la capacidad del arreglo. A medida que N se acerca al infinito, el valor de N/M también se acerca al infinito. La complejidad del tiempo es imposible acercarse a O (1). Sin embargo, podemos expandir el espacio dinámicamente, de modo que la complejidad del tiempo se acerque a O(1)

Dado que la lista vinculada que utiliza el método de dirección en cadena no tiene una capacidad total, no podemos expandirla de la misma manera que ArrayList, pero podemos usar dicho estándar:

  • Cuando la capacidad de carga promedio de cada dirección excede un cierto nivel, la capacidad se expande. Por ejemplo: expandir cuando N/M >= UpperTol (N: número total de elementos, capacidad de matriz M, límite de capacidad de UpperTol)
  • Cuando la capacidad de carga promedio de cada dirección es menor que un cierto nivel, la capacidad se reduce. Por ejemplo: reducir cuando N/M < lowerTol (N: número total de elementos, capacidad de matriz M, límite inferior de capacidad de lowerTol)
/**
 * Create by SunnyDay on 2022/05/06 14:23
 * custom hashTable base on TreeMap.
 */
public class MyHashTable<K, V> {
    
    

    // about resize
    private static final int upperTol = 10;
    private static final int lowerTol = 2;
    private static final int initCapacity = 7;

    private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
    private int M;
    private int size;

    public MyHashTable(int M) {
    
    
        this.M = M;
        this.size = 0;
        hashTable = new TreeMap[M];
        for (int i = 0; i < M; i++) {
    
    
            hashTable[i] = new TreeMap<>();
        }
    }

    /**
     * default constructor,default capacity is 97.
     */
    public MyHashTable() {
    
    
        this(initCapacity);
    }

    /**
     * calculate index
     */
    private int hash(K key) {
    
    
        return (key.hashCode() & 0x7fffffff) % M;
    }

    public int getSize() {
    
    
        return size;
    }

    /**
     * add element.
     */
    public void add(K key, V value) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        if (map.containsKey(key)) {
    
    
            map.put(key, value);
        } else {
    
    
            map.put(key, value);
            size++;
            //size就是N,与size/M >= upperTol 等价,这里改除法为乘法。
            if (size >= upperTol * M) {
    
    
                resize(2 * M);
            }
        }

    }

    /**
     * delete element.
     */
    public V remove(K key) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        V element = null;
        if (map.containsKey(key)) {
    
    
            element = map.remove(key);
            size--;
            // M / 2 >0 即可 。由于我们hashTab有初始容积则可写为M / 2 >= initCapacity
            if (size <= lowerTol * M && M / 2 >= initCapacity) {
    
    
                resize(M / 2);
            }
        }
        return element;
    }

    /**
     * Detect whether the target element exists.
     */
    public boolean containKey(K key) {
    
    
        return hashTable[hash(key)].containsKey(key);
    }

    /**
     * query the target element.
     */
    public V get(K key) {
    
    
        return hashTable[hash(key)].get(key);
    }

    private void resize(int newM) {
    
    
        // new array.
        TreeMap<K, V>[] newHashTable = new TreeMap[newM];
        for (int i = 0; i < newM; i++) {
    
    
            newHashTable[i] = new TreeMap<>();
        }

        int oldM = M;
        this.M = newM;

        for (int i = 0; i < oldM; i++) {
    
    
            // TreeMap element in old  array.
            TreeMap<K, V> map = hashTable[i];

            // element put into newHashTable
            for (K key : map.keySet()) {
    
    
                newHashTable[hash(key)].put(key, map.get(key));
            }
        }
        // reset pointer
        this.hashTable = newHashTable;
    }
}

Se puede ver que la probabilidad promedio de cada conflicto de direcciones está entre O (Tol inferior) y O (Tol superior). Dado que nosotros controlamos LowerTol y UpperTol, la complejidad del tiempo promedio se puede controlar dentro de un número pequeño y la complejidad del tiempo se acerca a O (1).

Tercera edición: optimización del espacio dinámico de matrices

En la expansión anterior, cada vez que se obtiene M*2, se debe obtener un número par, lo que resulta en un caso de distribución de índice desigual. Esto aún se puede optimizar: establezca dinámicamente la capacidad en un número primo.


/**
 * Create by SunnyDay on 2022/05/06 14:23
 * custom hashTable base on TreeMap.
 */
public class MyHashTable<K, V> {
    
    

    // int 范围内素数
    private final int capacity[] = {
    
    53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593,
            49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843,
            50331653, 100663319, 201326611, 402653189, 805306457, 1610612741};

    // about resize
    private static final int upperTol = 10;
    private static final int lowerTol = 2;
    // 默认指向 capacity数组中第一个元素
    private static int capacityIndex = 0;

    private TreeMap<K, V>[] hashTable; //TreeMap base on red black tree.
    private int M;
    private int size;

    public MyHashTable() {
    
    
        this.M = capacity[capacityIndex];
        this.size = 0;
        hashTable = new TreeMap[M];
        for (int i = 0; i < M; i++) {
    
    
            hashTable[i] = new TreeMap<>();
        }
    }


    /**
     * calculate index
     */
    private int hash(K key) {
    
    
        return (key.hashCode() & 0x7fffffff) % M;
    }

    public int getSize() {
    
    
        return size;
    }

    /**
     * add element.
     */
    public void add(K key, V value) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        if (map.containsKey(key)) {
    
    
            map.put(key, value);
        } else {
    
    
            map.put(key, value);
            size++;
            // 避免越界
            if (size >= upperTol * M && capacityIndex + 1 < capacity.length) {
    
    
                capacityIndex++;
                resize(capacity[capacityIndex]);
            }
        }

    }

    /**
     * delete element.
     */
    public V remove(K key) {
    
    
        TreeMap<K, V> map = hashTable[hash(key)];
        V element = null;
        if (map.containsKey(key)) {
    
    
            element = map.remove(key);
            size--;

            if (size <= lowerTol * M && capacityIndex - 1 >= 0) {
    
    
                capacityIndex--;
                resize(capacity[capacityIndex]);
            }
        }
        return element;
    }

    /**
     * Detect whether the target element exists.
     */
    public boolean containKey(K key) {
    
    
        return hashTable[hash(key)].containsKey(key);
    }

    /**
     * query the target element.
     */
    public V get(K key) {
    
    
        return hashTable[hash(key)].get(key);
    }

    private void resize(int newM) {
    
    
        // new array.
        TreeMap<K, V>[] newHashTable = new TreeMap[newM];
        for (int i = 0; i < newM; i++) {
    
    
            newHashTable[i] = new TreeMap<>();
        }

        int oldM = M;
        this.M = newM;

        for (int i = 0; i < oldM; i++) {
    
    
            // TreeMap element in old  array.
            TreeMap<K, V> map = hashTable[i];

            // element put into newHashTable
            for (K key : map.keySet()) {
    
    
                newHashTable[hash(key)].put(key, map.get(key));
            }
        }
        // reset pointer
        this.hashTable = newHashTable;
    }
}


resumen

premio

La complejidad temporal amortizada de la tabla hash es O (1),
la tabla hash pierde el orden de los elementos.

Soluciones a otros conflictos Hash

Método de dirección abierta: involucrando el concepto de tasa de carga, la complejidad temporal de seleccionar la tasa de carga también es O (1)

  • Método de detección lineal (+1 cada vez)
  • Método de detección de cuadrados (+2 cuadrados cada vez)
  • hash cuadrático

Hash de nuevo:

Hashing fusionado: combina el método de dirección en cadena y el método de dirección abierta.

Supongo que te gusta

Origin blog.csdn.net/qq_38350635/article/details/124614227
Recomendado
Clasificación