Two Pointers y Sliding Window: Técnicas para optimizar arreglos y cadenas
1. Descripción general
Cuando enfrentas un problema que requiere recorrer un arreglo o cadena y la solución brute-force ejecuta O(n²) u O(n³), dos técnicas two pointers y sliding window te ayudan a reducir la complejidad a O(n). Este artículo explica la idea central de cada técnica, con ejemplos Java concretos: two sum en un arreglo ordenado, verificación de palindrome, suma máxima de un subarreglo dentro de una ventana fija, y substring más largo sin caracteres repetidos. Al terminar, reconocerás el patrón y podrás aplicarlo a muchos problemas similares.
2. ¿Qué es Two Pointers?
Two pointers es una técnica que utiliza dos variables de índice (punteros) que se desplazan sobre una estructura de datos (generalmente un arreglo o cadena ordenada) para evitar recorridos anidados. Los dos punteros pueden moverse:
- En la misma dirección (fast/slow): se usa para eliminar elementos duplicados, detectar ciclos.
- En direcciones opuestas (left/right desde ambos extremos): se usa para encontrar un par con suma igual al target, verificar palindrome.
2.1. Two pointers en direcciones opuestas: encontrar un par con suma igual al target
Problema clásico: dado un arreglo ordenado en forma ascendente, encuentra dos números cuya suma sea igual al target.
public class TwoSumSorted {
public int[] findPair(int[] numbers, int target) {
int left = 0;
int right = numbers.length - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
return new int[]{numbers[left], numbers[right]};
} else if (sum < target) {
left++;
} else {
right--;
}
}
return new int[0];
}
}
Explicación:
leftcomienza desde el inicio del arreglo,rightdesde el final.- Si
sum < target, el número enleftes demasiado pequeño, así que aumentaleftpara obtener un número más grande. - Si
sum > target, el número enrightes demasiado grande, así que disminuyeright. - El bucle ejecuta como máximo n pasos porque en cada iteración
leftorightse desplaza una posición, resultando en una complejidad O(n) en lugar de O(n²) del enfoque con dos bucles anidados.
Prueba con JUnit 5 + AssertJ:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TwoSumSortedTest {
private final TwoSumSorted twoSum = new TwoSumSorted();
@Test
void shouldFindPairWhenSumMatchesTarget() {
int[] numbers = {1, 3, 5, 8, 11};
int[] result = twoSum.findPair(numbers, 13);
assertThat(result).containsExactly(5, 8);
}
}
Nota: esta técnica requiere que el arreglo esté ordenado. Si el arreglo aún no está ordenado, debes ordenarlo primero (O(n log n)) o usar un
HashMappara mantener O(n) sin necesidad de ordenar.
2.2. Two pointers para verificar palindrome
public class PalindromeChecker {
public boolean isPalindrome(String text) {
int left = 0;
int right = text.length() - 1;
while (left < right) {
if (text.charAt(left) != text.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
Los dos punteros comienzan desde ambos extremos de la cadena, comparan caracteres y avanzan hacia el centro. Si encuentran un par de caracteres diferentes, devuelven false inmediatamente — sin necesidad de recorrer todo. Esta es la razón por la que esta técnica es más rápida que invertir la cadena y luego comparar (reverse().equals()), ya que evita crear un objeto StringBuilder intermedio.
3. ¿Qué es Sliding Window?
Sliding window es una variante de two pointers que se aplica a problemas relacionados con subarreglos/subcadenas consecutivos. En lugar de recalcular desde el principio para cada posición inicial, mantienes una “ventana” [left, right] y la actualizas gradualmente mientras la ventana se desliza, reutilizando los cálculos anteriores.
Hay dos tipos:
- Fixed-size window: el tamaño de la ventana es fijo (por ejemplo, k elementos).
- Variable-size window: el tamaño se ajusta dinámicamente según la condición del problema.
3.1. Fixed-size window: suma máxima de un subarreglo de tamaño k
public class MaxSumSubarray {
public int maxSum(int[] numbers, int windowSize) {
int windowSum = 0;
for (int i = 0; i < windowSize; i++) {
windowSum += numbers[i];
}
int maxSum = windowSum;
for (int right = windowSize; right < numbers.length; right++) {
windowSum += numbers[right] - numbers[right - windowSize];
maxSum = Math.max(maxSum, windowSum);
}
return maxSum;
}
}
Explicación:
- El primer bucle calcula la suma de la primera ventana (desde el índice 0 hasta
windowSize - 1). - A partir de ahí, cada vez que desplazas la ventana un paso hacia la derecha: sumas el nuevo elemento (
numbers[right]), restas el elemento que salió de la ventana (numbers[right - windowSize]). - Gracias a esto, cada paso cuesta O(1) en lugar de recalcular la suma en O(k), resultando en una complejidad total de O(n).
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MaxSumSubarrayTest {
private final MaxSumSubarray solver = new MaxSumSubarray();
@Test
void shouldReturnMaxSumOfWindowSizeThree() {
int[] numbers = {2, 1, 5, 1, 3, 2};
int result = solver.maxSum(numbers, 3);
assertThat(result).isEqualTo(9); // ventana [5, 1, 3]
}
}
3.2. Variable-size window: substring más largo sin caracteres repetidos
Bài này cửa sổ co giãn tùy điều kiện “không có ký tự lặp”.
import java.util.HashMap;
import java.util.Map;
public class LongestUniqueSubstring {
public int lengthOfLongestSubstring(String text) {
Map<Character, Integer> lastSeenIndex = new HashMap<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < text.length(); right++) {
char current = text.charAt(right);
if (lastSeenIndex.containsKey(current) && lastSeenIndex.get(current) >= left) {
left = lastSeenIndex.get(current) + 1;
}
lastSeenIndex.put(current, right);
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
Explicación:
rightsiempre avanza (expande la ventana),leftsolo salta cuando encuentra un carácter repetido dentro de la ventana actual.lastSeenIndexalmacena la posición más reciente de cada carácter para saber a dónde debe saltarleft.- La condición
lastSeenIndex.get(current) >= leftgarantiza que solo se considere “repetido” si el carácter aún está dentro de la ventana actual, evitando queleftretroceda incorrectamente. - Cada carácter se procesa exactamente una vez en
right, por lo que la complejidad total es O(n).
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LongestUniqueSubstringTest {
private final LongestUniqueSubstring solver = new LongestUniqueSubstring();
@Test
void shouldReturnLengthOfLongestUniqueSubstring() {
int result = solver.lengthOfLongestSubstring("abcabcbb");
assertThat(result).isEqualTo(3); // "abc"
}
@Test
void shouldHandleAllSameCharacters() {
int result = solver.lengthOfLongestSubstring("bbbbb");
assertThat(result).isEqualTo(1);
}
}
Salida al depurar manualmente con "abcabcbb":
right=0 'a' -> ventana="a" longitud=1
right=1 'b' -> ventana="ab" longitud=2
right=2 'c' -> ventana="abc" longitud=3
right=3 'a' -> repetido, left=1 -> ventana="bca" longitud=3
right=4 'b' -> repetido, left=2 -> ventana="cab" longitud=3
...
maxLength = 3
4. Comparación y cuándo usar cada técnica
| Situación | Técnica apropiada |
|---|---|
| Arreglo ordenado, encontrar par/terna que cumple condición de suma | Two pointers en direcciones opuestas |
| Verificar simetría (palindrome), invertir arreglo in-place | Two pointers en direcciones opuestas |
| Eliminar elementos duplicados en un arreglo ordenado | Two pointers en la misma dirección (fast/slow) |
| Subarreglo/substring de tamaño fijo k | Sliding window de tamaño fijo |
| Subarreglo/substring que cumple una condición dinámica (sin repeticiones, suma ≤ target, etc.) | Sliding window de tamaño variable |
Punto en común: ambas técnicas evitan recalcular las partes ya procesadas, reduciendo la complejidad de O(n²) a O(n) manteniendo el estado (sum, set, map) mientras los punteros/ventanas se desplazan.
5. Errores comunes
- Olvidar la condición de parada
left < right: fácilmente causa bucles infinitos o compara un elemento consigo mismo cuandoleft == right. - Aplicar two pointers a un arreglo no ordenado sin olvidar ordenar primero — los resultados serán completamente incorrectos porque la lógica de incremento/decremento del puntero depende de la monotonía del arreglo.
- En sliding window, olvidar actualizar el estado cuando
leftse desplaza: por ejemplo, olvidar eliminar el elemento anterior delHashMap/sum, causando que la ventana “recuerde” datos que ya no están dentro de ella. - Confundir la condición de expansión/contracción de la ventana: con variable-size window, debes identificar claramente qué condición hace que
rightavance y cuál hace queleftavance — si las inviertes, la ventana nunca se contraerá correctamente.
Nota: estas dos técnicas no son apropiadas cuando el problema requiere considerar toda combinación de subconjuntos no consecutivos (como subset sum) — en esos casos necesitas backtracking o dynamic programming, no sliding window.
6. Conclusión
Two pointers y sliding window son dos herramientas fundamentales para resolver problemas de arreglos/cadenas con complejidad lineal. Identificar el patrón — si necesitas encontrar un par en un arreglo ordenado, o si necesitas optimizar un subarreglo/substring consecutivo — te ayuda a elegir la técnica correcta y escribir código conciso y fácil de probar como los ejemplos anteriores. Cuando practiques, intenta trazar manualmente el estado de left/right en cada paso para comprender bien el invariante antes de codificar.