Ya sabes construir una tabla hash desde cero; ahora toca usar las dos que Python trae de serie, afinadas durante décadas: dict y set. Esta lección es el retorno de la inversión de 05-01 y 05-02 — cada regla "arbitraria" de Python (por qué una lista no puede ser clave, por qué in es rapidísimo en un set y lento en una list) se volverá evidente ahora que conoces la maquinaria de debajo. La segunda mitad es pura práctica: los patrones profesionales con diccionarios y conjuntos — indexar, agrupar, contar, invertir — aplicados a TaskFlow, que en esta lección estrena su índice por id definitivo y su primer buscador por etiquetas. Cerraremos con la pregunta más importante para tu criterio de ingeniero: cuándo un hash no es la respuesta.
Contenido
dictyset: tablas hash de producción- El requisito de las claves: ser hashables
- El contrato
hash/__eq__ - Operaciones y costes del
dict - Orden de inserción preservado
- Patrones TaskFlow con
dict set: el diccionario sin valores- Álgebra de conjuntos para etiquetas
- Cuándo NO usar una tabla hash
dict y set: tablas hash de producción
Los dos protagonistas, traducidos al vocabulario del módulo:
dict: el TDA diccionario de 05-01 implementado como tabla hash de direccionamiento abierto (la segunda familia de 05-02, en una variante sofisticada), con redimensionado automático al superar 2/3 de carga. Todo lo que nuestraTablaHashhacía, más años de optimización en C.set: la misma tabla, guardando solo claves, sin valores. Su especialidad es una única pregunta contestada en O(1): ¿está o no está?
Llevamos usando dict desde la primera tarea del curso; la diferencia es que a partir de hoy sabes qué pagas y qué recibes con cada operación — y por qué existen sus reglas.
El requisito de las claves: ser hashables
Prueba esto:
indice = {}
indice[("backend", "urgente")] = "combinación válida" # tupla: funciona
indice[["backend", "urgente"]] = "?" # lista...
# TypeError: unhashable type: 'list'¿Por qué la tupla sí y la lista no? La respuesta está en el corazón de 05-01: la clave es la dirección. Al insertar, la tabla calcula hash(clave) y guarda el par en la casilla resultante; al buscar, repite el cálculo. Ahora imagina que Python permitiera listas como claves:
etiquetas = ["backend", "urgente"]
indice[etiquetas] = tarea # supongamos casilla 5
etiquetas.append("bloqueada") # la lista CAMBIA...
indice[etiquetas] # ...su hash cambiaría → miraría en OTRA casillaEl par seguiría físicamente en la casilla 5, pero la búsqueda iría a otra: dato perdido sin ningún error, la versión silenciosa del desastre de la TablaHashIngenua. Por eso Python exige que las claves sean hashables: que tengan un hash que no pueda cambiar durante su vida. En la práctica, eso significa inmutables:
| Tipo | ¿Hashable? | Motivo |
|---|---|---|
int, float, str, bool, None |
Sí | Inmutables |
tuple |
Sí, si todo su contenido lo es | Inmutable por fuera |
tuple con una lista dentro, p. ej. (1, [2]) |
No | Su interior puede mutar |
list, dict, set |
No | Mutables |
frozenset |
Sí | El set congelado, inmutable |
Dos consecuencias prácticas inmediatas: para usar varios valores como clave compuesta, empaquétalos en una tupla ((id_proyecto, id_tarea)); para usar un conjunto como clave (lo haremos con etiquetas), congélalo con frozenset.
El contrato hash/__eq__
Bajo el capó, la hashabilidad se apoya en dos métodos especiales: __hash__ (lo que llama la función hash()) y __eq__ (lo que llama ==). La tabla los usa en tándem, exactamente como nuestra TablaHash: el hash elige la cubeta, la igualdad identifica la clave dentro de ella (p[0] == clave). De ahí el contrato sagrado:
Si
a == b, entonceshash(a) == hash(b).
Si dos claves "iguales" tuvieran hashes distintos, irían a casillas distintas y la tabla contendría duplicados imposibles de encontrar. Python lo cumple de fábrica (hash(1) == hash(1.0) porque 1 == 1.0), y solo te afecta si defines clases propias: si sobrescribes __eq__, Python desactiva el __hash__ heredado (tu objeto deja de ser hashable) precisamente para que no rompas el contrato por accidente; recuperar la hashabilidad exige definir un __hash__ coherente, normalmente delegando en una tupla de campos inmutables:
class RefTarea:
"""Referencia ligera a una tarea, usable como clave de dict."""
def __init__(self, id_tarea):
self.id = id_tarea
def __eq__(self, otra):
return isinstance(otra, RefTarea) and self.id == otra.id
def __hash__(self):
return hash(self.id) # mismo id → mismo hash: contrato cumplidoPara TaskFlow no necesitamos tanto: nuestros dicts-tarea son mutables (cambian de estado, de prioridad), así que nunca serán claves; la clave es su id, que es un entero inmutable. Esa separación — dato mutable como valor, identificador inmutable como clave — es el diseño canónico.
Operaciones y costes del dict
La tabla de referencia, ahora con explicación conocida (promedios; el peor caso O(n) de 05-02 existe pero la aleatorización del hash lo confina):
| Operación | Sintaxis | Coste promedio |
|---|---|---|
| Insertar / actualizar | d[k] = v |
O(1) |
| Obtener (clave segura) | d[k] |
O(1); KeyError si no está |
| Obtener con respaldo | d.get(k, defecto) |
O(1), sin excepción |
| Obtener-o-crear | d.setdefault(k, inicial) |
O(1) |
| Borrar | del d[k] / d.pop(k, defecto) |
O(1) |
| Pertenencia | k in d |
O(1) |
| Recorrer todo | for k, v in d.items() |
O(n) |
| Volcar ordenado por clave | sorted(d) |
O(n log n) — ¡no es gratis! |
Los dos accesos "con red" merecen ser reflejos tuyos:
getpara leer sin miedo:d.get(id, None)en vez de unif id in dseguido ded[id](que además paga el hash dos veces).setdefaultpara el patrón "si la clave no existe, créala con un valor inicial y devuélvemelo": una llamada en lugar de tres líneas. Lo veremos en acción en el índice invertido.
Orden de inserción preservado
Desde Python 3.7, el lenguaje garantiza que iterar un dict recorre las claves en el orden en que se insertaron (una propiedad de la implementación compacta de CPython que acabó elevada a contrato del lenguaje). Tres precisiones para no malinterpretarla:
- Es orden de inserción, no orden por clave:
{3: "c", 1: "a"}itera 3, luego 1. Nadie ordena nada. - Actualizar el valor de una clave existente no la mueve al final: conserva su posición original.
- El
setno ofrece esta garantía: su orden de iteración es indefinido. No escribas código que dependa de él.
Es una propiedad muy cómoda — los registros de TaskFlow salen en orden cronológico de alta sin esfuerzo — pero cuidado con pedirle más de lo que da: "orden de llegada" no es "orden alfabético" ni "orden por prioridad". Para esos, sigue leyendo hasta la sección 9.
Patrones TaskFlow con dict
Datos de trabajo para toda la sección — fíjate en los campos opcionales asignada_a y etiquetas que estrenamos:
tareas = [
{"id": 1, "titulo": "Diseñar logo", "prioridad": 2, "estado": "pendiente",
"asignada_a": "eva", "etiquetas": ["diseño", "web"]},
{"id": 2, "titulo": "Migrar BD", "prioridad": 1, "estado": "en curso",
"asignada_a": "luis", "etiquetas": ["backend", "urgente"]},
{"id": 3, "titulo": "Revisar textos", "prioridad": 3, "estado": "pendiente",
"asignada_a": "eva", "etiquetas": ["web"]},
{"id": 4, "titulo": "Parchear API", "prioridad": 1, "estado": "pendiente",
"asignada_a": "luis", "etiquetas": ["backend", "urgente", "api"]},
{"id": 5, "titulo": "Cerrar sprint", "prioridad": 2, "estado": "hecha",
"asignada_a": "ana", "etiquetas": ["gestión"]},
]Patrón 1 — El índice por id. El IndiceTareas de 05-01, versión definitiva, en una línea de comprensión de diccionario:
indice = {t["id"]: t for t in tareas}
def buscar_por_id(id_tarea):
return indice.get(id_tarea) # O(1), None si no existe
print(buscar_por_id(4)["titulo"]) # Parchear API — sin recorrer nadaEl guiño de 02-03 queda saldado del todo: si las tareas viven en una ListaEnlazada, el índice puede apuntar id→nodo y dar acceso O(1) al interior de la lista. Regla de mantenimiento: quien da de alta o de baja una tarea debe tocar las dos estructuras (lista e índice); un índice desincronizado es peor que ningún índice.
Patrón 2 — Agrupar por estado con defaultdict. Queremos estado → lista de tareas. Con dict puro, cada inserción exige comprobar si la clave existe; collections.defaultdict elimina ese ruido fabricando el valor inicial (aquí, list()) en el primer acceso a cada clave nueva:
from collections import defaultdict
por_estado = defaultdict(list) # clave ausente → crea [] automáticamente
for t in tareas:
por_estado[t["estado"]].append(t)
print([t["id"] for t in por_estado["pendiente"]]) # [1, 3, 4]
print(len(por_estado["cancelada"])) # 0 (y crea la clave: ver Errores)Patrón 3 — Contar por prioridad con Counter. Contar ocurrencias es tan común que la biblioteca estándar lo trae hecho: Counter es un dict cuyo valor por defecto es 0:
from collections import Counter
por_prioridad = Counter(t["prioridad"] for t in tareas)
print(por_prioridad) # Counter({2: 2, 1: 2, 3: 1})
print(por_prioridad[1]) # 2 → tareas de prioridad 1 (máxima); clave ausente → 0, sin error
print(por_prioridad.most_common(1)) # [(2, 2)] — empate 2-2: devuelve la clave vista primeroPatrón 4 — El índice invertido etiqueta→tareas. El índice por id responde "dame la tarea 4"; el buscador de TaskFlow necesita lo inverso: "dame las tareas con la etiqueta urgente". La estructura se llama índice invertido — invertimos la relación tarea→etiquetas para obtener etiqueta→ids — y es, a pequeña escala, lo mismo que hace un buscador web con palabra→documentos:
def construir_indice_etiquetas(tareas):
indice_inv = {}
for t in tareas:
for etiqueta in t.get("etiquetas", []): # get: el campo es opcional
indice_inv.setdefault(etiqueta, set()).add(t["id"])
return indice_inv
por_etiqueta = construir_indice_etiquetas(tareas)
print(por_etiqueta["urgente"]) # {2, 4}
print(por_etiqueta["web"]) # {1, 3}Tres detalles de oficio: t.get("etiquetas", []) tolera tareas sin el campo; setdefault(etiqueta, set()) crea el conjunto la primera vez que aparece cada etiqueta (el patrón obtener-o-crear prometido); y el valor es un set de ids, no una lista de tareas — lo que habilita el álgebra de la sección 8. Construirlo cuesta O(total de etiquetas); consultarlo, O(1).
set: el diccionario sin valores
Cuando solo importa la pertenencia, el set es la herramienta. La comparación que llevamos haciendo todo el curso, en su forma final:
ids_lista = [t["id"] for t in tareas] # list
ids_set = {t["id"] for t in tareas} # set (comprensión de conjunto)
999 in ids_lista # O(n): recorre y compara una a una
999 in ids_set # O(1): hash, casilla, respuestaOperaciones básicas: add(x), discard(x) (no protesta si falta; remove(x) lanza KeyError), x in s, len(s) — todas O(1) promedio. Sus elementos, como las claves del dict, deben ser hashables: puedes tener un set de ids o de tuplas, no de listas ni de dicts-tarea.
El uso reflejo es la deduplicación: len(ids) != len(set(ids)) detecta duplicados en O(n); set(ids) los elimina. Y el patrón "vistos" — un set acumulando lo ya procesado para saltar repetidos en O(1) — reaparecerá literalmente en el BFS de grafos del módulo 7 bajo el nombre de visitados.
Álgebra de conjuntos para etiquetas
La joya del set son sus operaciones binarias, heredadas de la teoría de conjuntos:
| Operación | Operador | Método | Resultado |
|---|---|---|---|
| Unión | a | b |
a.union(b) |
En a, en b, o en ambos |
| Intersección | a & b |
a.intersection(b) |
Solo lo común |
| Diferencia | a - b |
a.difference(b) |
En a pero no en b |
| Diferencia simétrica | a ^ b |
— | En uno solo de los dos |
| ¿Subconjunto? | a <= b |
a.issubset(b) |
¿Todo a está en b? |
Sobre el índice invertido de la sección 6, estas operaciones son el lenguaje de consulta del buscador de TaskFlow:
urgentes = por_etiqueta.get("urgente", set())
backend = por_etiqueta.get("backend", set())
web = por_etiqueta.get("web", set())
print(urgentes & backend) # {2, 4} → urgente Y backend (AND)
print(urgentes | web) # {1, 2, 3, 4} → urgente O web (OR)
print(backend - web) # {2, 4} → backend pero NO webCada consulta compuesta se resuelve en una línea y en tiempo proporcional al tamaño de los conjuntos implicados — no al total de tareas. Y si un día necesitas la combinación de etiquetas como clave de un dict (p. ej. para cachear consultas), recuerda la sección 2: frozenset({"urgente", "backend"}) es hashable; el set normal, no.
Cuándo NO usar una tabla hash
El criterio de un buen ingeniero no es saber usar el martillo, sino saber cuándo el tornillo no es un clavo. La tabla hash compra su O(1) destruyendo el orden de las claves: la función hash esparce a propósito (uniformidad, 05-02), así que claves vecinas (ids 41, 42, 43) acaban en casillas sin ninguna relación. Consecuencias:
- "Dame las tareas ordenadas por id" → el hash no sabe; toca
sorted(indice)a O(n log n), cada vez. - "Dame las tareas con id entre 100 y 200" (consulta de rango) → el hash no puede ni aproximarse: no existe "la siguiente clave". Solo queda probar las 101 claves una a una o recorrer todo.
- "¿Cuál es el id mínimo pendiente?" → recorrido completo O(n). (Para extraer mínimos repetidamente ya tienes la cola de prioridad de 04-04.)
| Necesidad | Estructura adecuada |
|---|---|
| Buscar UNA clave exacta | Tabla hash — imbatible: O(1) |
| Recorrer en orden por clave | Estructura ordenada (módulo 6) |
| Consultas de rango (entre a y b) | Estructura ordenada (módulo 6) |
| Predecesor / sucesor de una clave | Estructura ordenada (módulo 6) |
Esa "estructura ordenada" que mantiene las claves navegables sin pagar una ordenación por consulta existe, es jerárquica, y es el programa del módulo 6: los árboles. De momento, quédate con la frontera: exacto → hash; ordenado o rango → otra cosa.
Errores Comunes y Consejos
- Usar un dict-tarea (o cualquier mutable) como clave:
TypeErrorinmediato — y ahora sabes que es Python protegiéndote del dato perdido silencioso de la sección 2. Clave = identificador inmutable; dato mutable = valor. d[k]a pelo con claves que pueden faltar: cadaKeyErroren producción suele delatar ungetque debió estar. Reservad[k]para cuando la ausencia sea un error del programa.- El acceso curioso al
defaultdict: consultarpor_estado["cancelada"]para "mirar" crea la clave con una lista vacía (es su función). Para consultar sin crear, usa"cancelada" in por_estadoo.get. Undefaultdictque crece solo por culpa de las consultas es un clásico desconcertante. - Mutar un dict mientras lo recorres:
RuntimeError: dictionary changed size during iteration. Recoge primero las claves a borrar en una lista y borra después, o itera sobre una copia (list(d.items())). - Confiar en el orden de un
set: no lo tiene. Si el orden de salida importa, ordena explícitamente (sorted(s)) o usa undictcon valores dummy si lo que quieres es "conjunto con orden de inserción". - Consejo: memoriza el trío
get/setdefault/defaultdictcomo niveles del mismo patrón — leer con respaldo, leer-o-crear puntual, crear-siempre masivo. Elegir el nivel justo hace el código corto y legible.
Ejercicios
- Tribunal de claves. Sin ejecutarlo, veredicto (hashable o no) y motivo:
(1, "a"),[1, 2],("x", (2, 3)),(1, [2, 3]),frozenset({"a", "b"}),{"a", "b"}. Bonus: ¿por quéhash(True) == hash(1)no es un accidente sino una obligación? - Panel de equipo. Con la lista
tareasde la sección 6, construye en una sola pasada por los datos (más lo que necesites deCounter): (a)por_persona: asignada_a → lista de títulos, condefaultdict; (b)carga: asignada_a → número de tareas no hechas, conCounter; y (c) imprime la persona más cargada conmost_common. - Buscador AND/NOT. Usando
construir_indice_etiquetasy el índice por id, escribebuscar(con, sin)que reciba dos listas de etiquetas y devuelva los títulos de las tareas que tienen todas las decony ninguna desin.buscar(["backend", "urgente"], ["api"])debe devolver["Migrar BD"]. Cuidado con la etiqueta inexistente.
Soluciones
Ejercicio 1. (1, "a"): hashable — tupla de inmutables. [1, 2]: no — lista, mutable. ("x", (2, 3)): hashable — tuplas anidadas, todo inmutable. (1, [2, 3]): no — la tupla es inmutable por fuera, pero contiene una lista que puede mutar; su hash no sería estable (el intento lanza TypeError). frozenset({"a", "b"}): hashable — está congelado. {"a", "b"}: no — el set es mutable (para clave, congélalo). Bonus: como True == 1, el contrato de la sección 3 obliga a que hash(True) == hash(1); si no, d[1] = "x" y d[True] mirarían en casillas distintas siendo claves "iguales".
Ejercicio 2.
from collections import defaultdict, Counter
por_persona = defaultdict(list)
carga = Counter()
for t in tareas: # una sola pasada: O(n)
por_persona[t["asignada_a"]].append(t["titulo"])
if t["estado"] != "hecha":
carga[t["asignada_a"]] += 1 # Counter: la clave nace en 0
print(dict(por_persona))
# {'eva': ['Diseñar logo', 'Revisar textos'], 'luis': ['Migrar BD', 'Parchear API'],
# 'ana': ['Cerrar sprint']}
print(carga) # Counter({'eva': 2, 'luis': 2})
persona, n = carga.most_common(1)[0]
print(f"Más cargada: {persona} con {n} tareas") # eva (o luis: empate a 2)Ana no aparece en carga: su única tarea está hecha y Counter solo crea claves al sumar — comportamiento correcto para "carga pendiente". Con empates, most_common devuelve primero la clave que alcanzó antes el recuento (orden de inserción); si el desempate importara, habría que definirlo explícitamente.
Ejercicio 3.
def buscar(con, sin):
if not con:
return []
# AND: intersecar los conjuntos de ids de todas las etiquetas requeridas
resultado = set(por_etiqueta.get(con[0], set())) # copia: no mutar el índice
for etiqueta in con[1:]:
resultado &= por_etiqueta.get(etiqueta, set())
# NOT: restar los ids de cada etiqueta excluida
for etiqueta in sin:
resultado -= por_etiqueta.get(etiqueta, set())
return [indice[id_]["titulo"] for id_ in sorted(resultado)]
print(buscar(["backend", "urgente"], ["api"])) # ['Migrar BD']
print(buscar(["backend", "inexistente"], [])) # [] — get(..., set()) salva la consultaAnatomía: el AND es una intersección encadenada (cada &= solo puede encoger el resultado); el NOT, una diferencia; get(etiqueta, set()) convierte la etiqueta desconocida en conjunto vacío en vez de KeyError — con una requerida inexistente el AND colapsa a vacío, que es la respuesta correcta. El paso final traduce ids→títulos con el índice por id: los dos índices de la lección cooperando, el invertido para filtrar y el directo para resolver. El sorted final da salida reproducible (los sets no prometen orden).
Conclusión
Ya dominas las tablas hash "de verdad": dict y set con sus reglas explicadas por dentro — claves hashables porque la clave es la dirección y una dirección no puede mutar, el contrato hash/__eq__, costes O(1) promedio y orden de inserción garantizado (que no es orden por clave). En lo práctico, TaskFlow ha ganado su infraestructura de consulta: índice por id, agrupación por estado (defaultdict), estadísticas por prioridad (Counter), un índice invertido de etiquetas y un lenguaje de consulta AND/OR/NOT gracias al álgebra de conjuntos. Y tienes la frontera clara: el hash es imbatible con la clave exacta, pero no sabe de orden ni de rangos. La próxima lección no introduce teoría nueva: es puro entrenamiento — seis ejercicios progresivos donde estos patrones (y la TablaHash de 05-02) resuelven problemas clásicos de entrevista y necesidades reales de TaskFlow. A calentar manos: nos vemos en 05-04.
Curso de Estructuras de Datos
Módulo 1: Introducción a las Estructuras de Datos
- ¿Qué son las Estructuras de Datos?
- Importancia de las Estructuras de Datos en la Programación
- Tipos de Estructuras de Datos
- Complejidad Algorítmica y Notación Big O
- Arrays y Memoria: la Base de las Estructuras de Datos
Módulo 2: Listas
- Introducción a las Listas
- Listas Enlazadas
- Listas Doblemente Enlazadas
- Listas Circulares
- Ejercicios con Listas
Módulo 3: Pilas
- Introducción a las Pilas
- Operaciones Básicas con Pilas
- Implementación de Pilas
- Aplicaciones de las Pilas
- Ejercicios con Pilas
Módulo 4: Colas
- Introducción a las Colas
- Operaciones Básicas con Colas
- Colas Circulares
- Colas de Prioridad
- Colas Dobles (Deques)
- Ejercicios con Colas
Módulo 5: Tablas Hash y Diccionarios
- Introducción a las Tablas Hash
- Funciones Hash y Resolución de Colisiones
- Diccionarios y Conjuntos en la Práctica
- Ejercicios con Tablas Hash
Módulo 6: Árboles
- Introducción a los Árboles
- Árboles Binarios
- Recorridos de Árboles
- Árboles Binarios de Búsqueda
- Árboles AVL
- Árboles B
- Montículos (Heaps)
- Ejercicios con Árboles
Módulo 7: Grafos
- Introducción a los Grafos
- Representación de Grafos
- Algoritmos de Búsqueda en Grafos
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Aplicaciones de los Grafos
- Ejercicios con Grafos
