Lumænaut_

El arte del código lento

Un viaje a través de la complejidad algorítmica y la notación Big O

Arte a color ASCII: curvas de crecimiento para complejidades de tiempo comunes — O(1), O(log n), O(n), O(n log n), O(n²) — en un gráfico, ilustrando la intuición de Big O.

Seguramente te ha pasado. Escribes una pequeña función que funciona perfectamente en tu laptop, con su diminuto arreglo de prueba de cinco elementos. Luego la despliegas y, de repente, el servidor de producción comienza a llorar. Los registros se llenan de tiempos de espera agotados. El pool de conexiones a la base de datos llora. Un gerente de producto te mira con una decepción amable.

¿Qué pasó? En la mayoría de los casos, el código no estaba mal. Simplemente era demasiado lento para la escala. Y ahí es donde la complejidad algorítmica entra bailando, vistiendo un traje de matemáticas pero hablando el idioma de los bucles, los árboles de recursión y las estructuras de datos.

Vamos a desglosar la notación Big O — el pan y mantequilla del pensamiento sobre rendimiento — y también sumergirnos en el esqueleto matemático que la sustenta. Porque una vez que conectas el código con las matemáticas, la complejidad deja de sentirse como magia negra y empieza a sentirse como… bueno, magia predecible. Vamos allá.

¿Qué Diablos es la Complejidad Algorítmica?

La complejidad algorítmica es el estudio de cómo el uso de recursos de un algoritmo (tiempo o memoria) crece a medida que crece el tamaño de la entrada. Por lo general, nos obsesionamos con la complejidad temporal — cuántos pasos toma. Pero la complejidad espacial (memoria) también importa, especialmente si estás programando en una tostadora inteligente o manejando conjuntos masivos de datos.

Imagina que estás organizando una fiesta. Necesitas encontrar a un amigo llamado "Alex" en una lista de invitados. Si escaneas cada nombre uno por uno, eso es como una búsqueda lineal. Si tienes 10 invitados, está tranquilo. Si tienes 10,000 invitados, podrías perderte la fiesta tú mismo. La complejidad nos da el lenguaje para describir que "escaneando empeora proporcionalmente con más invitados".

Notación Big O: La Celebridad de la Complejidad

Big O describe el límite superior de la tasa de crecimiento de un algoritmo. A menudo se usa para describir el comportamiento en el peor de los casos, pero técnicamente puede aplicarse también al caso promedio o al mejor caso — simplemente solemos usarlo para responder "¿qué tan malo puede llegar a ser?". Ignoramos las constantes y los términos de orden inferior porque no afectan la forma del crecimiento a medida que n se hace grande. Por eso decimos que un algoritmo O(2n) sigue siendo O(n).

Clases comunes de Big O que encontrarás:

  • O(1) — Tiempo constante. No le importa el tamaño de tu entrada. Como acceder a un elemento de un arreglo por su índice.
  • O(log n) — Tiempo logarítmico. Agradable, eficiente. La búsqueda binaria vive aquí.
  • O(n) — Tiempo lineal. Un solo bucle sobre los datos.
  • O(n log n) — Cuasilineal. El límite óptimo para algoritmos de ordenamiento basados en comparación como merge sort.
  • O(n²) — Tiempo cuadrático. Bucles dobles anidados. Podrían hacer llorar a tu conjunto de datos.
  • O(2ⁿ) — Tiempo exponencial. Aparece a menudo en soluciones recursivas ingenuas para problemas combinatorios. Evítalo a menos que n sea muy pequeño.

Veámoslos en forma de código (Python3). Observa cómo se comportan los bucles.

Python 3

# O(1) — tiempo constante
def obtener_primer_elemento(arr):
    return arr[0]                             # sin importar el tamaño del arreglo, es un paso

# O(n) — lineal
def encontrar_maximo(arr):
    max_val = arr[0]
    for num in arr:                           # iteramos una vez sobre 'n' elementos
        if num > max_val:
            max_val = num
    return max_val

# O(n²) — cuadrático
def todos_los_pares(arr):
    resultado = []
    for i in range(len(arr)):                 # n iteraciones
        for j in range(len(arr)):             # n iteraciones por cada iteración externa
            resultado.append((arr[i], arr[j]))
    return resultado
              

¿La función todos_los_pares? Si le das 10 elementos, crea 100 pares. Si le das 10,000 elementos… 100 millones de pares. Tu memoria pedirá el divorcio.

La Sección de Matemáticas: Donde el Código y el Cálculo se Toman de la Mano

Bien, arremanguémonos. Aquí hay una sección matemática completa que vincula la complejidad del código con las matemáticas reales. Así que aquí está el secreto: cada vez que escribes un bucle, básicamente estás escribiendo una sumatoria — que es solo un término matemático elegante para sumar un montón de números. Si un bucle se ejecuta 10 veces y hace una operación cada vez, acabas de realizar 1 + 1 + 1 + … (10 veces) = 10 operaciones. Eso es una sumatoria. Cuando los bucles están anidados, el trabajo total se convierte en sumar secuencias que crecen en patrones, y esos patrones pueden describirse con fórmulas ordenadas.

De manera similar, cuando escribes una función recursiva — una que se llama a sí misma — el flujo de llamadas forma un árbol de recursión. Cada nivel del árbol representa un conjunto de llamadas recursivas, y el trabajo realizado a través de los niveles a menudo se suma en un patrón llamado serie geométrica, donde cada término es un múltiplo constante del anterior (como 1, 2, 4, 8, …). Reconocer ese patrón nos permite calcular el trabajo total con una fórmula simple en lugar de rastrear cada llamada individual.

Big O es lo que usamos para describir cómo se comportan estas sumas y series a medida que el tamaño de la entrada se vuelve muy, muy grande. Elimina las constantes y se enfoca en la forma del crecimiento — ya sea que se mantenga plano, crezca en línea recta o explote hacia arriba. En resumen: los bucles se convierten en sumas, la recursión se convierte en series, y Big O te dice qué sucede cuando las cosas se hacen grandes.

Supón que tienes un doble bucle simple:

Python 3

for i in range(n):
    for j in range(i, n):
        # hacer trabajo O(1)
              

Analicemos exactamente cómo convertimos ese bucle en una fórmula limpia. Aquí es donde ocurre la magia.

Paso 1 — Anota el trabajo por iteración del bucle externo.
Cuando i = 0, el bucle interno se ejecuta desde j = 0 hasta n-1. Eso son n pasos.
Cuando i = 1, el bucle interno se ejecuta desde j = 1 hasta n-1. Eso son n-1 pasos.
Cuando i = 2, obtenemos n-2 pasos.
Esto continúa hasta i = n-1, donde el bucle interno se ejecuta desde j = n-1 hasta n-1. Eso es solo 1 paso.

Así que el número total de operaciones es:

T(n) = n + (n-1) + (n-2) + … + 3 + 2 + 1

Paso 2 — Empareja términos desde el principio y el final.
Un truco clásico: escribe la suma hacia adelante y hacia atrás, luego súmalas.

S = n + (n-1) + (n-2) + … + 2 + 1
S = 1 + 2 + 3 + … + (n-1) + n

Suma las dos ecuaciones verticalmente, término por término:

(n + 1) + (n-1 + 2) + (n-2 + 3) + … + (1 + n)

Cada par suma n + 1. ¿Cuántos pares? Exactamente n. Entonces:

2S = n × (n + 1)

Paso 3 — Resuelve para S.
Divide ambos lados entre 2:

S = n(n+1) / 2

Paso 4 — Conecta con Big O.
Expande la fórmula:

S = (1/2)n² + (1/2)n

En Big O, eliminamos el factor constante 1/2 y el término de orden inferior (1/2)n porque no cambian la forma del crecimiento a medida que n se hace grande. Lo que queda es , así que el bucle se ejecuta en tiempo O(n²).

¿Ves? Las matemáticas no están aquí para asustarte; están aquí para revelar lo que el código realmente está haciendo en términos de crecimiento. Si alguna vez escribes un triple bucle anidado, estás viendo un crecimiento cúbico — O(n³) — que es esencialmente la versión discreta de una suma triple.

Ahora, ¿qué hay de la complejidad logarítmica? Considera la búsqueda binaria:

Python 3

def busqueda_binaria(arr, objetivo):
    izquierda, derecha = 0, len(arr)-1
    while izquierda <= derecha:
        medio = (izquierda + derecha)//2
        if arr[medio] == objetivo:
            return medio
        elif arr[medio] < objetivo:
            izquierda = medio + 1
        else:
            derecha = medio - 1
    return -1
              

Ahora apliquemos el mismo pensamiento paso a paso a la búsqueda binaria. En lugar de bucles anidados, tenemos un bucle que corta repetidamente el problema a la mitad. Rastreemos exactamente qué sucede.

Paso 1 — Anota el tamaño del espacio de búsqueda después de cada paso.
Empezamos con n elementos.
Después de 1 comparación: el espacio de búsqueda se convierte en n/2 (descartamos la mitad).
Después de 2 comparaciones: se convierte en n/4.
Después de 3 comparaciones: se convierte en n/8.
Después de k comparaciones: se convierte en n/(2^k).

El algoritmo se detiene cuando no queda nada que buscar — cuando el espacio de búsqueda es 1 elemento o menos:

n/(2^k) ≤ 1

Paso 2 — Resuelve para k (el número de pasos).
Multiplica ambos lados por 2^k:

n ≤ 2^k

Toma el logaritmo en base 2 de ambos lados (esa es la operación inversa de la exponenciación):

log₂ n ≤ k

Así que el número de pasos k es al menos log₂ n. Eso significa que la búsqueda binaria toma aproximadamente log₂ n pasos.

Paso 3 — Conecta con Big O.
Si n = 16, entonces log₂ 16 = 4. Revisamos como máximo 4 elementos antes de encontrar la respuesta o concluir que no está allí. Si n = 1,000,000, revisamos solo unos 20 elementos. Ese es el poder del tiempo logarítmico: cuando el tamaño de la entrada se duplica, solo añadimos un paso extra.

Paso 4 — Observa el patrón como una recurrencia (con un recorrido concreto).
También podemos describir la búsqueda binaria con una relación de recurrencia, que es una fórmula que define el trabajo en términos de entradas más pequeñas:

T(n) = T(n/2) + O(1)

Esto dice: el tiempo para buscar n elementos es igual al tiempo para buscar n/2 elementos (la llamada recursiva o la siguiente iteración del bucle), más una cantidad constante de trabajo por la comparación. Si esto todavía se siente abstracto, reemplacemos los símbolos con un número real — digamos n = 16 — y expandámoslo como si desenvolviéramos un conjunto de muñecas rusas:

  • Queremos T(16). La regla dice: T(16) = T(8) + 1 (una comparación, luego buscar la mitad restante).
  • ¿Pero qué es T(8)? T(8) = T(4) + 1.
  • T(4) = T(2) + 1.
  • T(2) = T(1) + 1.
  • T(1) = 1 (caso base: solo queda un elemento, haz una comprobación).

Ahora sustituye de abajo hacia arriba:

T(2) = 1 + 1 = 2
T(4) = 2 + 1 = 3
T(8) = 3 + 1 = 4
T(16) = 4 + 1 = 5

Entonces T(16) = 5. Observa que 5 = log₂(16) + 1. El conteo exacto puede variar en ±1 dependiendo de cómo definamos el caso base, pero el patrón es lo que importa: cada vez que duplicamos el tamaño de la entrada (de 8 a 16), solo añadimos un paso extra. Si escribimos la recurrencia en términos generales, estamos añadiendo efectivamente un "+1" por cada nivel de reducción a la mitad, y tenemos alrededor de log₂ n niveles. Más formalmente, expandiendo la recurrencia:

T(n) = T(n/2) + 1
T(n/2) = T(n/4) + 1
T(n/4) = T(n/8) + 1

T(1) = 1

Sustituyendo hacia atrás: T(n) = 1 + 1 + 1 + … + 1, y tenemos alrededor de log₂ n de esos 1's. Así que T(n) = log₂ n (más quizás una pequeña constante), que es O(log n). La recurrencia es solo una forma compacta de decir: "sigue cortando el problema a la mitad hasta que no puedas más, y cuenta cuántos cortes hiciste". Al igual que con el doble bucle convertimos iteraciones anidadas en una sumatoria, aquí convertimos la reducción repetida a la mitad en un conteo logarítmico de pasos. Las matemáticas revelan exactamente cuántas operaciones ocurren — sin magia, solo conteo cuidadoso.

También puedes usar el Teorema Maestro para recurrencias de divide y vencerás, pero esa es otra capa más. Digamos simplemente que las matemáticas proporcionan la gramática; los bucles y la recursión son las oraciones.

Entendiendo la Definición Formal (Sin la Intimidación)

Quizás has visto la definición formal de Big O y sentiste que tus ojos se vidriaban. Vamos a desglosarla en lenguaje sencillo con un ejemplo concreto para que deje de sentirse como matemáticas abstractas y empiece a sentirse como una herramienta útil.

La definición formal dice:

f(n) = O(g(n)) si existen constantes positivas c y n₀ tales que
0 ≤ f(n) ≤ c * g(n) para todo n ≥ n₀

Traduzcamos eso al pensamiento cotidiano. Imagina que intentas describir qué tan rápido se ejecuta tu código. En lugar de decir "toma exactamente 0.5n² + 0.5n pasos", que es preciso pero torpe, Big O te permite decir "crece como n²". La definición anterior es solo la forma matemática de decir: "Si observamos entradas suficientemente grandes (n ≥ n₀), el tiempo de ejecución real f(n) nunca será más que algún múltiplo constante de g(n)". En otras palabras, g(n) multiplicada por algún número fijo se convierte en un límite superior seguro.

Probemos esto con nuestro ejemplo del doble bucle. Calculamos que el número exacto de operaciones es:

f(n) = 0.5n² + 0.5n

Queremos probar que f(n) = O(n²). Según la definición, necesitamos encontrar dos números: una constante c y un punto de inicio n₀ tales que para cada n mayor que n₀, nuestro f(n) sea menor o igual que c * n².

Probemos con c = 1 y n₀ = 1. ¿Se cumple la desigualdad?

f(1) = 0.5(1)² + 0.5(1) = 0.5 + 0.5 = 1
c * n² = 1 * 1² = 1
1 ≤ 1 ✓

Probemos con un n más grande, digamos n = 10:

f(10) = 0.5(100) + 0.5(10) = 50 + 5 = 55
c * n² = 1 * 100 = 100
55 ≤ 100 ✓

Prueba con n = 100:

f(100) = 0.5(10000) + 0.5(100) = 5000 + 50 = 5050
c * n² = 1 * 10000 = 10000
5050 ≤ 10000 ✓

¡Funciona! De hecho, para cualquier n ≥ 1, 0.5n² + 0.5n es siempre menor o igual que . Puedes ver esto porque 0.5n² + 0.5n ≤ 0.5n² + 0.5n² = n² para n ≥ 1 (ya que 0.5n ≤ 0.5n² cuando n ≥ 1). Así que hemos probado que f(n) = O(n²).

¿Qué significa esto en la práctica? Significa que no importa cuán grande sea n, el tiempo de ejecución real de nuestro doble bucle nunca excederá 1 * n² (más quizás un poquito, pero la definición nos permite elegir una constante para absorber eso). La constante c = 1 funcionó aquí, pero a menudo podríamos necesitar una constante más grande como c = 2 o c = 5 — y eso está perfectamente bien. La definición solo requiere que alguna constante exista; no nos importa si es enorme. Por eso Big O se enfoca en los patrones de crecimiento, no en números exactos.

Ahora, un matiz importante: Big O da un límite superior. Si un algoritmo se ejecuta en O(n²), también es técnicamente correcto decir que se ejecuta en O(n³) o O(2ⁿ), porque esos también son límites superiores (simplemente más laxos). Pero en la práctica, normalmente queremos el límite superior más ajustado — el que mejor describe el crecimiento real del algoritmo. Cuando decimos "este algoritmo es O(n²)", normalmente queremos decir "crece aproximadamente como n², y ninguna función más simple da un límite más ajustado". Los matemáticos usan la notación Theta (Θ) para ese límite ajustado, pero en las conversaciones cotidianas de programación, Big O se usa informalmente para significar el límite ajustado más significativo. Solo recuerda que la definición formal es un poco más laxa — es una relación de "como máximo", no de "exactamente".

Complejidad Espacial: La Hermana Pasada por Alto

La complejidad temporal acapara toda la atención, pero la complejidad espacial es igualmente importante — especialmente cuando trabajas con memoria limitada o procesas grandes cantidades de datos. Mientras que la complejidad temporal pregunta "¿cuántos pasos toma esto?", la complejidad espacial pregunta "¿cuánta memoria extra necesita para ejecutarse?"

Cada vez que tu código crea un nuevo arreglo, almacena valores en un diccionario o hace una llamada recursiva que se acumula en la pila de llamadas, estás usando memoria. Si no tienes cuidado, un algoritmo que se ejecuta rápido podría fallar porque consume toda la RAM disponible. Así que desglosemos la complejidad espacial con un ejemplo concreto que muestra cuán drásticamente diferentes pueden ser dos soluciones al mismo problema.

Considera calcular el n-ésimo número de Fibonacci. La secuencia de Fibonacci comienza 0, 1, 1, 2, 3, 5, 8, … donde cada número es la suma de los dos anteriores. Hay una forma recursiva clásica de escribir esto, y es bellamente simple. Pero veamos qué le hace a nuestra memoria.

Python 3

def fibonacci_recursivo(n):
    if n <= 1:
        return n
    return fibonacci_recursivo(n-1) + fibonacci_recursivo(n-2)
              

Esto se ve elegante, pero observa qué sucede cuando llamamos a fibonacci_recursivo(5). La computadora no hace una sola llamada — hace todo un árbol de llamadas. Llama a fibonacci_recursivo(4) y fibonacci_recursivo(3). Cada una de esas hace más llamadas, y así sucesivamente. La clave para la complejidad espacial es: ¿qué tan profunda es esta cadena de llamadas pendientes antes de que comencemos a devolver respuestas?

Rastreémoslo paso a paso con n = 5:

  1. Llamamos a fibonacci_recursivo(5). Para obtener su respuesta, necesita fibonacci_recursivo(4) y fibonacci_recursivo(3). Así que se pausa, se pone en espera, y llama a fibonacci_recursivo(4).
  2. fibonacci_recursivo(4) necesita fibonacci_recursivo(3) y fibonacci_recursivo(2). Se pausa y llama a fibonacci_recursivo(3).
  3. fibonacci_recursivo(3) necesita fibonacci_recursivo(2) y fibonacci_recursivo(1). Se pausa y llama a fibonacci_recursivo(2).
  4. fibonacci_recursivo(2) necesita fibonacci_recursivo(1) y fibonacci_recursivo(0). Se pausa y llama a fibonacci_recursivo(1).
  5. fibonacci_recursivo(1) alcanza el caso base y devuelve 1 inmediatamente.

En su punto más profundo, teníamos 5 llamadas a funciones esperando a que sus sub-llamadas terminaran — una por cada nivel desde 5 hasta 1. En general, para una entrada n, la pila de llamadas crecerá hasta una profundidad de n antes de desenrollarse. Eso significa que esta versión recursiva usa O(n) espacio extra solo para mantener el registro de dónde está en todas esas llamadas anidadas.

Ahora mira la versión iterativa:

Python 3

def fibonacci_iterativo(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a+b
    return a
              

Esta versión usa solo dos variables: a y b. No importa si n es 10 o 10,000, nunca necesita más que esos dos números en memoria. No hay pila de llamadas acumulándose, ni arreglos extra. Esto se ejecuta en O(1) espacio extra — espacio constante.

Pongamos esto en perspectiva con números reales:

  • Para calcular fibonacci_recursivo(1000), la versión recursiva intentaría construir una pila de llamadas de aproximadamente 1,000 llamadas de profundidad. Eso probablemente haría que tu programa fallara con un error de desbordamiento de pila. También repite los mismos cálculos millones de veces, haciéndolo dolorosamente lento.
  • La versión iterativa calcula fibonacci_iterativo(1000) usando solo dos variables enteras y un bucle simple. Termina rápidamente y apenas usa memoria.

La lección aquí es que la complejidad temporal no es lo único a lo que hay que prestar atención. Cuando veas recursión, pregúntate: ¿qué tan profunda es la pila de llamadas? Cuando asignes una nueva lista o diccionario, pregúntate: ¿esto crece con el tamaño de la entrada? Cada lista que creas y que crece hasta tamaño n es O(n) espacio. Cada mapa hash que almacena una entrada por elemento de entrada también es O(n) espacio. A veces eso es necesario — no puedes evitarlo — pero a veces puedes reescribir tu código para usar menos.

Un buen hábito es escanear tu código y resaltar mentalmente cada lugar donde creas una estructura de datos o haces una llamada recursiva. Para cada uno, pregúntate: "¿esto se hace más grande a medida que crece la entrada?" Si la respuesta es sí, eso está contribuyendo a tu complejidad espacial. Si encuentras una estructura anidada — como una lista de listas — el espacio puede multiplicarse. Entender la complejidad espacial te ayuda a evitar esos errores de "memoria agotada" que aparecen cuando tus datos crecen más allá de lo que esperabas.

En resumen: la complejidad temporal te dice si tu código terminará antes de la muerte térmica del universo. La complejidad espacial te dice si tu código se ejecutará sin fallar por agotamiento de memoria. Presta atención a ambas.

De la Teoría a la Práctica: Por Qué Esto Importa

Big O no es solo para entrevistas de pizarra. Es para el momento en que estás procesando un millón de entradas de registro y tu simple enfoque O(n²) lleva al servidor a sus rodillas. Es para decidir entre un mapa hash (búsqueda promedio O(1)) y una lista de tuplas (búsqueda O(n)). Es para esas realizaciones a las 3 AM de que tu elegante código de una línea en realidad esconde un monstruo cuadrático.

Una vez que interiorices el análisis de complejidad, empezarás a ver el código de manera diferente. Verás bucles anidados y pensarás: "uh-oh, si estos datos crecen, estoy en problemas". Instintivamente recurrirás a diccionarios, conjuntos y patrones de divide y vencerás. Eso es lo bueno.

Ejemplo del Mundo Real: El Problema de Agrupación

Imagina que necesitas agrupar palabras por su primera letra. Una solución ingenua podría ser:

Python 3

def agrupar_palabras_ingenua(palabras):
    resultado = []
    for palabra in palabras:                  # O(n)
        encontrada = False
        for grupo in resultado:               # puede ser hasta O(n) en el peor caso
            if grupo[0][0] == palabra[0]:
                grupo.append(palabra)
                encontrada = True
                break
        if not encontrada:
            resultado.append([palabra])
    return resultado
              

Peor caso: cada palabra tiene una primera letra diferente, por lo que el bucle interno crece linealmente: O(n²). Pero con un diccionario, bajamos a tiempo O(n) y espacio O(k) donde k es el número de primeras letras distintas:

Python 3

def agrupar_palabras_rapida(palabras):
    grupos = {}
    for palabra in palabras:
        primera_letra = palabra[0]
        if primera_letra not in grupos:
            grupos[primera_letra] = []
        grupos[primera_letra].append(palabra)
    return list(grupos.values())
              

Ese es el poder de elegir la estructura de datos correcta — pasamos de O(n²) a O(n).

Errores Comunes y Conceptos Equivocados

  • Big O no es sobre velocidad — Es sobre crecimiento. Un algoritmo O(n) puede ser más lento que uno O(n²) para n pequeños si las constantes difieren enormemente. Siempre haz perfiles si el rendimiento importa.
  • "Complejidad amortizada" — Algunas operaciones, como agregar a un arreglo dinámico, son O(1) en promedio aunque ocasionalmente sean O(n) debido al redimensionamiento.
  • No todas las constantes son iguales — Un algoritmo con 1000n sigue siendo O(n), pero en la práctica, puede ser menos eficiente que uno de n log n para n moderados. Presta atención a la constante oculta.
  • Recursión ≠ siempre exponencial — La memoización convierte el Fibonacci exponencial en O(n). Eso es la programación dinámica haciendo de las suyas.
  • Las tablas hash no son siempre O(1) — En la práctica lo son, pero en teoría, la búsqueda en el peor caso es O(n) debido a colisiones de hash. Buenas funciones hash hacen de esto un evento raro.

Big O en el Mundo Real: Una Hoja de Referencia Rápida

Aquí hay una referencia práctica de operaciones comunes y su complejidad típica:

  • Acceso a arreglo por índice – O(1)
  • Búsqueda en arreglo no ordenado – O(n)
  • Búsqueda binaria en arreglo ordenado – O(log n)
  • Búsqueda/inserción en tabla hash – O(1) promedio, O(n) peor caso (raro)
  • Inserción en una lista (al principio) – O(n) (desplazando elementos)
  • Merge sort / Heap sort – O(n log n) (óptimo para ordenamiento basado en comparación)
  • Bubble sort / Insertion sort – O(n²) promedio/peor caso

Si memorizas estos, evitarás el 80% de las palmadas en la frente por rendimiento. El otro 20% está reservado para DP recursivo complejo y algoritmos de grafos.

Reflexiones Finales

La complejidad algorítmica y Big O no se tratan de hacerte sentir mal porque tu código no es "perfecto". Se tratan de darte las herramientas para hacer concesiones conscientes. A veces O(n²) está perfectamente bien porque n siempre es pequeño. A veces necesitas esa búsqueda binaria O(log n) porque n puede estar en los miles de millones. Y a veces, la solución más optimizada no vale la pena el esfuerzo si estás construyendo un prototipo que podría ser descartado la próxima semana.

Pero conocer la complejidad significa que puedes tomar esa decisión deliberadamente, en lugar de accidentalmente. Y cuando tu código finalmente corre rápido incluso con conjuntos de datos enormes, obtienes ese pequeño golpe de dopamina. Eso es lo que importa. Adelante, escribe bucles con intención — y que tu profundidad de recursión sea superficial.

Hacia Dónde Ir Después: Más Allá de Big O

Big O es solo el primer paso. También existe Big Omega (Ω) para límites inferiores y Big Theta (Θ) para límites ajustados. La mayoría de los ingenieros viven cómodamente con Big O y alguna sesión ocasional de perfiles, pero conocer la distinción te hace parecer un mago en las revisiones de código.

Si quieres profundizar más, aprende sobre análisis amortizado, NP-completitud, o explora cómo el hardware moderno (caché, predicción de ramas) puede hacer sonrojar a la teoría de la complejidad. La teoría es genial, pero medir el tiempo de ejecución real es el jefe final.

La complejidad es la brújula. El profiling es el camino.

Referencias y lecturas adicionales

¿No te basta con Lumænaut_? Lee esto…