Complejidad y rendimiento: por qué dos scripts iguales en apariencia tardan distinto
Una demostración con diez elementos tiene una virtud extraordinaria: permite que casi cualquier algoritmo parezca una decisión excelente. El programa devuelve lo esperado, la ejecución termina enseguida y alguien da por resuelto el rendimiento. Después llegan cincuenta mil registros y descubrimos que, para encontrar cada código, estamos volviendo a leer el catálogo entero. La máquina no se ha vuelto perezosa. Está obedeciendo con una paciencia que yo no pienso imitar.
Vamos a comparar dos maneras de consultar un catálogo de códigos de espacios: recorrer una lista o buscar en un diccionario. Los resultados deberán coincidir, incluidos los códigos desconocidos y las consultas repetidas. Mediremos por separado la construcción del índice y su reutilización, con programas completos en Python y C#. El objetivo es entender cómo crece el trabajo y aprender a medirlo; no proclamar un ganador universal a partir del cronómetro de un portátil.
Antes de cronometrar, decide qué resultado debes conservar
Nuestro catálogo contendrá parejas de código y descripción. Una consulta devolverá la descripción correspondiente o un valor nulo si el código no existe. Mantendremos el orden de las consultas y sus repeticiones: preguntar dos veces por el mismo código producirá dos posiciones en la respuesta. Una descripción vacía seguirá siendo una descripción existente. Convertirla en «desconocido» sería cambiar el comportamiento mientras presumimos de haberlo optimizado.
Trabajaremos con textos y una comparación exacta, sensible a mayúsculas. P01, p01 y P01 serán claves diferentes. No estamos decidiendo cómo debería normalizarse un catálogo real; estamos fijando la misma regla para las dos versiones. Si el proyecto necesita equivalencias, habrá que definirlas antes y comprobar cómo afectan a los duplicados. La explicación de diccionarios y mapas desarrolla esa relación entre identidad y clave.
Cada código del catálogo deberá ser único. La función que construye el índice rechazará un duplicado, incluso si sus dos descripciones coinciden. Antes de comparar las búsquedas construiremos ese índice una vez para validar el catálogo. La búsqueda secuencial recibirá el mismo catálogo ya comprobado. Así no enfrentaremos una versión que devuelve la primera coincidencia con otra que conserva la última: esa competición sería rápida de montar y bastante inútil de interpretar.
El ejemplo opera sobre colecciones en memoria que ya cumplen esos tipos. No descarga datos, interpreta CSV ni consulta una API. Esta separación permite estudiar la búsqueda sin mezclarla con otras tareas. En una aplicación tendrás que conservar también las validaciones de entrada, la relación entre modelo e identificador y el tratamiento de errores. Una mejora que pierde esas garantías merece otro nombre; yo sugiero «regresión con buena presentación».
Contar el trabajo explica lo que el cronómetro solo observa
Llamemos n al número de consultas y m al número de entradas del catálogo. Si buscamos una clave recorriendo la lista, podemos encontrarla al principio, al final o no encontrarla. En este último caso tendremos que comparar las m entradas. Si todas las consultas fallan, realizaremos exactamente n × m comparaciones de códigos. El programa no sabe que no va a encontrar nada hasta terminar cada recorrido.
Cuando ambas colecciones tienen diez elementos, ese caso necesita cien comparaciones; con cien elementos en cada una serán diez mil, y con mil serán un millón. Estas cifras salen de contar operaciones del algoritmo, no de una prueba de velocidad. Si solo crece una colección, el crecimiento será distinto: duplicar las consultas con un catálogo fijo duplica ese trabajo; duplicar las dos colecciones lo multiplica por cuatro. Decir «hay dos bucles, luego es cuadrático» sin nombrar los tamaños es una forma muy solemne de saltarse la explicación.
La notación O(n × m) describe aquí cómo crece el coste en el peor caso de la búsqueda secuencial. Si identificamos ambos tamaños con uno solo, n = m, podemos escribir O(n²). No significa que el programa tarde una cantidad concreta de segundos, ni que cada entrada cueste lo mismo en cualquier máquina. Las comparaciones entre textos largos, las asignaciones de memoria y la forma de los datos también tienen coste.
Un índice basado en una tabla hash cambia la organización: primero asociamos cada código a su descripción y después consultamos por clave. Bajo las condiciones habituales de un buen reparto de hashes y claves de longitud acotada, construirlo y resolver el lote tiene un coste esperado cercano a O(m + n). Si el índice ya existe, el lote de consultas queda cerca de O(n). Son estimaciones de crecimiento, no garantías de tiempo constante absoluto. Microsoft explica la recuperación cercana a O(1) y su dependencia del hash en la documentación de Dictionary.
El índice ocupa memoria adicional, aproximadamente proporcional al catálogo, mientras que las dos soluciones crean una respuesta proporcional al número de consultas. Si solo vas a hacer una búsqueda en una lista corta, construir otra estructura puede no compensar. Si vas a consultar miles de veces el mismo catálogo, repetir el recorrido merece una justificación mejor que «siempre se ha hecho así». No hace falta inventar una arquitectura nueva: a veces basta con dejar de empezar desde el principio.
Ejecutar una comparación que incluya el coste del índice
Guarda el siguiente programa como medir_busquedas.py. Funciona con Python 3.10 o posterior y su biblioteca estándar. Primero comprueba un caso pequeño con resultado conocido. Después usa catálogos de 10, 100 y 1.000 entradas y el mismo número de consultas. Todas las consultas del experimento grande serán desconocidas: elegimos deliberadamente el caso que obliga a agotar cada recorrido. No representa la mezcla de aciertos de todos los proyectos.
Ejecútalo desde la carpeta donde lo hayas guardado:
Obtendrás cuatro líneas por tamaño. lista mide la resolución secuencial del lote; construir mide solo la creación del índice y el rechazo de duplicados; indice_preparado mide las consultas sobre el índice existente; indice_desde_cero incluye construir y consultar en cada ejecución. La creación de los datos sintéticos, las comprobaciones de igualdad y la impresión quedan fuera de la región medida. El tiempo de construir no debe sumarse alegremente a otra cifra para fingir una medición conjunta: precisamente por eso medimos también la operación completa.
Cada acción se ejecuta una vez antes de medir y después en cinco muestras de tres ejecuciones. Mostramos el menor tiempo por ejecución como aproximación al coste con menos interferencias en este ensayo pequeño. timeit ayuda a repetir la medida, pero no elimina las diferencias de equipo, versión, carga o gestión de memoria. Sus valores por defecto también desactivan temporalmente el recolector cíclico durante la medida; si ese trabajo importa en tu caso, tendrás que incluirlo. No conviertas la mejor muestra en una promesa de tiempo de respuesta.
Para repetir el ejercicio con C#, crea una aplicación de consola .NET 8:
Sustituye Program.cs por este contenido. El comparador ordinal conserva la misma intención de comparar claves exactamente. Se generan únicamente claves ASCII en la medición; no estamos midiendo políticas lingüísticas ni normalización Unicode.
Ejecuta la configuración Release:
Stopwatch mide el intervalo que rodea las tres llamadas. La llamada previa reduce algunos efectos de la primera ejecución, pero no estabiliza por completo la compilación y las optimizaciones del entorno .NET. Este pequeño banco permite observar tendencias; una evaluación de producción necesitaría más control del calentamiento, las asignaciones y las condiciones. Tampoco enfrentes sus milisegundos a los de Python como si hubiéramos preparado una comparación rigurosa entre lenguajes. No la hemos preparado.
Optimizar el cuello de botella, no el fragmento más vistoso
Al interpretar las salidas, comprueba primero que el programa termina sin fallos de igualdad y después observa cómo cambia cada operación al crecer el tamaño. El ensayo fuerza búsquedas fallidas y utiliza claves cortas. Si todas las consultas reales aciertan en la primera entrada, la lista hará mucho menos trabajo; si el catálogo cambia en cada consulta, no podrás amortizar el índice de la misma forma. Repite con la distribución de tu tarea antes de justificar una decisión con la distribución que acabamos de inventar.
Hay otra diferencia que suele desaparecer de las diapositivas: el índice preparado conserva una copia de las asociaciones de aquel momento. Si modificas el catálogo, tendrás que reconstruirlo o actualizarlo con una política explícita. Reutilizar un índice obsoleto puede producir errores con una rapidez admirable. En un proceso BIM conviene vincular esa estructura a la versión o al lote del que procede, en lugar de confiar en que nadie toque los datos entre dos pasos.
Para una aplicación mayor, mediría primero lectura, consultas externas, transformación, búsqueda y escritura por separado. Si descargar datos ocupa casi todo el recorrido, acelerar una búsqueda que apenas consume tiempo tendrá un efecto pequeño sobre el total. No afirmes que Revit o una API son el cuello de botella sin medirlos; tampoco hagas peticiones reales innecesarias para probar una comparación que puedes estudiar con datos preparados. Este artículo no ejecuta ninguna integración.
Python incluye un perfilador que ayuda a localizar funciones costosas. Sobre el archivo anterior puedes ejecutar:
La salida de cProfile permite observar llamadas y tiempo acumulado. Aquí incluirá también el propio mecanismo de repetición, las comprobaciones y la impresión, por lo que no equivale a los intervalos anteriores. El perfilado añade sobrecoste y sirve para encontrar dónde investigar; volvería a medir la operación aislada después del cambio. Para memoria necesitarás una medición específica de asignaciones y picos: contar entradas del diccionario no demuestra cuántos bytes consume el proceso.
Yo conservaría una optimización cuando mantenga los resultados, reduzca un coste relevante y pueda explicarse sin ocultar sus condiciones. En este caso, preparar un índice reutilizable tiene sentido para muchos lotes de consultas; en otro, un recorrido sencillo puede bastar. Si quieres contrastar un caso en los comentarios, indica tamaño del catálogo, número de consultas, porcentaje de aciertos y si el índice se reutiliza. «Con muchos elementos va lento» deja demasiadas decisiones a la imaginación, y ya tenemos bastante de eso en las estimaciones de entrega.
Respuestas