Capítulo 17: Prueba de Algoritmos y Optimización en
la Práctica
En los capítulos anteriores, aprendimos sobre la depuración y cómo solucionar problemas.
Ahora, vamos a llevar esto un paso más allá al combinar el conocimiento de la eficiencia
algorítmica (Notación Big O) con las herramientas prácticas para probar y perfilar tu código.
Entenderás cómo medir el rendimiento de tus algoritmos y cómo aplicar optimizaciones de
manera informada.
La Importancia de las Pruebas (Testing)
Antes de hablar de optimización, es fundamental asegurar que tu algoritmo funciona
correctamente. Optimizar un algoritmo incorrecto solo te dará un resultado incorrecto más
rápido. Las pruebas son tu primera línea de defensa para garantizar la fiabilidad del código.
Una prueba es simplemente una forma de verificar que tu código hace lo que se espera. Como
vimos en el Capítulo 16, las pruebas unitarias son una excelente manera de hacer esto.
Ejemplo: Prueba para un algoritmo de ordenamiento
import unittest
# Suponiendo que tienes esta función de ordenamiento (ej. de Capítulo
11)
def bubble_sort(lista):
n = len(lista)
for i in range(n - 1):
intercambio_hecho = False
for j in range(n - 1 - i):
if lista[j] > lista[j + 1]:
lista[j], lista[j + 1] = lista[j + 1], lista[j]
intercambio_hecho = True
if not intercambio_hecho:
break
class TestOrdenamiento([Link]):
def test_lista_vacia(self):
lista = []
bubble_sort(lista)
[Link](lista, [])
def test_lista_ya_ordenada(self):
lista = [1, 2, 3, 4, 5]
bubble_sort(lista)
[Link](lista, [1, 2, 3, 4, 5])
def test_lista_orden_inverso(self):
lista = [5, 4, 3, 2, 1]
bubble_sort(lista)
[Link](lista, [1, 2, 3, 4, 5])
def test_lista_con_duplicados(self):
lista = [3, 1, 4, 1, 5, 9, 2, 6]
bubble_sort(lista)
[Link](lista, [1, 1, 2, 3, 4, 5, 6, 9])
def test_lista_con_numeros_negativos(self):
lista = [-5, 0, -2, 3, 1]
bubble_sort(lista)
[Link](lista, [-5, -2, 0, 1, 3])
# Para ejecutar estas pruebas, guarda el código en un archivo .py (ej.
`test_sort.py`)
# y luego ejecútalo desde tu terminal:
# python -m unittest test_sort.py
Estas pruebas aseguran que tu bubble_sort funciona en una variedad de escenarios. Si en
algún momento modificas el algoritmo (quizás para optimizarlo), puedes ejecutar estas pruebas
para verificar que no introdujiste nuevos errores.
Midiendo el Rendimiento: El Profilado (Profiling)
Una vez que estás seguro de que tu algoritmo funciona, el siguiente paso para la optimización
es medir su rendimiento real. Como mencionamos, la Notación Big O nos da una idea teórica
de cómo escalará un algoritmo, pero el profilado nos dice dónde está el tiempo real de
ejecución de nuestro código.
Python tiene un módulo integrado llamado timeit para medir el tiempo de ejecución de
pequeñas piezas de código, y cProfile para un análisis más detallado de programas completos.
1. Usando timeit para pequeñas piezas de código
timeit es excelente para comparar la velocidad de pequeñas funciones o líneas de código.
Problema: Comparar la eficiencia de eliminar_duplicados_lento (con list y in) vs.
eliminar_duplicados_rapido (con set).
import timeit
import random
# Funciones de eliminación de duplicados (del Capítulo 13)
def eliminar_duplicados_lento(lista):
unicos = []
for item in lista:
if item not in unicos:
[Link](item)
return unicos
def eliminar_duplicados_rapido(lista):
return list(set(lista))
# Generar una lista grande con duplicados para probar
# Creamos una lista de 10000 números, con muchos duplicados
lista_grande_con_duplicados = [[Link](1, 2000) for _ in
range(10000)]
# Medir el tiempo de ejecución de la versión "lenta"
tiempo_lento = [Link](
lambda: eliminar_duplicados_lento(lista_grande_con_duplicados),
number=10 # Ejecutar la función 10 veces y promediar
)
print(f"Tiempo de ejecución (versión LENTA): {tiempo_lento:.6f}
segundos")
# Medir el tiempo de ejecución de la versión "rápida"
tiempo_rapido = [Link](
lambda: eliminar_duplicados_rapido(lista_grande_con_duplicados),
number=10
)
print(f"Tiempo de ejecución (versión RÁPIDA): {tiempo_rapido:.6f}
segundos")
# Ahora comparemos los algoritmos de ordenamiento
# Usamos copias para asegurar que las listas no estén pre-ordenadas
lista_ordenamiento_lento = [[Link](1, 1000) for _ in
range(1000)] # 1000 elementos para que sea notable
tiempo_bubble_sort = [Link](
lambda: bubble_sort(lista_ordenamiento_lento.copy()), # Usamos
.copy() para no modificar la lista original
number=10
)
print(f"Tiempo de Bubble Sort (1000 elementos):
{tiempo_bubble_sort:.6f} segundos")
lista_ordenamiento_rapido = [[Link](1, 1000) for _ in
range(1000)]
tiempo_python_sort = [Link](
lambda: lista_ordenamiento_rapido.copy().sort(), # Python's sort
in-place
number=10
)
print(f"Tiempo de [Link]() (1000 elementos):
{tiempo_python_sort:.6f} segundos")
Al ejecutar este código, verás una diferencia de tiempo abrumadora a favor de las versiones
optimizadas, confirmando que la elección del algoritmo y la estructura de datos es crucial.
2. Usando cProfile para programas completos
cProfile es más potente para analizar dónde se gasta el tiempo en un programa más grande,
desglosando el tiempo por función.
Ejemplo: Analizar un programa que realiza varias operaciones, incluyendo la recursión de
Fibonacci.
import cProfile
import random
# Funciones de ejemplo
def fibonacci_recursivo(n): # Versión ineficiente para mostrar el
problema
if n <= 1:
return n
return fibonacci_recursivo(n-1) + fibonacci_recursivo(n-2)
def procesar_datos_complejos(data):
total = sum(data)
promedio = total / len(data)
max_val = max(data)
min_val = min(data)
return total, promedio, max_val, min_val
def mi_programa_principal():
# Tarea 1: Generar datos
datos = [[Link](1, 100) for _ in range(5000)]
# Tarea 2: Procesar datos
res_proc = procesar_datos_complejos(datos)
# print(f"Resultado procesamiento: {res_proc}")
# Tarea 3: Calcular un Fibonacci (costoso si n es grande)
fib_n = 25 # Para un valor notable pero no infinito
res_fib = fibonacci_recursivo(fib_n)
# print(f"Fibonacci de {fib_n}: {res_fib}")
# Tarea 4: Ordenar algunos datos
datos_para_ordenar = [[Link](1, 1000) for _ in
range(1000)]
datos_para_ordenar.sort() # Usamos el sort optimizado de Python
# Para perfilar mi_programa_principal:
# Ejecuta esto desde la línea de comandos:
# python -m cProfile -s cumtime tu_archivo.py
# Donde tu_archivo.py contiene todo el código anterior.
# `-s cumtime` ordena la salida por tiempo acumulado.
La salida de cProfile te mostrará una tabla con el número de llamadas a cada función, el tiempo
total que pasaron en ella (incluyendo llamadas a subfunciones) y el tiempo que pasaron solo en
esa función. Esto te permite identificar los "cuellos de botella" y saber dónde enfocar tus
esfuerzos de optimización. Para el ejemplo anterior, verías que fibonacci_recursivo consume la
mayor parte del tiempo, lo que te indicaría que esa es la función a optimizar (por ejemplo,
pasándola a una versión iterativa o con memorización).
Estrategias de Optimización (Recordatorio y Aplicación)
Una vez que has identificado los cuellos de botella mediante el perfilado, puedes aplicar las
estrategias de optimización que discutimos en el Capítulo 15:
1. Reevalúa la Elección de Estructura de Datos: ¿Podría un diccionario o un conjunto ser
más rápido que una lista para ciertas operaciones (búsqueda, eliminación de
duplicados)?
2. Mejora del Algoritmo Subyacente: Si una tarea es inherentemente lenta (como el
ordenamiento de burbuja), ¿existe un algoritmo más eficiente (como Timsort,
implementado en [Link]())?
3. Elimina Operaciones Redundantes: Mueve cálculos fuera de bucles, cachea
resultados, evita llamadas a funciones costosas repetidas veces si el resultado es el
mismo.
4. Aprovecha las Funciones y Librerías Integradas de Python: Como vimos, [Link](),
sum(), max(), min(), o las compresiones de lista suelen ser más rápidas que tus propias
implementaciones manuales en Python.
5. Pensamiento "Divide y Vencerás": Para problemas grandes, ¿puedes dividir el
problema en subproblemas más pequeños y resolverlos de manera independiente?
Un Último Consejo: La Optimización es un Proceso Iterativo
No esperes optimizar todo de una vez. Es un proceso que a menudo sigue estos pasos:
1. Escribe el código para que funcione. (Correctitud es lo primero).
2. Asegúrate de que es correcto con pruebas.
3. Identifica cuellos de botella con perfilado.
4. Optimiza los cuellos de botella identificados.
5. Vuelve a perfilar para verificar la mejora.
6. Vuelve a probar para asegurar que no introdujiste nuevos bugs.
Repite este ciclo hasta que tu algoritmo cumpla con los requisitos de rendimiento deseados.
Este capítulo marca el cierre de tu viaje a través de los algoritmos y la programación
fundamental. Has adquirido conocimientos teóricos y habilidades prácticas esenciales para
cualquier aspirante a programador. Ahora, tienes las herramientas no solo para escribir código,
sino para escribir código correcto, eficiente y robusto.
El mundo de la programación es vasto y lleno de desafíos emocionantes. Sigue practicando,
sigue aprendiendo y, lo más importante, ¡sigue construyendo!
¡Fin del Libro!
Espero que este libro te haya proporcionado una base sólida para tu viaje en la programación.
Consejo final: toma todo absolutamente todo con tranquilidad y verás como todo va de la mano.