Hashing y tablas hash
"Un buen hash convierte cualquier cosa en un número. Un buen número te lleva directo a su lugar."
Qué vas a aprender en este capítulo
Cierre del libro y la estructura más útil de la programación práctica: la tabla hash. Está detrás de los dict de Python, los HashMap de Java, los caches HTTP, las bases de datos NoSQL clave-valor, y miles de aplicaciones más. Vas a entender qué es una función hash, cómo se construye una tabla hash, qué pasa con las colisiones, las dos estrategias clásicas para manejarlas, y la diferencia con el hashing criptográfico que usan SHA y bcrypt.
4.1 La idea: un número para cada cosa
💡 Intuición
Tenés un libro de 1000 páginas y necesitás encontrar la palabra "pupusa". Si fuera lista plana → recorrer hasta 1000 líneas. Si fuera ordenado alfabético → búsqueda binaria, ~10 saltos. Si fuera un diccionario con índice por letra → ir directo a "p", después a "pu", listo.
Una tabla hash es esa idea generalizada: una función hash convierte la palabra en un índice de array. Vas directo a esa posición, sin recorrer ni comparar.
"pupusa" ──→ función hash ──→ 4731 ──→ array[4731 % tamaño]
Una tabla hash transforma la pregunta "¿está esta clave?" de recorrer todo () a calcular un número e ir directo ( promedio).
Ese salto cualitativo — de lineal a constante — es el motivo por el que dicts son tan amados. Casi todo programa moderno usa tablas hash decenas de veces por segundo.
4.2 Función hash
📐 Fundamento
Una función hash es — toma una clave de cualquier tipo y devuelve un entero.
Propiedades deseables:
- Determinismo. Misma clave → mismo hash, siempre.
- Rapidez. o para una clave de longitud .
- Distribución uniforme. Hashes parecen aleatorios — bajan colisiones.
- Buen efecto avalancha. Cambio chico en la clave → cambio grande en el hash.
No es: un hash NO es único — dos claves pueden hashear al mismo número (colisión). Es matemáticamente inevitable cuando hay más claves posibles que números (siempre).
Hashes en Python:
hash("ana") # algún entero
hash(42) # 42 (Python hashea ints a sí mismos)
hash((1, 2, 3)) # tupla hashable
hash([1, 2, 3]) # TypeError — listas no son hashables
Python randomiza el hash de strings al iniciar el proceso (PYTHONHASHSEED). Eso evita que un atacante envíe claves diseñadas para colisionar (ataque de denegación de servicio).
Funciones hash simples
Modular para enteros:
def hash_int(x, tabla_size):
return x % tabla_size
Funciona bien si tabla_size es primo (mejor distribución).
Polinomial para strings:
def hash_str(s, tabla_size):
h = 0
for c in s:
h = (h * 31 + ord(c)) % tabla_size
return h
El "31" es típico (es primo y tiene buenas propiedades de bits). Java usa 31 para String.hashCode(). Eso te da una idea de qué constantes funcionan bien.
4.3 Tabla hash básica
📐 Fundamento
Idea: un array tabla de tamaño . Para guardar (clave, valor):
- Calculás
pos = hash(clave) % m. - Guardás
(clave, valor)entabla[pos].
Para buscar clave: vas a tabla[hash(clave) % m]. Comparás la clave (¡puede haber colisión!).
class TablaHash:
def __init__(self, m=8):
self.m = m
self.tabla = [None] * m
self.n = 0
def _hash(self, k):
return hash(k) % self.m
def put(self, k, v):
i = self._hash(k)
# Sin manejo de colisiones (ingenuo)
self.tabla[i] = (k, v)
self.n += 1
def get(self, k):
i = self._hash(k)
entrada = self.tabla[i]
if entrada is None or entrada[0] != k:
return None
return entrada[1]
Esto funciona... hasta la primera colisión. Si dos claves caen en la misma posición, la última pisa la primera. Necesitás resolver colisiones.
4.4 Resolución de colisiones: chaining
📐 Fundamento
Chaining (encadenamiento): cada celda de la tabla guarda una lista de pares (clave, valor) que cayeron ahí.
class TablaHashChaining:
def __init__(self, m=8):
self.m = m
self.tabla = [[] for _ in range(m)]
self.n = 0
def put(self, k, v):
i = hash(k) % self.m
for j, (kk, _) in enumerate(self.tabla[i]):
if kk == k:
self.tabla[i][j] = (k, v) # actualizar
return
self.tabla[i].append((k, v))
self.n += 1
def get(self, k):
i = hash(k) % self.m
for kk, v in self.tabla[i]:
if kk == k: return v
return None
def remove(self, k):
i = hash(k) % self.m
for j, (kk, _) in enumerate(self.tabla[i]):
if kk == k:
self.tabla[i].pop(j)
self.n -= 1
return True
return False
Análisis:
- En el caso ideal (distribución uniforme), cada chain tiene en promedio elementos.
- se llama factor de carga (load factor), .
- Operaciones: promedio.
- Si es chico (digamos < 1), promedio .
- Peor caso: todas las claves caen en la misma celda → .
Mantenimiento: cuando supera un umbral (típicamente 0.75), rehash: crear una tabla más grande y migrar todo. Costo pero amortizado por operación.
4.5 Resolución de colisiones: open addressing
📐 Fundamento
Open addressing (direccionamiento abierto): todos los pares viven directo en el array, sin listas. Si hay colisión, buscás la próxima celda libre.
Linear probing:
def put(self, k, v):
i = hash(k) % self.m
while self.tabla[i] is not None:
if self.tabla[i][0] == k:
self.tabla[i] = (k, v) # actualizar
return
i = (i + 1) % self.m
self.tabla[i] = (k, v)
self.n += 1
Variantes:
- Linear:
- Quadratic:
- Double hashing:
Pros: mejor uso de cache (todo está en un solo array, contiguo).
Cons: clustering — colisiones tienden a juntarse y degradar performance. Linear probing es el peor; quadratic y double hashing mitigan.
Eliminación es complicada. Si simplemente borrás una celda, podés "romper" la cadena de probing para otras claves. Solución: marcar como tombstone (lápida) en lugar de borrar.
Cuál usar. Las implementaciones reales:
- Java HashMap: chaining (con conversión a árbol Red-Black si una bucket pasa de 8 elementos).
- Python dict: open addressing con probing inteligente.
- C++ std::unordered_map: chaining.
Cada DBMS y lenguaje hace sus propias optimizaciones. La elección no es absoluta.
4.6 Aplicaciones
🛠️ En la práctica
Deduplicación
def unicos(items):
visto = set()
resultado = []
for x in items:
if x not in visto: # set: lookup O(1)
visto.add(x)
resultado.append(x)
return resultado
Sin set: . Con set: .
Two sum
Dado un array y un target , encontrar dos elementos que sumen .
def two_sum(nums, target):
visto = {}
for i, n in enumerate(nums):
if target - n in visto:
return [visto[target - n], i]
visto[n] = i
return None
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
tiempo, espacio. La versión naive () es dos bucles anidados.
Cache (memoización)
@functools.lru_cache(maxsize=128)
def fib(n):
if n < 2: return n
return fib(n - 1) + fib(n - 2)
@lru_cache usa dict + lista doblemente enlazada internamente — la combinación clásica de caché LRU.
Anagrama
def anagrama(a, b):
return sorted(a) == sorted(b)
# o: Counter(a) == Counter(b) ← más rápido O(n)
Longest substring sin repetir
def longest_unique(s):
visto = {}
inicio = 0
mejor = 0
for fin, c in enumerate(s):
if c in visto and visto[c] >= inicio:
inicio = visto[c] + 1
visto[c] = fin
mejor = max(mejor, fin - inicio + 1)
return mejor
print(longest_unique("abcabcbb")) # 3 ("abc")
Sliding window con dict para encontrar substrings sin caracteres repetidos. .
4.7 Hashing criptográfico
📐 Fundamento
Hash criptográfico (SHA-256, MD5, SHA-3) tiene requisitos extras:
- Resistencia a preimagen. Dado , encontrar debe ser inviable.
- Resistencia a segunda preimagen. Dado , encontrar con debe ser inviable.
- Resistencia a colisiones. Encontrar cualquier con debe ser inviable.
Eso los hace lentos (decenas de veces más que un hash de tabla) — porque la lentitud frustra ataques por fuerza bruta.
Distinción crucial:
| Hash para tablas | Hash criptográfico | |
|---|---|---|
| Ejemplos | Java's hashCode, Python's hash | SHA-256, bcrypt |
| Velocidad | Microsegundos | Milisegundos o más |
| Output | 32 bits típico | 256 bits |
| Para qué | Indexar | Verificar / firmar |
Nunca uses MD5 ni SHA-1 para seguridad nueva. Están rotos (se han encontrado colisiones). Para passwords usá bcrypt, argon2, scrypt — están diseñados para ser lentos y resistentes a hardware específico.
import hashlib
# Para integridad / firma
h = hashlib.sha256(b"datos").hexdigest()
print(h) # 64 caracteres hex
# Para passwords (bcrypt; pip install bcrypt)
import bcrypt
hashed = bcrypt.hashpw(b"contrasena", bcrypt.gensalt())
print(bcrypt.checkpw(b"contrasena", hashed)) # True
4.8 Bloom filters (vista rápida)
📐 Fundamento
Un filtro de Bloom es una estructura probabilística para test de pertenencia con memoria mínima y falsos positivos (pero nunca falsos negativos).
Cómo funciona: un array de bits de posiciones, funciones hash. Para insertar x, setear los bits en posiciones . Para test, chequear que todos esos bits están en 1.
- Si alguno es 0: no está (seguro).
- Si todos son 1: probablemente está (chance de falso positivo).
Ventaja: memoria diminuta. Para 1 millón de elementos con 1% falsos positivos, ~1 MB. Una tabla hash igual ocuparía decenas de MB.
Aplicaciones:
- Detectar URLs visitadas (Chrome).
- Cache miss reducer (el bloom filter dice "no está en cache" sin tocar disco).
- Spell checkers ligeros.
- Bases de datos: descartar lecturas innecesarias.
(No los implementamos a fondo aquí; se ven en cursos de algoritmos avanzados.)
4.9 Resumen visual
| Concepto | Una línea |
|---|---|
| Función hash | Convierte cualquier clave en entero |
| Tabla hash | Array indexado por hash |
| Colisión | Dos claves al mismo índice |
| Chaining | Cada celda es lista |
| Open addressing | Probar siguiente celda |
| Factor de carga | |
| Rehash | Crecer la tabla cuando alto |
| Hash criptográfico | Lento, seguro, output largo |
| Bloom filter | Pertenencia con memoria mínima, falsos positivos |
4.10 Cierre del libro
Llegaste al final de Estructuras de Datos y Algoritmos. Tu repertorio:
- Complejidad — el lenguaje universal para hablar de eficiencia.
- Listas enlazadas, pilas, colas — estructuras lineales.
- Árboles — jerarquía y búsqueda logarítmica.
- Hashing — lookup en tiempo constante promedio.
Dominaste las estructuras que la mayoría de programadores usa todos los días. En cualquier lenguaje, en cualquier framework, vas a reconocerlas.
Lo que sigue:
- Algoritmos avanzados: programación dinámica, grafos (Dijkstra, MST, flujos), strings (KMP, Boyer-Moore), geometría computacional.
- Análisis de algoritmos: demostraciones formales, recurrencias.
- Investigación: conferencias como STOC, FOCS, SODA — frontera del conocimiento.
Y aplicado:
- Bases de datos: índices son árboles + hashing.
- Compiladores: árboles de sintaxis, tablas de símbolos (hashing), grafos.
- Sistemas operativos: todos los temas se conectan.
- AI / ML: árboles de decisión, grafos de cómputo.
4.11 Ejercicios
✏️ Ejercicio 4.1 — Implementar contador
Implementá un contador de palabras: dado un texto, contá cuántas veces aparece cada palabra.
Solución
def contar_palabras(texto):
conteo = {}
for palabra in texto.split():
conteo[palabra] = conteo.get(palabra, 0) + 1
return conteo
# O con Counter:
from collections import Counter
def contar_palabras(texto):
return Counter(texto.split())
Counter es un dict especializado para contar.
✏️ Ejercicio 4.2 — Detectar duplicados (con tolerancia)
Dada lista de strings, agruparlas por anagrama. Por ejemplo: ["sano", "ansa", "noas", "perro", "porre"] → [["sano", "ansa", "noas"], ["perro", "porre"]].
Solución
from collections import defaultdict
def agrupar_anagramas(lista):
grupos = defaultdict(list)
for s in lista:
clave = ''.join(sorted(s)) # firma del anagrama
grupos[clave].append(s)
return list(grupos.values())
print(agrupar_anagramas(["sano", "ansa", "noas", "perro", "porre"]))
La clave es derivar una representación canónica (sorted) que dos anagramas comparten. Hashing implícito por dict.
✏️ Ejercicio 4.3 — Implementar tabla hash con chaining
Implementá una clase MiHash con put, get, remove, __len__, __contains__. Usá chaining. Implementá rehash al superar factor de carga 0.75.
Solución
class MiHash:
def __init__(self, m=8):
self.m = m
self.tabla = [[] for _ in range(m)]
self.n = 0
def __len__(self):
return self.n
def __contains__(self, k):
return self.get(k) is not None
def _bucket(self, k):
return hash(k) % self.m
def get(self, k):
for kk, v in self.tabla[self._bucket(k)]:
if kk == k: return v
return None
def put(self, k, v):
b = self._bucket(k)
for i, (kk, _) in enumerate(self.tabla[b]):
if kk == k:
self.tabla[b][i] = (k, v)
return
self.tabla[b].append((k, v))
self.n += 1
if self.n / self.m > 0.75:
self._rehash()
def remove(self, k):
b = self._bucket(k)
for i, (kk, _) in enumerate(self.tabla[b]):
if kk == k:
self.tabla[b].pop(i)
self.n -= 1
return True
return False
def _rehash(self):
viejo = self.tabla
self.m *= 2
self.tabla = [[] for _ in range(self.m)]
self.n = 0
for bucket in viejo:
for k, v in bucket:
self.put(k, v)
h = MiHash()
for i in range(100):
h.put(f"k{i}", i)
print(len(h), h.m) # 100, 256 (creció varias veces)
print(h.get("k50")) # 50
print("k50" in h) # True
h.remove("k50")
print("k50" in h) # False
✏️ Ejercicio 4.4 — LRU cache
Implementá LRUCache con get(key) y put(key, value), ambos , capacidad máxima. Cuando se supera, descarta el menos recientemente usado.
Solución
Combinación clásica: dict + lista doblemente enlazada.
class Nodo:
def __init__(self, k, v):
self.k, self.v = k, v
self.prev = self.next = None
class LRUCache:
def __init__(self, cap):
self.cap = cap
self.tabla = {}
self.head = Nodo(None, None) # más reciente
self.tail = Nodo(None, None) # menos reciente
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, n):
n.prev.next = n.next
n.next.prev = n.prev
def _push_front(self, n):
n.next = self.head.next
n.prev = self.head
self.head.next.prev = n
self.head.next = n
def get(self, k):
if k not in self.tabla: return None
n = self.tabla[k]
self._remove(n)
self._push_front(n)
return n.v
def put(self, k, v):
if k in self.tabla:
n = self.tabla[k]
n.v = v
self._remove(n); self._push_front(n)
return
if len(self.tabla) >= self.cap:
lru = self.tail.prev
self._remove(lru)
del self.tabla[lru.k]
n = Nodo(k, v)
self.tabla[k] = n
self._push_front(n)
cache = LRUCache(2)
cache.put("a", 1)
cache.put("b", 2)
print(cache.get("a")) # 1 (queda como más reciente)
cache.put("c", 3) # desalojará "b"
print(cache.get("b")) # None
Diseño usado por Redis, Memcached, browsers, kernel page cache. Conocer este pattern es importante.
4.12 Para profundizar
- CLRS cap 11 (hashing).
- Donald Knuth, The Art of Computer Programming, Vol 3 — sección de hashing es histórica.
- Bloom filters explicados: https://llimllib.github.io/bloomfilter-tutorial/
- Cursos siguientes: Algoritmos Avanzados (programación dinámica, grafos).
Definiciones nuevas: función hash, tabla hash, colisión, chaining, open addressing, linear/quadratic/double probing, factor de carga, rehash, hash criptográfico, bloom filter, LRU cache.