Hasta aquí has entrenado cada músculo por separado: análisis (06-01), diseño (06-02) y optimización (06-03). Los tres proyectos de esta lección los usan todos a la vez: son piezas completas de RutaBus, de principio a fin, como las que construirías en un equipo real.

Cómo trabajar esta lección: cada proyecto trae contexto, requisitos, un plan por fases (con el módulo del curso que aplica cada una), un esqueleto de partida y una lista de autoevaluación. Implementa el proyecto entero antes de mirar la solución de referencia, y usa la lista de autoevaluación como criterio de "terminado". La solución de referencia no es "la" solución: es una solución correcta y razonada contra la que contrastar la tuya. Si tu enfoque difiere pero cumple la lista, es igual de válido.

Contenido

  1. Proyecto 1: Planificador de trayectos (M4 + M2).
  2. Proyecto 2: Cuadro de mandos de la flota (M2 + M4 + M5).
  3. Proyecto 3: Planificador de turnos y refuerzos (M3).
  4. Conclusión del curso.

Proyecto 1: Planificador de trayectos

Dificultad: media · Integra: Módulo 4 (Dijkstra, Floyd-Warshall) + Módulo 2 (análisis para decidir).

Contexto. La app de RutaBus necesita responder "¿cómo voy de A a B?" contando los transbordos: cambiar de línea en una parada cuesta 5 minutos. La red:

  • L1: Plaza Mayor —3— Estación Norte —4— Hospital Central —6— Parque del Río
  • L2: Estación Norte —5— Mercado Viejo —4— Universidad —3— Parque del Río
  • L3: Plaza Mayor —7— Universidad —4— Polideportivo Sur

(los números son minutos entre paradas consecutivas; todas las líneas circulan en ambos sentidos).

Requisitos funcionales.

  1. Modelar la red como grafo ponderado que capture los transbordos.
  2. mejor_trayecto(origen, destino): minutos totales y el itinerario completo (paradas y cambios de línea).
  3. Precalcular la tabla de tiempos todos-con-todos con Floyd-Warshall.
  4. Decidir, con argumentos de complejidad, qué enfoque debe usar la app en producción.

Plan por fases.

Fase Tarea Aplica
1 Modelar: el truco es que el nodo no es la parada, sino el par (parada, línea) 01-02, 05-02 (dict vs matriz)
2 Dijkstra con heap + reconstrucción del camino 04-05
3 Floyd-Warshall sobre los mismos nodos 04-06
4 Comparar coste/memoria de ambos y decidir M2, 05-01

Esqueleto de partida.

import heapq

LINEAS = {
    "L1": [("Plaza Mayor", 3), ("Estación Norte", 4),
           ("Hospital Central", 6), ("Parque del Río", 0)],
    "L2": [("Estación Norte", 5), ("Mercado Viejo", 4),
           ("Universidad", 3), ("Parque del Río", 0)],
    "L3": [("Plaza Mayor", 7), ("Universidad", 4), ("Polideportivo Sur", 0)],
}
TRANSBORDO = 5   # minutos por cambiar de línea en una misma parada

def construir_red():
    """Devuelve dict de adyacencia: {(parada, linea): {(parada, linea): minutos}}."""
    # TODO: aristas entre paradas consecutivas de cada línea (ambos sentidos)
    # TODO: aristas de transbordo entre las líneas que comparten parada

def mejor_trayecto(red, origen, destino):
    """Devuelve (minutos, [(parada, linea), ...]) o None si no hay camino."""
    # TODO: Dijkstra multi-origen (el viajero empieza en la parada, en cualquier línea)

def floyd_warshall(red):
    """Devuelve (nodos, matriz) con los tiempos mínimos todos-con-todos."""
    # TODO

Criterios de autoevaluación.

  • [ ] Un trayecto sin transbordo gana a uno con transbordo cuando los minutos lo justifican (Plaza Mayor → Universidad debe dar 7 min por L3, no 17 por L1+L2).
  • [ ] El itinerario devuelto muestra dónde se cambia de línea.
  • [ ] Dijkstra usa heap y no revisita nodos cerrados (04-05).
  • [ ] Floyd-Warshall y Dijkstra dan los mismos tiempos para todos los pares (¡pruébalo con un bucle!).
  • [ ] La decisión final cita complejidades concretas, no impresiones.

Solución de referencia

Fase 1 — Modelado. Si el nodo fuera solo la parada, no habría manera de cobrar el transbordo: el grafo no sabría en qué línea "estás". La solución estándar es desdoblar: el nodo es (parada, línea), con aristas de viaje dentro de cada línea y aristas de transbordo (5 min) entre las líneas que comparten parada.

from collections import defaultdict

def construir_red():
    red = defaultdict(dict)
    for linea, paradas in LINEAS.items():
        for (p1, w), (p2, _) in zip(paradas, paradas[1:]):
            red[(p1, linea)][(p2, linea)] = w     # ida
            red[(p2, linea)][(p1, linea)] = w     # vuelta
    por_parada = defaultdict(list)
    for nodo in list(red):
        por_parada[nodo[0]].append(nodo)
    for nodos in por_parada.values():             # transbordos
        for a in nodos:
            for b in nodos:
                if a != b:
                    red[a][b] = TRANSBORDO
    return dict(red)

Elegimos dict de adyacencia y no matriz porque la red es dispersa: cada nodo tiene 2–4 vecinos, y como vimos en 05-02, la matriz solo compensa en grafos densos.

Fase 2 — Dijkstra con reconstrucción. Detalle importante: el viajero empieza "en la parada", sin línea asignada. En vez de inventar un nodo virtual, sembramos el heap con todos los nodos (origen, línea) a distancia 0 (Dijkstra multi-origen: mismo algoritmo, varias semillas):

def mejor_trayecto(red, origen, destino):
    inicios = [n for n in red if n[0] == origen]
    dist = {n: 0 for n in inicios}
    previo = {n: None for n in inicios}
    heap = [(0, n) for n in inicios]
    heapq.heapify(heap)
    cerrados = set()
    while heap:
        d, nodo = heapq.heappop(heap)
        if nodo in cerrados:
            continue
        cerrados.add(nodo)
        if nodo[0] == destino:                    # primera vez que cerramos
            camino = []                           #   el destino: es óptimo
            while nodo is not None:               #   (pesos >= 0, 04-05)
                camino.append(nodo)
                nodo = previo[nodo]
            return d, list(reversed(camino))
        for vecino, w in red[nodo].items():
            nd = d + w
            if vecino not in dist or nd < dist[vecino]:
                dist[vecino] = nd
                previo[vecino] = nodo
                heapq.heappush(heap, (nd, vecino))
    return None

red = construir_red()
print(mejor_trayecto(red, "Plaza Mayor", "Universidad"))
# (7, [('Plaza Mayor', 'L3'), ('Universidad', 'L3')])
print(mejor_trayecto(red, "Hospital Central", "Universidad"))
# (14, [('Hospital Central', 'L1'), ('Parque del Río', 'L1'),
#       ('Parque del Río', 'L2'), ('Universidad', 'L2')])

Fíjate en el segundo ejemplo: el itinerario hace visible el transbordo (Parque del Río L1 → Parque del Río L2, +5 min), y Dijkstra eligió transbordar en Parque del Río (6+5+3 = 14) y no en Estación Norte (4+5+5+4 = 18). Poder terminar en cuanto se cierra el destino es la propiedad de 04-05: con pesos no negativos, un nodo cerrado ya tiene su distancia definitiva.

Fase 3 — Floyd-Warshall. Sobre los mismos nodos, la versión de 04-06:

def floyd_warshall(red):
    nodos = list(red)
    idx = {n: i for i, n in enumerate(nodos)}
    INF = float("inf")
    n = len(nodos)
    D = [[INF] * n for _ in range(n)]
    for i in range(n):
        D[i][i] = 0
    for a, vecinos in red.items():
        for b, w in vecinos.items():
            D[idx[a]][idx[b]] = w
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if D[i][k] + D[k][j] < D[i][j]:
                    D[i][j] = D[i][k] + D[k][j]
    return nodos, D

Fase 4 — Decisión. Nuestra red tiene V = 11 nodos: Floyd-Warshall hace 11³ ≈ 1300 operaciones y su matriz ocupa 121 celdas — todo trivial. Pero la app aspira a redes urbanas reales; escalemos con el análisis de M2 a V ≈ 5000 nodos (2000 paradas, varias líneas):

Enfoque Coste Memoria Consulta
Floyd-Warshall precalculado Θ(V³) ≈ 1,25·10¹¹ ops (minutos u horas) Θ(V²) ≈ 200 MB O(1)
Dijkstra bajo demanda Θ(V + E) (el grafo) O((V+E) log V) ≈ milisegundos

Recomendación: Dijkstra bajo demanda. Una consulta en milisegundos es sobradamente rápida para una app móvil, la memoria es mínima, y —decisivo— la red cambia (cortes, refuerzos): recalcular todo el Θ(V³) por cada incidencia es inviable, mientras que Dijkstra siempre trabaja sobre el grafo vigente. Floyd-Warshall queda para redes pequeñas y estáticas o para análisis offline (p. ej. detectar los pares de paradas peor conectados de toda la red, donde de todos modos quieres la tabla completa). Y si un día las consultas dominan, el camino intermedio es cachear los pares frecuentes — medir primero (05-01) dirá si hace falta.

Proyecto 2: Cuadro de mandos de la flota

Dificultad: media-alta · Integra: Módulo 2 (costes) + Módulo 4 (merge, top-k) + Módulo 5 (memoria).

Contexto. Cada autobús vuelca al final del día un fichero CSV de fichajes, ordenado por hora: hora;parada;linea;importe (p. ej. 07:41:03;Estación Norte;L2;1.20). Con cientos de buses y millones de líneas por día, operaciones quiere un cuadro de mandos diario que hoy revienta el servidor de memoria.

Requisitos funcionales.

  1. Leer los ficheros sin cargarlos enteros en memoria.
  2. Calcular en una sola pasada: fichajes por línea, fichajes por parada y recaudación total.
  3. Combinar los k ficheros diarios (cada uno ya ordenado por hora) en un único flujo cronológico, también sin materializarlo.
  4. Obtener el top-10 de paradas por número de fichajes sin ordenar todas las paradas.
  5. Justificar la memoria de cada pieza y con qué herramienta la verificarías.

Plan por fases.

Fase Tarea Aplica
1 Ingesta con generadores 05-02
2 Agregados en streaming con dict (coste O(1) por acceso) 02-01
3 Fusión cronológica de k flujos ordenados (merge_k con heap) 04-03, 04-05
4 Top-k con heap (y por qué no quickselect aquí) 04-04
5 Verificación: tracemalloc y timeit 05-01, 05-02

Esqueleto de partida.

import heapq

def leer_fichajes(ruta):
    """Generador que produce tuplas (hora, parada, linea, importe)."""
    # TODO: no usar readlines()

def resumen(fichajes):
    """Una sola pasada: (fichajes_por_linea, fichajes_por_parada, recaudacion)."""
    # TODO

def fusionar_dias(rutas, ruta_salida):
    """Mezcla k ficheros ordenados por hora en uno global ordenado."""
    # TODO: memoria O(k), no O(n)

def top_paradas(fichajes_por_parada, k=10):
    """Top-k paradas por fichajes, sin ordenar todas."""
    # TODO

Criterios de autoevaluación.

  • [ ] leer_fichajes puede procesar un fichero de 10 GB en una máquina de 8 GB (razónalo; no hace falta el fichero).
  • [ ] resumen hace una pasada y ningún acceso dentro del bucle es O(n) (repasa 06-01, ejercicio 2).
  • [ ] fusionar_dias mantiene en memoria como mucho un registro por fichero abierto.
  • [ ] top_paradas es Θ(p log k), no Θ(p log p).
  • [ ] Sabes decir el pico de memoria teórico de cada función y cómo confirmarlo.

Solución de referencia

Fases 1 y 2 — Ingesta y agregados en streaming.

def leer_fichajes(ruta):
    with open(ruta, encoding="utf-8") as f:
        for registro in f:                    # el fichero ya es un iterador
            hora, parada, linea, importe = registro.rstrip("\n").split(";")
            yield hora, parada, linea, float(importe)

def resumen(fichajes):
    por_linea, por_parada, recaudacion = {}, {}, 0.0
    for hora, parada, linea, importe in fichajes:
        por_linea[linea] = por_linea.get(linea, 0) + 1
        por_parada[parada] = por_parada.get(parada, 0) + 1
        recaudacion += importe
    return por_linea, por_parada, recaudacion

Memoria: el generador retiene un registro cada vez (O(1) respecto al fichero, 05-02); los acumuladores crecen con el número de líneas y paradas distintas (decenas), no con los millones de fichajes. Cada acceso al dict es O(1) — con listas y in sería el desastre cuadrático del ejercicio 2 de 06-01. Un fichero de 10 GB pasa por una máquina de 8 GB sin inmutarse, porque nunca está entero en memoria.

Fase 3 — Fusión cronológica. Es la mezcla de k listas ordenadas de 04-03 (merge_k), con el heap manteniendo el mínimo de las k cabezas. La librería estándar la trae hecha, y acepta iterables perezosos:

def fusionar_dias(rutas, ruta_salida):
    flujos = [leer_fichajes(r) for r in rutas]
    with open(ruta_salida, "w", encoding="utf-8") as salida:
        for hora, parada, linea, importe in heapq.merge(*flujos):
            salida.write(f"{hora};{parada};{linea};{importe:.2f}\n")

En memoria solo viven k registros (uno por flujo) más el heap de tamaño k: O(k), con k = número de buses, da igual que cada fichero mida gigabytes. Tiempo: Θ(n log k) para n registros totales. Esto es, literalmente, la fase de mezcla de la ordenación externa que esbozamos en 04-03. (Las horas con formato HH:MM:SS de ancho fijo se comparan bien como cadenas; si el formato fuera variable, habría que convertirlas a una clave comparable.) ¿Y si un fichero individual llegara casi ordenado pero con algún rezagado? sorted() lo repararía casi en tiempo lineal gracias a Timsort (04-02/04-03)… a costa de materializarlo; para ficheros que caben en memoria es la opción simple, para los que no, ordenación externa por trozos.

Fase 4 — Top-k.

def top_paradas(por_parada, k=10):
    return heapq.nlargest(k, por_parada.items(), key=lambda kv: kv[1])

Θ(p log k) con p paradas: para p = 2000 y k = 10, unas 2000 comparaciones con un heap de 10, frente a Θ(p log p) de ordenarlo todo. ¿Y quickselect (04-04, k_mas_tempranas)? Es Θ(p) promedio, mejor asintóticamente… pero exige la lista completa en memoria y reordenable, no devuelve el top ordenado y su peor caso es Θ(p²). Con p pequeño y flujos perezosos, el heap es la herramienta natural; quickselect brilla cuando p es enorme, ya está en memoria y k es grande.

Fase 5 — Verificación. Pico de memoria teórico: leer_fichajes O(1), resumen O(líneas + paradas distintas), fusionar_dias O(k), top_paradas O(k). Confirmación: tracemalloc alrededor de cada fase con un fichero sintético (05-02) y timeit para los tiempos (05-01). Si el pico real no cuadra con el teórico, casi siempre hay un list(...) o un sorted(...) traicionero materializando un generador — el error silencioso número uno de este estilo de código.

Proyecto 3: Planificador de turnos y refuerzos

Dificultad: alta · Integra: Módulo 3 completo (backtracking, greedy con justificación, PD con reconstrucción).

Contexto. Operaciones planifica el sábado: cubrir los turnos de conductor, atender las franjas de saturación con el único bus de refuerzo disponible, y decidir en qué mejoras de infraestructura gastar el presupuesto anual.

Requisitos funcionales.

  1. Turnos: asignar un conductor a cada uno de los 6 turnos (L1/L2/L3 × mañana/noche). Disponibilidad: Ana (mañana y noche), Bruno (solo mañana), Carla (solo noche), Diego (mañana y noche). Máximo 2 turnos por conductor y, por descanso obligatorio, nadie puede hacer mañana y noche el mismo día. Backtracking con al menos una poda.
  2. Refuerzos: dadas las franjas de saturación previstas [(9, 11), (10, 12), (11, 13), (14, 15), (15, 17), (16, 18)] (horas), elegir el máximo número de franjas que el bus de refuerzo puede atender sin solaparse, justificando que el criterio elegido es óptimo.
  3. Presupuesto: con 10 k€, elegir entre marquesina nueva (3 k€, beneficio 4), pantallas de información (4 k€, beneficio 5), wifi en paradas (2 k€, beneficio 3) y carril reservado (7 k€, beneficio 9), maximizando beneficio. PD con tabla y reconstrucción de qué mejoras comprar. Comprueba si el greedy por ratio beneficio/coste habría acertado.

Plan por fases.

Fase Tarea Aplica
1 Turnos: decidir → recurrir → deshacer, con poda de capacidad 03-04
2 Refuerzos: criterio greedy + argumento de intercambio 03-02
3 Presupuesto: mochila 0/1 con tabla y reconstrucción 03-03

Esqueleto de partida.

TURNOS = [("L1", "mañana"), ("L1", "noche"), ("L2", "mañana"),
          ("L2", "noche"), ("L3", "mañana"), ("L3", "noche")]
DISPONIBILIDAD = {"Ana": {"mañana", "noche"}, "Bruno": {"mañana"},
                  "Carla": {"noche"}, "Diego": {"mañana", "noche"}}

def asignar_turnos():
    """Dict turno -> conductor, o None si no hay asignación válida."""
    # TODO: backtracking con poda

def atender_franjas(franjas):
    """Máximo conjunto de franjas sin solapamiento para el bus de refuerzo."""
    # TODO: greedy justificado

def plan_mejoras(mejoras, presupuesto):
    """(beneficio_maximo, lista_de_mejoras_elegidas)."""
    # TODO: PD con reconstrucción

Criterios de autoevaluación.

  • [ ] asignar_turnos deshace siempre sus decisiones al retroceder (prueba: llámala dos veces; debe dar lo mismo).
  • [ ] Existe una poda que corta antes de bajar de nivel, no una comprobación final.
  • [ ] La justificación del greedy es un argumento de intercambio, no "lo probé y funciona".
  • [ ] plan_mejoras devuelve qué comprar, no solo cuánto beneficio.
  • [ ] Has comparado PD contra el greedy por ratio y sabes cuál gana y por qué.

Solución de referencia

Fase 1 — Turnos con backtracking.

def asignar_turnos():
    asignacion, turnos_de = {}, {c: [] for c in DISPONIBILIDAD}

    def puede(conductor, franja):
        if franja not in DISPONIBILIDAD[conductor]:
            return False
        if len(turnos_de[conductor]) >= 2:
            return False
        franjas_previas = {f for _, f in turnos_de[conductor]}
        return not (franjas_previas and franja not in franjas_previas)  # descanso

    def resolver(i):
        if i == len(TURNOS):
            return True
        linea, franja = TURNOS[i]
        # PODA: ¿queda capacidad suficiente para esta franja?
        pendientes = sum(1 for l, f in TURNOS[i:] if f == franja)
        capacidad = sum(2 - len(turnos_de[c]) for c in DISPONIBILIDAD
                        if puede(c, franja))
        if capacidad < pendientes:
            return False
        for c in DISPONIBILIDAD:
            if puede(c, franja):
                asignacion[(linea, franja)] = c          # decidir
                turnos_de[c].append((linea, franja))
                if resolver(i + 1):
                    return True
                turnos_de[c].pop()                       # deshacer
                del asignacion[(linea, franja)]
        return False

    return dict(asignacion) if resolver(0) else None

print(asignar_turnos())
# {('L1','mañana'): 'Ana', ('L1','noche'): 'Carla', ('L2','mañana'): 'Ana',
#  ('L2','noche'): 'Carla', ('L3','mañana'): 'Bruno', ('L3','noche'): 'Diego'}

El patrón es el de asignar_turnos de 03-04: decidir, recurrir, deshacer. La regla de descanso se implementa como restricción incremental (las franjas previas del conductor deben coincidir con la nueva), y la poda de capacidad corta la rama cuando los turnos pendientes de una franja superan la capacidad restante de los conductores que aún pueden tomarla — la imposibilidad se detecta niveles antes de tropezar con ella. Observa la solución: Ana y Carla absorben las mañanas y noches "dobles" y nadie mezcla franjas.

Fase 2 — Refuerzos con greedy justificado. Es estructuralmente la selección de actividades de 03-02: mismo valor por franja, maximizar cuántas caben. Criterio: atender siempre la franja que termina antes de entre las compatibles.

def atender_franjas(franjas):
    elegidas, fin_actual = [], float("-inf")
    for inicio, fin in sorted(franjas, key=lambda x: x[1]):
        if inicio >= fin_actual:
            elegidas.append((inicio, fin))
            fin_actual = fin
    return elegidas

print(atender_franjas([(9, 11), (10, 12), (11, 13), (14, 15), (15, 17), (16, 18)]))
# [(9, 11), (11, 13), (14, 15), (15, 17)]  -> 4 franjas

Justificación (intercambio): sea g la primera franja que elige el greedy (la de fin más temprano) y O una solución óptima cualquiera. La primera franja de O termina en algún f ≥ fin(g); si sustituimos esa franja por g, ninguna franja posterior de O se solapa (todas empiezan tras f ≥ fin(g)), así que O sigue siendo válida y del mismo tamaño. Iterando el argumento sobre el resto, el greedy alcanza el tamaño óptimo. Coste: Θ(n log n) por la ordenación. Nótese que "empezar por la más larga" o "la que empieza antes" fallan — (9,11),(10,12),(11,13) es un contraejemplo de la segunda si se elige (10,12).

Fase 3 — Presupuesto con PD y reconstrucción. Mochila 0/1 (03-03) con capacidad 10:

def plan_mejoras(mejoras, presupuesto):
    n = len(mejoras)
    dp = [[0] * (presupuesto + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        nombre, coste, beneficio = mejoras[i - 1]
        for c in range(presupuesto + 1):
            dp[i][c] = dp[i - 1][c]                       # no comprar
            if coste <= c:
                dp[i][c] = max(dp[i][c], dp[i - 1][c - coste] + beneficio)
    elegidas, c = [], presupuesto                          # reconstrucción
    for i in range(n, 0, -1):
        if dp[i][c] != dp[i - 1][c]:                       # se compró la mejora i
            nombre, coste, _ = mejoras[i - 1]
            elegidas.append(nombre)
            c -= coste
    return dp[n][presupuesto], list(reversed(elegidas))

MEJORAS = [("marquesina", 3, 4), ("pantallas", 4, 5),
           ("wifi", 2, 3), ("carril reservado", 7, 9)]
print(plan_mejoras(MEJORAS, 10))
# (13, ['marquesina', 'carril reservado'])

Óptimo: marquesina + carril reservado = 10 k€ y beneficio 13. El greedy por ratio beneficio/coste (wifi 1,5 → marquesina 1,33 → carril 1,29 → pantallas 1,25) compraría wifi + marquesina (5 k€) y luego pantallas (9 k€ en total), beneficio 12, y ya no puede pagar el carril: se queda a uno del óptimo. El fallo es el de siempre (03-02): el criterio local llena el presupuesto con piezas "eficientes" que bloquean la combinación buena — exactamente lo que motivó mochila_mejoras en 03-03. La reconstrucción (comparar dp[i][c] con dp[i-1][c] hacia atrás) es la parte que los proyectos reales no perdonan: al departamento de compras no le vale "el beneficio máximo es 13"; necesita la lista.

Conclusión del Curso

Se acabó el temario. Merece la pena mirar atrás y ver el edificio completo:

  • En el Módulo 1 pusiste los cimientos: qué es un algoritmo, cómo expresarlo (pseudocódigo, diagramas, Python), la diferencia entre iterativo y recursivo, entre exacto y heurístico, y el lenguaje que lo vertebra todo: la notación asintótica y su jerarquía, de O(1) a O(2ⁿ).
  • En el Módulo 2 aprendiste a analizar: derivar la complejidad temporal línea a línea (con los costes ocultos de Python incluidos), medir el espacio auxiliar y el total, y matizar con mejor caso, peor caso, promedio y coste amortizado.
  • En el Módulo 3 aprendiste a diseñar: divide y vencerás, greedy (y su obligación de demostrar o refutar), programación dinámica y backtracking con poda — y, sobre todo, a diagnosticar cuál pide cada problema.
  • En el Módulo 4 incorporaste los clásicos — búsqueda binaria, inserción, merge sort, quicksort, Dijkstra, Floyd-Warshall — no como recetas, sino como piezas cuyo funcionamiento y límites entiendes y sabes adaptar.
  • En el Módulo 5 aprendiste a optimizar con método: medir primero, atacar por orden (algoritmo → código → memoria → paralelizar) y saber cuándo parar.
  • Y en este Módulo 6 lo has entrenado todo junto, hasta montar piezas completas de RutaBus: un planificador de trayectos, un cuadro de mandos que procesa gigabytes sin despeinarse y un planificador de turnos, refuerzos y presupuesto.

Lo que sabes hacer ahora, dicho sin rodeos: mirar código y ver su coste; mirar un problema y reconocer su estrategia; mirar un sistema lento y saber por dónde empezar. Esa tripleta —analizar, diseñar, optimizar— es transversal a cualquier lenguaje y cualquier dominio: hoy ha sido Python y una app de autobuses; mañana será otro stack y otro negocio, y el razonamiento será el mismo.

Sugerencias para seguir creciendo desde aquí:

  • Estructuras de datos avanzadas: árboles equilibrados, tries, tablas hash por dentro, union-find, grafos con A* — son el complemento natural de este curso, porque muchos "algoritmos mejores" son en realidad "estructuras mejores".
  • Práctica deliberada: plataformas de problemas (LeetCode, Codeforces, Advent of Code) mantienen en forma el diagnóstico del ejercicio 5 de 06-02; unas pocas por semana bastan.
  • Tus propios proyectos: la próxima vez que escribas un bucle sobre datos de verdad, hazle el análisis de 06-01 antes de dar a ejecutar. Ese hábito, sostenido unos meses, es lo que convierte el contenido de este curso en instinto.
  • Lecturas: cuando quieras profundidad formal, los textos de referencia habituales (Cormen et al., Introduction to Algorithms; Skiena, The Algorithm Design Manual) amplían con rigor todo lo que aquí se ha presentado con enfoque práctico.

La soltura que anunciábamos al empezar este módulo no termina de llegar nunca del todo — siempre hay un problema que se resiste — pero ya no parte de cero: parte de un método. Gracias por llegar hasta aquí, y buen viaje. En RutaBus dirían: fin de trayecto; esperamos que haya sido un buen recorrido.

© Copyright 2026. Todos los derechos reservados