study.plynte.com
@franguh
Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)

Sesión 04: Cracking the Coding Interview (Capítulo 1, Parte 2) — Hash Maps, Tablas Hash y Análisis de Frecuencia#

Navegación rápida:


1. Diccionario de Conceptos del Día (Desde Cero para Junior / Trainee)#

Las Tablas Hash son la navaja suiza de las entrevistas técnicas, permitiendo reducir complejidades de O(N²) a O(N). Consulta el Glosario Maestro.

Tabla Hash / Diccionario (HashMap<K, V>)#

  • (a) En palabras simples (intuición): Un guardarropa con cientos de perchas numeradas. Entregas tu boleto (clave), una fórmula matemática calcula al instante el número de percha exacto y te devuelven tu abrigo (valor) en 1 segundo sin buscar percha por percha.
  • (b) Definición técnica paso a paso: Estructura de datos asociativa que implementa una interfaz de mapa clave-valor. Transforma la clave mediante una función matemática de hash en un número entero, el cual se proyecta mediante la operación módulo (±od M) a un índice dentro de un arreglo de casillas o cubetas (buckets), logrando acceso, inserción y eliminación en O(1) tiempo promedio.
  • (c) Cómo funciona en Rust (memoria y tipos): La biblioteca estándar de Rust utiliza la arquitectura Swiss Tables (basada en hashbrown). Mantiene una tabla de bytes de control (1 byte por casilla) que aprovecha instrucciones vectoriales SIMD de la CPU para comprobar hasta 16 casillas simultáneamente en un solo ciclo de reloj.
  • (d) Error típico de Junior: Creer que el acceso a un HashMap es O(1) estricto garantizado. Si la función hash es deficiente o sufre de colisiones masivas, el rendimiento se degrada a O(N) en el peor caso.

Función Hash y Colisiones#

  • (a) En palabras simples (intuición): Una trituradora matemática que toma cualquier texto o número y lo convierte en un código numérico fijo. Si dos palabras distintas arrojan por mala suerte el mismo número, tenemos un "choque" o colisión.
  • (b) Definición técnica paso a paso: Una función determinista h(k): K → ℤ que mapea datos de longitud arbitraria a enteros de longitud fija. Una colisión ocurre cuando k₁ ≠ k₂, pero h(k₁) ≡ h(k₂) ±od M.
  • (c) Cómo funciona en Rust (memoria y tipos): Por defecto, Rust usa SipHash 1-3, una función criptográficamente segura resistente a ataques de denegación de servicio (HashDoS Attack). Aunque es ultra-segura, para algoritmos competitivos se puede usar FxHash o AHash que operan a velocidades de GHz.
  • (d) Error típico de Junior: Usar claves mutables o tipos que modifican su valor de hash mientras residen dentro de la tabla, corrompiendo la capacidad de encontrar el elemento posteriormente.

API Entry en Rust (entry().or_insert())#

  • (a) En palabras simples (intuición): En lugar de preguntar al bibliotecario "¿está el libro?" y luego volver a hacer la fila para decirle "si está, ponle un sello", haces todo en una sola visita al mostrador.
  • (b) Definición técnica paso a paso: Mecanismo ergonómico de Rust para operaciones de "inspeccionar o insertar" (upsert). Realiza el cálculo del hash y la búsqueda en la tabla exactamente una sola vez, retornando una referencia mutable directa al valor alojado dentro de la cubeta.
  • (c) Cómo funciona en Rust (memoria y tipos): La instrucción *map.entry(key).or_insert(0) += 1 evalúa el enum Entry::Occupied o Entry::Vacant. Evita duplicar el costo del hash y las lecturas en memoria RAM.
  • (d) Error típico de Junior: Escribir en Rust el patrón estilo Python if map.contains_key(&k) { map.get_mut(&k)... } else { map.insert(k, 1); }, realizando dos o tres búsquedas de hash completas e innecesarias.

Invariante de Palíndromo en Frecuencia#

  • (a) En palabras simples (intuición): Para armar una palabra simétrica como "radar" o "reconocer", cada letra debe tener una pareja gemela idéntica (frecuencia par), excepto a lo sumo una letra central solitaria si la longitud total es impar.
  • (b) Definición técnica paso a paso: Una cadena puede reordenarse como palíndromo si y solo si la cantidad de caracteres con frecuencia de aparición impar es estrictamente ≤ 1.
  • (c) Cómo funciona en Rust (memoria y tipos): Podemos rastrear esta paridad con una sola variable de 32 bits u32 mediante operaciones de bits XOR (bitmask ^= 1 << shift). Al final, verificar si a lo sumo un bit está encendido se resuelve con la fórmula matemática (v & (v - 1)) == 0 en 1 ciclo de CPU.
  • (d) Error típico de Junior: Generar todas las permutaciones posibles de la cadena (O(N!)) y verificar una por una si alguna es palíndromo, provocando un desbordamiento temporal inmanejable.

2. Desglose Conceptual desde Primeros Principios (Sin Asumir Nada)#

El Algoritmo Two Sum: De O(N²) a O(N) en Una Pasada#

Problema (LeetCode 1): Dado un arreglo nums y un entero target, encuentra los dos índices cuya suma sea igual a target.

Enfoque de Fuerza Bruta (O(N²)):#

Compara cada elemento nums[i] con todos los demás elementos nums[j]:

RUST
for i in 0..n {
    for j in (i + 1)..n {
        if nums[i] + nums[j] == target { return Some((i, j)); }
    }
}

Esto realiza (N(N-1)) / (2) comparaciones. Si N = 100,000, son 5,000,000,000 operaciones.

Enfoque Óptimo con Tabla Hash en Una Sola Pasada (O(N)):#

Por álgebra elemental:

nums[i] + nums[j] = target ⟺ nums[j] = target - nums[i]

Llamamos a target - nums[i] el complemento. Conforme recorremos el arreglo:

  1. Calculamos el complemento del número actual: complemento = target - num.
  2. Preguntamos a la Tabla Hash si ya ha visto ese complemento en iteraciones previas: - Si SÍ: ¡Encontramos la pareja! Retornamos el índice guardado en el mapa y el índice actual i. - Si NO: Guardamos el número actual y su índice en el mapa: map.insert(num, i).

Cada consulta e inserción toma O(1) promedio. Recorremos el arreglo una sola vez: tiempo total O(N).


3. Visualización Paso a Paso y Gráfica de Memoria en Rust#

Flujo de Análisis de Frecuencia con entry()#

Consulta la visualización vectorial de arquitectura:

  • Gráfico SVG: ![Visualización del Día 04 y Memoria en Rust](../diagrams/sesiones/dia_04.svg)
  • Enlace al código fuente Mermaid: Fuente Mermaid
DIAGRAMA MERMAID
flowchart LR
    IN["Texto 'banana'"] --> FOR["Para cada char c"]
    FOR --> ENTRY["counts.entry(c).or_insert(0)"]
    ENTRY --> INC["*val += 1 (Incremento directo)"]
    INC --> OUT["Resultado final: { 'a': 3, 'n': 2, 'b': 1 }"]

Visualización Cuadro por Cuadro de Memoria: Two Sum#

Entrada: nums = [2, 11, 7, 15], target = 9.

TEXT
ESTADO INICIAL:
map = {} (vacío en Heap)

PASO 1 (i = 0):
- Elemento actual: nums[0] = 2.
- Complemento requerido: 9 - 2 = 7.
- ¿Está 7 en map? -> NO.
- Acción: map.insert(2, 0).
- Estado Heap: { 2: 0 }

PASO 2 (i = 1):
- Elemento actual: nums[1] = 11.
- Complemento requerido: 9 - 11 = -2.
- ¿Está -2 en map? -> NO.
- Acción: map.insert(11, 1).
- Estado Heap: { 2: 0, 11: 1 }

PASO 3 (i = 2):
- Elemento actual: nums[2] = 7.
- Complemento requerido: 9 - 7 = 2.
- ¿Está 2 en map? -> ¡SÍ! Su índice es map[2] = 0.
- Retorno inmediato: Some((0, 2)).
Termina en apenas 3 pasos sin necesidad de examinar el resto del arreglo.

4. Implementación Canónica en Rust + Comparativa con Python#

Implementación en Rust (practica-rust/dia_04.rs)#

RUST
//! Implementación canónica de Hash Maps y análisis de frecuencia en Rust.
//! Compilable y verificable con `rustc --test`.

use std::collections::HashMap;

/// CTCI 1.2: Verifica si s1 y s2 son permutaciones (anagramas).
pub fn check_permutation_anagram(s1: &str, s2: &str) -> bool {
    if s1.len() != s2.len() {
        return false;
    }

    let mut counts = HashMap::new();
    for c in s1.chars() {
        *counts.entry(c).or_insert(0) += 1;
    }

    for c in s2.chars() {
        match counts.get_mut(&c) {
            Some(count) => {
                if *count == 0 {
                    return false;
                }
                *count -= 1;
            }
            None => return false,
        }
    }

    true
}

/// LeetCode 1: Two Sum en una sola pasada O(N).
pub fn two_sum(nums: &[i32], target: i32) -> Option<(usize, usize)> {
    let mut map = HashMap::with_capacity(nums.len());

    for (i, &num) in nums.iter().enumerate() {
        let complement = target - num;
        if let Some(&prev_idx) = map.get(&complement) {
            return Some((prev_idx, i));
        }
        map.insert(num, i);
    }

    None
}

/// CTCI 1.4: Permutación de palíndromo mediante bit vector O(1) espacio auxiliar.
pub fn is_palindrome_permutation(s: &str) -> bool {
    let mut bit_vector: u32 = 0;

    for c in s.chars() {
        if c.is_alphabetic() {
            let lower = c.to_ascii_lowercase();
            if lower.is_ascii_lowercase() {
                let shift = (lower as u8 - b'a') as u32;
                bit_vector ^= 1 << shift; // Alterna el bit con XOR
            }
        }
    }

    // A lo sumo 1 bit encendido: (v & (v - 1)) == 0
    (bit_vector & bit_vector.saturating_sub(1)) == 0
}

/// LeetCode 49: Agrupación de anagramas con clave ordenada.
pub fn group_anagrams(words: &[String]) -> Vec<Vec<String>> {
    let mut map: HashMap<String, Vec<String>> = HashMap::new();

    for word in words {
        let mut chars: Vec<char> = word.chars().collect();
        chars.sort_unstable();
        let key: String = chars.into_iter().collect();
        map.entry(key).or_default().push(word.clone());
    }

    map.into_values().collect()
}

/// LeetCode 49 Optimizado O(N * K): Clave mediante array de frecuencia [u8; 26].
pub fn group_anagrams_frequency(words: &[String]) -> Vec<Vec<String>> {
    let mut map: HashMap<[u8; 26], Vec<String>> = HashMap::new();

    for word in words {
        let mut counts = [0u8; 26];
        for b in word.bytes() {
            if b.is_ascii_lowercase() {
                counts[(b - b'a') as usize] += 1;
            }
        }
        map.entry(counts).or_default().push(word.clone());
    }

    map.into_values().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_check_permutation_anagram() {
        assert!(check_permutation_anagram("anagram", "nagaram"));
        assert!(!check_permutation_anagram("rat", "car"));
    }

    #[test]
    fn test_two_sum() {
        let nums = [2, 7, 11, 15];
        assert_eq!(two_sum(&nums, 9), Some((0, 1)));
    }

    #[test]
    fn test_is_palindrome_permutation() {
        assert!(is_palindrome_permutation("Tact Coa"));
        assert!(!is_palindrome_permutation("hello"));
    }
}

Implementación Canónica en Python (practica/dia_04_hash_tables_frequency.py)#

PYTHON
from collections import defaultdict
from typing import List, Tuple, Optional

def check_permutation_anagram(s1: str, s2: str) -> bool:
    if len(s1) != len(s2):
        return False
    counts = defaultdict(int)
    for c in s1:
        counts[c] += 1
    for c in s2:
        counts[c] -= 1
        if counts[c] < 0:
            return False
    return True

def two_sum(nums: List[int], target: int) -> Optional[Tuple[int, int]]:
    seen = {}
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return None

5. Las 3 Trampas Clásicas en Entrevistas#

  1. Usar el Mismo Elemento Dos Veces en Two Sum: - En una solución ingenua con HashMap de dos pasadas (llenar el mapa primero y luego consultar), si nums = [3, 2, 4] y target = 6, al evaluar el primer 3, el complemento es 6 - 3 = 3. Si no compruebas i != map[complement], retornarás erróneamente (0, 0). El patrón de una sola pasada previene este error por construcción, pues el número actual aún no se ha insertado.
  2. Ignorar Espacios y Mayúsculas/Minúsculas en Permutaciones de Palíndromos: - "Tact Coa" es permutación de "taco cat". Debes normalizar con .to_ascii_lowercase() y filtrar espacios o signos de puntuación.
  3. El Costo Asintótico de la Clave en Group Anagrams: - Ordenar cada palabra cuesta O(K log K). Para palabras muy largas, usar una tupla o array de frecuencias de tamaño 26 ([u8; 26]) reduce el tiempo por palabra a O(K) lineal.

6. Preguntas de Comprensión Profunda y Diagnóstico Socrático#

Pregunta 1: ¿Cómo funciona la arquitectura de Swiss Tables utilizada en el HashMap de Rust y por qué es significativamente más rápida que las tablas hash tradicionales con encadenamiento (chaining)?

Respuesta Técnica Detallada: Las tablas tradicionales con encadenamiento asignan una lista enlazada por cada cubeta. Cada colisión requiere asignar un nodo en el Heap y seguir un puntero, causando fallos de caché de CPU continuos. En cambio, la arquitectura de Swiss Tables (adoptada por Rust a través de hashbrown):

  1. Almacena todos los pares clave-valor contiguamente en un solo arreglo plano en el Heap.
  2. Mantiene un arreglo paralelo de bytes de control (1 byte por casilla). Los 7 bits más bajos del hash de la clave se almacenan en este byte de control (H2 hash).
  3. Utiliza instrucciones SIMD de la CPU (como AVX2 o SSE en x86) para cargar 16 bytes de control en un único registro vectorial de 128 bits y compararlos contra el hash buscado en un solo ciclo de procesador.
  4. Solo si la instrucción vectorial reporta una coincidencia en los 7 bits, la CPU accede al par clave-valor real para verificar igualdad completa. Esto reduce los accesos a la memoria principal en un 95%, logrando velocidades extremas.
Pregunta 2: ¿Cuál es la demostración matemática detrás del truco de bits (v & (v - 1)) == 0 para verificar si un entero tiene a lo sumo un bit encendido?

Respuesta Técnica Detallada: Sea v un entero positivo no nulo con representación binaria:

v = bₙ b_{n-1} … b_{k+1} 1 0 0 … 0

Donde el bit en la posición k es el bit menos significativo encendido (least significant set bit). Cuando restamos 1 a v (v - 1):

  • Todos los bits a la izquierda de la posición k permanecen inalterados.
  • El bit en la posición k cambia de $1$ a $0$.
  • Todos los bits a la derecha de la posición k (que eran ceros) se convierten en unos:
v - 1 = bₙ b_{n-1} … b_{k+1} 0 1 1 … 1

Al aplicar la operación lógica AND bit a bit (v & (v - 1)):

  • Los ceros a la derecha cancelan a los unos.
  • El bit k (1 & 0) se apaga a $0$.
  • Los bits superiores cancelan o preservan según su valor.

En conclusión: la operación v & (v - 1) apaga exactamente el bit encendido más bajo de v. Si v tenía un solo bit encendido originalmente, al apagarlo el resultado es estrictamente $0$. Si v ya era 0, la expresión también resulta en 0. Por lo tanto, (v & (v - 1)) == 0 es verdadero si y solo si la cantidad de bits encendidos en v es cero o uno.

Pregunta 3: ¿Por qué la función hash por defecto de Rust es SipHash 1-3 y bajo qué circunstancias de ingeniería un desarrollador debería sustituirla por FxHash o AHash?

Respuesta Técnica Detallada: SipHash 1-3 es una función con clave pseudoaleatoria criptográficamente robusta diseñada específicamente para mitigar ataques de colisión dirigida (HashDoS). En un ataque HashDoS, un usuario malicioso envía millones de solicitudes HTTP con claves especialmente calculadas para colisionar en la misma cubeta de un servidor web, forzando la tabla de O(1) a O(N) y saturando la CPU al 100%.

Sin embargo, para algoritmos internos de procesamiento de datos, compiladores (como el propio rustc) o resolución de problemas en LeetCode donde las entradas no provienen de atacantes maliciosos, la sobrecarga criptográfica de SipHash es innecesaria. En estos casos, se utiliza FxHash (desarrollado por Firefox, basado en multiplicación y rotación de bits) o AHash (acelerado por instrucciones AES de hardware), alcanzando aumentos de velocidad de hasta 3x a 5x en operaciones de consulta e inserción masiva.

Pregunta 4: ¿Por qué en el método group_anagrams_frequency podemos usar [u8; 26] directamente como clave de un HashMap en Rust, mientras que en Python una lista [0] * 26 provocaría un TypeError: unhashable type: 'list'?

Respuesta Técnica Detallada: En Python, los tipos mutables como list no implementan el método mágico __hash__, porque si una lista pudiera mutar dentro de una tabla hash, su valor de hash cambiaría y la tabla quedaría corrompida. En Python es obligatorio convertir la lista a una tupla inmutable: tuple(counts).

En Rust, los arrays de tamaño fijo [T; N] son tipos por valor (value types) almacenados directamente en el Stack sin punteros indirectos. En Rust, [u8; 26] implementa automáticamente los traits Hash, Eq y PartialEq porque su tipo interno u8 los implementa y su tamaño $26$ es conocido en tiempo de compilación. Por lo tanto, Rust permite usar arrays fijos como claves directas sin necesidad de conversiones intermedias ni asignaciones en el Heap.

Pregunta 5: Si un arreglo ordenado se entrega como entrada para Two Sum (LeetCode 167), ¿por qué la técnica de Dos Punteros es superior a la solución con HashMap?

Respuesta Técnica Detallada: Si el arreglo ya está ordenado:

  • Solución con HashMap: Toma tiempo O(N) y espacio auxiliar O(N) en el Heap para almacenar los índices y números.
  • Solución con Dos Punteros (convergentes desde los extremos):
  1. Si nums[left] + nums[right] == target, retornamos la solución.
  2. Si la suma es menor que target, aumentamos left += 1 para sumar un número mayor.
  3. Si la suma es mayor que target, reducimos right -= 1 para sumar un número menor.

Esta solución toma exactamente tiempo O(N) y espacio auxiliar O(1), sin pedir un solo byte de memoria al sistema operativo y aprovechando al 100% la caché de la CPU. Siempre que una colección esté ordenada, Dos Punteros domina sobre Hash Maps.


7. Puente a LeetCode y Práctica de Código#

  1. LeetCode 1 — Two Sum (Easy): - Implementa la versión de una sola pasada con HashMap::with_capacity(nums.len()).
  2. LeetCode 242 — Valid Anagram (Easy): - Resuelve comparando frecuencias con tabla o array de conteo.
  3. LeetCode 49 — Group Anagrams (Medium): - Implementa la variante con clave ordenada y la variante con clave de frecuencia [u8; 26].

Navegación:

Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)