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 (O(n)O(n)) a calcular un número e ir directo (O(1)O(1) 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 h:KZh: K \to \mathbb{Z} — toma una clave de cualquier tipo y devuelve un entero.

Propiedades deseables:

  1. Determinismo. Misma clave → mismo hash, siempre.
  2. Rapidez. O(1)O(1) o O(L)O(L) para una clave de longitud LL.
  3. Distribución uniforme. Hashes parecen aleatorios — bajan colisiones.
  4. 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 mm. Para guardar (clave, valor):

  1. Calculás pos = hash(clave) % m.
  2. Guardás (clave, valor) en tabla[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 n/mn/m elementos.
  • n/mn/m se llama factor de carga (load factor), α\alpha.
  • Operaciones: O(1+α)O(1 + \alpha) promedio.
  • Si α\alpha es chico (digamos < 1), promedio O(1)O(1).
  • Peor caso: todas las claves caen en la misma celda → O(n)O(n).

Mantenimiento: cuando α\alpha supera un umbral (típicamente 0.75), rehash: crear una tabla más grande y migrar todo. Costo O(n)O(n) pero amortizado O(1)O(1) 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: h(k)+1,h(k)+2,h(k) + 1, h(k) + 2, \ldots
  • Quadratic: h(k)+1,h(k)+4,h(k)+9,h(k) + 1, h(k) + 4, h(k) + 9, \ldots
  • Double hashing: h(k)+g(k),h(k)+2g(k),h(k) + g(k), h(k) + 2 g(k), \ldots

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: O(n2)O(n^2). Con set: O(n)O(n).

Two sum

Dado un array y un target TT, encontrar dos elementos que sumen TT.

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]

O(n)O(n) tiempo, O(n)O(n) espacio. La versión naive (O(n2)O(n^2)) 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. O(n)O(n).

4.7 Hashing criptográfico

📐 Fundamento

Hash criptográfico (SHA-256, MD5, SHA-3) tiene requisitos extras:

  1. Resistencia a preimagen. Dado h(x)h(x), encontrar xx debe ser inviable.
  2. Resistencia a segunda preimagen. Dado xx, encontrar yxy \neq x con h(x)=h(y)h(x) = h(y) debe ser inviable.
  3. Resistencia a colisiones. Encontrar cualquier xyx \neq y con h(x)=h(y)h(x) = h(y) 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 mm posiciones, kk funciones hash. Para insertar x, setear los bits en posiciones h1(x),h2(x),,hk(x)h_1(x), h_2(x), \ldots, h_k(x). Para test, chequear que todos esos bits están en 1.

  • Si alguno es 0: xx no está (seguro).
  • Si todos son 1: xx 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 α\alpha n/mn / m
Rehash Crecer la tabla cuando α\alpha 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:

  1. Complejidad — el lenguaje universal para hablar de eficiencia.
  2. Listas enlazadas, pilas, colas — estructuras lineales.
  3. Árboles — jerarquía y búsqueda logarítmica.
  4. 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:

Y aplicado:

4.11 Ejercicios

✏️ Ejercicio 4.1 — Implementar contador

Implementá un contador de palabras: dado un texto, contá cuántas veces aparece cada palabra.

✏️ 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"]].

✏️ 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.

✏️ Ejercicio 4.4 — LRU cache

Implementá LRUCache con get(key) y put(key, value), ambos O(1)O(1), capacidad máxima. Cuando se supera, descarta el menos recientemente usado.

4.12 Para profundizar


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.