Anotaciones empíricas, de ocurrencia esporádica y naturaleza ecléctica

Disponible para proyectos

¡Hablemos!

PHP, Ruby, Python, TypeScript, MySQL, PostgreSQL y más...

4Sum diseccionado

Vamos a llevar un célebre problema de entrevistas técnicas a la morgue. Lo diseccionaremos con precisión quirúrgica, cortándolo en diminutos fragmentos hasta revelar sus más oscuros secretos. Acompáñame en este relato teñido de sangre y lógica implacable. Te advierto: será un viaje largo… y quizás perturbador.

Problema:

Dado un array de enteros nums y un entero target, encontrar todos los cuartetos únicos [nums[a], nums[b], nums[c], nums[d]] tales que:

  • 0 ≤ a,b,c,d < n
  • a,b,c y d son índices distintos
  • nums[a] + nums[b] + nums[c] + nums[d] == target

Ejemplo:

  • Input: nums = [1,0,-1,0,-2,2], target = 0
  • Output: [[-2,-1,1,2], [-2,0,0,2], [-1,0,0,1]]

Fuerza bruta ¡bruta!

Hagamos un primer intento de resolver esto: nos piden un arreglo con sub-arreglos de 4 elementos cada uno, cuya suma es 0, no parece muy complicado.

// input de ejemplo
$nums = [1, 0, -1, 0, -2, 2];

$target = 0;

// acá almacenaremos los resultados cuya suma es 0
$result = [];

// Necesitamos un `for` para recorrer el arreglo... de perogrullo!
for($i = 0; $i < count($nums); $i++) {

    // Necesitamos otro `for` para combinar con $i
    for($j = 0; $j < count($nums); $j++) {

        // Ehhhh necesitamos otro `for` para combinar con $j
        for($k = 0; $k < count($nums); $k++) {

            // ¡adivina!... otro para combinar con $k
            for($l = 0; $l < count($nums); $l++) {

                // Verificamos si la suma de los 4 números es igual al objetivo
                if ($nums[$i] + $nums[$j] + $nums[$k] + $nums[$l] == $target) {

                  $quadruplet = [$nums[$i], $nums[$j], $nums[$k], $nums[$l]];

                  if (!in_array($quadruplet, $result)) {
                    $result[] = $quadruplet;
                  } 
                }

            }
        }
    }
}

El resultado final es un $result con 85 arreglos que suman 0… ¡espera un segundo!… ¡85!… WTF!

Acá hay algo muy mal, veamos, si te fijas todos los bucles for arrancan en 0, lo que nos permite generar combinaciones con los mismos números de índice en diferente orden, o dicho de otra forma, se repiten los índices ¿Cómo arreglamos esto? incrementando en uno el valor de inicio del siguiente for:

for($i = 0; ...)
    for($j = $i + 1; ... )
        for($k = $j + 1; ... )
            for($l = $l + 1; ...)

Una mejora más: para asegurar de que siempre haya al menos tres números después de $i debemos restringir el alcance de los bucles, es decir: $i solo puede llegar a $n - 3, $j solo puede llegar a $n - 2 y $k solo puede llegar a $n - 1, $l al ser el último puedo menor a $n.

En consecuencia nuestros bucles fors deberían quedar así:

for($i = 0; $i < $n - 3; $i++) {
    for($j = $i + 1; $j < $n - 2; $j++) {
        for($k = $j + 1; $k < $n - 1; $k++) {
            for($l = $k + 1; $l < $n; $l++) {

Así ha quedado:

function fourSum($nums, $target) {

	$result = [];
	$n = count($nums);

	for( $i = 0; $i < $n - 3; $i++) {
		for ($j = $i + 1; $j < $n - 2; $j++) {
			for ($k = $j + 1; $k < $n - 1; $k++) {
				for ($l = $k + 1; $l < $n; $l++) {
					if ($nums[$i] + $nums[$j] + $nums[$k] + $nums[$l] == $target) {

						$quadruplet = [$nums[$i], $nums[$j], $nums[$k], $nums[$l]];

						if (!in_array($quadruplet, $result)) {
							$result[] = $quadruplet;
						}
					}
				}
			}
		}
	}
	return $result;
}

Dejemos de lado la fuerza bruta

Hasta el momento nuestro script sigue siendo fuerza bruta, podríamos decir «fuerza bruta funcional». Tenemos 4 bucles fors anidados que nos dejan los ojos ensangrentados. El golpe en la performance es demasiado grande, la complejidad temporal es de O(n4). Veamos si podemos optimizar esto.

Two pointers

Existe una técnica en programación de nombre Two Pointers; en la cual dos punteros recorren una estructura de datos, generalmente en direcciones opuestas; éstos avanzan o retroceden de acuerdo a las condiciones especificadas.

// Problema: Busca el par de elementos que sumen 7
// Lista ordenada
$list = [2, 3, 4, 8, 13];

// objetivo
$target = 7;

$result = null;

// puntero izquierdo
$left = 0;

// puntero derecho
$right = count($list) - 1;

// Iniciamos bucle while
while ($left < $right) {

  // sumamos los valores
  $currentSum = $list[$left] + $list[$right];

  // evaluamos ¿es igual a target?
  // si lo es ¡problema resuelto!
  if ($currentSum === $target) {
    $result = [$list[$left], $list[$right]];
    break;
  }

  // Evaluamos: ¿Es la suma menor al objetivo?
  if ($currentSum < $target) {

    // Es menor... entonces avanzamos 
    // un lugar por la izquierda
    $left++;

  // de lo contrario retrocedemos 
  // un lugar por la derecha
  } else {
    $right--;
  }

}

echo implode(", ", $result) . PHP_EOL; // 3, 4

La complejidad temporal de un bucle while es O(n), ya que, en el peor de los casos, ejecutará como máximo n iteraciones. Es importante asegurarse de que la lista inicial esté ordenada; de lo contrario, la complejidad aumentaría a O(n log n).

Volviendo a nuestro problema

Ya tenemos claro cómo encontraremos los dos últimos números (con la ayuda de Two Pointers). Para los primeros dos usaremos dos bucles for anidados.

Resolviendo el problema

El siguiente gráfico muestra cómo resolveremos finalmente el problema 4Sum:

4sum explicado

Necesitamos:

  • Listado inicial ordenado.
  • Bucle externo (i) para encontrar el primer número.
  • Bucle interno (j) para encontrar el segundo número.
  • Two pointers para k (left) y l (right) para encontrar los últimos dos números.

Cuartetos únicos

La primera vez que intenté resolver este problema pasé por alto algo muy importante: los cuartétos deben ser únicos, en otras palaras [1, 2, -1, -2] y [-1, -2, 1, 2] son soluciones válidas, sí, pero son la misma combinación de números. Así que debemos evitar duplicados.

Solución final

public function fourSum(array $nums, int $target): array
{
	// Requisito fundamental, ordenar
	sort($nums);

	// Donde guardaremos los resultados
	$result = [];

	// Contamos los elementos del arreglo inicial
	$n = count($nums);

	// For externo
	for($i = 0; $i < $n - 3; $i++) {
		// Evitamos cuartetos duplicados para el primer número
		if ($i > 0 && $nums[$i] == $nums[$i - 1]) {
			continue;
		}

		// For interno
		for($j = $i + 1; $j < $n - 2; $j++) {
			// Evitamos cuartetos duplicados para el segundo número
			if ($j > $i + 1 && $nums[$j] == $nums[$j - 1]) {
				continue;
			}

			// punteros izquierda y derecha
			$left = $j + 1;
			$right = $n - 1;

			// Acá la técnica Two Pointers inicia su magia
			while($left < $right) {

				// Hacemos la suma para ver si nos sirve, de no tener 
				// un resultado esperado avanzaremos los punteros izq 
				// o der según el resultado que nos de $sum.
				$sum = $nums[$i] + $nums[$j] + $nums[$left] + $nums[$right];

				// Hora de la verdad
				if ($sum == $target) {

					// Se encontró un cuarteto válido, guadamos el resultado
					$result[] = [$nums[$i], $nums[$j], $nums[$left], $nums[$right]];

					// Saltamos valores duplicados para el tercer número
					while ($left < $right && $nums[$left] == $nums[$left + 1]) {
						$left++;
					}
					// Saltamos valores duplicados para el 4to número
					while ($left < $right && $nums[$right] == $nums[$right - 1]) {
						$right--;
					}

					// Mueve los punteros
					$left++;
					$right--;

				} elseif ($sum < $target) {
					// Si la suma es menor que el objetivo, mueva el puntero izquierdo hacia la derecha.
					$left++;
				} else {
					// Si la suma es mayor que el objetivo, mueva el puntero derecho hacia la izquierda.
					$right--;
				}


			} // while

		} // for $j

	} // for $i

	return $result;
}

Complejidad temporal

  • Ordenar el arreglo toma O(N log N)
  • Los dos bucles anidados toman O(N^2)
  • La búsqueda con dos punteros toma O(N)
  • Complejidad total: O(N^3)

¿Test?… test:

Te comparto la solución final junto a sus test. Acá el resultado de los mismos:

Four Sum
 ✔ Basic case
 ✔ No solution
 ✔ Multiple solutions
 ✔ Negative numbers
 ✔ Duplicates in array
 ✔ Small array

OK (6 tests, 6 assertions)
más añejo