El término hashing se refiere en realidad a un tipo de función con un fin dado (que vamos a ver más adelante) que se llaman funciones hash. Ésta es una función que dado un algoritmo mapea, un set de datos de longitud variable a otro set fijo.
Por ejemplo, dado un nombre de persona (que obviamente es variable), una función nos genera un número.
El número generado es lo que se llama hashcode.
Y se preguntarán para qué sirve esto ? Tiene varias aplicaciones, no vamos a ver todas nosotros, de hecho solo una, pero va acá una lista:
Para que la función sea considerada y se utiliza como una función hash debe cumplir algunas condiciones.
La principal es que sea:
Ejemplo:
hash("Hola Mundo") -> 99123545
hash("Hola Mundo") -> 99123545
Por otro lado, dependiendo de la aplicación, es decir para qué se utilice (como vimos antes tiene varias aplicaciones) podría tener otros requerimientos.
Para mezclar un poco de teoría con algo de código, veamos como sería eso de calcular el hash de un objeto en Python.
Existe una función que ya calcula el hash de un objeto:
print hash("Hola Mundo")
print hash("Hola Mundo")
Produce:
5087365090501027439
5087365090501027439
Sin embargo podemos hacer nuestra propia función de hash. Acá una muy simple para strings:
def hashMuyPavo(unString):
return ord(unString[0]) / len(unString)
Nota: ord(caracter) es una función que la representación numérica de un caracter.
Entonces veamos para qué es que querríamos tener esta función hash que dado un objeto de entrada me genera un número.
Como ya vimos al tratar otras estructuras como listas enlazadas, algunas operaciones sobre los elementos de las estructuras suelen ser costosas, por ejemplo con complejidad lineal, O(n), donde el tiempo se incrementará con la cantidad de elementos.
Entonces, existen implementaciones que utilizan funciones de hash para implementar operaciones en forma constante, es decir O(k), que de otra manera serían O(n).
El ejemplo:
Entones, para resolver esto, existe una estructura llamada Hash Table (o tabla de hash), que consiste en almacenar los elementos de entrada utilizando el hashcode como índice de la posición en la que ocupará.
Veamos acá un ejemplo para la implementación de un Diccionario como hashtable:
La ventaja es que ahora ubicar a "John Smith" por ejempo, no requiere recorrer todos los 153 elementos anteriores y comparar 153 veces.
Veamos un ejemplo bien pavo en python. No vamos a implementar una estructura de datos completa, pero mostramos una simplificación para que se den una idea:
Definimos operaciones para crear una lista que va a representar nuestra nueva estructura, llamada HashSet
def crearHashSet(): pass
def agregar(hashset, elemento): pass
def contiene(hashset, elemento): pass
Solo tendremos dos operaciones básicas, el agregar un elemento, y el contiene que sirve para checkear si el elemento dado está en el set.
Entonces esto lo usaríamos así:
s = crearHashSet()
agregar(s, "Hola")
print 'Contiene Hola:', contiene(s, "Hola")
print 'Contiene Chau:', contiene(s, "Chau")
Lo cual imprime:
Contiene Hola: True
Contiene Chau: False
Las implementaciones de Lista que vimos hasta ahora, deberían recorrer todos los elementos e ir comparando hasta el final, o bien encontrar el elemento. Con costo lineal.
Una implementación muy simple que utiliza hashing sería:
def crearHashSet():
l = []
for _ in range(200):
l.append(None)
return l
def agregar(hashset, elemento):
hashset[hashMuyPavo(elemento)] = elemento
def contiene(hashset, elemento):
return hashset[hashMuyPavo(elemento)] == elemento
Lo interesante acá está en la implementación del contiene. Estamos usando la función hash simple que hicimos antes más arriba. Con lo cual este set solo va a funcionar con strings.
Lo interesante es que el contiene() no está recorriendo los elementos. Lo que hace es:
Así, solo realizó una comparación, y no tuvo que recorrer. El costo ahora es O(k) constante.
Espacio ocupado
De esto que vimos recién surge la desventaja del HashSet o HashTable, y es que, podría desperdiciar memoria. Ya que la lista tiene que tener un tamaño fijo, no importa si tiene elementos en todas las posiciones o no. En realidad la causa es que los datos suelen estar "alejados", y las posiciones entre ellos estarían vacías.
Veamos como lo utilizamos y las posiciones que realmete ocupa:
agregar(s, "Chau")
agregar(s, "Adios")
agregar(s, "Au revoir")
agregar(s, "Bye")
agregar(s, "A plus tard")
print '-' * 20
print 'Contiene Bye:', contiene(s, "Bye")
Si imprimimos la lista veremos algo así:
SET: [None, None, None, None, None, 'A plus tard', None, 'Au revoir', None, None, None, None, None, 'Adios', None, None, 'Chau', None, ....]
Tamaño de la lista
Otro problema que seguramente habrán notada es que la lista donde se guardan los elementos tiene un tamaño fijo (esa es la idea de hashtable). Ahora cómo calculamos ese tamaño ? En nuestro caso tenemos hardcodeado un 200 porque queríamos evitar encarar ese problema de entrada.
Sin embargo hay dos problemas entonces:
En nuestra implementación para el segundo caso, se va a romper, porque va a intentar acceder a una posición de la lista que no existe.
Existen diferentes estrategias para resolver esto, nombramos algunas comunes acá:
Nuestra nueva implementación con mod, para que no se rompa ante hashcodes muy grandes sería:
def crearHashSet():
return [None for _ in range(200)]
def agregar(hashset, elemento):
hashCode = hashMuyPavo(elemento) % 200
hashset[hashCode] = elemento
def contiene(hashset, elemento):
hashCode = hashMuyPavo(elemento) % 200
return hashset[hashCode] == elemento
De paso cambiamos la forma en la que se crea la lista para utilizar listas por comprensión.
Con esta solución igualmente vamos a tener el siguiente problema, de hecho incrementamos la posibilidad de...
Colisiones
Podemos identificar otro problema mirando la implementación de nuestra función de hash. Así, a simple vista, vemos que sería fácil reproducir un caso donde dos strings distintos producen el mismo hash.
Se tienen que dar dos condiciones, dado el string A, y el B
Ejemplo:
print "Chau=", hashMuyPavo("Chau")
print "Ciao=", hashMuyPavo("Ciao")
Produce:
Chau= 16
Ciao= 16
Esto va a ser un problema en nuestra implementación simple de HashSet.
Veamos:
s = crearHashSet()
agregar(s, "Chau")
print 'Contiene Chau:', contiene(s, "Chau") # BOOM!
agregar(s, "Ciao")
print 'Contiene Ciao:', contiene(s, "Ciao")
print 'Contiene Chau:', contiene(s, "Chau") # BOOM!
Esto produce:
Contiene Chau: True
Contiene Ciao: True
Contiene Chau: False
Como vemos al agregar el nuevo elemento "Ciao" que tiene el mismo hashcode que "Chau", se está pisando el anterior. A esto se lo denomina colisión.
Todas las funciones de hash que mapean un conjunto de entrada más grande que el de salida, van a producir colisiones. Lógicamente, porque estamos tratando de mapear por ejemplo 5.000 nombres de personas, a un array fijo de 200 posiciones, por ejemplo.
Entonces por un lado se intentará utilizar funciones uniformes, para evitar colisiones incluso cuando todavía queda espacio en la estructura.
Sin embargo esto no soluciona el problema. Entonces, otra estrategia muy común es modificar nuestro HashSet para que cada posición de la lista no contenga un único elemento, sino que contendrá a su vez una lista. Entonces ahora si, en cada posición podrá haber más de un elemento, sin que se pisen. Si dice que nuestra lista ahora tiene buckets.
Acá vemos un diagrama ejemplificador:
Hagamos entonces una nueva implementación de nuestro HashSet. Lo primero que definimos es cómo queremos que funcione:
s = crearHashSet(hashMuyPavo)
agregar(s, "Chau")
agregar(s, "Ciao")
print 'Contiene Chau?', contiene(s, "Chau")
print 'Contiene Ciao?', contiene(s, "Ciao")
Esperamos que esto de: True y True
Y acá la implementación con algunas mejoras adicionales respecto de la anterior.
Por un lado vamos a definir una clase (struct) para evitar usar la lista diréctamente:
class HashSetConBuckets:
def __init__(self, funcionHash):
self.funcionHash = funcionHash
self.buckets = [ [] for _ in range(200) ]
Acá notamos que nuestra clase tiene dos elementos:
Luego definimos el crear, agregar y contiene:
def crearHashSet(funcionHash):
return HashSetConBuckets(funcionHash)
def hashCodeDe(hashset, elemento):
return hashset.funcionHash(elemento) % len(hashset.buckets)
def agregar(hashset, elemento):
hashCode = hashCodeDe(hashset, elemento)
hashset.buckets[hashCode].append(elemento)
def contiene(hashset, elemento):
hashCode = hashCodeDe(hashset, elemento)
bucket = hashset.buckets[hashCode]
return elemento in bucket # aca recorreriamos comparando
Como calcular el hashcode se hizo un poco complejo lo llevamos a otra función chiquita hashCodeDe que dado un set, y un elemento, retorna el hashcode del elemento. Para esto, utiliza la función de hash que se había pasado al HashSet al momento de construirlo. Además al resultado le aplica el modulo de la división por el tamaño de la lista de buckets, en lugar de hardcodera el 200 :)
Pero lo más interesante está en el agregar y el contiene.
De esto se desprende que, si bien ahora nuestro set soporta varios elementos con mismo hashcode, tenemos un costo de tiempo de procesamiento. Sin embargo sigue siendo una mejor solución que tener una lista única y recorrerla toda porque:
HashMap es una implementación del TDA Diccionario, que utiliza funciones de hash para almacenar los elementos utilizando el hashcode como índice. En definitiva la idea es la misma que vimos antes en nuestra implementación de HashSet. La diferencia es que:
Acá vemos un diagrama ejemplo de implementación de HashMap:
A continuación una lista de características "deseables" (no obligatorias) para las funciones de hash.