Hashing de strings
Los algoritmos de hashing son útiles para resolver muchos problemas.
Queremos resolver el problema de comparar strings de forma eficiente. La forma por fuerza bruta es simplemente comparar las letras de ambos strings, lo que tiene complejidad temporal si y son los tamaños de los dos strings. Queremos hacerlo mejor. La idea detrás del hashing de strings es la siguiente: mapeamos cada string a un entero y comparamos esos enteros en lugar de los strings. Hacer esto nos permite reducir el tiempo de ejecución de la comparación de strings a .
Para la conversión necesitamos una función de hash (hash function). Su objetivo es convertir un string en un entero, el llamado hash del string. Tiene que cumplirse la siguiente condición: si dos strings y son iguales (), entonces sus hashes también tienen que ser iguales (). En caso contrario, no podremos comparar strings.
Nótese que la dirección contraria no tiene por qué cumplirse. Si los hashes son iguales (), entonces los strings no tienen por qué ser necesariamente iguales. Por ejemplo, una función de hash válida sería simplemente para cada . Ahora, esto es solo un ejemplo tonto, porque esta función será completamente inútil, pero es una función de hash válida. La razón por la que la dirección contraria no tiene por qué cumplirse es que hay exponencialmente muchos strings. Si solo queremos que esta función de hash distinga entre todos los strings formados por caracteres en minúscula de longitud menor que 15, entonces el hash ya no cabría en un entero de 64 bits (p. ej. unsigned long long), porque hay demasiados. Y, por supuesto, no queremos comparar enteros arbitrariamente largos, porque eso también tendría complejidad .
Así que, por lo general, queremos que la función de hash mapee los strings a números de un rango fijo ; entonces comparar strings es simplemente comparar dos enteros de longitud fija. Y, por supuesto, queremos que sea muy probable si .
Esa es la parte importante que hay que tener presente. Usar hashing no será 100% correcto de forma determinista, porque dos strings completamente distintos pueden tener el mismo hash (los hashes colisionan). Sin embargo, en una amplia mayoría de tareas esto se puede ignorar con seguridad, ya que la probabilidad de que colisionen los hashes de dos strings distintos sigue siendo muy pequeña. Y en este artículo discutiremos algunas técnicas para mantener muy baja la probabilidad de colisiones.
Cálculo del hash de un string
La forma buena y ampliamente usada de definir el hash de un string de longitud es
donde y son algunos números positivos elegidos. Se llama función de hash polinómico rolling (polynomial rolling hash).
Es razonable tomar como un número primo aproximadamente igual a la cantidad de caracteres del alfabeto de entrada. Por ejemplo, si la entrada está compuesta solo por letras minúsculas del alfabeto inglés, es una buena elección. Si la entrada puede contener letras mayúsculas y minúsculas, entonces es una elección posible. El código de este artículo usará .
Obviamente debería ser un número grande, ya que la probabilidad de que colisionen dos strings aleatorios es aproximadamente . A veces se elige , porque entonces los desbordamientos de enteros de 64 bits funcionan exactamente como la operación módulo. Sin embargo, existe un método que genera strings que colisionan (y funciona independientemente de la elección de ). Así que, en la práctica, no se recomienda . Una buena elección para es algún número primo grande. El código de este artículo usará simplemente . Este es un número grande, pero todavía lo bastante chico como para poder multiplicar dos valores usando enteros de 64 bits.
Acá hay un ejemplo de cómo calcular el hash de un string que contiene solo letras minúsculas. Convertimos cada carácter de a un entero. Acá usamos la conversión , , , . Convertir no es una buena idea, porque entonces los hashes de los strings , , , todos evalúan a .
long long compute_hash(string const& s) {
const int p = 31;
const int m = 1e9 + 9;
long long hash_value = 0;
long long p_pow = 1;
for (char c : s) {
hash_value = (hash_value + (c - 'a' + 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return hash_value;
}Precomputar las potencias de puede dar una mejora de rendimiento.
Tareas de ejemplo
Buscar strings duplicados en un arreglo de strings
Problema: Dada una lista de strings , cada uno de no más de caracteres, encontrar todos los strings duplicados y dividirlos en grupos.
Con el algoritmo obvio que ordena los strings, obtendríamos una complejidad temporal de , donde el ordenamiento requiere comparaciones y cada comparación tarda . Sin embargo, usando hashes reducimos el tiempo de comparación a , lo que nos da un algoritmo que corre en tiempo .
Calculamos el hash de cada string, ordenamos los hashes junto con los índices, y después agrupamos los índices por hashes idénticos.
vector<vector<int>> group_identical_strings(vector<string> const& s) {
int n = s.size();
vector<pair<long long, int>> hashes(n);
for (int i = 0; i < n; i++)
hashes[i] = {compute_hash(s[i]), i};
sort(hashes.begin(), hashes.end());
vector<vector<int>> groups;
for (int i = 0; i < n; i++) {
if (i == 0 || hashes[i].first != hashes[i-1].first)
groups.emplace_back();
groups.back().push_back(hashes[i].second);
}
return groups;
}Cálculo rápido del hash de subcadenas de un string dado
Problema: Dado un string e índices y , encontrar el hash de la subcadena .
Por definición, tenemos:
Al multiplicar por obtenemos:
Así, conociendo el valor del hash de cada prefijo del string , podemos calcular el hash de cualquier subcadena directamente con esta fórmula. El único problema que enfrentamos al calcularlo es que debemos poder dividir por . Por lo tanto hay que encontrar el inverso multiplicativo modular de y después multiplicar por ese inverso. Podemos precomputar el inverso de cada , lo que permite calcular el hash de cualquier subcadena de en tiempo .
Sin embargo, existe una forma más fácil. En la mayoría de los casos, en lugar de calcular los hashes de las subcadenas de forma exacta, basta con calcular el hash multiplicado por alguna potencia de . Supongamos que tenemos dos hashes de dos subcadenas, uno multiplicado por y el otro por . Si entonces multiplicamos el primer hash por ; en caso contrario, multiplicamos el segundo hash por . Al hacer esto, obtenemos ambos hashes multiplicados por la misma potencia de (que es el máximo de y ) y ahora estos hashes se pueden comparar fácilmente sin necesidad de ninguna división.
Aplicaciones del hashing
Acá hay algunas aplicaciones típicas del hashing:
- Algoritmo de Rabin-Karp para búsqueda de patrones en un string en tiempo
- Calcular el número de subcadenas distintas de un string en (ver más abajo)
- Calcular el número de subcadenas palindrómicas de un string.
Determinar el número de subcadenas distintas de un string
Problema: Dado un string de longitud , formado solo por letras minúsculas inglesas, encontrar el número de subcadenas distintas de este string.
Para resolver este problema, iteramos sobre todas las longitudes de subcadena . Para cada longitud de subcadena construimos un arreglo de hashes de todas las subcadenas de longitud multiplicados por la misma potencia de . El número de elementos distintos en el arreglo es igual al número de subcadenas distintas de longitud en el string. Este número se suma a la respuesta final.
Por conveniencia, usaremos como el hash del prefijo con caracteres, y definimos .
int count_unique_substrings(string const& s) {
int n = s.size();
const int p = 31;
const int m = 1e9 + 9;
vector<long long> p_pow(n);
p_pow[0] = 1;
for (int i = 1; i < n; i++)
p_pow[i] = (p_pow[i-1] * p) % m;
vector<long long> h(n + 1, 0);
for (int i = 0; i < n; i++)
h[i+1] = (h[i] + (s[i] - 'a' + 1) * p_pow[i]) % m;
int cnt = 0;
for (int l = 1; l <= n; l++) {
unordered_set<long long> hs;
for (int i = 0; i <= n - l; i++) {
long long cur_h = (h[i + l] + m - h[i]) % m;
cur_h = (cur_h * p_pow[n-i-1]) % m;
hs.insert(cur_h);
}
cnt += hs.size();
}
return cnt;
}Nótese que no es la mejor complejidad temporal posible para este problema. Una solución en se describe en el artículo sobre arreglos de sufijos, y hasta es posible calcularlo en usando un árbol de sufijos o un autómata de sufijos.
Mejorar la probabilidad de no colisión
Muy a menudo el hash polinómico mencionado arriba es suficientemente bueno, y no habrá colisiones durante los casos de prueba. Recordemos que la probabilidad de que ocurra una colisión es solo . Para la probabilidad es , que es bastante baja. Pero nótese que solo hicimos una comparación. ¿Qué pasa si comparamos un string con strings distintos? La probabilidad de que ocurra al menos una colisión es ahora . Y si queremos comparar strings distintos entre sí (p. ej. contando cuántos strings únicos existen), entonces la probabilidad de que ocurra al menos una colisión ya es . Está prácticamente garantizado que esta tarea terminará con una colisión y devolverá el resultado incorrecto.
Hay un truco realmente fácil para obtener mejores probabilidades. Podemos simplemente calcular dos hashes distintos para cada string (usando dos distintos, y/o distintos ) y comparar esos pares en su lugar. Si es del orden de para cada una de las dos funciones de hash, entonces esto es más o menos equivalente a tener una sola función de hash con . Al comparar strings entre sí, la probabilidad de que ocurra al menos una colisión queda reducida a .
Problemas de práctica
- Good Substrings - Codeforces
- A Needle in the Haystack - SPOJ
- String Hashing - Kattis
- Double Profiles - Codeforces
- Password - Codeforces
- SUB_PROB - SPOJ
- INSQ15_A
- SPOJ - Ada and Spring Cleaning
- GYM - Text Editor
- 12012 - Detection of Extraterrestrial
- Codeforces - Games on a CD
- UVA 11855 - Buzzwords
- Codeforces - Santa Claus and a Palindrome
- Codeforces - String Compression
- Codeforces - Palindromic Characteristics
- SPOJ - Test
- Codeforces - Palindrome Degree
- Codeforces - Deletion of Repeats
- HackerRank - Gift Boxes