Publicado el

Mi obsesión con LeetCode 41: Una inmersión profunda en el acertijo del primer entero positivo que falta

Autores

El problema de "First Missing Positive" de LeetCode (número 41) se ha convertido en mi última obsesión en la codificación. Es un rompecabezas engañosamente simple que ha consumido mis pensamientos, impulsándome a explorar sus intrincados detalles y a crear la solución en TypeScript más eficiente posible. Esta publicación no trata solo de sobresalir en las entrevistas de codificación (aunque ese es un buen beneficio adicional). Se trata del puro placer de resolver problemas, la emoción de la optimización y la elegante belleza de un código eficiente. Y como soy un poco aficionado a las matemáticas, incluso profundizaremos en los fundamentos matemáticos de la solución.

El Problema: Un Lobo con Piel de Cordero

El problema presenta un array desordenado de enteros. Tu tarea: encontrar el entero positivo más pequeño que falta. ¿Parece sencillo? Piénsalo de nuevo. Las restricciones de tiempo O(n)O(n) y complejidad espacial O(1)O(1) transforman esta tarea aparentemente simple en un desafiante rompecabezas algorítmico.

Luchas Iniciales: La Trampa de la Fuerza Bruta

Mis intentos iniciales incluyeron la ordenación (violando la restricción de tiempo O(n)O(n)) y conjuntos hash (excediendo el límite de espacio O(1)O(1)). Claramente, se necesitaba un enfoque más sofisticado.

El Momento Eureka: Transformación In-situ

La idea clave fue darme cuenta de que podía usar el array de entrada como una tabla hash. Al reorganizar los elementos, podía colocar el número xx en el índice x1x - 1 (si 1xn1 \le x \le n, donde nn es la longitud del array). Esta transformación in-situ desbloquea el camino hacia una solución eficiente.

Evolución del Código: Una Saga de TypeScript

Aquí está la crónica de la evolución de mi código, con explicaciones detalladas y conocimientos matemáticos:

Versión 1: El Enfoque Ingenuo (con Intercambios Redundantes)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length

  // Coloca cada número en su posición correcta si está en el rango [1, n]
  for (let i = 0; i < n; i++) {
    while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] !== nums[i]) {
      const correctIndex = nums[i] - 1
      ;[nums[i], nums[correctIndex]] = [nums[correctIndex], nums[i]] // Intercambio
    }
  }

  // Encuentra el primer positivo que falta
  for (let i = 0; i < n; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  // Si todos los números están en sus posiciones correctas, el número que falta es n + 1
  return n + 1
}

Esta versión, aunque funcional, sufre de intercambios redundantes. El número de intercambios en el peor de los casos se aproxima a O(n2)O(n^2), aunque el caso promedio está más cerca de O(n)O(n).

Explicación:

  1. Reorganiza el array: Cada número nums[i] se coloca en su índice "correcto" (nums[i] - 1) si está dentro del rango [1, n] y no está ya en la posición correcta.

  2. Identifica el positivo que falta: Después de la reorganización, el primer índice i donde nums[i] !== i + 1 indica que i + 1 es el entero positivo que falta.

  3. Devuelve n + 1 si es necesario: Si todos los números de 1 a n están en las posiciones correctas, el número que falta es n + 1.

Puntos Clave:

  • Reorganización in-situ: Modificamos el array en sí para evitar el uso de espacio extra.
  • Eficiencia: Tanto la reorganización como el escaneo final tardan O(n)O(n) tiempo, asegurando un rendimiento óptimo.

Versión 2: Aumento del Rendimiento (Eliminando Intercambios Redundantes)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length
  let idx = 0

  // Reorganiza los números a sus posiciones correctas si es posible
  while (idx < n) {
    const correctIdx = nums[idx] - 1
    if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
      // Intercambio sin usar una variable temporal
      ;[nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]]
    } else {
      idx++
    }
  }

  // Identifica el primer positivo que falta
  for (let i = 0; i < n; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  return n + 1
}

Explicación:

  1. Reorganización Eficiente:

    • El bucle while intercambia elementos directamente a sus posiciones correctas (nums[idx] - 1) solo cuando el número está en el rango válido [1, n] y no está ya en su posición correcta.
    • La condición if asegura que los números inválidos o ya correctos se salten sin intercambios innecesarios.
  2. Comprobación Directa del Índice:

    • Durante la fase de reorganización, se evitan intercambios inválidos y operaciones redundantes comprobando el rango y el valor de nums[idx].
  3. Intercambio Eficiente en Memoria:

    • El uso de desestructuración ([nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]]) elimina la necesidad de una variable temporal, minimizando el uso de memoria.
  4. Bucles Mínimos:

    • El algoritmo utiliza solo dos pasadas lineales:
      • Una para reorganizar el array in-situ.
      • Otra para encontrar el primer positivo que falta.

Análisis de Complejidad:

  • Complejidad Temporal:
    • El bucle while y el bucle for se ejecutan en O(n)O(n), haciendo que la complejidad temporal total sea O(n)O(n).
  • Complejidad Espacial:
    • El algoritmo opera in-situ sin usar ninguna estructura de datos auxiliar, por lo que la complejidad espacial es O(1)O(1).

Por Qué Este Código Es Óptimo:

  • Rendimiento: El bucle while optimizado asegura operaciones redundantes mínimas, maximizando la velocidad.
  • Eficiencia de Memoria: El uso de intercambio in-situ y la evitación de variables adicionales asegura la menor huella de memoria posible.

Versión 3: Optimizado para Memoria (Uso Mínimo de Espacio)

function firstMissingPositive(nums: number[]): number {
  const n = nums.length
  let idx = 0

  while (idx < n) {
    const correctIdx = nums[idx] - 1

    if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
      nums[idx] = nums[correctIdx]
      nums[correctIdx] = correctIdx + 1
    } else {
      idx++
    }
  }

  for (let idx = 0; idx < n; idx++) {
    if (nums[idx] !== idx + 1) {
      return idx + 1
    }
  }

  return nums.length + 1
}

Optimizaciones Clave de Memoria:

  1. Actualizaciones In-situ con Variables Temporales Mínimas:

    • En lugar de usar una variable temp para el intercambio, este código modifica directamente el array con nums[idx] = nums[correctIdx]; nums[correctIdx] = correctIdx + 1.
    • Esto elimina la necesidad de una variable temporal, reduciendo la sobrecarga de memoria en una cantidad constante.
  2. Menos Valores Temporales en el Bucle:

    • El código calcula correctIdx directamente en el bucle, evitando la necesidad de almacenar variables intermedias adicionales fuera de la lógica principal.
    • Menos asignaciones de variables significan un uso de memoria ligeramente menor por iteración.
  3. Una Sola Pasada para la Reorganización:

    • A diferencia del primer código que utiliza condiciones anidadas en el bucle while, esta implementación completa los intercambios o avanza el índice de una manera más eficiente, reduciendo la profundidad de la pila de ejecución y el uso de memoria.
    • La estructura del bucle (while con un solo incremento o reorganización) es más directa, requiriendo menos valores auxiliares.
  4. Sin Comprobaciones Redundantes:

    • Las comprobaciones para correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx] son concisas y evitan operaciones innecesarias que podrían usar temporalmente memoria de pila o caché de CPU.
    • Esto evita la necesidad de asignar recursos adicionales para operaciones que no contribuirán al resultado.

Por Qué Esto Marca la Diferencia en Memoria:

  • Almacenamiento Temporal Reducido: Al no depender de una variable temp y minimizar los cálculos intermedios, la huella de memoria es menor.
  • Ejecución Optimizada: Menos pasos y una lógica más simple se traducen en un menor uso de memoria por operación.
  • Utilización Mejorada de la Caché: El patrón de acceso lineal y predecible del algoritmo mejora el rendimiento de la caché de la CPU, reduciendo indirectamente la sobrecarga de memoria.

El enfoque de esta solución en actualizaciones in-situ directas, uso mínimo de variables y patrones de acceso a memoria eficientes la convierte en la mejor en términos de eficiencia de memoria. Si bien los ahorros son constantes (O(1)O(1)), son lo suficientemente significativos como para registrarse en plataformas competitivas como LeetCode, especialmente para conjuntos de datos grandes.

Versión 4: La Obra Maestra Optimizada (Intercambio In-situ, Sin Variable Temporal)

function firstMissingPositive(nums: number[]): number {
  for (let i = 0; i < nums.length; i++) {
    while (nums[i] !== i + 1) {
      if (nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1]) {
        break
      }

      const temp = nums[i]
      nums[i] = nums[temp - 1]
      nums[temp - 1] = temp
    }
  }

  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== i + 1) {
      return i + 1
    }
  }

  return nums.length + 1
}

Esta versión final logra tanto un rendimiento óptimo como un uso mínimo de memoria mediante un elegante intercambio in-situ sin una variable temporal. La complejidad temporal ahora es firmemente O(n)O(n), y la complejidad espacial es O(1)O(1), cumpliendo todos los requisitos. Matemáticamente, estamos realizando un tipo de permutación cíclica dentro del array para colocar los elementos en sus posiciones "correctas".

Al agregar una comprobación (nums[i] === nums[nums[i] - 1]), evitamos intercambios innecesarios. Esto mejora significativamente el rendimiento, acercando la complejidad temporal al O(n)O(n) deseado.

Diferencias Clave en la Implementación:

  1. Evitando Intercambios Redundantes:

    • El código proporcionado comprueba explícitamente si nums[i] === nums[nums[i] - 1] antes de realizar el intercambio. Esto evita intercambios innecesarios cuando el número ya está en la posición correcta o existen duplicados.
    • En la primera implementación, esta comprobación se omite, lo que potencialmente lleva a intercambios redundantes incluso cuando son innecesarios. Cada intercambio adicional agrega sobrecarga, especialmente para arrays más grandes.
  2. Comparación Directa del Índice:

    • El código proporcionado utiliza while (nums[i] !== i + 1) para asegurar que un número se intercambia repetidamente a su posición correcta hasta que se coloca correctamente o se cumple una condición inválida. Esto elimina iteraciones de bucle innecesarias.
    • El primer código no compara explícitamente el número con su índice previsto dentro de la condición del bucle. Puede realizar más operaciones en casos donde los números necesitan un movimiento mínimo.
  3. Comprobaciones Condicionales Optimizadas:

    • Al combinar condiciones en un solo bloque if (por ejemplo, nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1]), el código evita la sobrecarga adicional de comprobaciones o cálculos innecesarios.

Por Qué Esto Importa en LeetCode:

  • Consistencia del Tiempo de Ejecución:

    • Las mediciones del tiempo de ejecución de LeetCode pueden ser sensibles a operaciones innecesarias, como intercambios redundantes o comparaciones adicionales.
    • El código proporcionado minimiza estas operaciones, lo que lleva a un tiempo de ejecución más rápido en promedio.
  • Eficiencia de la Caché:

    • Menos intercambios y una lógica de bucle más simple resultan en una mejor utilización de la caché de la CPU. Los procesadores modernos se benefician de patrones de acceso predecibles y optimizados, que el código proporcionado exhibe.

Resultados

Aquí hay un resumen presentado en formato de tabla:

IntentoTiempo de EjecuciónUso de MemoriaNotas
4 (Optimizado)1 ms58.8 MBMejor equilibrio de rendimiento y memoria.
3 (Mejor Memoria)2 ms58.5 MBLigeramente más lento pero menor uso de memoria.
2 (Mejor Rendimiento)1 ms58.9 MBTiempo de ejecución más rápido.
1 (Intento Inicial)3 ms59 MBMás lento y mayor uso de memoria.

Esta tabla destaca las compensaciones y la optimalidad de la solución final.

El resumen indica que el intento para el mejor rendimiento y memoria es de hecho el más optimizado, logrando:

  • 1 ms de tiempo de ejecución: Coincidiendo con el resultado más rápido en términos de rendimiento.
  • 58.9 MB de uso de memoria: Ligeramente superior al clon de "mejor memoria" pero inferior a otros intentos.

Análisis:

  1. Tiempo de Ejecución:

    • El tiempo de ejecución de 1 ms muestra que el enfoque optimizado elimina las comprobaciones redundantes y los intercambios innecesarios.
    • La complejidad temporal consistente de O(n)O(n) asegura la escalabilidad para conjuntos de datos más grandes.
  2. Memoria:

    • 58.9 MB es competitivo, mostrando que la huella de memoria es baja a pesar de la ejecución rápida. Este ligero aumento con respecto a "mejor memoria" (58.5 MB) podría deberse a diferencias en la desestructuración o optimizaciones específicas del motor.
  3. Compensaciones:

    • La solución de "mejor memoria" sacrifica ligeramente el tiempo de ejecución para un menor uso de memoria.
    • La solución de "mejor rendimiento" se centra más en la velocidad pero utiliza marginalmente más memoria.

Conclusión:

La solución optimizada logra un buen equilibrio:

  • 1 ms de tiempo de ejecución es tan rápido como el código con mejor rendimiento.
  • 58.9 MB de uso de memoria está cerca del mejor resultado de memoria, con una sobrecarga insignificante.

Es una opción completa, especialmente para escenarios donde tanto el rendimiento como la memoria son críticos.

Fundamentos Matemáticos: Permutaciones Cíclicas y Principio del Palomar

La idea central gira en torno al concepto de permutaciones cíclicas. Nuestro objetivo es crear un ciclo donde cada número xx se coloque en el índice x1x - 1. El bucle while recorre eficazmente estos ciclos, asegurando que cada elemento encuentre su lugar designado.

El Principio del Palomar juega un papel sutil aquí. Dado que tenemos nn posiciones posibles (índices 00 a n1n-1) y estamos buscando un entero positivo que falta dentro del rango [1,n+1][1, n+1], si todos los números del 1 al nn están presentes, el número que falta debe ser n+1n + 1.

La Obsesión Continúa...

Mi fascinación con LeetCode 41 permanece. Constantemente busco más optimizaciones y conocimientos más profundos. ¡Únete a mí en esta búsqueda! Comparte tus ideas, tus propias soluciones y exploremos juntos la elegante intersección de las matemáticas y los algoritmos.