Por

Keith Williams

12 jul 2025

Encontrar y solucionar los cuellos de botella de rendimiento O(n²): Lecciones de las optimizaciones de algoritmos de Cal.com

Encontrar y solucionar los cuellos de botella de rendimiento O(n²): Lecciones de las optimizaciones de algoritmos de Cal.com

Encontrar y solucionar los cuellos de botella de rendimiento O(n²): Lecciones de las optimizaciones de algoritmos de Cal.com

Una profunda inmersión en cómo detectamos, analizamos y optimizamos algoritmos de bajo rendimiento en una base de código de producción real.

Introducción

Los cuellos de botella en el rendimiento de los sistemas de programación pueden significar la diferencia entre una experiencia de usuario receptiva y los usuarios abandonando tu plataforma por frustración. En Cal.com, recientemente descubrimos y optimizamos varios algoritmos O(n²) críticos que estaban causando ralentizaciones a medida que nuestra base de usuarios y volúmenes de datos crecía.

En este artículo, compartiré cuatro optimizaciones reales de algoritmos del código de Cal.com. Aprenderás cómo detectamos la complejidad O(n²), qué estrategias de optimización funcionaron mejor y cómo implementamos verificaciones automáticas para prevenir regresiones futuras.

El problema: Cómo el código inocente se convierte en O(n²)

A medida que las plataformas de programación crecen, es sorprendentemente fácil que los bucles anidados de aspecto inocente se conviertan en asesinos silenciosos del rendimiento. Considera este ejemplo real de nuestro código:

// O(n²): Every available slot is checked against every busy period
availableTimeSlots.map(slot => {
  return busy.some(busyTime => checkOverlap(slot, busyTime));
});

Para 100 intervalos y 50 períodos ocupados, son 5,000 comprobaciones de superposición. A escalas mayores —500 intervalos y 200 períodos ocupados— explota a 100,000 operaciones. Eso es un aumento de veinte veces en la carga computacional por solo un incremento de cinco veces en los datos.

Estudio de caso 1: Optimización de la verificación de conflicto de intervalos

El problema original

La función checkForConflicts utilizaba iteración anidada para verificar si los intervalos de reserva entraban en conflicto con los períodos ocupados. Esto rápidamente se convirtió en un problema de rendimiento con horarios grandes.

Implementación original: Bucles anidados O(n²)

function checkForConflicts({ busy, time, eventLength }) {
  return busy.some(busyTime => {
    const slotStart = time.valueOf();
    const slotEnd = slotStart + eventLength * 60 * 1000;
    const busyStart = dayjs.utc(busyTime.start).valueOf();
    const busyEnd = dayjs.utc(busyTime.end).valueOf();
    return !(busyEnd <= slotStart || busyStart >= slotEnd);
  });
}

availableTimeSlots = availableTimeSlots
  .map((slot) => {
    if (
      !checkForConflicts({
        time: slot.time,
        busy: busySlotsFromReservedSlots,
        ...availabilityCheckProps,
      })
    ) {
      return slot;
    }
    return undefined;
  })

Implementación optimizada: O(n log n) con ordenación y salida anticipada

function checkForConflicts({ sortedBusyTimes, time, eventLength }) {
  const slotStart = time.valueOf();
  const slotEnd = slotStart + eventLength * 60 * 1000;

  for (const busyTime of sortedBusyTimes) {
    if (busyTime.start >= slotEnd) {
      break;
    }
    if (busyTime.end <= slotStart) {
      continue;
    }
    return true;
  }
  return false;
}

Qué cambió:
Ordenamos los tiempos ocupados por sus tiempos de inicio, lo que nos permitió omitir períodos que no se superponen y salir anticipadamente cuando no era posible que hubiera conflictos. El resultado fue una caída dramática en el número de comparaciones innecesarias.

Impacto:
La complejidad se redujo de O(intervalos disponibles × períodos ocupados) a O(n log n), y la mayoría de las comprobaciones se omitieron completamente en casos típicos.

Estudio de caso 2: Intersección de rangos de fechas usando la técnica de dos punteros

El problema original

La función intersect previamente usaba bucles anidados para encontrar superposiciones entre la disponibilidad de múltiples usuarios.

Implementación original: Mapeo anidado O(n²)

commonAvailability.forEach(commonRange => {
  userRanges.forEach(userRange => {
    const intersection = getIntersection(commonRange, userRange);
    if (intersection !== null) {
      intersectedRanges.push(intersection);
    }
  });
});

Implementación optimizada: O(n log n) con recorrido de dos punteros

const sortedRanges = ranges.map(userRanges =>
  userRanges
    .map(r => ({
      ...r,
      startValue: r.start.valueOf(),
      endValue: r.end.valueOf()
    }))
    .sort((a, b) => a.startValue - b.startValue)
);

let commonIndex = 0;
let userIndex = 0;

while (commonIndex < commonAvailability.length && userIndex < userRanges.length) {
  const commonRange = commonAvailability[commonIndex];
  const userRange = userRanges[userIndex];

  const intersectStartValue = Math.max(commonRange.startValue, userRange.startValue);
  const intersectEndValue = Math.min(commonRange.endValue, userRange.endValue);

  if (intersectStartValue < intersectEndValue) {
    intersectedRanges.push(createIntersection(commonRange, userRange));
  }

  if (commonRange.endValue <= userRange.endValue) {
    commonIndex++;
  } else {
    userIndex++;
  }
}

Qué cambió:
Al ordenar y usar dos punteros para recorrer los rangos, eliminamos la necesidad de iteración anidada. Esto redujo la complejidad de cuadrática a casi lineal para la mayoría de los inputs prácticos.

Estudio de caso 3: Árbol de intervalos para filtrado de rangos redundantes

El problema original

La función filterRedundantDateRanges verificaba cada rango contra los demás para ver si uno contenía al otro. Para horarios grandes, esto se volvió dolorosamente lento.

Implementación original: Comprobación de contención O(n²)

return dateRanges.filter((range, index) => {
  return !dateRanges.some((otherRange, otherIndex) => {
    if (index === otherIndex) return false;
    return isCompletelyContained(range, otherRange);
  });
});

Implementación optimizada: O(n log n) con un árbol de intervalos

export function filterRedundantDateRanges(dateRanges) {
  const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
  const intervalNodes = createIntervalNodes(
    sortedRanges,
    (range) => range.start.valueOf(),
    (range) => range.end.valueOf()
  );
  const intervalTree = new IntervalTree(intervalNodes);
  const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree);

  return sortedRanges.filter((range, index) => {
    const containingIntervals = searchAlgorithm.findContainingIntervals(
      range.start.valueOf(),
      range.end.valueOf(),
      index
    );
    return containingIntervals.length === 0;
  });
}

Qué cambió:
En lugar de comparación de fuerza bruta, utilizamos una estructura de árbol de intervalos para permitir consultas de contención rápidas O(log n). Este es un enfoque clásico en geometría computacional que es muy efectivo para aplicaciones de programación.

Resultados de rendimiento: Antes y después

Nuestros benchmarks mostraron mejoras significativas:

  • Verificación de conflicto de intervalos: Reducción de O(n²) a O(n log n), haciendo que 100 intervalos y 50 períodos ocupados sean aproximadamente diez veces más rápidos.

  • Intersección de rangos de fechas: Disminución de O(n²) a O(n log n), permitiendo procesar cientos de rangos de fechas en milisegundos.

  • Filtrado de rangos redundantes: Reducido de O(n²) a O(n log n), con una aceleración de hasta cincuenta veces para miles de rangos de fechas.

  • Intersección de rangos en general: A través de muchos patrones, vimos mejoras de veinticinco veces o más.


Técnicas fáciles para detectar algoritmos O(n²)

Reconocer patrones problemáticos

Presta atención a:

  • Iteraciones anidadas de arrays, especialmente .map, .forEach, .filter dentro de otro.

  • Métodos de arrays como .some, .find, o .filter dentro de bucles o callbacks de arrays.

  • Filtros encadenados o mapeo anidado sobre listas grandes.

Probar escalabilidad explícitamente

Escribe pruebas de rendimiento que ejecuten tu función en datos de tamaño creciente y registre los tiempos de ejecución. Si el tiempo crece cuadráticamente con el tamaño de los datos, tienes un problema O(n²).

Automatizar análisis estáticos

Añade reglas de lint o análisis estático que:

  • Marquen funciones con bucles anidados o métodos de arrays anidados.

  • Identifiquen recursión sin memoización.

  • Detecten funciones con múltiples llamadas anidadas a .some, .find o .filter.

Estrategias probadas para reemplazar código O(n²)

  • Usar búsqueda binaria para búsquedas en arrays ordenados.

  • Usar técnicas de dos punteros para fusionar o intersectar secuencias ordenadas.

  • Usar estructuras de datos basadas en árboles para consultas de intervalos o datos jerárquicos.

Automatizando verificaciones de rendimiento

Añade benchmarks de rendimiento a tu pipeline de CI que ejecuten algoritmos críticos en datos realistas. Compara tiempos de ejecución en cada pull request y bloquea fusiones que impliquen regresiones. Agrega reglas de lint personalizadas para prevenir que patrones obvios de O(n²) lleguen a producción. Implementa monitoreo en tiempo de ejecución para rutas críticas para detectar nuevas regresiones antes de que los usuarios las noten.

Puntos clave

  • La prevención siempre es mejor que la cura. Las verificaciones automatizadas detectarán algoritmos O(n²) antes de que causen problemas.

  • Siempre haz un perfil antes de optimizar. Usa pruebas para encontrar cuellos de botella reales.

  • Elige las estructuras de datos correctas para tus necesidades. Los arrays ordenados, los enfoques de dos punteros y los árboles son invaluables.

  • Prueba con datos grandes y realistas. Las pruebas pequeñas pueden ocultar grandes problemas.

  • Monitorea en producción. Las alertas automatizadas pueden detectar regresiones temprano.

Conclusión

Las optimizaciones que hicimos en Cal.com muestran que se pueden lograr mejoras significativas en el rendimiento aplicando técnicas algorítmicas probadas. Combina revisión de código, estructuras de datos inteligentes y monitoreo automatizado para mantener tu aplicación rápida a medida que escala.

El rendimiento debe incorporarse en cada etapa del desarrollo, desde las revisiones de código que marcan patrones sospechosos hasta los pipelines de CI que ejecutan pruebas de escalabilidad y el monitoreo en tiempo de ejecución que detecta regresiones.

El algoritmo rápido de hoy puede convertirse fácilmente en el cuello de botella de mañana a medida que tus datos crecen. Mantente vigilante, prueba a escala y siempre estar listo para optimizar.

¡Comienza con Cal.com gratis hoy!

Experimenta una programación y productividad sin problemas, sin tarifas ocultas. ¡Regístrate en segundos y comienza a simplificar tu programación hoy, sin necesidad de tarjeta de crédito!