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 pero el otro es " 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 segundos.
- Algoritmo B: tarda segundos.
Para :
- A: segundos.
- B: segundos.
A gana con datos chicos.
Para :
- A: segundos = 31 años.
- B: segundos = 23 días.
B gana con datos grandes — y por mucho.
Lo que importa para juzgar algoritmos es cómo crecen con , no cuánto tardan en un caso específico. Una constante chica solo te ayuda mientras 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 () — cota superior
si existe y tales que para todo .
En palabras: no crece más rápido que (a la larga, ignorando constantes).
Ejemplos:
- . (Para grande, domina al 5; constante 3 se ignora.)
- . (Para grande, domina.)
- .
- . (Una constante es .)
- . (Sí — porque no crece más rápido que . Big-O da una cota superior, no necesariamente apretada.)
Big-Omega () — cota inferior
si para grande.
En palabras: crece al menos tan rápido como .
Big-Theta () — cota apretada
si es a la vez y .
En palabras: y 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
- Constantes se ignoran. .
- Términos no dominantes se ignoran. .
- El término más rápido manda. .
Jerarquía común (de más rápido a más lento):
1.3 Las clases más comunes
📐 Fundamento
— 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
— 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 , . 30 comparaciones para 1000 millones. Mágico.
— 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
— 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
— 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 , . Lento pero manejable. Para , inviable.
— 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 , ya tarda segundos. Para , siglos.
— Factorial
Permutaciones. Algoritmos de fuerza bruta sobre todas las combinaciones.
Para , ya estás en mil millones. Para , totalmente inviable.
Comparación visual
| 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)
iteraciones, cada una . Total: .
Bucle anidado
for i in range(n):
for j in range(n):
op_constante()
# Total: O(n²)
ejecuciones.
Bucle con paso variable
i = 1
while i < n:
op_constante()
i *= 2
# Total: O(log n)
duplica cada paso → iteraciones.
Bucle dependiente
for i in range(n):
for j in range(i):
op_constante()
Total: .
Recursión
Para una recursión con llamadas que reducen el problema a tamaño con trabajo extra, el Teorema Maestro dice:
Ejemplos:
- Búsqueda binaria: . . Caso → .
- Mergesort: . . Caso → .
- Karatsuba (multiplicación): . → .
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 , pero pasa rara vez.
Si hacés appends:
- operaciones, suma total de copias: .
- Promedio por append: amortizado.
Por eso list.append() se considera 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 en tiempo. Pero la primera crea una copia ( en espacio), la segunda no.
Trade-off típico: memoización gasta espacio para reducir tiempo a . Vale la pena casi siempre.
Espacio en recursión: la pila ocupa . Una recursión que va a niveles consume espacio incluso si su tiempo es .
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: está en posición 0 → .
- Peor caso: no está → .
- Caso promedio: asumiendo posición uniforme, .
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 , promedio . Casi siempre se reporta el promedio.
- Tablas hash: peor (si todo colisiona), promedio . 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 .
- Binaria: tiempo crece muy levemente — .
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 → 1M comparaciones por búsqueda. Inviable.
- Lista ordenada + búsqueda binaria: → ~20 comparaciones.
- Dict: → 1 lookup. Ganador.
Detectar ineficiencia
Tu programa funciona con 100 datos, tarda mucho con 10,000. Probable indicio: algún algoritmo es o peor. Buscá bucles anidados.
Optimizar
Convertir a usando ordenamiento + búsqueda binaria, o 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 , total .
1.9 Resumen visual
| Clase | Ejemplo |
|---|---|
| Acceso a array, dict lookup | |
| Búsqueda binaria, BST balanceado | |
| Lineal, suma, máximo | |
| Mergesort, heapsort | |
| Bubble sort, doble bucle | |
| Triple bucle, multiplicación de matrices naive | |
| Subsets, recursión sin memo | |
| Permutaciones, TSP brute force |
| Reglas |
|---|
| Ignorar constantes: |
| Ignorar términos no dominantes: |
| 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:])
Solución
a. — un bucle simple.
b. — dos bucles anidados.
c. — se duplica.
d. Tiempo: por las llamadas. Espacio: también por la pila + slices intermedios. (Nota: lista[1:] crea copia, así que técnicamente la versión real es . Sin slicing pasando índice, sería tiempo.)
✏️ Ejercicio 1.2 — Comparar
Tenés que procesar 1 millón de elementos. Te ofrecen tres algoritmos:
- A: , constante chica.
- B: , constante mediana.
- C: pero usa memoria adicional.
¿Cuál elegirías y por qué?
Solución
Para 1M elementos:
- A: operaciones. Inviable sin importar la constante.
- B: operaciones. Manejable.
- C: operaciones. Más rápido, pero usa el doble de memoria.
Si la memoria alcanza: C. Si la memoria es ajustada: B.
A queda descartado. Los algoritmos son aceptables solo para chico (hasta unos miles).
✏️ 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
Solución
Complejidad actual: — bucles anidados.
Mejorada:
def tiene_duplicados(lista):
visto = set()
for x in lista:
if x in visto: return True
visto.add(x)
return False
— una sola pasada con set.
Aún más corta:
def tiene_duplicados(lista):
return len(set(lista)) != len(lista)
Igualmente , una línea.
✏️ Ejercicio 1.4 — Análisis amortizado
Implementá una pila con array dinámico (que crezca cuando se llena). Argumentá por qué push es amortizado.
Solución
class Pila:
def __init__(self):
self.data = [None] * 4
self.n = 0
def push(self, x):
if self.n == len(self.data):
# Crecer: doblar capacidad
nuevo = [None] * (len(self.data) * 2)
for i in range(self.n):
nuevo[i] = self.data[i]
self.data = nuevo
self.data[self.n] = x
self.n += 1
Análisis amortizado: insertando elementos, las copias totales suman . Total operaciones: (insert) + (copias) = . Promedio por push: .
Aunque un push puntual puede tardar (cuando crece), el costo se "amortiza" en los pushes baratos.
1.11 Para profundizar
- CLRS, Introduction to Algorithms — capítulos 1-4.
- Sedgewick & Wayne, Algorithms (gratis online en princeton.edu).
- Big-O Cheat Sheet: https://www.bigocheatsheet.com/
- Próximo capítulo: Listas enlazadas, pilas y colas — primeras estructuras concretas.
Definiciones nuevas: complejidad, tiempo, espacio, Big-O, Big-Omega, Big-Theta, peor/mejor/promedio caso, análisis amortizado, teorema maestro.