46. Optimización y rendimiento

Escribir código correcto es el primer paso; escribir código rápido es el arte. La optimización explota relaciones entre datos, cálculos y memoria. Técnicas como memoización, caching, paralelismo y análisis de complejidad transforman algoritmos lentos en rápidos.

46.1 Introducción

La optimización es la disciplina de mejorar rendimiento sin sacrificar correctitud. Se basa en reconocer y eliminar redundancias en el cálculo.

Las técnicas de optimización tienen un denominador común: explotar relaciones entre cálculos:

  • Memoización: Relación entre entrada y resultado (reutilizar cálculos idénticos)
  • Caching: Relación temporal entre accesos (proximidad espacial y temporal)
  • Índices: Relación de orden que permite búsqueda rápida
  • Paralelismo: Relación de independencia entre tareas
  • Algoritmos eficientes: Estructura relacional del problema permite atajo

Este tema explora técnicas prácticas de optimización y cómo reconocer cuándo aplicarlas.

46.2 Memoización: Cache de Resultados

La memoización es una relación entre entrada y salida que se almacena para reutilizar. Si una función pura recibe la misma entrada, devuelve el mismo resultado sin recalcular.

Condición: La función debe ser pura (sin efectos secundarios, determinista). La memoización es inútil para funciones impuras.
// Memoización manual
function fibonacci(n, memo = {}) {
  if (n in memo) return memo[n];
  if (n <= 1) return n;
  
  memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
  return memo[n];
}

// Memoización automática con decorador
function memoizar(func) {
  const cache = new Map();
  
  return function(...args) {
    const clave = JSON.stringify(args);
    
    if (cache.has(clave)) {
      return cache.get(clave);
    }
    
    const resultado = func.apply(this, args);
    cache.set(clave, resultado);
    return resultado;
  };
}

// Uso
const fibMemo = memoizar((n) => {
  if (n <= 1) return n;
  return fibMemo(n - 1) + fibMemo(n - 2);
});

console.log("Fibonacci sin memoización:");
console.log(fibonacci(35)); // Lento
console.log("Fin de Fibonacci sin memoización");

console.log("Fibonacci con memoización:");
console.log(fibMemo(35)); // Rápido
console.log("Fin de Fibonacci con memoización");

// Análisis de impacto
class AnalisisRendimiento {
  static memoizarConEstadísticas(func) {
    const cache = new Map();
    let hits = 0, misses = 0;
    
    return {
      func: function(...args) {
        const clave = JSON.stringify(args);
        
        if (cache.has(clave)) {
          hits++;
          return cache.get(clave);
        }
        
        misses++;
        const resultado = func.apply(this, args);
        cache.set(clave, resultado);
        return resultado;
      },
      estadísticas: () => ({
        hits,
        misses,
        tasaExito: (hits / (hits + misses) * 100).toFixed(2) + '%'
      })
    };
  }
}

const fibStats = AnalisisRendimiento.memoizarConEstadísticas((n) => {
  if (n <= 1) return n;
  return fibStats.func(n - 1) + fibStats.func(n - 2);
});

fibStats.func(20);
console.log("Estadísticas:", fibStats.estadísticas());

46.3 Caching: Optimización de Acceso

El caching explota la localidad espacial y temporal: datos accedidos recientemente o cercanos tienden a accederse nuevamente pronto.

// Cache LRU (Least Recently Used)
class CacheLRU {
  constructor(capacidad) {
    this.capacidad = capacidad;
    this.cache = new Map();
  }

  get(clave) {
    if (!this.cache.has(clave)) {
      return -1;
    }
    
    // Mover a final (más recientemente usado)
    const valor = this.cache.get(clave);
    this.cache.delete(clave);
    this.cache.set(clave, valor);
    
    return valor;
  }

  set(clave, valor) {
    if (this.cache.has(clave)) {
      this.cache.delete(clave);
    } else if (this.cache.size >= this.capacidad) {
      // Eliminar el primero (menos recientemente usado)
      const primerKey = this.cache.keys().next().value;
      this.cache.delete(primerKey);
    }
    
    this.cache.set(clave, valor);
  }

  estadísticas() {
    return {
      tamaño: this.cache.size,
      capacidad: this.capacidad,
      lleno: this.cache.size === this.capacidad
    };
  }
}

// Simulación de acceso a datos
class SistemaMemoria {
  constructor(cacheSize) {
    this.cache = new CacheLRU(cacheSize);
    this.accesosDisco = 0;
    this.accesosCache = 0;
  }

  acceder(direccion) {
    const datos = this.cache.get(direccion);
    
    if (datos !== -1) {
      this.accesosCache++;
      return datos;
    }
    
    // Simular lectura de "disco"
    this.accesosDisco++;
    const valor = direccion * 2; // Simulación
    this.cache.set(direccion, valor);
    
    return valor;
  }

  tasaAcierto() {
    const total = this.accesosCache + this.accesosDisco;
    if (total === 0) return 0;
    return (this.accesosCache / total * 100).toFixed(2) + '%';
  }
}

// Uso
const sistema = new SistemaMemoria(3);

// Patrón con localidad temporal
const secuencia = [1, 2, 3, 1, 2, 1, 4, 2, 1];
for (const dir of secuencia) {
  sistema.acceder(dir);
}

console.log("Tasa de acierto de cache:", sistema.tasaAcierto());
console.log("Accesos cache:", sistema.accesosCache);
console.log("Accesos disco:", sistema.accesosDisco);

46.4 Índices y Estructuras de Búsqueda Rápida

Un índice es una estructura relacional que acelera búsqueda. Un árbol B, árbol de búsqueda binaria, o tabla hash son índices que reducen búsqueda de O(n) a O(log n) o O(1).

// Comparación: búsqueda lineal vs. búsqueda binaria vs. hash

class AnalisisBusqueda {
  static busquedaLineal(arr, objetivo) {
    for (let i = 0; i < arr.length; i++) {
      if (arr[i] === objetivo) return i;
    }
    return -1;
  }

  static busquedaBinaria(arr, objetivo) {
    let izq = 0, der = arr.length - 1;
    
    while (izq <= der) {
      const mid = Math.floor((izq + der) / 2);
      if (arr[mid] === objetivo) return mid;
      if (arr[mid] < objetivo) izq = mid + 1;
      else der = mid - 1;
    }
    return -1;
  }

  static busquedaHash(datos, objetivo) {
    return datos.indexOf(objetivo) !== -1 ? 0 : -1;
  }

  static compararRendimiento() {
    const tamano = 1000000;
    const arr = Array.from({length: tamano}, (_, i) => i);
    const objetivo = tamano - 1; // Peor caso: último elemento
    
    console.log("Búsqueda lineal:");
    AnalisisBusqueda.busquedaLineal(arr, objetivo);
    console.log("Fin de búsqueda lineal");
    
    console.log("Búsqueda binaria:");
    AnalisisBusqueda.busquedaBinaria(arr, objetivo);
    console.log("Fin de búsqueda binaria");
    
    console.log("Búsqueda con Set (hash):");
    const set = new Set(arr);
    set.has(objetivo);
    console.log("Fin de búsqueda con Set (hash)");
    
    console.log("Complejidades: O(n) vs O(log n) vs O(1)");
  }
}

// Índice multi-clave para consultas rápidas
class ÍndiceMultiClave {
  constructor() {
    this.índices = new Map();
  }

  crearÍndice(datos, clave) {
    const índice = new Map();
    
    for (const registro of datos) {
      const valor = registro[clave];
      if (!índice.has(valor)) {
        índice.set(valor, []);
      }
      índice.get(valor).push(registro);
    }
    
    this.índices.set(clave, índice);
  }

  buscar(clave, valor) {
    const índice = this.índices.get(clave);
    if (!índice) return [];
    return índice.get(valor) || [];
  }
}

// Uso
const registros = [
  { id: 1, nombre: "Ana", ciudad: "Madrid" },
  { id: 2, nombre: "Luis", ciudad: "Barcelona" },
  { id: 3, nombre: "Marta", ciudad: "Madrid" },
  { id: 4, nombre: "Carlos", ciudad: "Valencia" }
];

const índice = new ÍndiceMultiClave();
índice.crearÍndice(registros, "ciudad");

console.log("Búsqueda con índice:");
const resultado = índice.buscar("ciudad", "Madrid");
console.log("Fin de búsqueda con índice");

console.log("Registros en Madrid:", resultado);

46.5 Lazy Evaluation y Evaluación Perezosa

La evaluación perezosa pospone cálculos hasta que realmente se necesiten. Evita computar resultados innecesarios.

// Generadores para evaluación perezosa
function* rangoPerezoso(inicio, fin) {
  for (let i = inicio; i < fin; i++) {
    yield i;
  }
}

function* mapPerezoso(iterable, func) {
  for (const item of iterable) {
    yield func(item);
  }
}

function* filtroPerezoso(iterable, predicado) {
  for (const item of iterable) {
    if (predicado(item)) {
      yield item;
    }
  }
}

// Sin evaluación perezosa (genera array completo)
function operacionEager(n) {
  const arr = Array.from({length: n}, (_, i) => i + 1);
  const mapeado = arr.map(x => x * 2);
  const filtrado = mapeado.filter(x => x > 100);
  return filtrado.slice(0, 5);
}

// Con evaluación perezosa (calcula solo lo necesario)
function operacionLazy(n) {
  const rango = rangoPerezoso(1, n);
  const mapeado = mapPerezoso(rango, x => x * 2);
  const filtrado = filtroPerezoso(mapeado, x => x > 100);
  
  const resultado = [];
  let count = 0;
  for (const item of filtrado) {
    resultado.push(item);
    if (++count === 5) break;
  }
  return resultado;
}

console.log("Eager evaluation:");
const eagerResult = operacionEager(1000000);
console.log("Fin de eager evaluation");

console.log("Lazy evaluation:");
const lazyResult = operacionLazy(1000000);
console.log("Fin de lazy evaluation");

console.log("Resultados iguales:", 
            JSON.stringify(eagerResult) === JSON.stringify(lazyResult));

46.6 Paralelismo y Concurrencia

El paralelismo explota la independencia entre tareas para ejecutarlas simultáneamente. Reduce tiempo total diviendo trabajo entre múltiples procesadores.

// Procesamiento paralelo con Web Workers (simulado en Node.js con promesas)

class ProcesadorParalelo {
  constructor(numHilos = 4) {
    this.numHilos = numHilos;
  }

  // Dividir array en chunks para procesamiento paralelo
  dividirEnChunks(arr, size) {
    const chunks = [];
    for (let i = 0; i < arr.length; i += size) {
      chunks.push(arr.slice(i, i + size));
    }
    return chunks;
  }

  // Simular procesamiento paralelo
  async procesarParalelo(datos, operacion) {
    const chunkSize = Math.ceil(datos.length / this.numHilos);
    const chunks = this.dividirEnChunks(datos, chunkSize);
    
    // Crear promesas para cada chunk
    const promesas = chunks.map(chunk => 
      new Promise(resolve => {
        setImmediate(() => {
          const resultado = chunk.map(operacion);
          resolve(resultado);
        });
      })
    );
    
    // Esperar a que terminen todas
    const resultados = await Promise.all(promesas);
    
    // Combinar resultados
    return resultados.flat();
  }

  // Procesamiento secuencial (para comparar)
  procesarSecuencial(datos, operacion) {
    return datos.map(operacion);
  }
}

// Uso
const procesador = new ProcesadorParalelo(4);
const datos = Array.from({length: 1000000}, (_, i) => i);

// Operación: calcular cuadrado
const operacion = (x) => {
  let suma = 0;
  for (let i = 0; i < 1000; i++) {
    suma += Math.sqrt(x + i);
  }
  return suma;
};

console.log("Procesamiento secuencial:");
const resultSecuencial = procesador.procesarSecuencial(datos.slice(0, 1000), operacion);
console.log("Fin de procesamiento secuencial");

console.log("Procesamiento paralelo:");
procesador.procesarParalelo(datos.slice(0, 1000), operacion).then(() => {
  console.log("Fin de procesamiento paralelo");
});

// Pool de tareas
class PoolTareas {
  constructor(numWorkers) {
    this.numWorkers = numWorkers;
    this.colaEspera = [];
    this.activos = 0;
  }

  async ejecutar(tarea) {
    if (this.activos < this.numWorkers) {
      this.activos++;
      try {
        return await tarea();
      } finally {
        this.activos--;
        this.procesarSiguiente();
      }
    } else {
      return new Promise(resolve => {
        this.colaEspera.push(async () => {
          try {
            resolve(await tarea());
          } finally {
            this.activos--;
            this.procesarSiguiente();
          }
        });
      });
    }
  }

  procesarSiguiente() {
    if (this.colaEspera.length > 0 && this.activos < this.numWorkers) {
      this.activos++;
      const tareaFunc = this.colaEspera.shift();
      tareaFunc();
    }
  }
}

// Uso del pool
const pool = new PoolTareas(2);

for (let i = 0; i < 5; i++) {
  pool.ejecutar(async () => {
    console.log(`Tarea ${i} iniciada`);
    await new Promise(r => setTimeout(r, 100));
    console.log(`Tarea ${i} completada`);
  });
}

46.7 Análisis y Profiling

No puedes optimizar lo que no mides. El profiling identifica cuellos de botella reales, no especulados.

// Herramienta de profiling simple
class Profiler {
  constructor() {
    this.mediciones = new Map();
  }

  marcar(etiqueta) {
    if (!this.mediciones.has(etiqueta)) {
      this.mediciones.set(etiqueta, []);
    }
    return performance.now();
  }

  terminar(etiqueta, inicio) {
    const fin = performance.now();
    const duracion = fin - inicio;
    this.mediciones.get(etiqueta).push(duracion);
    return duracion;
  }

  estadísticas(etiqueta) {
    const tiempos = this.mediciones.get(etiqueta) || [];
    if (tiempos.length === 0) return null;

    const suma = tiempos.reduce((a, b) => a + b, 0);
    const promedio = suma / tiempos.length;
    const minimo = Math.min(...tiempos);
    const maximo = Math.max(...tiempos);

    return {
      llamadas: tiempos.length,
      promedio: promedio.toFixed(3),
      minimo: minimo.toFixed(3),
      maximo: maximo.toFixed(3),
      total: suma.toFixed(3)
    };
  }

  reporte() {
    console.log("=== Reporte de Profiling ===");
    for (const [etiqueta, tiempos] of this.mediciones) {
      console.log(`${etiqueta}:`, this.estadísticas(etiqueta));
    }
  }
}

// Uso
const profiler = new Profiler();

function funcionLenta() {
  let suma = 0;
  for (let i = 0; i < 1000000; i++) {
    suma += Math.sqrt(i);
  }
  return suma;
}

function funcionRapida() {
  return "rápido";
}

// Medir
for (let i = 0; i < 5; i++) {
  let inicio = profiler.marcar("lenta");
  funcionLenta();
  profiler.terminar("lenta", inicio);

  inicio = profiler.marcar("rápida");
  funcionRapida();
  profiler.terminar("rápida", inicio);
}

profiler.reporte();

// Identificar punto caliente
class AnalizadorRendimiento {
  static identificarCuelloBotel(funciones) {
    const resultados = [];

    for (const [nombre, func] of Object.entries(funciones)) {
      const inicio = performance.now();
      for (let i = 0; i < 1000; i++) {
        func();
      }
      const duracion = performance.now() - inicio;
      resultados.push({ nombre, duracion });
    }

    resultados.sort((a, b) => b.duracion - a.duracion);
    
    console.log("Funciones por tiempo total:");
    resultados.forEach(r => {
      console.log(`  ${r.nombre}: ${r.duracion.toFixed(2)}ms`);
    });
    
    return resultados[0].nombre; // Función más lenta
  }
}

const candidatos = {
  fibonacci: () => {
    let a = 0, b = 1;
    for (let i = 0; i < 20; i++) {
      [a, b] = [b, a + b];
    }
    return b;
  },
  sumaArray: () => {
    const arr = Array.from({length: 100}, (_, i) => i);
    return arr.reduce((a, b) => a + b, 0);
  },
  busquedaLineal: () => {
    const arr = Array.from({length: 1000}, (_, i) => i);
    return arr.indexOf(500);
  }
};

console.log("\nCuello de botella:", AnalizadorRendimiento.identificarCuelloBotel(candidatos));

46.8 Resumen: Técnicas de Optimización

Estrategias de Optimización
  • Memoización: Reutilizar resultados de cálculos idénticos
  • Caching: Explotar localidad temporal y espacial
  • Índices: Acelerar búsqueda con estructuras ordenadas
  • Evaluación Perezosa: Calcular solo lo necesario
  • Paralelismo: Explotar independencia entre tareas
  • Profiling: Medir para identificar verdaderos cuellos de botella
  • Algoritmos Eficientes: O(n) vs O(log n) vs O(1) importa
  • Prematura Optimization Kills Projects: Mide primero, optimiza después

Qué debes recordar de este tema

Puntos Clave
  • La optimización es un arte basado en medición, no en intuición
  • Memoización explota la relación entrada-salida en funciones puras
  • Caching explota localidad: temporal y espacial
  • Los índices convierten O(n) en O(log n) explotando orden
  • Evaluación perezosa evita cálculos innecesarios
  • Paralelismo requiere independencia: divide tareas sin dependencias
  • Profile primero: el 80% del tiempo está en 20% del código
  • La complejidad asintótica importa: O(n²) es desastre a escala

Conclusión

La optimización es la disciplina de explotar relaciones en datos y cálculos para acelerar programas sin sacrificar correctitud.

Hemos explorado cómo cada técnica de optimización se basa en una observación relacional:

  • Memoización explota la relación determinista entre entrada y salida
  • Caching explota la relación de proximidad temporal y espacial
  • Índices explotan la relación de orden para búsqueda rápida
  • Evaluación perezosa explota que algunos cálculos pueden ser innecesarios
  • Paralelismo explota la independencia entre tareas
  • Profiling mide relaciones reales de costo

La lección más importante: no optimices por especulación, mide y dirige tu esfuerzo donde realmente cuenta. El 80% del tiempo se consume en 20% del código. Encuentra ese 20% con profiling, luego aplica las técnicas apropiadas.

Ahora que has completado este curso, tienes el marco conceptual para entender cualquier técnica de optimización que encuentres: pregúntate qué relación está siendo explotada. ¿Es orden? ¿Es proximidad? ¿Es determinismo? ¿Es independencia? La respuesta te dirá cuándo aplicar cada técnica correctamente.

Las relaciones discretas son el lenguaje subyacente de toda la programación eficiente.