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

  1. dict y set: tablas hash de producción
  2. El requisito de las claves: ser hashables
  3. El contrato hash/__eq__
  4. Operaciones y costes del dict
  5. Orden de inserción preservado
  6. Patrones TaskFlow con dict
  7. set: el diccionario sin valores
  8. Álgebra de conjuntos para etiquetas
  9. 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 nuestra TablaHash hací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 casilla

El 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 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 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, entonces hash(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 cumplido

Para 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:

  • get para leer sin miedo: d.get(id, None) en vez de un if id in d seguido de d[id] (que además paga el hash dos veces).
  • setdefault para 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 set no 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 nada

El 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 primero

Patró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, respuesta

Operaciones 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 web

Cada 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: TypeError inmediato — 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: cada KeyError en producción suele delatar un get que debió estar. Reserva d[k] para cuando la ausencia sea un error del programa.
  • El acceso curioso al defaultdict: consultar por_estado["cancelada"] para "mirar" crea la clave con una lista vacía (es su función). Para consultar sin crear, usa "cancelada" in por_estado o .get. Un defaultdict que 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 un dict con valores dummy si lo que quieres es "conjunto con orden de inserción".
  • Consejo: memoriza el trío get / setdefault / defaultdict como 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

  1. 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?
  2. Panel de equipo. Con la lista tareas de la sección 6, construye en una sola pasada por los datos (más lo que necesites de Counter): (a) por_persona: asignada_a → lista de títulos, con defaultdict; (b) carga: asignada_a → número de tareas no hechas, con Counter; y (c) imprime la persona más cargada con most_common.
  3. Buscador AND/NOT. Usando construir_indice_etiquetas y el índice por id, escribe buscar(con, sin) que reciba dos listas de etiquetas y devuelva los títulos de las tareas que tienen todas las de con y ninguna de sin. 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 consulta

Anatomí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.

© Copyright 2026. Todos los derechos reservados