Complejidad y notación Big-O

"Programar sin entender complejidad es como manejar sin entender velocidad."

Qué vas a aprender en este capítulo

Antes de cualquier estructura concreta, necesitás un lenguaje para hablar de "qué tan rápido es un algoritmo". Ese lenguaje es la notación asintótica — la famosa Big-O. Vas a aprender a calcularla mirando código, a comparar algoritmos sin necesidad de ejecutarlos, y a tomar decisiones de diseño basadas en mediciones, no en intuición. Al final, el lenguaje "este algoritmo es O(n2)O(n^2) pero el otro es O(nlogn)O(n \log n)" va a ser tan natural como hablar de horas o kilómetros.

1.1 La idea: medir lo que importa

💡 Intuición

Tenés dos algoritmos para ordenar una lista:

  • Algoritmo A: tarda 0.001n20.001 \cdot n^2 segundos.
  • Algoritmo B: tarda 0.1nlogn0.1 \cdot n \log n segundos.

Para n=100n = 100:

  • A: 0.00110,000=100.001 \cdot 10{,}000 = 10 segundos.
  • B: 0.1100log1000.11006.6=660.1 \cdot 100 \cdot \log 100 \approx 0.1 \cdot 100 \cdot 6.6 = 66 segundos.

A gana con datos chicos.

Para n=1,000,000n = 1{,}000{,}000:

  • A: 0.0011012=1090.001 \cdot 10^{12} = 10^9 segundos = 31 años.
  • B: 0.110620=21060.1 \cdot 10^6 \cdot 20 = 2 \cdot 10^6 segundos = 23 días.

B gana con datos grandes — y por mucho.

Lo que importa para juzgar algoritmos es cómo crecen con nn, no cuánto tardan en un caso específico. Una constante chica solo te ayuda mientras nn es chico; el orden de crecimiento te define a la larga.

La notación Big-O captura justamente eso: ignora constantes y términos secundarios, se queda con el comportamiento dominante.

1.2 Notación asintótica formal

📐 Fundamento

Big-O (OO) — cota superior

f(n)=O(g(n))f(n) = O(g(n)) si existe c>0c > 0 y n0n_0 tales que f(n)cg(n)f(n) \leq c \cdot g(n) para todo nn0n \geq n_0.

En palabras: ff no crece más rápido que gg (a la larga, ignorando constantes).

Ejemplos:

  • 3n+5=O(n)3n + 5 = O(n). (Para nn grande, 3n3n domina al 5; constante 3 se ignora.)
  • n2+100n=O(n2)n^2 + 100n = O(n^2). (Para nn grande, n2n^2 domina.)
  • 5logn=O(logn)5 \log n = O(\log n).
  • 1000=O(1)1000 = O(1). (Una constante es O(1)O(1).)
  • n=O(n2)n = O(n^2). (Sí — porque nn no crece más rápido que n2n^2. Big-O da una cota superior, no necesariamente apretada.)

Big-Omega (Ω\Omega) — cota inferior

f(n)=Ω(g(n))f(n) = \Omega(g(n)) si f(n)cg(n)f(n) \geq c \cdot g(n) para nn grande.

En palabras: ff crece al menos tan rápido como gg.

Big-Theta (Θ\Theta) — cota apretada

f(n)=Θ(g(n))f(n) = \Theta(g(n)) si ff es a la vez O(g)O(g) y Ω(g)\Omega(g).

En palabras: ff y gg crecen al mismo orden.

En la práctica casi siempre decimos "Big-O" aunque queramos decir Big-Theta. No es 100% formal pero la convención es así.

Reglas para simplificar

  1. Constantes se ignoran. 5n3n35n^3 \to n^3.
  2. Términos no dominantes se ignoran. n2+nn2n^2 + n \to n^2.
  3. El término más rápido manda. logn+nn\log n + n \to n.

Jerarquía común (de más rápido a más lento):

O(1)<O(logn)<O(n)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)O(1) < O(\log n) < O(\sqrt{n}) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)

1.3 Las clases más comunes

📐 Fundamento

O(1)O(1) — Constante

No depende del tamaño. Acceso directo, lookup en hash, asignación.

def primero(lista):
    return lista[0]      # O(1) sin importar el tamaño

O(logn)O(\log n) — Logarítmica

Cada paso reduce el problema a la mitad. Búsqueda binaria, operaciones en árboles balanceados, exponenciación rápida.

def buscar_binario(lista, x):    # O(log n)
    lo, hi = 0, len(lista) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if lista[mid] == x: return mid
        if lista[mid] < x: lo = mid + 1
        else: hi = mid - 1
    return -1

Para n=109n = 10^9, log2n30\log_2 n \approx 30. 30 comparaciones para 1000 millones. Mágico.

O(n)O(n) — Lineal

Una pasada por los datos. Búsqueda lineal, suma, máximo.

def maximo(lista):       # O(n)
    m = lista[0]
    for x in lista[1:]:
        if x > m: m = x
    return m

O(nlogn)O(n \log n) — Cuasi-lineal

Algoritmos de ordenamiento eficientes (mergesort, heapsort, Timsort). Es el mejor caso para ordenamiento por comparación.

sorted(lista)            # O(n log n) en promedio

O(n2)O(n^2) — Cuadrática

Bucle anidado. Algoritmos simples de ordenamiento (bubble, selection, insertion).

def ordenamiento_burbuja(lista):     # O(n²)
    n = len(lista)
    for i in range(n):
        for j in range(n - i - 1):
            if lista[j] > lista[j+1]:
                lista[j], lista[j+1] = lista[j+1], lista[j]

Para n=104n = 10^4, n2=108n^2 = 10^8. Lento pero manejable. Para n=106n = 10^6, inviable.

O(2n)O(2^n) — Exponencial

Cada elemento duplica el trabajo. Subsets, recursión sin memoización (Fibonacci ingenuo), backtracking sin podas.

def fib(n):              # O(2^n)
    if n < 2: return n
    return fib(n-1) + fib(n-2)

Para n=40n = 40, ya tarda segundos. Para n=60n = 60, siglos.

O(n!)O(n!) — Factorial

Permutaciones. Algoritmos de fuerza bruta sobre todas las combinaciones.

Para n=12n = 12, ya estás en mil millones. Para n=20n = 20, totalmente inviable.

Comparación visual

n logn\log n nn nlognn \log n n2n^2 2n2^n
10 3 10 33 100 1024
100 7 100 700 10K enorme
1000 10 1K 10K 1M inalcanzable
10K 13 10K 130K 100M "
1M 20 1M 20M 1T "

1.4 Cómo calcular complejidad

📐 Fundamento

Bucle simple

for i in range(n):
    op_constante()
# Total: O(n)

nn iteraciones, cada una O(1)O(1). Total: O(n)O(n).

Bucle anidado

for i in range(n):
    for j in range(n):
        op_constante()
# Total: O(n²)

nn=n2n \cdot n = n^2 ejecuciones.

Bucle con paso variable

i = 1
while i < n:
    op_constante()
    i *= 2
# Total: O(log n)

ii duplica cada paso → log2n\log_2 n iteraciones.

Bucle dependiente

for i in range(n):
    for j in range(i):
        op_constante()

Total: 0+1+2++(n1)=n(n1)2=O(n2)0 + 1 + 2 + \ldots + (n-1) = \frac{n(n-1)}{2} = O(n^2).

Recursión

Para una recursión con aa llamadas que reducen el problema a tamaño n/bn/b con O(nd)O(n^d) trabajo extra, el Teorema Maestro dice:

T(n)=aT(n/b)+O(nd)    T(n)={O(nd)si a<bdO(ndlogn)si a=bdO(nlogba)si a>bdT(n) = aT(n/b) + O(n^d) \implies T(n) = \begin{cases} O(n^d) & \text{si } a < b^d \\ O(n^d \log n) & \text{si } a = b^d \\ O(n^{\log_b a}) & \text{si } a > b^d \end{cases}

Ejemplos:

  • Búsqueda binaria: T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1). a=1,b=2,d=0a=1, b=2, d=0. Caso a=bda = b^dO(logn)O(\log n).
  • Mergesort: T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n). a=2,b=2,d=1a=2, b=2, d=1. Caso a=bda = b^dO(nlogn)O(n \log n).
  • Karatsuba (multiplicación): T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n). a>bda > b^dO(nlog23)O(n1.58)O(n^{\log_2 3}) \approx O(n^{1.58}).

Análisis amortizado

A veces una operación parece cara pero en promedio es barata.

Ejemplo: append a una lista dinámica.

Una lista dinámica (Python list) tiene capacidad fija. Cuando se llena, se duplica. La copia es O(n)O(n), pero pasa rara vez.

Si hacés nn appends:

  • nn operaciones, suma total de copias: 1+2+4+8++n2n1 + 2 + 4 + 8 + \ldots + n \approx 2n.
  • Promedio por append: O(1)O(1) amortizado.

Por eso list.append() se considera O(1)O(1) aunque a veces tarde más.

1.5 Tiempo vs espacio

📐 Fundamento

Big-O se aplica a tiempo (operaciones) y espacio (memoria) por igual.

def duplicar(lista):           # tiempo O(n), espacio O(n)
    return [x * 2 for x in lista]

def duplicar_inplace(lista):   # tiempo O(n), espacio O(1)
    for i in range(len(lista)):
        lista[i] *= 2

Las dos versiones son O(n)O(n) en tiempo. Pero la primera crea una copia (O(n)O(n) en espacio), la segunda no.

Trade-off típico: memoización gasta O(n)O(n) espacio para reducir O(2n)O(2^n) tiempo a O(n)O(n). Vale la pena casi siempre.

Espacio en recursión: la pila ocupa O(profundidad)O(profundidad). Una recursión que va a nn niveles consume O(n)O(n) espacio incluso si su tiempo es O(logn)O(\log n).

1.6 Casos: peor, mejor, promedio

📐 Fundamento

Una función puede tardar distinto según los datos:

def buscar(lista, x):
    for i, e in enumerate(lista):
        if e == x: return i
    return -1
  • Mejor caso: xx está en posición 0 → O(1)O(1).
  • Peor caso: xx no está → O(n)O(n).
  • Caso promedio: asumiendo posición uniforme, O(n/2)=O(n)O(n/2) = O(n).

Convención: cuando decimos "Big-O" sin clarificar, hablamos del peor caso. Es lo más útil para ingeniería: garantiza límite superior.

Excepciones notables:

  • Quicksort: peor O(n2)O(n^2), promedio O(nlogn)O(n \log n). Casi siempre se reporta el promedio.
  • Tablas hash: peor O(n)O(n) (si todo colisiona), promedio O(1)O(1). Se reporta el promedio.

Saber distinguir es importante. Un algoritmo con peor caso malo pero promedio bueno puede ser inaceptable para sistemas críticos (donde necesitás garantías).

1.7 Medir empíricamente

🛠️ En la práctica

El análisis teórico es indispensable, pero también medir ayuda a confirmar.

import time

def medir(funcion, *args):
    inicio = time.perf_counter()
    resultado = funcion(*args)
    fin = time.perf_counter()
    return fin - inicio, resultado


# Ejemplo: comparar búsqueda lineal vs binaria
import random

def lineal(lista, x):
    for e in lista: 
        if e == x: return True
    return False

def binaria(lista, x):
    lo, hi = 0, len(lista) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if lista[mid] == x: return True
        if lista[mid] < x: lo = mid + 1
        else: hi = mid - 1
    return False


for n in [100, 1000, 10_000, 100_000, 1_000_000]:
    lista = sorted(random.sample(range(n*10), n))
    target = lista[-1]
    
    t_lin, _ = medir(lineal, lista, target)
    t_bin, _ = medir(binaria, lista, target)
    
    print(f"n={n:>8}: lineal={t_lin*1000:.3f}ms binaria={t_bin*1000:.3f}ms")

Vas a ver:

  • Lineal: tiempo crece proporcionalmente a nn.
  • Binaria: tiempo crece muy levemente — logn\log n.

Es espectacular ver cómo coinciden los números con la teoría.

timeit para mediciones más serias:

import timeit
print(timeit.timeit("sorted([3,1,4,1,5,9,2,6])", number=100000))

1.8 Aplicaciones prácticas

🛠️ En la práctica

Elegir estructura

Tenés 1 millón de productos y vas a buscar precios por código miles de veces.

  • Lista: lookup O(n)O(n) → 1M comparaciones por búsqueda. Inviable.
  • Lista ordenada + búsqueda binaria: O(logn)O(\log n) → ~20 comparaciones.
  • Dict: O(1)O(1) → 1 lookup. Ganador.

Detectar ineficiencia

Tu programa funciona con 100 datos, tarda mucho con 10,000. Probable indicio: algún algoritmo es O(n2)O(n^2) o peor. Buscá bucles anidados.

Optimizar

Convertir O(n2)O(n^2) a O(nlogn)O(n \log n) usando ordenamiento + búsqueda binaria, o O(n)O(n) usando dict, suele ser el truco.

Ejemplo: "encontrar todos los pares (i, j) con suma = k".

  • Naive O(n²): dos bucles anidados.
  • Mejor O(n): un dict que registra "ya vi este número".
def pares_con_suma(lista, k):
    visto = set()
    pares = []
    for x in lista:
        if k - x in visto:
            pares.append((k - x, x))
        visto.add(x)
    return pares

Una sola pasada, dict para lookup O(1)O(1), total O(n)O(n).

1.9 Resumen visual

Clase Ejemplo
O(1)O(1) Acceso a array, dict lookup
O(logn)O(\log n) Búsqueda binaria, BST balanceado
O(n)O(n) Lineal, suma, máximo
O(nlogn)O(n \log n) Mergesort, heapsort
O(n2)O(n^2) Bubble sort, doble bucle
O(n3)O(n^3) Triple bucle, multiplicación de matrices naive
O(2n)O(2^n) Subsets, recursión sin memo
O(n!)O(n!) Permutaciones, TSP brute force
Reglas
Ignorar constantes: 5nn5n \to n
Ignorar términos no dominantes: n2+nn2n^2 + n \to n^2
Big-O = peor caso, salvo aclaración
Espacio cuenta también

1.10 Ejercicios

✏️ Ejercicio 1.1 — Identificar complejidad

¿Cuál es la complejidad de cada función?

def a(n):
    s = 0
    for i in range(n):
        s += i
    return s

def b(n):
    s = 0
    for i in range(n):
        for j in range(n):
            s += 1
    return s

def c(n):
    i = 1
    s = 0
    while i < n:
        s += 1
        i *= 2
    return s

def d(lista):
    if not lista: return 0
    return lista[0] + d(lista[1:])

✏️ Ejercicio 1.2 — Comparar

Tenés que procesar 1 millón de elementos. Te ofrecen tres algoritmos:

  • A: O(n2)O(n^2), constante chica.
  • B: O(nlogn)O(n \log n), constante mediana.
  • C: O(n)O(n) pero usa O(n)O(n) memoria adicional.

¿Cuál elegirías y por qué?

✏️ Ejercicio 1.3 — Optimizar

Esta función chequea si una lista tiene duplicados. ¿Su complejidad? ¿Cómo la mejorarías?

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

✏️ Ejercicio 1.4 — Análisis amortizado

Implementá una pila con array dinámico (que crezca cuando se llena). Argumentá por qué push es O(1)O(1) amortizado.

1.11 Para profundizar


Definiciones nuevas: complejidad, tiempo, espacio, Big-O, Big-Omega, Big-Theta, peor/mejor/promedio caso, análisis amortizado, teorema maestro.