Empieza el entrenamiento. En esta lección no hay teoría nueva: hay una batería de ejercicios que ejercita todo el Módulo 2 —análisis temporal (02-01), análisis espacial (02-02) y casos mejor/peor/promedio (02-03)— usando la notación asintótica del Módulo 1 (01-03). Todos los ejercicios trabajan sobre código de RutaBus, nuestra aplicación de movilidad urbana.
Cómo trabajar esta lección: intenta cada ejercicio en papel antes de mirar la solución. En serio. Leer una solución de análisis de complejidad produce la sensación de "esto ya lo sé"; derivarla tú mismo es lo que construye el reflejo. Si te atascas más de diez minutos, lee la pista (si la hay) y vuelve a intentarlo antes de rendirte.
Contenido
- Derivar Θ de bucles simples, anidados y dependientes.
- Los costes ocultos de Python:
insobre lista einsert(0). - Complejidad espacial: auxiliar frente a total, y recursión con slices frente a índices.
- Mejor, peor y caso promedio de una función de validación.
- Emparejar cada código con su orden de crecimiento a partir de mediciones.
- Detecta el coste oculto: la función que parece lineal pero no lo es.
Ejercicio 1: Tres funciones, tres cuentas
Dificultad: básica
RutaBus registra cada fichaje como una tupla (hora, parada, linea). Analiza estas tres funciones y deriva la complejidad temporal Θ de cada una en función de n (el tamaño de la lista de entrada). Para la tercera, calcula además el número exacto de iteraciones del bucle interno.
def contar_fichajes_linea(fichajes, linea):
total = 0
for f in fichajes: # n fichajes
if f[2] == linea:
total += 1
return total
def matriz_coincidencias(paradas):
n = len(paradas)
m = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
m[i][j] = coinciden_lineas(paradas[i], paradas[j]) # O(1)
return m
def pares_conflictivos(horarios):
conflictos = []
n = len(horarios)
for i in range(n):
for j in range(i + 1, n):
if se_solapan(horarios[i], horarios[j]): # O(1)
conflictos.append((i, j))
return conflictosSolución
contar_fichajes_linea: un solo bucle que recorre los n fichajes haciendo trabajo O(1) en cada vuelta (una comparación y, a veces, una suma). Como vimos en 02-01, el coste es proporcional al número de iteraciones: Θ(n). Fíjate en que el if no cambia el orden: se ejecute o no el cuerpo, la comparación se hace siempre.
matriz_coincidencias: dos bucles anidados independientes (el rango de j no depende de i): n · n iteraciones con trabajo O(1) cada una. La creación previa de la matriz también cuesta Θ(n²), pero no cambia el total: Θ(n²).
pares_conflictivos: aquí el bucle interno depende de i. Contemos las iteraciones exactas: para i = 0 hay n−1, para i = 1 hay n−2, …, para i = n−1 hay 0. La suma es:
(n−1) + (n−2) + … + 1 + 0 = n(n−1)/2
Como vimos en 02-01, n(n−1)/2 = n²/2 − n/2, y al simplificar (01-03) descartamos constantes y términos menores: Θ(n²). El bucle dependiente hace la mitad de trabajo que el independiente, pero la mitad de un cuadrático sigue siendo cuadrático.
Errores típicos: (1) pensar que el bucle dependiente es "algo intermedio" tipo O(n log n) — no: la serie aritmética da cuadrático; (2) sumar Θ(n²) + Θ(n²) de la creación de la matriz y decir Θ(2n²) — las constantes se descartan.
Ejercicio 2: El precio de in y de insert(0)
Dificultad: media
El equipo de datos de RutaBus escribió estas dos funciones. Deriva su coste temporal real. Llama n al número de fichajes/avisos, m al tamaño de paradas_conocidas y k al número de paradas nuevas encontradas.
def paradas_nuevas(fichajes, paradas_conocidas):
# paradas_conocidas es una LISTA de m nombres
nuevas = []
for hora, parada, linea in fichajes: # n iteraciones
if parada not in paradas_conocidas: # ¿coste?
if parada not in nuevas: # ¿coste?
nuevas.append(parada)
return nuevas
def ultimos_primero(avisos):
resultado = []
for aviso in avisos: # n iteraciones
resultado.insert(0, aviso) # ¿coste?
return resultadoPreguntas: (a) ¿cuál es el coste de cada comprobación marcada?; (b) ¿cuál es el coste total de cada función?; (c) ¿qué pasa con paradas_nuevas en el peor caso en que todas las paradas son nuevas?
Pista: repasa la tabla de costes ocultos de 02-01:
x in listano cuesta lo mismo quex in conjunto, y las listas de Python están optimizadas para tocar su final, no su principio.
Solución
(a) Como vimos en 02-01, parada not in paradas_conocidas sobre una lista es una búsqueda lineal: O(m). La segunda comprobación, parada not in nuevas, cuesta O(k) donde k es el tamaño actual de nuevas. Y resultado.insert(0, aviso) desplaza todos los elementos existentes una posición: O(longitud actual).
(b) paradas_nuevas hace n iteraciones y en cada una paga O(m) + O(k): total O(n·(m + k)). ultimos_primero paga 0 + 1 + 2 + … + (n−1) = n(n−1)/2 desplazamientos: Θ(n²) — la misma serie aritmética del Ejercicio 1, esta vez escondida dentro de un método.
(c) Si todas las paradas son nuevas, k crece hasta n y el coste se convierte en O(n·m + n²): la función que "parecía" un filtrado lineal es cuadrática.
Para que conste (la reparación se practica en 06-03, aquí solo diagnosticamos): la comprobación sería O(1) con un set, y ultimos_primero sería Θ(n) con append + reverse() (o directamente avisos[::-1]).
Error típico: contar cada línea de Python como "una operación". La línea if parada not in paradas_conocidas: es una línea, pero son m comparaciones. El análisis línea a línea de 02-01 exige conocer el coste real de cada operación, no su longitud tipográfica.
Ejercicio 3: Espacio auxiliar, espacio total y la pila
Dificultad: media
Dos versiones recursivas de la suma de ocupación de un trayecto (lista de pasajeros por tramo):
def suma_ocupacion_slice(ocupacion):
if not ocupacion:
return 0
return ocupacion[0] + suma_ocupacion_slice(ocupacion[1:])
def suma_ocupacion_indices(ocupacion, i=0):
if i == len(ocupacion):
return 0
return ocupacion[i] + suma_ocupacion_indices(ocupacion, i + 1)Preguntas: (a) espacio auxiliar de cada versión; (b) espacio total; (c) complejidad temporal de cada una; (d) ¿qué ocurre en la práctica al llamarlas con una lista de 10 000 tramos?
Solución
(a) Espacio auxiliar. Ambas versiones hacen n+1 llamadas anidadas, así que ambas pagan una pila de profundidad Θ(n) (02-02: cada marco de la pila cuenta como espacio auxiliar). Pero la versión con slice paga además una copia por llamada: ocupacion[1:] crea una lista nueva de n−1 elementos, la siguiente llamada crea otra de n−2, etc. Y aquí está lo importante: mientras la recursión está en su punto más profundo, todas esas copias están vivas a la vez (cada una es argumento de un marco que aún no ha retornado). Total: (n−1) + (n−2) + … + 1 ≈ n²/2 → espacio auxiliar Θ(n²). La versión con índices no copia nada: solo la pila, Θ(n) auxiliar.
(b) Espacio total = entrada + auxiliar (02-02). Con slice: Θ(n) + Θ(n²) = Θ(n²). Con índices: Θ(n) + Θ(n) = Θ(n).
(c) Tiempo. El slice no solo ocupa: también tarda O(longitud) en copiarse (02-01). La versión slice paga la misma serie aritmética en tiempo: Θ(n²). La versión con índices: Θ(n).
(d) Ninguna de las dos sobrevive a 10 000 elementos: Python limita la profundidad de recursión a ~1000 por defecto, así que ambas lanzan RecursionError mucho antes de que la memoria sea el problema. Como vimos en 01-02 al comparar iterativo y recursivo, cuando la recursión es puramente lineal, un bucle (o sum(ocupacion)) hace el mismo trabajo con pila O(1).
Errores típicos: (1) olvidar que el slice copia (parece un acceso, pero es una lista nueva); (2) contar solo "un marco de pila" en lugar de la profundidad máxima simultánea; (3) confundir espacio auxiliar con total y reportar Θ(n²) para la versión con índices "porque la entrada más la pila suman mucho" — la entrada es Θ(n) y la pila Θ(n): total Θ(n).
Ejercicio 4: Mejor, peor y promedio en la validación de billetes
Dificultad: media
En cada fichaje, RutaBus valida el billete contra la lista de billetes activos:
def validar_fichaje(billete, billetes_activos):
# billetes_activos: lista de n identificadores
for activo in billetes_activos:
if activo == billete:
return True
return FalseLos datos de explotación dicen que el 90 % de los fichajes son válidos, y que los billetes válidos aparecen en posiciones uniformemente distribuidas de la lista. El 10 % restante son billetes inválidos (no están en la lista).
Preguntas: (a) mejor caso y peor caso, con la notación adecuada; (b) número esperado de comparaciones con esa distribución, y el Θ del caso promedio; (c) si el porcentaje de inválidos subiera al 99 %, ¿cambiaría el Θ del promedio?
Solución
(a) Mejor caso: el billete es el primero de la lista → 1 comparación → Θ(1). Peor caso: el billete es el último o no está → n comparaciones → Θ(n). Fíjate en que usamos Θ del caso, como precisamos en 02-03: cada caso tiene su cota ajustada.
(b) Es exactamente el esquema de la búsqueda lineal de 02-03, pero con la distribución dada. Si el billete es válido y está en posición uniforme, el número medio de comparaciones es (1 + 2 + … + n)/n = (n+1)/2. Si es inválido, siempre son n. Esperanza total:
E[comparaciones] = 0,9 · (n+1)/2 + 0,1 · n = 0,45n + 0,45 + 0,1n = 0,55n + 0,45
Eso es Θ(n): una recta con pendiente 0,55, pero recta al fin y al cabo.
(c) Con 99 % de inválidos: E = 0,01 · (n+1)/2 + 0,99 · n ≈ 0,995n. La constante casi se duplica respecto al caso anterior (0,55 → 0,995), lo que en producción se nota, pero el Θ no cambia: sigue siendo Θ(n). Esta es la lección de 02-03: la distribución de entradas mueve las constantes del promedio, y solo a veces mueve el orden.
Error típico: escribir "el promedio es Θ(n/2), que es mejor que Θ(n)". Θ(n/2) es Θ(n) — la notación asintótica no distingue constantes (01-03). Si quieres comunicar la constante, di "≈ 0,55n comparaciones", no inventes un Θ nuevo.
Ejercicio 5: Empareja el código con su curva
Dificultad: media
El equipo de RutaBus midió cuatro funciones (F1–F4) con entradas de tamaño creciente y obtuvo estos tiempos (milisegundos):
| n | F1 | F2 | F3 | F4 |
|---|---|---|---|---|
| 1 000 | 0,8 | 0,0004 | 90 | 0,0011 |
| 2 000 | 1,6 | 0,0004 | 360 | 0,0012 |
| 4 000 | 3,2 | 0,0004 | 1 440 | 0,0013 |
Y estos son los cuatro códigos, sin decir cuál es cuál:
def A(tiempos, i, j): # tiempos: matriz de minutos entre paradas
return tiempos[i][j]
def B(salidas, hora): # salidas: lista ORDENADA de horas
lo, hi = 0, len(salidas)
while lo < hi:
mid = (lo + hi) // 2
if salidas[mid] < hora:
lo = mid + 1
else:
hi = mid
return lo
def C(ocupacion):
return sum(ocupacion) / len(ocupacion)
def D(paradas):
cerca = 0
for i in range(len(paradas)):
for j in range(i + 1, len(paradas)):
if distancia(paradas[i], paradas[j]) < 500:
cerca += 1
return cercaEmpareja A, B, C y D con F1, F2, F3 y F4, razonando a partir de cómo escala el tiempo al duplicar n, y asigna a cada una su Θ.
Pista: al duplicar n, un Θ(n) duplica su tiempo, un Θ(n²) lo cuadruplica, un Θ(log n) suma una cantidad fija minúscula y un Θ(1) no se inmuta. Es la jerarquía de 01-03 vista desde el cronómetro.
Solución
Aplicamos la heurística de duplicación:
- F1: 0,8 → 1,6 → 3,2. Se duplica al duplicar n: crecimiento lineal, Θ(n). Es C, que recorre la lista una vez para sumar.
- F2: constante, 0,0004 siempre: Θ(1). Es A: un acceso a matriz por índices no depende de n.
- F3: 90 → 360 → 1 440. Se cuadruplica al duplicar: Θ(n²). Es D, el bucle dependiente de pares (n(n−1)/2 iteraciones, Ejercicio 1).
- F4: 0,0011 → 0,0012 → 0,0013. Suma una cantidad fija diminuta al duplicar n: Θ(log n). Es B: cada vuelta del
whilereduce el intervalo a la mitad, así que hay ~log₂ n vueltas.
| Función | Código | Θ | Señal en la tabla |
|---|---|---|---|
| F1 | C | Θ(n) | ×2 al duplicar n |
| F2 | A | Θ(1) | no cambia |
| F3 | D | Θ(n²) | ×4 al duplicar n |
| F4 | B | Θ(log n) | +constante al duplicar n |
Errores típicos: (1) comparar valores absolutos entre columnas ("F3 es la más lenta, luego es la peor función") — lo que importa es cómo escala, no cuánto vale en un n concreto: para n pequeño un Θ(n²) puede ganar a un Θ(n) con constante grande; (2) confundir Θ(log n) con Θ(1) porque "casi no crece" — crece, y la diferencia importa cuando n tiene nueve cifras.
Ejercicio 6: Detecta el coste oculto
Dificultad: alta
Esta función genera el informe diario de incidencias de la red. Su autor asegura que "es un solo bucle, así que es O(n)". Tu trabajo: (a) decir cuál es la complejidad real; (b) señalar cada coste oculto con su línea y su coste individual; (c) justificar el total. No hace falta arreglarla (eso es materia de 06-03): solo diagnosticarla con precisión.
def informe_incidencias(incidencias):
# incidencias: lista de n cadenas "hora;linea;texto"
informe = ""
procesadas = []
for inc in incidencias:
hora, linea, texto = inc.split(";")
if inc in procesadas: # línea A
continue
procesadas.append(inc)
informe = informe + linea + " " + hora + "\n" # línea B
return informeSolución
(a) La complejidad real es Θ(n²), no Θ(n).
(b) Hay dos costes ocultos, ambos vistos en 02-01:
- Línea A —
inc in procesadas: búsqueda lineal sobre una lista que crece. En la iteración i,procesadastiene hasta i elementos, así que la comprobación cuesta O(i). Sumando: 0 + 1 + … + (n−1) = n(n−1)/2 → Θ(n²) solo esta línea. - Línea B —
informe = informe + ...: las cadenas de Python son inmutables; cada concatenación crea una cadena nueva copiando todo lo anterior. Si el informe llega a tener L caracteres, el coste acumulado de reconstruirlo trocito a trocito es Θ(L²) — y L es proporcional a n, así que también Θ(n²).
(El split(";") de cada línea es O(longitud de la cadena), que tratamos como constante si las incidencias tienen tamaño acotado — este no es un coste oculto problemático.)
(c) Total: Θ(n²) + Θ(n²) = Θ(n²). El "un solo bucle, luego O(n)" falla porque el análisis línea a línea exige el coste de cada operación interna, y aquí dos operaciones de apariencia inocente (un in y un +) esconden recorridos completos de estructuras que crecen con n.
Error típico: detectar uno de los dos costes y dar por terminado el análisis. En código real los costes ocultos vienen en grupo, porque el mismo estilo de programación (acumular sobre lista/cadena y consultar con in) los produce todos a la vez. Acostúmbrate a auditar cada operación del cuerpo del bucle.
Conclusión
Has ejercitado el ciclo completo de análisis del Módulo 2: derivar Θ contando iteraciones (bucles simples, anidados y dependientes con su n(n−1)/2), desenmascarar los costes ocultos de Python (in sobre lista, insert(0), slices que copian, concatenación de cadenas), distinguir espacio auxiliar de total (con la pila de recursión incluida), separar mejor/peor/promedio con distribuciones concretas, y leer órdenes de crecimiento directamente en una tabla de mediciones con la heurística de duplicación.
Si algún ejercicio se te ha resistido, vuelve a la lección correspondiente (02-01 para tiempo y costes ocultos, 02-02 para espacio, 02-03 para casos) y reinténtalo mañana: el análisis de complejidad es de esas habilidades que se consolidan a la segunda pasada. En la siguiente lección cambiamos de músculo: de analizar código dado a diseñar algoritmos nuevos con las cuatro estrategias del Módulo 3.
Curso de Análisis y Diseño de Algoritmos
Módulo 1: Introducción a los Algoritmos
Módulo 2: Análisis de Algoritmos
- Análisis de Complejidad Temporal
- Análisis de Complejidad Espacial
- Casos de Complejidad: Mejor, Peor y Promedio
Módulo 3: Estrategias de Diseño de Algoritmos
Módulo 4: Algoritmos Clásicos
- Búsqueda Binaria
- Ordenamiento por Inserción
- Ordenamiento por Mezcla (Merge Sort)
- Ordenamiento Rápido (Quick Sort)
- Algoritmo de Dijkstra
- Algoritmo de Floyd-Warshall
