El Módulo 4 terminó con una promesa: ya sabemos elegir el algoritmo correcto, pero entre esa elección y un programa rápido todavía media un oficio. Esta lección enseña la primera parte de ese oficio: cómo hacer que una implementación dada corra más rápido sin cambiar lo que hace. Y empieza por la regla que lo gobierna todo: no se optimiza lo que uno cree que es lento, sino lo que se ha medido que es lento. Sobre esa base construiremos una jerarquía de cuatro niveles — del cambio de estructura de datos que multiplica la velocidad por cien hasta la micro-optimización que araña un 20 % — y, tan importante como saber optimizar, aprenderemos a reconocer cuándo hay que parar.

Contenido

  1. La regla de oro: medir, no adivinar
  2. Las herramientas de medición: perf_counter, timeit y cProfile
  3. La jerarquía de la optimización
  4. Nivel 1 — Mejor algoritmo o estructura de datos
  5. Nivel 2 — Sacar trabajo invariante de los bucles
  6. Nivel 3 — No repetir trabajo: lru_cache
  7. Nivel 4 — Micro-optimizaciones idiomáticas de Python
  8. Salir cuanto antes: early exit y cortocircuito
  9. Cuándo parar de optimizar

La regla de oro: medir, no adivinar

Donald Knuth escribió en 1974 la frase más citada (y peor citada) de la ingeniería de software: "la optimización prematura es la raíz de todos los males". La cita completa es más matizada y más útil:

"Deberíamos olvidarnos de las pequeñas eficiencias, digamos el 97 % de las veces: la optimización prematura es la raíz de todos los males. Sin embargo, no deberíamos dejar pasar nuestras oportunidades en ese 3 % crítico."

Knuth no dice "no optimices": dice que el 97 % del código no merece optimización y el 3 % restante la merece toda. El problema es que la intuición es pésima localizando ese 3 %. Los desarrolladores señalan sistemáticamente el bucle "con mala pinta" mientras el tiempo real se va en una línea inocente — un in sobre una lista, una concatenación de strings — de esas que ya desenmascaramos en 02-01. De ahí la regla de oro de este módulo:

  • Primero se mide (con datos de tamaño realista, no con 10 elementos de prueba).
  • Se optimiza únicamente lo que la medición señala.
  • Se vuelve a medir para confirmar que la mejora existe y cuantificarla.

Optimizar sin medir tiene dos finales posibles: complicar código que no era el problema (coste de mantenimiento sin beneficio) o, peor, "optimizar" a ciegas y hacer el programa más lento.

Las herramientas de medición: perf_counter, timeit y cProfile

Cronometrar un fragmento: time.perf_counter

El cronómetro básico. perf_counter() devuelve un instante de alta resolución; la resta de dos instantes da el tiempo transcurrido:

import time

inicio = time.perf_counter()
resultado = asignar_trayectos(solicitudes, autobuses)   # el código a medir
fin = time.perf_counter()
print(f"asignar_trayectos: {fin - inicio:.4f} s")

Es perfecto para medir operaciones que tardan décimas de segundo o más. Para fragmentos muy rápidos (microsegundos) una sola ejecución es ruido puro: el sistema operativo, la caché o el recolector de basura distorsionan la medida.

Medir fragmentos rápidos: timeit

timeit resuelve ese ruido ejecutando el fragmento miles de veces y promediando:

import timeit

# ¿Cuánto cuesta buscar una parada en una lista frente a un set?
t_lista = timeit.timeit("'Universidad' in paradas",
                        setup="paradas = ['Parada %d' % i for i in range(5000)] + ['Universidad']",
                        number=10_000)
t_set = timeit.timeit("'Universidad' in paradas",
                      setup="paradas = set('Parada %d' % i for i in range(5000)) | {'Universidad'}",
                      number=10_000)
print(f"lista: {t_lista:.4f} s   set: {t_set:.4f} s")
# lista: 0.5410 s   set: 0.0004 s   (orientativo: ~1000x de diferencia)
  • setup prepara los datos (no se cronometra).
  • number es cuántas veces se ejecuta la sentencia; el resultado es el tiempo total.

Localizar el cuello de botella: cProfile

Cuando no sabes qué medir, cProfile mide todo: ejecuta el programa completo y reparte el tiempo entre funciones.

import cProfile
cProfile.run("planificar_dia(solicitudes, red)", sort="cumulative")

Salida típica (recortada):

         2.847 seconds

   ncalls  tottime  cumtime  filename:lineno(function)
        1    0.002    2.847  planificador.py:12(planificar_dia)
    10000    0.011    2.790  planificador.py:31(validar_fichaje)
 10000000    2.779    2.779  {built-in method 'in' on list}
    10000    0.041    0.055  planificador.py:48(calcular_tarifa)

Cómo leerla:

  • ncalls: veces que se llamó a la función.
  • tottime: tiempo dentro de la función, sin contar las funciones a las que llama.
  • cumtime: tiempo acumulado, incluyendo lo que llama.

La lectura es un diagnóstico completo: planificar_dia acapara todo el cumtime pero apenas tiene tottime — no es ella, es alguien a quien llama. Ese alguien es validar_fichaje, y dentro de ella los diez millones de llamadas al in sobre lista se comen 2,78 de los 2,85 segundos: el 97 % del tiempo está en una sola línea. calcular_tarifa, la función que "parecía lenta" por sus fórmulas, cuesta 55 milésimas: irrelevante. Sin el perfil, ¿a que habrías empezado por ella?

La jerarquía de la optimización

No todas las optimizaciones valen lo mismo. Conviene atacarlas en orden descendente de impacto:

Nivel Qué se cambia Ganancia típica Ejemplo
1 Algoritmo o estructura de datos 10x – 1000x o más lista → set, lineal → binaria (M4)
2 Trabajo invariante fuera de bucles 2x – 50x hoisting de cálculos repetidos
3 No repetir trabajo (caché de resultados) 2x – 100x lru_cache sobre funciones puras
4 Micro-optimizaciones idiomáticas 1.1x – 3x comprehensions, join, built-ins

La consecuencia práctica: nunca empieces por el nivel 4. Pulir microsegundos en un bucle que no debería existir es abrillantar los cromados de un coche sin motor.

Nivel 1 — Mejor algoritmo o estructura de datos

Es el territorio de los Módulos 2–4, así que aquí solo lo situamos en la jerarquía con el caso del perfil anterior. RutaBus valida cada noche 10.000 fichajes comprobando que su abono está en la lista de abonos válidos:

def validar_fichajes(fichajes, abonos_validos):     # abonos_validos: lista de 5.000
    correctos = []
    for fichaje in fichajes:                        # 10.000 vueltas
        if fichaje["abono"] in abonos_validos:      # 'in' en lista: O(m) — ¡02-01!
            correctos.append(fichaje)
    return correctos

Coste: 10.000 × O(5.000) = 50 millones de comparaciones. El arreglo es una línea:

def validar_fichajes(fichajes, abonos_validos):
    validos = set(abonos_validos)                   # O(m), una sola vez
    return [f for f in fichajes if f["abono"] in validos]   # 'in' en set: O(1)

De O(n·m) a O(n + m): de 2,8 segundos a 4 milésimas en la máquina del perfil. Ninguna optimización de los niveles siguientes se acerca a esto — por eso el nivel 1 va primero, y por eso los módulos de análisis y diseño precedieron a este.

Nivel 2 — Sacar trabajo invariante de los bucles

Un cálculo es invariante de bucle (loop-invariant) si produce el mismo resultado en todas las vueltas. Dentro del bucle se paga n veces; fuera, una. A esta mudanza se la llama hoisting ("izado"):

# ANTES — informe de fichajes de las paradas actualmente en servicio
def fichajes_en_servicio(fichajes, paradas):
    resultado = []
    for f in fichajes:                                     # n vueltas
        activas = {p["nombre"] for p in paradas            # ¡se reconstruye n veces!
                   if p["en_servicio"]}
        if f["parada"] in activas and f["minuto"] < len(fichajes) - 1:
            resultado.append(f)
    return resultado

El set activas no depende de f: es idéntico en las n vueltas. Y len(fichajes) - 1 tampoco cambia. Ambos se izan:

# DESPUÉS
def fichajes_en_servicio(fichajes, paradas):
    activas = {p["nombre"] for p in paradas if p["en_servicio"]}   # una vez
    ultimo = len(fichajes) - 1                                     # una vez
    return [f for f in fichajes if f["parada"] in activas and f["minuto"] < ultimo]

De O(n·p) a O(n + p). Las formas camufladas más frecuentes del invariante: compilar una expresión regular dentro del bucle (re.compile fuera), abrir/consultar un recurso por vuelta, ordenar una lista que no cambia, o llamar dentro del bucle a una función pura con los mismos argumentos — que es justo el caso del nivel 3.

Nivel 3 — No repetir trabajo: lru_cache

En 03-03 escribimos la memoización a mano con un diccionario memo, y una nota anticipaba que Python la trae de serie. Es el momento de usarla. La función de tarifas de RutaBus calcula el precio entre dos paradas, y para ello lanza un Dijkstra (04-05) que cuesta lo suyo:

from functools import lru_cache

@lru_cache(maxsize=None)
def tarifa(origen, destino):
    dist, _ = dijkstra(red, origen)          # caro: se paga solo la PRIMERA vez
    minutos = dist[destino]
    return 100 + 15 * minutos                # céntimos: fijo + variable por minuto

tarifa("Plaza Mayor", "Hospital Central")    # calcula: ejecuta dijkstra → 235
tarifa("Plaza Mayor", "Hospital Central")    # caché: devuelve 235 sin ejecutar NADA
print(tarifa.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)

El decorador @lru_cache envuelve la función con un diccionario argumentos → resultado: la primera llamada con unos argumentos ejecuta el cuerpo y guarda el resultado; las siguientes lo devuelven en O(1). Con miles de usuarios consultando los mismos pares de paradas populares, el ahorro es masivo. Tres condiciones para usarlo con seguridad:

  • La función debe ser pura: mismo resultado para mismos argumentos, sin efectos secundarios. Si las tarifas cambian a medianoche, hay que invalidar con tarifa.cache_clear().
  • Los argumentos deben ser hashables (strings, números, tuplas sí; listas y dicts no).
  • maxsize=None significa caché ilimitada — memoria a cambio de tiempo, el trade-off de 02-02. Un maxsize=1024 acota la memoria descartando lo menos usado recientemente (eso significa LRU, Least Recently Used); la cara y cruz de esa moneda la analizará 05-02.

Nivel 4 — Micro-optimizaciones idiomáticas de Python

Cuando el algoritmo ya es el correcto y el perfil sigue señalando un bucle caliente, queda exprimir el intérprete. Estas mejoras son modestas pero reales, y tienen algo en común: la versión rápida suele ser también la más idiomática.

Patrón lento Alternativa idiomática Mejora orientativa*
bucle + append list comprehension 1.2x – 1.5x
s = s + trozo en bucle "".join(trozos) 2x – 100x (crece con n, 02-01)
bucle manual para sumar/buscar máximo sum(), max(), min() 2x – 5x
ordenar con comparaciones manuales sorted(datos, key=...) 2x – 10x
atributo/global consultado en bucle caliente copia en variable local 1.1x – 1.3x

* Medidos con timeit en CPython 3.12 sobre listas de 10⁵–10⁶ elementos; los tuyos variarán — por eso se miden.

Los cuatro patrones sobre RutaBus:

# 1) Comprehension: el bucle corre en C, no en bytecode Python
tiempos = [f["minuto"] for f in fichajes if f["linea"] == "L1"]

# 2) join: una sola reserva de memoria en vez de n copias (el O(n²) de 02-01)
informe = "\n".join(f"{f['parada']};{f['minuto']}" for f in fichajes)

# 3) Built-ins y sorted con key: recorridos en C, y Timsort (04-03) de regalo
mas_madrugador = min(fichajes, key=lambda f: f["minuto"])
por_parada = sorted(fichajes, key=lambda f: f["parada"])

# 4) Variable local en bucle caliente: las locales se resuelven más rápido
#    que atributos (obj.metodo) o globales, y el alias se busca una sola vez
def contar_por_linea(fichajes):
    contador = {}
    get = contador.get                    # alias local del método
    for f in fichajes:
        linea = f["linea"]
        contador[linea] = get(linea, 0) + 1
    return contador

El patrón 4 es el único que sacrifica algo de naturalidad: resérvalo para bucles que el perfil haya señalado. Los otros tres deberían ser tu estilo por defecto — rinden más y se leen mejor.

Salir cuanto antes: early exit y cortocircuito

La operación más rápida es la que no se ejecuta. Early exit es devolver el resultado en cuanto se conoce, en vez de completar el recorrido:

# ANTES: recorre las 3.000 paradas aunque encuentre el problema en la primera
def hay_parada_saturada(paradas):
    saturada = False
    for p in paradas:
        if p["ocupacion"] > 0.9:
            saturada = True
    return saturada

# DESPUÉS: sale al primer hallazgo — y any() lo expresa en una línea
def hay_parada_saturada(paradas):
    return any(p["ocupacion"] > 0.9 for p in paradas)

any() devuelve True en cuanto un elemento lo cumple; all() devuelve False en cuanto uno falla. Con un generador como argumento (sin corchetes), ni siquiera se materializa la lista. Esto no cambia el peor caso — sigue siendo O(n), como vimos en 02-03 — pero transforma el caso promedio.

Su pariente es el cortocircuito de and/or: en A and B, si A es falso, B no se evalúa. Ordena las condiciones de barata a cara:

# La comprobación O(1) primero; el Dijkstra solo se lanza si hace falta
if parada in red and tarifa("Plaza Mayor", parada) < 300:
    ofertar_ruta(parada)

Cuándo parar de optimizar

Optimizar tiene rendimientos decrecientes y costes crecientes. Criterios de parada:

  • Cuando se cumple el requisito. Si el informe nocturno debe estar en menos de 5 minutos y tarda 40 segundos, ya está. Optimizar más es coste sin beneficio.
  • Cuando la siguiente mejora cuesta legibilidad. Código optimizado suele ser código más difícil de entender, probar y modificar. Un 15 % de velocidad a cambio de que el próximo desarrollador (tú en seis meses) necesite una tarde para entender la función casi nunca compensa.
  • Cuando el perfil se aplana. Si ya ninguna función domina el tiempo (todo reparte un 5–10 %), las ganancias fáciles se acabaron; lo que queda son los niveles de memoria (05-02) y paralelización (05-03), o aceptar el rendimiento actual.
  • Documenta lo no evidente. Si una optimización obliga a escribir código raro, un comentario con la medición que la justifica (# join: de 12 s a 0.3 s con 10⁶ fichajes) evita que alguien la "limpie" de vuelta a la versión lenta.

Errores Comunes y Consejos

  • Optimizar sin perfil. El error número uno, del que descienden todos los demás. La intuición señala el código complejo; el tiempo suele estar en el código simple ejecutado millones de veces.
  • Medir con datos de juguete. Con 10 fichajes, lista y set tardan lo mismo y el O(n²) es invisible. Mide con tamaños del orden real de producción.
  • Medir una sola ejecución de algo rápido. El ruido del sistema domina. Para microsegundos, timeit; para segundos, perf_counter con varias repeticiones.
  • lru_cache sobre funciones impuras. Si la función depende de estado que cambia (tarifas actualizables, hora actual), la caché servirá resultados obsoletos. Puridad primero, decorador después.
  • Confundir la excepción con la regla. El nivel 4 existe, pero un programa no se salva a base de micro-optimizaciones: si el perfil muestra un problema estructural, vuelve a los niveles 1–3.
  • Consejo: guarda las mediciones antes/después junto al cambio (en el commit o en un comentario). Una optimización sin números es una anécdota; con números es ingeniería.

Ejercicios

Ejercicio 1. Este fragmento genera el informe diario de RutaBus. Identifica —sin ejecutarlo— los problemas de rendimiento que contiene, clasifícalos por nivel de la jerarquía (1–4) y reescríbelo aplicando las correcciones:

def informe_diario(fichajes, paradas_vip):        # paradas_vip: lista de 200 nombres
    informe = ""
    for f in fichajes:                            # ~100.000 fichajes
        if f["parada"] in paradas_vip:            # ¿?
            linea = f["parada"] + ";" + str(f["minuto"]) + "\n"
            informe = informe + linea             # ¿?
    return informe

Ejercicio 2. La función tarifa(origen, destino) del apartado de lru_cache tiene un defecto de eficiencia sutil incluso con la caché: cada par (origen, destino) nuevo lanza un Dijkstra completo, aunque ya se haya lanzado un Dijkstra desde ese mismo origen para otro destino. Rediseña la caché para que Dijkstra se ejecute como mucho una vez por origen. Pista: cachea otra función.

Ejercicio 3. Usando timeit, escribe una medición que compare "".join(...) contra la concatenación con += para construir un string a partir de 10.000 fragmentos. Antes de ejecutarla, anota tu predicción de la diferencia; después, compárala con el resultado y con la fila correspondiente de la tabla del nivel 4.

Soluciones

Solución 1. Tres problemas: (a) in sobre la lista paradas_vip dentro del bucle — nivel 1, cambiar a set; (b) el set debe construirse fuera del bucle — nivel 2, hoisting (construirlo dentro recaería en el problema); (c) concatenación de strings en bucle, el O(n²) de 02-01 — nivel 4, join:

def informe_diario(fichajes, paradas_vip):
    vip = set(paradas_vip)                               # niveles 1+2: set, y fuera del bucle
    return "\n".join(f"{f['parada']};{f['minuto']}"      # nivel 4: join + generador
                     for f in fichajes if f["parada"] in vip)

Con 100.000 fichajes y 200 paradas VIP se pasa de ~20 millones de comparaciones más copias cuadráticas a un recorrido lineal. Nota el orden de la corrección: primero estructura (1), luego colocación (2), al final el idiom (4).

Solución 2. La unidad de trabajo caro es "Dijkstra desde un origen", no "un par". Se cachea esa unidad:

@lru_cache(maxsize=None)
def distancias_desde(origen):
    dist, _ = dijkstra(red, origen)
    return dist                          # dict destino → minutos, calculado UNA vez por origen

def tarifa(origen, destino):
    return 100 + 15 * distancias_desde(origen)[destino]

Con V paradas, antes se hacían hasta V² Dijkstras (uno por par); ahora, como mucho V. La lección general: cachea en la granularidad del trabajo caro, no en la de la consulta. (¿Te suena? Es la discusión Dijkstra-por-origen vs Floyd-Warshall de 04-06 reapareciendo como política de caché.)

Solución 3.

import timeit

t_concat = timeit.timeit(
    "s = ''\nfor x in trozos:\n    s = s + x",
    setup="trozos = ['tramo;%d\\n' % i for i in range(10_000)]",
    number=100)
t_join = timeit.timeit(
    "''.join(trozos)",
    setup="trozos = ['tramo;%d\\n' % i for i in range(10_000)]",
    number=100)
print(f"concatenación: {t_concat:.3f} s   join: {t_join:.3f} s   ratio: {t_concat/t_join:.0f}x")
# Orientativo en CPython 3.12: concatenación ~0.9 s, join ~0.02 s → ~40x

El ratio exacto depende de la máquina y la versión de Python (CPython optimiza algunos casos de +=), y crece con el número de fragmentos: la concatenación es O(n²) y join es O(n). Si tu predicción falló por mucho, esa es exactamente la lección: por eso se mide.

Conclusión

Esta lección ha establecido el método que gobierna todo el módulo: medir primero (perf_counter para lo lento, timeit para lo rápido, cProfile para saber dónde mirar), y optimizar después siguiendo la jerarquía — estructura de datos (el salto de 1000x del set de fichajes), izado de invariantes, caché de resultados con lru_cache, y solo al final las micro-optimizaciones idiomáticas con join, comprehensions y any/all. Y ha puesto límites: se para cuando se cumple el requisito, cuando la legibilidad empieza a pagar la factura, o cuando el perfil se aplana. Hay sin embargo un recurso que hoy hemos gastado alegremente: lru_cache(maxsize=None), el set auxiliar, la lista materializada del informe... todo ahorra tiempo pagando con memoria. La próxima lección le da la vuelta al mostrador: qué hacer cuando la que escasea es la memoria — cómo medirla de verdad, y cómo un job nocturno de RutaBus puede procesar millones de fichajes sin tenerlos nunca todos a la vez en la RAM.

© Copyright 2026. Todos los derechos reservados