Hashtable (cómo construir una tabla hash de grado industrial)

Tabla de contenido

ideas de hachís

función hash

 colisión de hash

1. Método de direccionamiento abierto

2. Método de lista enlazada

Resolver el problema del factor de carga demasiado grande

Elección de un método de resolución de colisión de hash apropiado


ideas de hachís


La tabla hash (hashtable) es una extensión de la matriz, evolucionada a partir de la matriz, y la capa inferior se basa en la matriz para admitir la
función de acceder rápidamente a los elementos presionando el índice. En otras palabras, sin arreglos, no hay tabla hash. Vamos a explicar con un ejemplo.

Suponga que hay 69 jugadores que participan en la reunión deportiva escolar. Para facilitar el registro de resultados, cada concursante tendrá su propio número de inscripción pegado en su pecho. Los 69 jugadores están numerados del 1 al 69 por turnos. Ahora esperamos implementar tal función en la programación: encontrar rápidamente la información del jugador correspondiente por número. ¿Cómo realizar esta función?
Podemos poner la información de estos 69 jugadores en una matriz. Para la información del jugador con el número 1, colóquelo en la posición con el subíndice 1 en la matriz; para la información del jugador con el número 2, colóquelo en la posición con el subíndice 2 en la matriz, y así sucesivamente, para el jugador con el número k Coloque la información en la matriz en la posición subíndice k.
Existe una correspondencia de uno a uno entre el número de entrada y el subíndice de la matriz. Cuando necesite consultar la información del jugador con el número de entrada x, solo necesita sacar el elemento de la matriz con el subíndice x. La complejidad del tiempo es 0(1), y la eficiencia es muy alta.
De hecho, este ejemplo ya ha utilizado la idea hash. En este ejemplo, el número de entrada es un número natural y forma una relación de mapeo uno a uno con el subíndice de la matriz. Por lo tanto, al usar la característica de que la complejidad temporal de acceder a un elemento por subíndice en la matriz es 0(1), podemos lograr una rápida búsqueda de información del jugador.
Sin embargo, la idea de hash contenida en este ejemplo no es lo suficientemente obvia, así que modifiquemos un poco este ejemplo.
Suponiendo que el director solicite que el número de entrada no sea tan simple, se debe agregar información detallada como el grado y la clase, por lo que modificamos ligeramente las reglas de numeración y usamos 6 dígitos para representarlo, como 01120639, donde los primeros dos dígitos 01 representan el grado, los dos dígitos centrales 12 representan la clase, y los dos últimos dígitos siguen siendo el número original 1~69. En este momento, ¿cómo almacenar la información del jugador para admitir la búsqueda rápida de información del jugador por número?

La solución al problema es similar a la anterior. Aunque ahora el número de entrada no se puede usar directamente como un subíndice de matriz, podemos interceptar los dos últimos dígitos del número de entrada como un subíndice de matriz. Al consultar la información del jugador a través del número de entrada, usamos el mismo método para tomar los dos últimos dígitos del número de entrada como subíndice de la matriz y obtener la información del jugador correspondiente a este subíndice de la matriz.
Este es el típico pensamiento hash. En donde, el número del concursante se denomina clave (key) o palabra clave (keyword). Usamos claves para identificar a un jugador. Nuestro método de mapeo para convertir números de entrada en subíndices de matriz se denomina función hash. El valor calculado por la función hash se denomina valor hash.

 La tabla hash utiliza la característica de que la complejidad del tiempo para acceder a los elementos de la matriz es O(1). Mapeamos el valor clave del elemento en un subíndice a través de una función hash y luego almacenamos los datos correspondientes en la posición correspondiente al subíndice en la matriz. Al consultar elementos por valor clave, usamos la misma función hash para convertir el valor clave en un subíndice de matriz y obtener datos de la posición correspondiente al subíndice en la matriz.

función hash

Las funciones hash juegan un papel clave en las tablas hash. La función hash es primero una función, podemos definirla como hash(clave), donde clave representa el valor clave del elemento, y el valor de hash(clave) representa el valor hash calculado por la función hash.

En el ejemplo anterior, la función hash es relativamente simple y el código es fácil de implementar. Pero si el número del concursante es un número de 6 dígitos generado aleatoriamente, o una cadena de a~z, ¿cómo construir una función hash? A continuación se resumen los tres requisitos básicos para el diseño de funciones hash

:

  • 1) El valor hash calculado por la función hash es un número entero no negativo;
  • 2) Si clave1=clave2, entonces hash(clave1)==hash(clave2);
  • 3) Si clavel≠clave2, entonces hash(clave1)≠hash(clave2).

Entre ellos, el primer requisito básico debe entenderse sin ningún problema, porque el subíndice del arreglo parte de 0, por lo tanto, el valor hash generado por la función hash también debe ser un número entero no negativo. El segundo requisito básico también es fácil de entender: el valor hash obtenido por la misma clave a través de la función hash también debe ser el mismo. El tercer requisito básico puede ser difícil de entender Este requisito parece razonable, pero en situaciones reales, es casi imposible encontrar una función hash sin conflictos (diferentes claves corresponden a diferentes valores hash). Incluso los algoritmos de hash más conocidos, como MD5, SHA y CRC en la industria, no pueden evitar por completo las colisiones de hash. Además, debido a que el espacio de almacenamiento de la matriz es limitado, la probabilidad de colisión de hash también aumentará.

Por ejemplo, hay un conjunto de datos {11, 8, 6, 14, 5, 9};

Función hash : hash(clave) = clave%capacidad , la capacidad es el tamaño total del espacio subyacente del elemento de almacenamiento.
Si almacenamos la colección en una tabla hash con una capacidad de 10, la ubicación de almacenamiento de cada elemento corresponde a la siguiente:

Con este método de almacenamiento, solo es necesario utilizar la función hash para determinar si el elemento a buscar se almacena en la posición correspondiente al buscar, sin tener que comparar los códigos clave muchas veces, por lo que la velocidad de búsqueda es relativamente rápida.

De acuerdo con el método hash anterior, inserte el elemento 44 en el conjunto, ¿qué problemas ocurrirán?

 colisión de hash

Incluso la mejor función hash no puede evitar las colisiones de hash. Entonces, ¿cómo resolver el conflicto hash?Hay dos métodos comúnmente utilizados: el método de direccionamiento abierto y el método de lista enlazada.

1. Método de direccionamiento abierto

La idea central del método de direccionamiento abierto: una vez que ocurre un conflicto hash, el conflicto se resuelve volviendo a detectar la nueva ubicación. Cómo volver a probar la nueva posición
El método de detección más simple es el método de detección lineal .

Por ejemplo, en el escenario anterior, ahora necesita insertar el elemento 44, primero calcule la dirección hash a través de la función hash, hashAddr es 4, por lo que teóricamente debería insertarse 44 en la posición con el subíndice 4, pero el elemento con el valor de 4 ya se ha colocado en esta posición, es decir, ocurrirá una colisión hash.

Inserción : al insertar datos en la tabla hash, si la función hash ha calculado ciertos datos y la ubicación de almacenamiento correspondiente ya está ocupada, comenzamos desde esta ubicación y buscamos hacia atrás en la matriz hasta encontrar una posición de espacio libre.

 Eliminación : las tablas hash no solo admiten operaciones de inserción y búsqueda, sino también operaciones de eliminación. Para una tabla hash que utiliza un sondeo lineal para resolver conflictos, la operación de eliminación es un poco especial y no puede simplemente establecer la posición del elemento que se eliminará en NULL. Al buscar datos, una vez que se recorre la posición libre a través del método de detección lineal, creemos que los datos no existen en la tabla hash. Sin embargo, si la posición libre se elimina más tarde, el algoritmo de búsqueda original quedará invalidado. Los datos que existen en primer lugar pueden considerarse como no existentes. Por ejemplo, para eliminar el elemento 4, si se elimina directamente, la búsqueda de 44 puede verse afectada. Por lo tanto, el sondeo lineal utiliza una pseudoeliminación marcada para eliminar un elemento. Marque especialmente el espacio de almacenamiento de los datos que se eliminarán como "eliminados". Al usar el método de detección lineal para buscar datos, no se detendrá cuando encuentre un espacio marcado como "eliminado", sino que continuará detectando.

// Cada espacio en la tabla hash está marcado
// EMPTY está vacío, EXIST ya tiene un elemento y DELETE ha sido eliminado
enum State{

        VACÍO,

        EXISTIR,

        BORRAR

     }; 

Para el método de detección lineal, cuando se insertan más y más datos en la tabla hash, habrá cada vez menos posiciones libres, la probabilidad de colisiones hash aumentará y el tiempo de detección lineal será cada vez más largo. En el caso extremo, se debe sondear toda la tabla hash para encontrar una ubicación libre e insertar los datos, por lo que la complejidad de tiempo en el peor de los casos es 0(n). De la misma manera, también es posible sondear linealmente toda la tabla hash al eliminar datos y buscar datos.

Para el direccionamiento abierto, además del sondeo lineal, existen otros dos métodos de sondeo clásicos: el sondeo cuadrático y el hash doble .

El método de detección secundario es muy similar al método de detección lineal. El paso de detección del método de detección lineal es 1, y la secuencia de subíndice de detección es hash(clave)+0, hash(clave)+1, hash(clave+2. .... El paso de detección del método de detección cuadrático se convierte en el "cuadrático" original, y la secuencia de subíndice de detección es hash(key)+0, hash(key)+1^2, hash(key)+2^2 . ..
el hash doble utiliza varias funciones hash: hash1(clave), hash2(clave), hash3(clave)... Si la ubicación de almacenamiento calculada por la primera función hash ha sido ocupada, utilice la segunda función hash para recalcular la ubicación de almacenamiento, y así sucesivamente, hasta que se encuentre una ubicación de almacenamiento libre.

Los estudios han demostrado que: cuando la longitud de la tabla es un número primo y el factor de carga de la tabla (factor de carga: la cantidad de elementos en la tabla hash / la longitud de la tabla hash (la cantidad de "ranuras")) a no no supere 0,5, la inserción de la nueva entrada en la tabla debe ser posible sin que se pruebe dos veces ninguna ubicación individual. Por lo tanto, mientras haya la mitad de las posiciones vacías en la mesa, no habrá problema de que la mesa esté llena. Puede ignorar la plenitud de la tabla al buscar, pero debe asegurarse de que el factor de carga a de la tabla no exceda 0.5 al insertar.Si excede, debe considerar aumentar la capacidad.

Por lo tanto: el mayor defecto del método de direccionamiento abierto es la tasa de utilización del espacio relativamente baja, que también es un defecto del hashing.

código:

enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};

	template<class K, class V>
	class HashTable
	{
	public:
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;

			// 负载因子超过0.7就扩容
			if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
			{
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				HashTable<K, V> newht;
				newht._tables.resize(newsize);

				// 遍历旧表,重新映射到新表
				for (auto& data : _tables)
				{
					if (data._state == EXIST)
					{
						newht.Insert(data._kv);
					}
				}

				_tables.swap(newht._tables);
			}

			size_t hashi = kv.first % _tables.size();

			// 线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state == EXIST)
			{
				index = hashi + i;
				index %= _tables.size();
				++i;
			}

			_tables[index]._kv = kv;
			_tables[index]._state = EXIST;
			_n++;

			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return false;
			}

			size_t hashi = key % _tables.size();

			// 线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state != EMPTY)
			{
				if (_tables[index]._state == EXIST
					&& _tables[index]._kv.first == key)
				{
					return &_tables[index];
				}

				index = hashi + i;
				index %= _tables.size();
				++i;

				// 如果已经查找一圈,那么说明全是存在+删除
				if (index == hashi)
				{
					break;
				}
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0; // 存储的数据个数

	};

2. Método de lista enlazada

El método de lista enlazada es un método más utilizado para resolver colisiones de hash y es mucho más simple que el método de direccionamiento abierto. En la tabla hash, cada "cubo" o "ranura" (ranura) corresponde a una lista enlazada, y colocamos elementos con el mismo valor hash en la lista enlazada correspondiente a la misma ranura.
Al insertar datos, solo necesitamos calcular la "ranura" correspondiente a través de la función hash, y luego insertar los datos en la lista vinculada correspondiente a esta "ranura". La complejidad temporal de la inserción de datos es O(1). Cuando queremos buscar y eliminar datos, también calculamos la "ranura" correspondiente a través de la función hash y luego recorremos la lista vinculada para buscar o eliminar datos.

Para una tabla hash basada en el método de lista enlazada para resolver conflictos, la complejidad temporal de las operaciones de búsqueda y eliminación es proporcional a la longitud k de la lista enlazada, es decir, O(k). Para una función hash con un hash relativamente uniforme, teóricamente hablando, k=n/m, donde n representa el número de datos en la tabla hash y m representa el número de "ranuras" en la tabla hash. es una constante pequeña, podemos pensar aproximadamente que la complejidad temporal de encontrar y eliminar datos en la tabla hash es O(1).

A la k anterior la llamamos factor de carga. El factor de carga se expresa mediante la fórmula: factor de carga = número de elementos en la tabla hash/longitud de la tabla hash (número de "ranuras"). Cuanto mayor sea el factor de carga, mayor será la longitud de la lista vinculada y menor será el rendimiento de la tabla hash.

Resolver el problema del factor de carga demasiado grande

Cuanto mayor sea el factor de carga, más elementos habrá en la tabla hash y menos posiciones libres, mayor será la probabilidad de colisión de hash y menor será el rendimiento de inserción, eliminación y búsqueda. Para recopilaciones de datos estáticos sin operaciones frecuentes de inserción y eliminación, debido a que los datos se conocen estáticamente, podemos diseñar fácilmente una buena función hash con pocos conflictos según las características de los datos. Sin embargo, para recopilaciones de datos dinámicos con frecuentes operaciones de inserción y eliminación, es imposible solicitar una tabla hash lo suficientemente grande por adelantado porque la cantidad de datos que se agregarán no se puede estimar de antemano. A medida que se agregan más y más datos, el factor de carga será cada vez mayor. Cuando el factor de carga es grande hasta cierto punto, una gran cantidad de colisiones hash provocará una fuerte disminución en el rendimiento de la tabla hash. ¿Qué debemos hacer en este momento?
Para la tabla hash, cuando el factor de carga es demasiado grande, también podemos realizar una expansión dinámica, volver a solicitar una tabla hash más grande y mover los datos de la tabla hash original al nuevo hash mesa Suponga que cada vez que se expande la capacidad, se vuelve a aplicar una nueva tabla hash del doble del tamaño de la tabla hash original. Si el factor de carga de la tabla hash original es 0,8, después de la expansión, el factor de carga de la nueva tabla hash se reduce a la mitad del valor original y se convierte en 0,4. Para la expansión de arreglos, pilas y colas, la operación de movimiento de datos es relativamente simple. Sin embargo, para que la tabla hash n represente la expansión del tamaño de la tabla hash, la operación de movimiento de datos es mucho más complicada. Debido a que el tamaño de la tabla hash ha cambiado, la ubicación de almacenamiento de los datos también ha cambiado, por lo que debemos volver a calcular la ubicación de almacenamiento de cada dato en la nueva tabla hash a través de la función hash. En la mayoría de los casos, la inserción de nuevos datos no desencadena la expansión, por lo que la mejor complejidad de tiempo para la inserción es O(1). Si el factor de carga es demasiado alto y supera el umbral establecido de antemano, cuando se inserten nuevos datos, se activará la expansión de la capacidad y será necesario volver a aplicar el espacio de memoria, se recalculará el valor hash de cada dato y se recuperarán los datos. movido de la tabla hash original a la nueva tabla hash, por lo tanto, la complejidad de tiempo en el peor de los casos de la operación de inserción es O(n).

Para la tabla Harbin que admite la protección de capacidad dinámica, en la mayoría de los casos, la velocidad de inserción de datos es muy rápida. Sin embargo, en casos especiales, cuando el factor de carga ha alcanzado el umbral, es necesario expandir la capacidad antes de insertar datos. En este momento, la inserción de datos será muy lenta, incluso inaceptable.
Vamos a explicar con un ejemplo extremo. Si el tamaño actual de la tabla hash es de 1 GB, cuando se inicia la expansión, el valor hash debe volver a calcularse para los datos de 1 GB y moverse a la nueva tabla hash. Tal operación parece que requiere mucho tiempo. Si el código de nuestro proyecto sirve directamente a los usuarios, los requisitos de tiempo de respuesta son relativamente altos, aunque en la mayoría de los casos la velocidad de inserción de datos es muy rápida, muy pocas operaciones de inserción con una velocidad muy lenta también provocarán que los usuarios se "bloqueen". En este momento, este mecanismo de expansión centralizado no es adecuado.
Para resolver el problema de la expansión centralizada que consume demasiado tiempo, intercalamos la operación de expansión en el proceso de múltiples operaciones de inserción y la completamos en lotes. Cuando el factor de carga alcanza el umbral, solo creamos una nueva tabla hash, pero no movemos todos los datos de la tabla hash original a la nueva tabla hash.
Cuando haya que insertar nuevos datos, además de insertar los nuevos datos en la nueva tabla hash, se moverá una parte de los datos de la tabla hash original a la nueva tabla hash. Cada vez que insertamos un nuevo dato en la tabla hash, repetimos el proceso anterior. Después de varias operaciones de inserción, los datos de la tabla hash original se trasladan a la nueva tabla hash poco a poco. De esta manera, dispersamos el trabajo de movimiento de datos en múltiples operaciones de inserción de datos Sin operaciones centralizadas de movimiento de datos a gran escala de una sola vez, todas las operaciones de inserción se vuelven muy rápidas.

 A través de este método, el costo de la expansión se distribuye uniformemente entre múltiples operaciones de inserción, lo que evita el problema de una expansión que consume demasiado tiempo. Según este método de expansión, en cualquier caso, la complejidad temporal de la inserción de datos es O(1).
Sin embargo, antes de que los datos de la tabla hash original se trasladen por completo a la nueva tabla hash, la memoria ocupada por la tabla hash original no se liberará y el uso de la memoria será mayor. compatible con la tabla hash nueva y antigua Para recuperar los datos en la tabla hash, debemos buscarlos en las tablas hash nuevas y antiguas al mismo tiempo.

código:

template<class K, class V>
	struct HashNode
	{
		HashNode<K, V>* _next;
		pair<K, V> _kv;

		HashNode(const pair<K, V>& kv)
			:_next(nullptr)
			, _kv(kv)
		{}
	};

	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	// 特化
	template<>
	struct HashFunc<string>
	{
		// BKDR
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				hash += ch;
				hash *= 31;
			}

			return hash;
		}
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		~HashTable()
		{
			for (auto& cur : _tables)
			{
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}

				cur = nullptr;
			}
		}

		Node* Find(const K& key)
		{
			if (_tables.size() == 0)
				return nullptr;

			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;

					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}

			return false;
		}
		size_t GetNextPrime(size_t prime)
		{
			// SGI
			static const int __stl_num_primes = 28;
			static const unsigned long __stl_prime_list[__stl_num_primes] =
			{
				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, 3221225473, 4294967291
			};

			size_t i = 0;
			for (; i < __stl_num_primes; ++i)
			{
				if (__stl_prime_list[i] > prime)
					return __stl_prime_list[i];
			}

			return __stl_prime_list[i];
		}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
			{
				return false;
			}

			Hash hash;

			// 负载因因子==1时扩容
			if (_n == _tables.size())
			{
				size_t newsize = GetNextPrime(_tables.size());
				vector<Node*> newtables(newsize, nullptr);
				for (auto& cur : _tables)
				{
					while (cur)
					{
						Node* next = cur->_next;

						size_t hashi = hash(cur->_kv.first) % newtables.size();

						// 头插到新表
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;

						cur = next;
					}
				}

				_tables.swap(newtables);
			}

			size_t hashi = hash(kv.first) % _tables.size();
			// 头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;

			++_n;
			return true;
		}

		size_t MaxBucketSize()
		{
			size_t max = 0;
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				auto cur = _tables[i];
				size_t size = 0;
				while (cur)
				{
					++size;
					cur = cur->_next;
				}

				if (size > max)
				{
					max = size;
				}
			}

			return max;
		}
	private:
		vector<Node*> _tables; // 指针数组
		size_t _n = 0; // 存储有效数据个数
	};

Elección de un método de resolución de colisión de hash apropiado

Método de direccionamiento abierto
Ventajas:
para la tabla hash basada en el método de direccionamiento abierto para resolver conflictos, los datos se almacenan en la matriz, que puede usar efectivamente el caché de la CPU para acelerar la consulta. En comparación con la tabla hash basada en el método de lista enlazada para resolver conflictos, la tabla hash basada en el método de direccionamiento abierto para resolver conflictos no involucra listas enlazadas ni punteros, lo cual es conveniente para la serialización.
Desventajas:
basado en el método de direccionamiento abierto para resolver conflictos en la tabla hash, la operación de eliminación de datos será más problemática y los datos eliminados deben marcarse especialmente. Además, en el método de direccionamiento abierto, todos los datos se almacenan en una matriz, que tiene una mayor probabilidad de conflicto que el método de lista enlazada. Por lo tanto, el factor de carga de la tabla hash basada en el método de direccionamiento abierto para resolver conflictos no puede ser demasiado grande y debe ser inferior a 1, mientras que el factor de carga de la tabla hash basada en el método de lista enlazada para resolver conflictos puede ser superior a 1. Esto lleva a almacenar la misma cantidad de datos y el método de direccionamiento abierto requiere más espacio de almacenamiento que el método de lista enlazada.
En resumen, cuando los datos son los más pequeños. Cuando el factor de carga es pequeño, el método de direccionamiento abierto es adecuado.
Método de lista enlazada
Una tabla hash basada en el método de lista enlazada para resolver conflictos, y los datos se almacenan en la lista enlazada. Una tabla hash basada en direccionamiento abierto para resolver conflictos y los datos se almacenan en matrices. Los nodos de lista enlazada se pueden crear cuando se utilizan, mientras que las matrices se deben crear con antelación. Por lo tanto, la tasa de utilización de la memoria del método de lista enlazada es mayor que la del método de direccionamiento abierto.
El método de lista enlazada es más tolerante a grandes factores de carga que el método de direccionamiento abierto. El direccionamiento abierto solo es aplicable cuando el factor de carga es inferior a 1. Cuando el factor de carga está cerca de 1, habrá una gran cantidad de colisiones de hash, lo que dará como resultado una gran cantidad de detecciones, rehashing, etc., y una fuerte caída en el rendimiento. Pero para el método de lista enlazada, siempre que el valor calculado por la función hash sea relativamente aleatorio y uniforme, incluso si el factor de carga se convierte en 10, la longitud de la lista enlazada es solo un poco más larga y la degradación del rendimiento no es mucho. . Sin embargo, los nodos en la lista enlazada necesitan almacenar el siguiente puntero, por lo que se consumirá espacio de memoria adicional.Para el almacenamiento de objetos pequeños, el consumo de memoria puede duplicarse. Además, los nodos de la lista enlazada están dispersos en la memoria, no son continuos y no son compatibles con la memoria caché de la CPU, lo que también tiene un cierto impacto en el rendimiento de la tabla hash.
Por supuesto, si almacenamos un objeto grande, es decir, el tamaño del objeto es mucho mayor que el tamaño de un puntero (4B u 8B), entonces se puede ignorar el consumo de memoria del puntero en la lista enlazada.
De hecho, podemos transformar la lista enlazada en el método de lista enlazada en otras estructuras de datos más eficientes, como árboles rojo-negro. De esta manera, incluso si hay un conflicto de hash, en casos extremos, todos los datos se codificarán en el mismo "cubo", y la tabla hash final solo degenerará en un árbol rojo-negro, y la eficiencia de la consulta no ser muy malo La complejidad es O (log N). Esto puede evitar efectivamente las colisiones de hash.

La eficiencia de consulta de la tabla hash no puede considerarse generalmente como la complejidad del tiempo O(1), porque está relacionada con la función hash, el factor de carga y la colisión hash. Si la función hash no está bien diseñada o el factor de carga es demasiado alto, la probabilidad de colisiones de hash puede aumentar y la eficiencia de las consultas disminuirá.
En casos extremos, algunos atacantes maliciosos pueden usar datos cuidadosamente construidos para convertir todos los datos en la misma "ranura" después de pasar por la función hash. Si usamos un método de resolución de conflictos basado en una lista enlazada, entonces, en este momento, la tabla hash degenerará en una lista enlazada y la complejidad temporal de la consulta se degradará drásticamente de O(1) a O(n).
Si hay 100 000 datos en la tabla hash, el tiempo de consulta de la tabla hash degenerada se vuelve 100 000 veces mayor que el original. Por ejemplo, si antes solo se necesitaban 0,1 s para ejecutar 100 consultas, ahora se necesitan 10000 s. De esta forma, es posible que el sistema no pueda responder a otras solicitudes porque la operación de consulta consume una gran cantidad de recursos de CPU o subprocesos, lo que permite que los atacantes maliciosos logren el propósito de un ataque de "denegación de servicio" (DoS). Este es el principio básico del ataque de colisión de tablas hash.

Cuando diseñamos una tabla hash, la tabla hash debe tener las siguientes características:

  • Admite operaciones rápidas de consulta, inserción y eliminación;
  • El uso de la memoria es razonable y no se puede desperdiciar demasiado espacio de memoria;
  • El rendimiento es estable y, en casos extremos, el rendimiento de la tabla hash no se degradará a un nivel inaceptable.

Necesitamos establecer una función hash adecuada, establecer un umbral de factor de carga razonable, diseñar una estrategia de expansión dinámica y elegir un método de resolución de conflictos hash adecuado.

Supongo que te gusta

Origin blog.csdn.net/m0_55752775/article/details/129434945
Recomendado
Clasificación