Guía Completa de Tablas Hash en Python
Introducción
Las tablas hash son una de las estructuras de datos más eficientes para operaciones de búsqueda, inserción y eliminación en tiempo constante amortizado. En Python, la clase dict es una implementación interna de una tabla hash altamente optimizada. Sin embargo, comprender su funcionamiento interno y saber crear una versión propia resulta esencial para:
- Entender los conceptos de colisión y factor de carga.
- Diseñar soluciones a medida (p.ej., claves no hashables, persistencia en disco).
- Optimizar casos de uso críticos donde el comportamiento de
dictno sea suficiente.
Fundamentos de una Tabla Hash
Una tabla hash se compone de dos partes principales:
- Función hash: transforma una clave arbitraria en un índice entero dentro del rango del array subyacente.
- Mecanismo de resolución de colisiones: define cómo almacenar varios pares clave‑valor que resultan en el mismo índice.
Los dos enfoques más comunes para resolver colisiones son:
- Separate chaining (encadenamiento separado): cada posición del array contiene una lista (o una estructura más compleja) de entradas que comparten el mismo índice.
- Open addressing (direccionamiento abierto): la tabla entera se recorre siguiendo una secuencia predefinida (lineal, cuadrática, doble hashing) hasta encontrar una celda libre.
Implementación desde Cero: Separate Chaining
A continuación se muestra una implementación didáctica en Python que utiliza listas enlazadas para manejar colisiones.
class Node:
"""Nodo de la lista enlazada que almacena una pareja clave‑valor."""
__slots__ = ('key', 'value', 'next')
def __init__(self, key, value, nxt=None):
self.key = key
self.value = value
self.next = nxt
class HashTableChaining:
"""Tabla hash con encadenamiento separado.
Características:
• Redimensionamiento automático (factor de carga < 0.75).
• Función hash basada en hash() de Python + máscara.
"""
def __init__(self, initial_capacity=8):
self._capacity = max(2, initial_capacity)
self._buckets = [None] * self._capacity
self._size = 0
self._load_factor = 0.75
# ---------- Utilidades internas ----------
def _hash(self, key):
"""Devuelve el índice del bucket para key."""
return hash(key) % self._capacity
def _resize(self, new_capacity):
old_buckets = self._buckets
self._capacity = new_capacity
self._buckets = [None] * self._capacity
self._size = 0
for head in old_buckets:
while head:
self[head.key] = head.value # re‑insertar
head = head.next
# ---------- API pública ----------
def __setitem__(self, key, value):
idx = self._hash(key)
head = self._buckets[idx]
# Buscar si la clave ya existe
cur = head
while cur:
if cur.key == key:
cur.value = value
return
cur = cur.next
# Insertar nuevo nodo al frente de la lista
new_node = Node(key, value, head)
self._buckets[idx] = new_node
self._size += 1
# Redimensionar si supera el factor de carga
if self._size / self._capacity > self._load_factor:
self._resize(self._capacity * 2)
def __getitem__(self, key):
idx = self._hash(key)
cur = self._buckets[idx]
while cur:
if cur.key == key:
return cur.value
cur = cur.next
raise KeyError(key)
def __delitem__(self, key):
idx = self._hash(key)
cur = self._buckets[idx]
prev = None
while cur:
if cur.key == key:
if prev:
prev.next = cur.next
else:
self._buckets[idx] = cur.next
self._size -= 1
return
prev, cur = cur, cur.next
raise KeyError(key)
def __len__(self):
return self._size
def __contains__(self, key):
try:
_ = self[key]
return True
except KeyError:
return False
def items(self):
for head in self._buckets:
cur = head
while cur:
yield (cur.key, cur.value)
cur = cur.next
Ejemplo de uso:
ht = HashTableChaining()
ht['alice'] = 30
ht['bob'] = 25
ht['carol'] = 27
print('Edad de Bob:', ht['bob'])
print('Tamaño:', len(ht))
# Eliminación
del ht['alice']
print('Alice' in ht) # False
Esta implementación es adecuada para educación y casos donde se necesita control total sobre la estrategia de colisión.
Implementación con Open Addressing: Linear Probing
El direccionamiento abierto evita listas enlazadas y mantiene todos los datos en el propio array. El siguiente ejemplo usa linear probing y gestiona los estados EMPTY, OCCUPIED y DELETED para permitir borrados sin romper la cadena de búsqueda.
class _Slot:
__slots__ = ('key', 'value', 'state')
EMPTY, OCCUPIED, DELETED = 0, 1, 2
def __init__(self):
self.key = None
self.value = None
self.state = self.EMPTY
class HashTableOpen:
def __init__(self, capacity=8):
self._capacity = max(2, capacity)
self._table = [_Slot() for _ in range(self._capacity)]
self._size = 0
self._load_factor = 0.6
# ---------- Internos ----------
def _hash(self, key):
return hash(key) % self._capacity
def _probe(self, start_idx, key):
idx = start_idx
first_deleted = None
while True:
slot = self._table[idx]
if slot.state == _Slot.EMPTY:
return first_deleted if first_deleted is not None else idx
if slot.state == _Slot.DELETED and first_deleted is None:
first_deleted = idx
if slot.state == _Slot.OCCUPIED and slot.key == key:
return idx
idx = (idx + 1) % self._capacity
if idx == start_idx:
raise RuntimeError('Hash table full')
def _resize(self, new_capacity):
old = self._table
self._capacity = new_capacity
self._table = [_Slot() for _ in range(new_capacity)]
self._size = 0
for slot in old:
if slot.state == _Slot.OCCUPIED:
self[slot.key] = slot.value
# ---------- API ----------
def __setitem__(self, key, value):
idx = self._hash(key)
target = self._probe(idx, key)
slot = self._table[target]
if slot.state != _Slot.OCCUPIED:
self._size += 1
slot.key, slot.value, slot.state = key, value, _Slot.OCCUPIED
if self._size / self._capacity > self._load_factor:
self._resize(self._capacity * 2)
def __getitem__(self, key):
idx = self._hash(key)
start = idx
while True:
slot = self._table[idx]
if slot.state == _Slot.EMPTY:
raise KeyError(key)
if slot.state == _Slot.OCCUPIED and slot.key == key:
return slot.value
idx = (idx + 1) % self._capacity
if idx == start:
raise KeyError(key)
def __delitem__(self, key):
idx = self._hash(key)
start = idx
while True:
slot = self._table[idx]
if slot.state == _Slot.EMPTY:
raise KeyError(key)
if slot.state == _Slot.OCCUPIED and slot.key == key:
slot.state = _Slot.DELETED
self._size -= 1
return
idx = (idx + 1) % self._capacity
if idx == start:
raise KeyError(key)
def __len__(self):
return self._size
def __contains__(self, key):
try:
_ = self[key]
return True
except KeyError:
return False
Uso práctico:
ht2 = HashTableOpen()
for i in range(20):
ht2[f'clave{i}'] = i * 10
print('Valor de clave15:', ht2['clave15'])
print('Tamaño después de inserciones:', len(ht2))
del ht2['clave5']
print('¿clave5 sigue? ', 'clave5' in ht2)
Esta variante es más cache‑friendly porque los datos están contiguos en memoria, lo que suele traducirse en mejor rendimiento en entornos de alta velocidad.
Comparativa: dict vs Implementaciones Propias
| Característica | Python dict | Separate Chaining (Python) | Open Addressing (Linear Probing) |
|---|---|---|---|
| Complejidad promedio (búsqueda) | O(1) | O(1) (pero con factor de carga) | O(1) (pero peor en alta carga) |
| Complejidad peor caso | O(n) | O(n) (lista enlazada larga) | O(n) (sondeo largo) |
| Uso de memoria | Optimizado (compacto) | Mayor (punteros extra por nodo) | Moderado (un slot extra por borrado) |
| Redimensionamiento | Automático (factor ~0.66) | Manual (ejemplo 0.75) | Manual (ejemplo 0.6) |
| Seguridad contra ataques de colisión | Hash randomization (Python 3.3+) | Depende de hash() | Igual que dict |
| Orden de iteración | Preserva inserción (desde 3.7) | No garantiza orden | No garantiza orden |
En la práctica, dict supera a la mayoría de implementaciones caseras en velocidad y consumo de memoria, pero crear tu propia tabla te brinda control para:
- Persistir en disco o en estructuras de datos no‑Python.
- Implementar funciones hash personalizadas (p.ej., hash criptográfico).
- Integrar lógica de expiración de claves (TTL) directamente en la tabla.
Gestión de Colisiones y Factor de Carga
El factor de carga (load factor) se define como size / capacity. Mantenerlo bajo control evita degradaciones de rendimiento.
- En separate chaining un factor de carga de 0.75 es típico.
- En open addressing se recomienda 0.5‑0.7 para evitar sondeos largos.
Cuando el factor supera el umbral, la tabla se redimensiona (usualmente duplicando la capacidad) y se rehash todos los elementos.
Seguridad y Ataques de Denegación de Servicio (DoS) por Colisión
Los atacantes pueden intentar generar muchas claves con el mismo hash para degradar la tabla a O(n). Python mitigó este riesgo introduciendo hash randomization a partir de la versión 3.3.
Para implementaciones propias, considera:
- Usar hashes criptográficos (p. ej.,
hashlib.sha256) y truncar a un entero. - Aplicar salting aleatorio al iniciar la tabla.
- Limitar el número de inserciones por segundo desde una misma IP o proceso.
Depuración y Troubleshooting
Al desarrollar tu propia tabla, los siguientes puntos son críticos:
- Verificar colisiones: imprime
hash(key) % capacitypara claves sospechosas. - Chequear ciclos infinitos en el sondeo: asegura que el algoritmo de probing siempre avanza y detecta tablas llenas.
- Validar el redimensionamiento: después de cada resize, recorre
items()y compara con una copia dedictpara garantizar integridad.
Ejemplo de rutina de diagnóstico:
def debug_table(table):
print('Capacity:', table._capacity)
print('Size:', len(table))
print('Load factor:', len(table)/table._capacity)
for i, bucket in enumerate(table._buckets if hasattr(table, '_buckets') else []):
if bucket:
print(f'Bucket {i}:', list(table._table[i].__dict__.values()))
Mejores Prácticas y Optimización
- Elige la estrategia de colisión adecuada según la carga esperada y la naturaleza de las claves.
- Prefiere tipos inmutables y hashables (str, int, tuple) para evitar recalcular hashes.
- Usa
__slots__en los nodos para reducir la sobrecarga de memoria. - Evita reasignaciones de objetos grandes dentro de la tabla; almacena referencias.
- Considera una política de expiración (TTL) si las claves son temporales; implementa un heap de timestamps para purgar entradas caducadas.
Compatibilidad y Rendimiento en Entornos de Producción
Las implementaciones mostradas son compatibles con Python 3.8+. En entornos de high‑performance computing o micro‑servicios, se recomienda:
- Ejecutar bajo
PyPypara obtener JIT que acelera los bucles de sondeo. - Utilizar
cffioCythonpara portar la lógica de hashing a C y reducir la latencia. - Monitorizar la latencia promedio de acceso con
timeitoperfy ajustar el factor de carga.
Conclusión
Dominar las tablas hash te permite diseñar soluciones a medida que van más allá de lo que ofrece el diccionario estándar de Python. Hemos cubierto:
- Conceptos básicos y métricas de rendimiento.
- Implementaciones completas: separate chaining y open addressing con ejemplos funcionales.
- Comparativas, consideraciones de seguridad y estrategias de depuración.
- Mejores prácticas para escalar y mantener la tabla en producción.
Con este conocimiento, podrás elegir la arquitectura adecuada para tu caso de uso, optimizar el consumo de recursos y proteger tu aplicación contra vulnerabilidades relacionadas con el hashing.
Guía Completa de Tablas Hash en Python: Implementación, Uso y Buenas Prácticas