Sesión 05: Cracking the Coding Interview (Capítulo 1, Parte 3) — Técnica de Dos Punteros (Two Pointers Pattern)#
Navegación rápida:
- ← Sesión 04: Hash Maps y Frecuencia
- Sesión 06: Strings Avanzados y Compresión →
- Glosario Maestro
- Guía de Diagramas de Arquitectura
- Tiempo estimado de estudio: 50-65 minutos.
- Código fuente compilado y probado:
practica-rust/dia_05.rs
1. Diccionario de Conceptos del Día (Desde Cero para Junior / Trainee)#
El patrón de dos punteros es una de las técnicas de optimización más elegantes de la ingeniería de software: sustituye bucles anidados O(N²) por un único recorrido lineal O(N) en memoria contigua sin asignar memoria auxiliar (O(1) espacio). Consulta el Glosario Maestro.
Punteros Convergentes (Opposite Direction Pointers)#
- (a) En palabras simples (intuición): Dos personas paradas en los extremos opuestos de un puente angosto que caminan hacia el centro. Se detienen en el momento exacto en que se cruzan o se dan la mano.
- (b) Definición técnica paso a paso: Técnica donde dos variables de índice (
leftyright) se inicializan en los extremos opuestos de un arreglo ($0$ y N-1) y avanzan en direcciones contrarias (left += 1,right -= 1) bajo una condición de parada estrictawhile left < right. - (c) Cómo funciona en Rust (memoria y tipos): En Rust, como los índices son
usize, decrementarrightrequiere cautela para evitar pánicos por underflow sirightllega a 0. Se utiliza idiomáticamenteright = right.saturating_sub(1). - (d) Error típico de Junior: Usar bucles anidados para buscar parejas en listas ordenadas en lugar de aprovechar la monotonicidad con punteros convergentes.
Punteros de Lectura y Escritura (Fast & Slow / Read & Write Pointers)#
- (a) En palabras simples (intuición): Un inspector de calidad (
read_idx) que camina rápido revisando cada pieza de una cinta transportadora, y un obrero (write_idx) que solo coloca en la caja las piezas aprobadas que no estén repetidas. - (b) Definición técnica paso a paso: Técnica in-place donde ambos punteros se mueven en la misma dirección (de izquierda a derecha), pero a ritmos diferentes.
read_idxitera sobre todos los elementos de entrada, mientraswrite_idxmantiene la frontera del subarreglo filtrado y válido. - (c) Cómo funciona en Rust (memoria y tipos): Opera sobre un slice mutable
&mut [T]. El borrow checker garantiza que somos el único hilo modificando la memoria en ese instante, permitiendo sobreescribir slots de memoria en tiempo O(1) sin realocar el vector. - (d) Error típico de Junior: Usar
vec.remove(i)dentro de un bucle para borrar duplicados, lo cual desplaza todos los elementos hacia la izquierda en cada borrado (O(N) por borrado) resultando en una degradación catastrófica a O(N²).
Resta Saturada (saturating_sub)#
- (a) En palabras simples (intuición): Un velocímetro que al frenar nunca baja de 0 km/h; si intentas restar velocidad a 0, se queda clavado en 0 en lugar de romperse.
- (b) Definición técnica paso a paso: Operación aritmética de Rust provista para tipos enteros sin signo (
usize,u32, etc.). Si el resultado de la resta a - b es negativo, en vez de producir un pánico por subdesbordamiento, retorna el valor mínimo del tipo ($0$). - (c) Cómo funciona en Rust (memoria y tipos): Se compila directamente a una instrucción de hardware de saturación condicional o sustracción con branchless conditional move (
sub+cmov), sin sobrecosto de ejecución. - (d) Error típico de Junior: Escribir
right -= 1;en un bucle donde una condición interna previa pudo haber dejado arighten $0$, provocando un cierre inesperado del programa en producción.
2. Desglose Conceptual desde Primeros Principios (Sin Asumir Nada)#
La Matemática de Two Sum sobre Arreglos Ordenados (Monotonicidad)#
Imagina que tenemos un arreglo ordenado en orden ascendente:
Colocamos:
left = 0(el menor elemento de todo el arreglo).right = N - 1(el mayor elemento de todo el arreglo).
Calculamos la suma actual: S = nums[left] + nums[right]. Surgen exactamente tres posibilidades exhaustivas y mutuamente excluyentes:
- S == target: - Hemos encontrado el par solución de forma exacta.
- S < target:
- La suma actual es demasiado pequeña.
- Como el arreglo está ordenado, combinar
leftcon cualquier otro elemento a la izquierda deright(es decir, con números más pequeños que nums[right]) daría una suma todavía menor. - Por lo tanto, el elemento nums[left] es matemáticamente incapaz de formar una pareja válida con ningún elemento restante. - Acción no negociable: Descartamos nums[left] y avanzamosleft += 1para buscar un número mayor. - S > target:
- La suma actual es demasiado grande.
- Combinar
rightcon cualquier otro número a la derecha deleftdaría una suma aún más grande. - Por lo tanto, nums[right] es incapaz de formar una suma válida con ningún elemento restante. - Acción no negociable: Descartamos nums[right] y retrocedemosright -= 1para buscar un número menor.
En cada paso individual, descartamos definitivamente un elemento del arreglo. Como hay N elementos, el algoritmo termina con garantía matemática en a lo sumo N pasos: Complejidad Temporal O(N) y Espacial O(1).
3. Visualización Paso a Paso y Gráfica de Memoria en Rust#
Flujo de Decisión de Two Sum Ordenado#
Consulta la arquitectura visual en los diagramas del sistema:
- Gráfico SVG:
 - Enlace al código fuente Mermaid: Fuente Mermaid
flowchart TD
A["left = 0, right = arr.len() - 1"] --> B{"left < right"}
B -->|No| NF["Retornar None (No existe pareja)"]
B -->|Sí| C["sum = arr[left] + arr[right]"]
C --> D{"sum vs target"}
D -->|sum == target| FOUND["Retornar Some((left, right))"]
D -->|sum > target| DEC["right -= 1 (reducir suma)"]
D -->|sum < target| INC["left += 1 (aumentar suma)"]
DEC --> B
INC --> B
Visualización Cuadro por Cuadro de Memoria: Eliminación de Duplicados In-Place#
Entrada: let mut nums = [1, 1, 2, 2, 3];
ESTADO INICIAL:
write_idx = 1 (el elemento en índice 0 siempre es único)
Índices: 0 1 2 3 4
Valores: [ 1, 1, 2, 2, 3 ]
^ ^
write-1 read
PASO 1 (read_idx = 1):
- nums[1] (1) == nums[write_idx - 1] (1). ¡Duplicado detectado!
- No se escribe nada. write_idx se mantiene en 1.
PASO 2 (read_idx = 2):
- nums[2] (2) != nums[write_idx - 1] (1). ¡Nuevo valor único encontrado!
- nums[write_idx] = nums[2] -> nums[1] = 2.
- write_idx incrementa a 2.
Arreglo en Stack: [ 1, 2, 2, 2, 3 ]
^
write_idx
PASO 3 (read_idx = 3):
- nums[3] (2) == nums[write_idx - 1] (2). ¡Duplicado detectado!
- write_idx se mantiene en 2.
PASO 4 (read_idx = 4):
- nums[4] (3) != nums[write_idx - 1] (2). ¡Nuevo valor único encontrado!
- nums[write_idx] = nums[4] -> nums[2] = 3.
- write_idx incrementa a 3.
Arreglo final en Stack: [ 1, 2, 3, 2, 3 ]
Subarreglo único válido: &nums[..3] == [1, 2, 3]. Retorna longitud k = 3.
4. Implementación Canónica en Rust + Comparativa con Python#
Implementación en Rust (practica-rust/dia_05.rs)#
//! Implementación canónica de algoritmos con Dos Punteros en Rust.
//! Compilable y verificable con `rustc --test`.
/// LeetCode 125: Palíndromo válido con dos punteros convergentes ignorando no-alfanuméricos.
pub fn is_valid_palindrome(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() {
return true;
}
let mut left = 0;
let mut right = bytes.len() - 1;
while left < right {
while left < right && !bytes[left].is_ascii_alphanumeric() {
left += 1;
}
while left < right && !bytes[right].is_ascii_alphanumeric() {
right = right.saturating_sub(1);
}
if left < right {
let cl = bytes[left].to_ascii_lowercase();
let cr = bytes[right].to_ascii_lowercase();
if cl != cr {
return false;
}
left += 1;
right = right.saturating_sub(1);
}
}
true
}
/// LeetCode 26: Eliminación de duplicados in-place con punteros de lectura y escritura.
pub fn remove_duplicates_sorted(nums: &mut [i32]) -> usize {
if nums.is_empty() {
return 0;
}
let mut write_idx = 1;
for read_idx in 1..nums.len() {
if nums[read_idx] != nums[write_idx - 1] {
nums[write_idx] = nums[read_idx];
write_idx += 1;
}
}
write_idx
}
/// LeetCode 167: Two Sum II sobre arreglo ordenado en O(N) tiempo y O(1) espacio.
pub fn two_sum_sorted(numbers: &[i32], target: i32) -> Option<(usize, usize)> {
if numbers.len() < 2 {
return None;
}
let mut left = 0;
let mut right = numbers.len() - 1;
while left < right {
let sum = numbers[left] + numbers[right];
if sum == target {
return Some((left, right));
} else if sum < target {
left += 1;
} else {
right = right.saturating_sub(1);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_palindrome() {
assert!(is_valid_palindrome("A man, a plan, a canal: Panama"));
assert!(!is_valid_palindrome("race a car"));
assert!(is_valid_palindrome(" "));
assert!(!is_valid_palindrome("0P"));
}
#[test]
fn test_remove_duplicates_sorted() {
let mut nums = [1, 1, 2];
let k = remove_duplicates_sorted(&mut nums);
assert_eq!(k, 2);
assert_eq!(&nums[..k], &[1, 2]);
}
#[test]
fn test_two_sum_sorted() {
let numbers = [2, 7, 11, 15];
assert_eq!(two_sum_sorted(&numbers, 9), Some((0, 1)));
}
}
Implementación Canónica en Python (practica/dia_05_two_pointers.py)#
from typing import List, Optional, Tuple
def is_valid_palindrome(s: str) -> bool:
left, right = 0, len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
def remove_duplicates_sorted(nums: List[int]) -> int:
if not nums:
return 0
write_idx = 1
for read_idx in range(1, len(nums)):
if nums[read_idx] != nums[write_idx - 1]:
nums[write_idx] = nums[read_idx]
write_idx += 1
return write_idx
5. Las 3 Trampas Clásicas en Entrevistas#
- Olvidar la Condición
left < righten Bucles Internos: - Al filtrar caracteres no alfanuméricos conwhile !bytes[left].is_ascii_alphanumeric(), es imperativo incluirwhile left < right && .... De lo contrario, para una cadena compuesta solo de espacios o signos (" "),leftavanzará más allá del final del slice, provocando un pánico por violación de límites. - Subdesbordamiento de
usizeal Decrementarright: - En Python,right -= 1cuandoright == 0produce-1. En Rust,0usize - 1aborta el programa en debug. Siempre usa.saturating_sub(1). - No Reconocer la Condición de Entrada Ordenada: - Dos Punteros Convergentes solo funciona si los datos son monótonos (ordenados). Si te entregan un arreglo desordenado para Two Sum y no puedes alterar el orden de los índices de salida, debes usar la solución de Hash Map de la Sesión 04.
6. Preguntas de Comprensión Profunda y Diagnóstico Socrático#
Pregunta 1: En Two Sum sobre arreglos ordenados (LeetCode 167), ¿por qué podemos estar 100% seguros de que al mover left += 1 o right -= 1 jamás nos saltamos accidentalmente la respuesta correcta?
Respuesta Técnica Detallada: Supongamos por contradicción que la pareja óptima es (L^*, R^*) y que el algoritmo comete el error de descartar a uno de ellos prematuramente. Sin pérdida de generalidad, supongamos que el puntero izquierdo está en L^* y el algoritmo erróneamente incrementa left, descartando L^*. Para que el algoritmo incremente left, la condición evaluada debe haber sido:
Como el puntero derecho aún no ha llegado a R^* y solo se mueve hacia la izquierda, debe cumplirse que right > R^*. Dado que el arreglo está ordenado ascendentemente:
Sustituyendo en la desigualdad:
¡Esto contradice la hipótesis inicial de que (L^*, R^*) es la solución cuya suma es exactamente igual a target! Por reducción al absurdo, es matemáticamente imposible que el algoritmo descarte L^* mientras right ≥ R^*. El mismo razonamiento simétrico aplica para el puntero derecho, demostrando que ningún par válido es omitido jamás.
Pregunta 2: ¿Cuál es la diferencia de rendimiento entre procesar una cadena carácter por carácter mediante s.chars() frente a trabajar directamente con los bytes con s.as_bytes() en problemas de palíndromos ASCII?
Respuesta Técnica Detallada: s.chars() es un iterador que decodifica continuamente secuencias UTF-8 de longitud variable (1 a 4 bytes) a valores de 32 bits char (u32). Además, no es un iterador de indexación aleatoria O(1): no puedes acceder directamente a chars[right] sin recorrerlo. Por tanto, para tener dos punteros con chars(), tendrías que clonar todos los caracteres en un Vec<char> en el Heap (O(N) espacio y O(N) tiempo de asignación).
En contraste, s.as_bytes() devuelve un slice directo de bytes &[u8]:
- Ocupa 16 bytes en el Stack (puntero y longitud).
- Cero asignaciones en el Heap (O(1) espacio auxiliar).
- El acceso por índice
bytes[left]ybytes[right]se traduce en una lectura directa de 1 ciclo de CPU (mov). - La verificación de caracteres alfanuméricos ASCII se realiza mediante una tabla de búsqueda o comparaciones de rango entero inmediato (
b >= b'a' && b <= b'z'), logrando un rendimiento hasta 8x superior.
Pregunta 3: En el problema de eliminar duplicados de un arreglo ordenado (LeetCode 26), ¿qué valor contendrán los elementos del arreglo situados a partir del índice write_idx al finalizar el algoritmo?
Respuesta Técnica Detallada: El contrato formal de LeetCode 26 (y de funciones estándar como std::unique en C++) establece que los primeros k elementos del arreglo (&nums[..k], donde k = writeᵢdx) deben contener los elementos únicos en su orden relativo original. Los elementos ubicados más allá de la frontera (nums[k..]) contienen los valores "sobrantes" que no fueron sobreescritos durante la compactación hacia la izquierda. Su contenido es técnicamente irrelevante y no debe ser consultado por el llamador. Rust permite expresar esto idiomáticamente devolviendo el slice rebanado &mut nums[..k], ocultando los datos residuales de forma segura y sin costo de recolección de basura.
Pregunta 4: ¿Por qué la técnica de Dos Punteros Convergentes no es aplicable directamente sobre una Lista Enlazada simple (LinkedList)?
Respuesta Técnica Detallada: La técnica de punteros convergentes requiere que el puntero derecho pueda retroceder en tiempo constante (O(1)): right -= 1. En una Lista Simplemente Enlazada (Singly Linked List):
- Cada nodo posee únicamente un puntero hacia su sucesor (
next). No existe un puntero hacia el nodo anterior (prev). - Para retroceder un paso desde el último nodo, es obligatorio reiniciar desde la cabeza (
head) y recorrer N-2 nodos. - Retroceder en cada paso convertiría el algoritmo de tiempo lineal O(N) en un algoritmo cuadrático O(N²).
Por esta razón, en listas enlazadas el patrón de Dos Punteros adopta la variante de *Punteros Rápido y Lento (Runner Technique)*, donde ambos punteros se mueven exclusivamente hacia adelante pero a diferentes velocidades (1x vs 2x), como veremos en el Día 10.
Pregunta 5: Si te solicitan resolver el problema 3Sum (encontrar todos los tripletes únicos que sumen 0), ¿cómo combinas ordenamiento con la técnica de dos punteros para reducir la complejidad de O(N³) a O(N²)?
Respuesta Técnica Detallada:
- Paso 1: Ordenar el arreglo: Aplicamos
nums.sort_unstable(), lo cual toma O(N log N) tiempo y O(1) espacio in-place. - Paso 2: Fijar el primer elemento: Iteramos con un bucle exterior i desde $0$ hasta N-3. El valor actual es nums[i]. Buscamos dos números que sumen -nums[i].
- Paso 3: Dos punteros para los dos restantes: Inicializamos left = i + 1 y right = N - 1. Como el subarreglo está ordenado, aplicamos la técnica de Two Sum ordenado en O(N) tiempo.
- Paso 4: Evasión de duplicados: Cuando encontramos una terna válida o avanzamos los punteros, saltamos valores idénticos consecutivos (
while left < right && nums[left] == nums[left+1] { left += 1; }).
Costo total: O(N log N) + N × O(N) = O(N²) tiempo y O(1) espacio auxiliar, superando por órdenes de magnitud la fuerza bruta cúbica O(N³).
7. Puente a LeetCode y Práctica de Código#
- LeetCode 125 — Valid Palindrome (Easy):
- Resuelve con dos punteros convergentes sobre
&[u8]. - LeetCode 26 — Remove Duplicates from Sorted Array (Easy):
- Compactación in-place con
write_idxyread_idx. - LeetCode 167 — Two Sum II - Input Array Is Sorted (Medium): - Monotonicidad con dos punteros convergentes.
- LeetCode 15 — 3Sum (Medium): - Bucle exterior más dos punteros convergentes.
Navegación: