Explicación de la complejidad temporal y espacial de un algoritmo

Explicación de la complejidad temporal y espacial de un algoritmo

Imagina dos programas que realizan exactamente la misma tarea. Si les introduces mil registros a cada uno, no podrás distinguirlos. Pero si les introduces mil millones, uno terminará antes de que sueltes la tecla Intro, mientras que el otro seguirá trabajando sin parar el martes que viene.

El mismo portátil. Los mismos datos. Lo que los diferencia es la complejidad temporal, que no es más que una forma de describir la rapidez con la que aumenta la carga de trabajo de un algoritmo a medida que se le proporciona más información.

Analizaremos qué significa realmente el término, cómo se representa con la notación Big O, cómo aplicarlo a un fragmento de código real y dónde entra en juego la complejidad espacial. Finalmente, llegaremos al punto en que todo esto deja de ser tarea y empieza a costar dinero de verdad: los contratos inteligentes.

¿Qué es la complejidad temporal de un algoritmo?

Olvídese de los segundos por un momento. La complejidad temporal se mide en pasos. Comparaciones, asignaciones, un poco de aritmética aquí y allá, y el total se expresa como una función de n, que es el tamaño de la entrada.

Esta es la función más aburrida que puedas imaginar:

python def print_all(items): for item in items: print(item)

¿Diez artículos? Diez recorridos por el circuito. ¿Diez mil? Diez mil recorridos.

El trabajo aumenta en línea recta junto a la entrada, por lo que lo llamamos O(n).

Ahora bien, tal vez cada viaje realmente requiera tres pequeñas operaciones en lugar de una. De acuerdo, eso es 3n. A nadie le importa, la verdad, porque duplicar la lista sigue duplicando el trabajo, y esa curva de crecimiento es lo que nos interesa. El número exacto se descarta a propósito.

Por eso mismo, todos los cursos de Estructuras de Datos y Algoritmos, y la mitad de las entrevistas de programación, hacen tanto hincapié en la complejidad temporal. Si puedes deducir la complejidad de un algoritmo con solo mirarlo, puedes adivinar cómo se comportará con datos de entrada que nunca llegaste a probar. Lo cual, en producción, representa la mayor parte de los datos.

Por qué el tiempo de ejecución es una medida deficiente de un algoritmo

Entonces, ¿por qué no ejecutarlo simplemente con un temporizador? Mucha gente lo hace, y lo entiendo. El problema es que el tiempo de ejecución añade un montón de cosas con las que tu algoritmo no tiene nada que ver:

  • Hardware. Mi portátil de cinco años y un servidor en la nube completamente nuevo te darán resultados muy diferentes para el mismo script.
  • Lenguaje de programación. Escribe el mismo bucle en C y en Python y podrías ver una diferencia de 10 veces, o incluso más.
  • Carga del sistema. Se inicia algún actualizador en segundo plano, el recolector de basura se activa, la caché está vacía. El resultado se mueve.
  • Cualquier dato que hayas obtenido. Un algoritmo de ordenación que funciona a la perfección con datos casi ordenados puede ser muy lento con datos desordenados.

Un cronómetro te da una sola medición en un solo dispositivo con una sola entrada. Si le pides cien veces más datos, no te dará nada.

El número de pasos no varía. Con el mismo algoritmo y la misma entrada, se obtiene el mismo número de pasos, ya sea Rust en un servidor o JavaScript en el teléfono de alguien. Por eso, la complejidad temporal es una comparación justa entre dos soluciones antes de realizar las pruebas de rendimiento. Aun así, yo las realizaría después, por si sirve de algo. Simplemente es una cuestión diferente.

Explicación de la notación Big O y otras notaciones asintóticas.

Los libros de texto la llaman notación asintótica, que suena peor de lo que es. Se deja que n crezca enormemente y se deja de prestar atención a los detalles, por lo que los multiplicadores constantes y las entradas pequeñas desaparecen y solo sobrevive la curva de crecimiento. Hay tres de ellas que vale la pena conocer:

Notación Lo que describe Significado simple Ejemplo
O grande, O(f(n)) Límite superior El algoritmo no crece más rápido que f(n). La búsqueda lineal es O(n).
Omega grande, Ω(f(n)) Límite inferior El algoritmo necesita al menos f(n) trabajo Cualquier ordenación por comparación es Ω(n log n)
Big-Theta, Θ(f(n)) Apretado Los límites superior e inferior coinciden. La ordenación por fusión es Θ(n log n)

En la práctica, escucharás la notación Big O aproximadamente el 95% de las veces. Establece un límite superior, lo que significa que el tiempo de ejecución no crecerá más rápido que la tasa dentro de los paréntesis. En mi primera revisión de código en un trabajo real, un desarrollador senior escribió "O(n²), por favor, corrígelo" junto a un bucle anidado, y lo que quería decir era que la complejidad temporal en el peor de los casos era cuadrática y la lista estaba a punto de hacerse mucho más larga.

Omega y Theta se usan principalmente en cursos universitarios sobre análisis de algoritmos, y creo que hace años que no escribo ninguna de las dos en el trabajo. La notación Big O gana porque responde a la pregunta que la gente realmente hace en una reunión: "¿Qué tan grave se vuelve esto si el tráfico se triplica?".

Hay un detalle técnico que me confundió durante un tiempo. Dado que la notación Big O solo representa un límite superior, una función O(n) también es O(n²), estrictamente hablando, del mismo modo que una persona de 1,52 metros (5 pies) mide menos de 2,13 metros (7 pies). Cierto, pero inútil. Cada quien cita el límite más estricto que puede justificar, y yo haré lo mismo.

Complejidad temporal y complejidad espacial

Clases de complejidad temporal comunes con ejemplos

No hay muchas clases que verás en código real, tal vez siete si contamos las más complejas. Esto es aproximadamente lo que cuesta cada una con n = 1000:

Clase Nombre Ejemplo típico Operaciones con n = 1000
O(1) Constante Índice de matriz, búsqueda en tabla hash 1
O(log n) Logarítmico Búsqueda binaria ~10
En) Lineal Escanear una lista 1.000
O(n log n) Linearítmico Ordenación por fusión, ordenación rápida (promedio) ~10.000
O(n²) Cuadrático Bucles anidados, ordenación de burbuja 1.000.000
O(2ⁿ) Exponencial Fibonacci recursivo ingenuo Más que átomos en el universo
¡En!) Factorial Vendedor ambulante de fuerza bruta Aún más grande

O(1): Tiempo constante

Ya sea que tu diccionario de Python tenga diez claves o diez millones, extraer una por clave cuesta prácticamente lo mismo, al igual que leer items[5] de una lista. Esa uniformidad es la clave del tiempo constante.

O(log n): Tiempo logarítmico

Pienso en estos algoritmos como el de "adivina el número entre 1 y 100", donde cada intento descarta la mitad de lo que queda. La búsqueda binaria hace exactamente eso en una lista ordenada:

python def binary_search(arr, target): lo, hi = 0, len(arr) - 1 while lo <= hi: mid = (lo + hi) // 2 if arr[mid] == target: return mid if arr[mid] < target: lo = mid + 1 else: hi = mid - 1 return -1

Con mil millones de elementos ordenados, necesita unas 30 comparaciones, mientras que revisarlos uno por uno podría llevar los mil millones completos, y nunca he dejado de encontrar eso gracioso.

O(n) y O(n log n)

Un algoritmo lineal visita cada elemento un número fijo de veces, como si buscaras a simple vista el número más grande en una columna de una hoja de cálculo. O(n log n) es el umbral para una ordenación eficiente. El algoritmo de ordenación por fusión divide la lista en elementos individuales (log n niveles de división) y luego realiza una pasada lineal por nivel durante la fusión, por lo que su tiempo de ejecución se mantiene constante independientemente de si los datos llegan ordenados o desordenados.

O(n²) y peor

El código cuadrático casi siempre tiene un bucle dentro de otro bucle, como esta comprobación de duplicados que compara cada par:

python def has_duplicate(items): for i in range(len(items)): for j in range(i + 1, len(items)): if items[i] == items[j]: return True return False

El crecimiento exponencial y factorial es muchísimo peor. El método recursivo de Fibonacci de los libros de texto resuelve los mismos subproblemas una y otra vez, por lo que cada incremento de n prácticamente duplica el trabajo. Con n = 20 no lo notarás; con n = 60 puedes ir a preparar la cena, comerla, lavar los platos y aún así no habrá terminado.

Cómo calcular la complejidad temporal paso a paso

Casi nunca se necesita una demostración formal para calcular la complejidad temporal. Repaso mentalmente esta lista y abarca la mayor parte del código que veo:

  1. Defina n. Decida cuál es el tamaño de entrada: la longitud de una lista, el número de nodos en un grafo, el número de dígitos en un número.
  2. Cuenta el trabajo en cada línea. Las instrucciones simples (asignaciones, comparaciones, aritméticas) cuestan O(1).
  3. Añade bloques secuenciales. Dos bucles uno tras otro, cada uno sobre n, dan como resultado n + n = 2n.
  4. Multiplicar bloques anidados. Un bucle sobre n dentro de otro bucle sobre n da como resultado n × n = n².
  5. Elimina las constantes. 2n se convierte en n, y 500 se convierte en 1.
  6. Conserva solo el término dominante. En n² + n, la parte n² predomina sobre el resto a medida que n aumenta.

Un ejemplo rápido. El resultado es T(n) = 3n² + 5n + 2. Sustituimos un millón: 3n² son tres billones, 5n son cinco millones. Error de redondeo. Lo llamamos O(n²), listo.

Los bucles que se saltan uno en lugar de avanzar paso a paso son más astutos:

python def count_halvings(n): steps = 0 while n > 1: n //= 2 steps += 1 return steps

Cada iteración reduce n a la mitad, por lo que este bucle while se detiene después de aproximadamente log₂(n) iteraciones. Esta es, básicamente, la respuesta a la pregunta "¿cuándo la complejidad temporal es O(log n)?". Si lo que queda se reduce por un factor constante en cada paso, se ha alcanzado ese punto.

La recursión requiere una herramienta más: una recurrencia. El algoritmo de ordenación por fusión realiza dos operaciones de tamaño reducido y luego una fusión, por lo que T(n) = 2T(n/2) + n. El teorema maestro es una guía rápida para recurrencias de esa forma, y para esta en particular arroja un valor de O(n log n).

Y una regla general de la programación competitiva en la que me apoyo mucho: una máquina normal realiza aproximadamente 10⁸ operaciones simples por segundo. Es una aproximación, pero suficiente para comprobar la complejidad temporal de una solución antes de enviarla. Con n = 10⁵, O(n²) son aproximadamente 10¹⁰ operaciones, por lo que se agota el tiempo. O(n log n) es inferior a 2 millones. Se ejecuta sin problemas.

Análisis del peor caso, el mejor caso y el caso promedio

Si se introducen diferentes datos en el mismo algoritmo, la cantidad de trabajo puede variar enormemente. La búsqueda lineal lo demuestra claramente:

  • En el mejor de los casos, lo que buscas está justo al frente. Una comparación y te quedas fuera.
  • En el peor de los casos: está al final, o no está en absoluto. Eso son comparaciones.
  • Caso promedio: si pudiera estar en cualquier lugar con igual probabilidad, comprobarás alrededor de n/2 en promedio. Aun así, O(n), ojo.

Si le preguntas a un desarrollador sobre "la complejidad" de algo, casi siempre te dará la complejidad temporal en el peor de los casos, porque ese número es una promesa en torno a la cual puedes planificar.

Supongamos que tu API procesa una solicitud en O(n) como máximo. Perfecto, ahora sabes cómo se comporta la solicitud más lenta y, además, estás protegido si alguien te envía datos basura a propósito. Esto es algo que ocurre con frecuencia: si un atacante descubre qué claves provocan una colisión en tu tabla hash, esas serán las únicas claves que verás durante un tiempo.

Vale la pena analizar el caso promedio cuando el escenario desfavorable rara vez se presenta. Quicksort es el ejemplo paradigmático, con una complejidad promedio de O(n log n), pero que puede llegar a O(n²) si se eligen pivotes de forma ingenua en datos que ya están ordenados. Las bibliotecas estándar solucionan este problema utilizando pivotes aleatorios o cambiando a un algoritmo de ordenación diferente cuando la recursión alcanza niveles excesivamente profundos.

¿Qué es la complejidad espacial y el espacio auxiliar?

La memoria es otro gasto que conlleva un algoritmo. La complejidad espacial indica cuánta memoria se necesita a medida que aumenta el tamaño de la entrada, y se expresa con la misma notación Big O que el tiempo.

Parte de esa memoria es la entrada en sí, por la que pagas independientemente de lo que hagas con ella. La parte que realmente controlas se llama espacio auxiliar, es decir, la información adicional que tu código asigna mientras se ejecuta, como variables temporales y nuevos arreglos, además de la pila de llamadas que nadie recuerda hasta que les causa problemas.

Algunos ejemplos rápidos:

  • Intercambiar dos variables necesita un par de ranuras independientemente de cualquier otra cosa, por lo que es un espacio auxiliar O(1).
  • Copiar una lista de n elementos en una lista nueva le cuesta O(n) memoria adicional.
  • La recursión a n niveles de profundidad deja n marcos en la pila, lo que es un espacio O(n) aunque nunca hayas construido una matriz.

Vi a un desarrollador junior perder una tarde entera con ese último problema. Su función recursiva parecía no usar memoria en absoluto, pero cada llamada incompleta se quedaba en la pila esperando a que las más profundas devolvieran el resultado, y con una entrada grande, todo fallaba con un desbordamiento de pila.

Gran parte de la optimización consiste simplemente en intercambiar memoria por tiempo, o viceversa. La memorización almacena en caché los resultados ya calculados, lo que reduce el tiempo de cálculo de Fibonacci de O(2ⁿ) a O(n) a cambio de O(n) de memoria. Las tablas hash ofrecen un promedio de O(1) consultas al ocupar espacio en cubetas. En un servidor con 64 GB de RAM, este intercambio suele ser una decisión obvia, pero en un microcontrolador o dentro de un contrato inteligente, conviene pensarlo dos veces.

Complejidad temporal y complejidad espacial

Complejidad temporal y espacial de las estructuras de datos comunes

Elegir la estructura de datos adecuada suele ser más efectivo que optimizar minuciosamente el código dentro del bucle. Costos promedio para las estructuras que usarás con mayor frecuencia:

Estructura de datos Acceso Buscar Insertar Borrar Espacio
Formación O(1) En) En) En) En)
Lista enlazada En) En) O(1) O(1) En)
Tabla hash n / A O(1) O(1) O(1) En)
Árbol de búsqueda binaria equilibrado O(log n) O(log n) O(log n) O(log n) En)
Montón binario O(1) para min/max En) O(log n) O(log n) En)

Dos notas al pie. Las inserciones y eliminaciones en listas enlazadas solo tienen una complejidad de O(1) si ya se posee el nodo. Y una tabla hash puede llegar a tener una complejidad de O(n) cuando se producen colisiones entre muchas claves.

Los algoritmos de ordenación varían igualmente:

Algoritmo de ordenación Mejor Promedio El peor Espacio auxiliar
Clasificación de burbujas En) O(n²) O(n²) O(1)
Ordenación por fusión O(n log n) O(n log n) O(n log n) En)
Ordenación rápida O(n log n) O(n log n) O(n²) O(log n)
Ordenación por montículos O(n log n) O(n log n) O(n log n) O(1)

El algoritmo Merge Sort es predecible, pero requiere memoria adicional. El algoritmo Heap Sort ordena in situ y mantiene el mismo resultado en el peor de los casos. Quicksort, a pesar de su límite superior menos eficiente, suele ser superior en la práctica.

Complejidad temporal en blockchain y contratos inteligentes

Aquí es donde empieza lo interesante. En tu propio servidor, un algoritmo deficiente consume CPU y puede molestar a algún usuario. En Ethereum o cualquier cadena EVM, supone un derroche de dinero. Cada operación que ejecuta un contrato inteligente cuesta gas, que se paga con la criptomoneda nativa de la cadena, por lo que el número de operaciones aparece en la factura de alguien. En otras palabras, una mala complejidad temporal tiene un precio.

Digamos que tienes un contrato que recorre a todos los poseedores de tokens para pagar recompensas. O(n), y el gas aumenta gradualmente con cada nuevo poseedor. El día del lanzamiento nadie se da cuenta.

Entonces, la lista de poseedores sigue creciendo. Un día, el pago requiere más gas que el límite de gas del bloque, que es el tope de la cantidad de gas que puede contener un solo bloque. Después de eso, la transacción no se puede completar. No es que sea lenta. Simplemente nunca se completa, y los fondos que estaban esperando detrás de esa función quedan bloqueados. Los auditores clasifican los bucles infinitos como este como denegación de servicio, y con razón.

Las soluciones son, simple y llanamente, pensar en la complejidad:

  • Divide el trabajo en lotes de tamaño fijo a lo largo de varias transacciones.
  • Dale la vuelta para que cada usuario reclame su propia recompensa. Cada llamada es O(1).
  • Sustituye el bucle por una prueba Merkle.

Me encantan las pruebas Merkle como ejemplo de por qué log n es tan importante en la cadena de bloques. Un árbol Merkle aplica un hash a una gran cantidad de datos, convirtiéndolos en un único hash raíz.

Para demostrar que una entrada pertenece a un conjunto de n, se necesitan aproximadamente log₂(n) hashes. Para un millón de entradas, eso equivale a unos 20, no a un millón. Los airdrops se basan en esto, y las carteras ligeras de Bitcoin utilizan el mismo truco para confirmar una transacción sin descargar el bloque completo.

Fuera de la cadena, ocurre lo mismo. Imagina un sistema de pago que relaciona cada transacción entrante de la cadena de bloques con una factura escaneando todas las facturas abiertas. Esto implica una complejidad de O(n) por pago, y se vuelve más lento a medida que crece el negocio. Si colocas las facturas en un mapa hash indexado por dirección o ID, cada coincidencia tiene una complejidad de O(1). Y si prefieres no desarrollar ni gestionar tú mismo esta infraestructura, una pasarela de pago de criptomonedas como Plisio lo hace por ti.

¿Alguna pregunta?

No, porque el conteo de operaciones es relativo al tamaño de la entrada y el hardware no influye en dicho conteo. Lo que sí varía de una máquina a otra es el tiempo de ejecución. Una CPU rápida termina una tarea de complejidad O(n²) antes que una lenta, pero si se duplica la entrada en cualquiera de ellas, el trabajo se cuadruplica aproximadamente.

Tiempo constante, O(1), ya que el trabajo nunca aumenta. El logaritmo, O(log n), ocupa un cercano segundo lugar y procesa mil millones de elementos en aproximadamente 30 pasos. Le sigue el lineal, luego O(n log n), después el cuadrático, y las clases exponenciales cierran la lista.

Cuando las entradas son grandes, sí, pero con entradas pequeñas puede ser cualquiera. La notación Big O ignora los factores constantes, por lo que una llamada O(1) que cuesta 10 000 pasos puede ser menos eficiente que un bucle O(n) sobre 50 elementos, razón por la cual escanear un array pequeño a veces supera a una búsqueda en una tabla hash.

La complejidad temporal describe cómo aumenta el número de operaciones con la entrada, y la complejidad espacial describe cómo aumenta la memoria. El algoritmo de ordenación por fusión es rápido, pero requiere un array adicional de O(n), mientras que el algoritmo de ordenación de burbuja es lento y funciona con un espacio de O(1), por lo que no es necesario que se muevan juntos.

Todo se reduce a cómo se mueve la variable del bucle. Si se incrementa de uno en uno hasta n, se obtiene O(n), si se duplica o se reduce a la mitad en cada iteración, se obtiene O(log n), y si hay otro bucle sobre n dentro del cuerpo, se multiplican los dos.

Te permite comparar algoritmos según su escalabilidad sin necesidad de utilizar una máquina específica. Eliminas las constantes y los términos pequeños, conservas el límite superior y obtienes una visión clara de cómo se comporta un algoritmo a medida que aumenta la entrada, que suele ser lo que necesitas al elegir entre dos enfoques.

Ready to Get Started?

Create an account and start accepting payments – no contracts or KYC required. Or, contact us to design a custom package for your business.

Make first step

Always know what you pay

Integrated per-transaction pricing with no hidden fees

Start your integration

Set up Plisio swiftly in just 10 minutes.