Sesión 18: Cracking the Coding Interview (Capítulo 3, Parte 2) — Stack Min (CTCI 3.2) y SetOfStacks (CTCI 3.3)#
Navegación rápida:
- ← Sesión 17: CTCI Ch3 — Stacks, Queues y Three in One
- Sesión 19: CTCI Ch3 — Queue via Stacks y Sort Stack →
- Glosario Maestro
- Guía de Diagramas de Arquitectura
- Tiempo estimado de estudio: 60-75 minutos.
- Código fuente compilado y probado:
practica-rust/dia_18.rs
1. Diccionario de Conceptos del Día (Desde Cero para Junior / Trainee)#
En esta sesión profundizamos en el diseño de tipos abstractos de datos avanzados, donde decoramos el comportamiento básico de una pila para soportar consultas analíticas en tiempo constante O(1) y particionamiento físico de memoria. Consulta el Glosario Maestro y la Guía de Diagramas.
Invariante de Mínimo Histórico#
- (a) En palabras simples (intuición): Un registro contable que anota cuál era el récord del precio más bajo en cada día del pasado. Aunque el precio de hoy suba, el récord histórico no se pierde; y si el día del récord se borra, sabemos cuál era el récord anterior.
- (b) Definición técnica paso a paso: Propiedad estructural que garantiza que para cualquier estado o altura h de la pila, la consulta
get_min()retorne el valor mínimo de todos los elementos presentes en el rango [0, h] en tiempo estrictamente constante O(1). - (c) Cómo funciona en Rust (memoria y tipos): Se implementa manteniendo sincronizado un segundo vector o almacenando tuplas con el mínimo acumulado en cada nivel de la pila.
- (d) Error típico de Junior: Usar una sola variable escalar
min_val: i32; al desapilar el elemento mínimo, la estructura pierde el conocimiento del mínimo anterior y se ve obligada a recorrer toda la pila en O(N).
Pila Auxiliar de Mínimos (min_stack)#
- (a) En palabras simples (intuición): Una libreta de bolsillo donde solo anotas un número cuando alguien grita un nuevo récord más bajo. Si alguien grita un número más alto, no anotas nada.
- (b) Definición técnica paso a paso: Estructura de datos secundaria de tipo LIFO que almacena de forma monótonamente no creciente los valores mínimos observados. El tope de esta pila siempre refleja el mínimo global actual de la pila principal.
- (c) Cómo funciona en Rust (memoria y tipos): Un
Vec<(i32, usize)>en el Heap que solo realizapushcuando el nuevo valor es menor o igual al tope actual, ahorrando asignaciones innecesarias de memoria. - (d) Error típico de Junior: Usar la condición de inserción estricta
val < min_stack.top(), olvidando que ante elementos mínimos duplicados (ej. insertar dos veces el número3), el segundo no se registrará y el primerpop()corromperá el estado.
Optimización de Duplicados por Frecuencia ((min_val, count))#
- (a) En palabras simples (intuición): En vez de escribir la palabra "oferta" 500 veces seguidas en una lista, escribes "oferta (x500)" en una sola línea.
- (b) Definición técnica paso a paso: Técnica de compresión de memoria (run-length compression) donde cada entrada en la pila de mínimos es una tupla que contiene el valor mínimo y un contador entero de repeticiones continuas.
- (c) Cómo funciona en Rust (memoria y tipos): Se modela como
(i32, usize). Si el nuevo elemento es igual al mínimo actual, simplemente se incrementa el contador con*count += 1sin reservar un nuevo nodo en el vector. - (d) Error típico de Junior: Asignar memoria para miles de nodos auxiliares redundantes cuando entran secuencias masivas de valores idénticos.
Set of Stacks (Capacidad Dinámica y Platos de Restaurante - CTCI 3.3)#
- (a) En palabras simples (intuición): La torre de platos en la cocina de un restaurante. Si apilas más de 10 platos en una sola torre, la torre se vuelve inestable y peligrosa; por tanto, cuando una torre llega a 10, abres una nueva torre al lado.
- (b) Definición técnica paso a paso: Estructura de datos compuesta que agrupa múltiples sub-pilas de capacidad fija C. Para el cliente exterior se comporta como una única pila unificada con semántica LIFO, pero internamente crea o destruye sub-pilas conforme los datos rebasan o descienden de la capacidad límite.
- (c) Cómo funciona en Rust (memoria y tipos): Se implementa como un vector de vectores (
Vec<Vec<T>>) en el Heap, gestionando dinámicamente la creación de sub-arreglos y su recolección de memoria cuando quedan vacíos. - (d) Error típico de Junior: Tratar la estructura como un único arreglo gigante sin encapsular el límite por sub-pila, o no liberar las sub-pilas vacías del vector principal.
Operación pop_at(index) con Rollover vs sin Rollover#
- (a) En palabras simples (intuición): Sacar un plato del medio de una torre intermedia:
- Sin Rollover: Dejas esa torre con un plato menos y continúas trabajando normalmente.
- Con Rollover: Trasladas un plato del fondo de la siguiente torre para llenar el espacio vacío y mantener todas las torres intermedias perfectamente llenas.
- (b) Definición técnica paso a paso:
- Sin Rollover: Desapila directamente de la sub-pila en el índice dado en tiempo O(1). Permite que existan sub-pilas con capacidad parcial en posiciones intermedias.
- Con Rollover (Cascada): Mantiene la invariante estricta de que todas las sub-pilas excepto la última están 100% llenas. Requiere rotar elementos entre sub-pilas en tiempo lineal O(N).
- (c) Cómo funciona en Rust (memoria y tipos): En Rust, la versión sin rollover realiza
self.stacks[index].pop()en O(1), y si la sub-pila queda con longitud 0, la remueve del vector conself.stacks.remove(index). - (d) Error típico de Junior: Prometer al entrevistador que
pop_atcon rollover opera en O(1); rotar elementos a través de múltiples sub-arreglos es físicamente una operación O(N).
2. Desglose Conceptual desde Primeros Principios (Sin Asumir Nada)#
La Falacia de la Variable Escalar min_val#
El error más común de un programador principiante al enfrentarse a LeetCode 155 es declarar:
struct BadMinStack {
stack: Vec<i32>,
min_val: i32,
}
Analicemos qué ocurre en la memoria física paso a paso:
- Insertamos
5→min_val = 5. - Insertamos
3→min_val = 3. - Insertamos
7→min_val = 3.
Hasta aquí todo parece funcionar en O(1). Pero ahora invocamos pop():
- Se retira el número
7.min_valsigue siendo3. - Invocamos
pop()nuevamente: se retira el número3.
En este instante preciso, el valor mínimo almacenado en min_val (el 3) acaba de salir de la estructura. ¿Cuál es el nuevo mínimo de los elementos que quedaron en la pila ([5])? La variable escalar min_val no tiene memoria histórica. No tiene forma física de saber qué número era el mínimo antes de que llegara el 3. La única alternativa sería recorrer todos los elementos restantes uno por uno con un bucle for, lo que cuesta O(N) tiempo, destruyendo la especificación de diseño de la entrevista.
Comparativa de Espacio: Tuplas por Nodo vs Pila Auxiliar Comprimida#
Existen dos arquitecturas canónicas correctas para resolver el problema:
| Característica | Enfoque A: Tupla en cada celda (val, min) | Enfoque B: Pila Auxiliar Comprimida (min, count) |
|---|---|---|
| Estructura | Vec<(i32, i32)> | stack: Vec<i32>, min_stack: Vec<(i32, usize)> |
Tiempo de push / pop | O(1) estricto | O(1) estricto |
| Consumo de Memoria | Duplica el tamaño de memoria de todos los elementos (2 × N × sizeof(T)). | Solo almacena elementos cuando hay un nuevo mínimo o duplicado. |
| Mejor Caso Espacial | Secuencia decreciente: 2N enteros. | Secuencia estrictamente creciente: solo $1$ entrada en min_stack → O(1) espacio extra. |
| Duplicados Masivos | Si entran 1,000,000 ceros, gasta 2,000,000 celdas. | Si entran 1,000,000 ceros, gasta 1 celda en min_stack: (0, 1000000). |
El Enfoque B es netamente superior en entornos de producción y demuestra maestría de hardware y eficiencia de memoria ante el entrevistador.
3. Visualización Paso a Paso y Gráfica de Memoria en Rust#
Diagramas de Arquitectura#
- Gráfico SVG vectorial:
 - Código fuente del diagrama: Fuente Mermaid
Evolución de Estados en MinStack#
flowchart TD
subgraph S1["Estado 1: push(5), push(3), push(7), push(3)"]
A1["Pila Principal: [5, 3, 7, 3] (Top=3)"]
B1["Pila Mínimos: [(5, 1), (3, 2)] (Min=3)"]
end
subgraph S2["Estado 2: pop() -> Retira 3"]
A2["Pila Principal: [5, 3, 7] (Top=7)"]
B2["Pila Mínimos: [(5, 1), (3, 1)] (Min=3)"]
end
subgraph S3["Estado 3: pop() -> Retira 7, pop() -> Retira 3"]
A3["Pila Principal: [5] (Top=5)"]
B3["Pila Mínimos: [(5, 1)] (Min=5)"]
end
S1 --> S2 --> S3
Visualización Cuadro por Cuadro de Memoria en Rust (Stack vs Heap)#
Estructura SetOfStacks con capacidad máxima 2 por sub-pila:
STACK HEAP
+-------------------------------+
| SetOfStacks |
| capacity: 2 |
| stacks: Vec<Vec<i32>> ------+--------------> Vector Exterior (Punteros a sub-arreglos)
+-------------------------------+ +------------+------------+------------+
| [0] Ptr | [1] Ptr | [2] Ptr |
+-----+------+-----+------+-----+------+
| | |
v v v
+----+----+ +----+----+ +----+----+
| 10 | 20 | | 30 | 40 | | 50 | |
+----+----+ +----+----+ +----+----+
Sub-pila 0 Sub-pila 1 Sub-pila 2
(Llena: 2) (Llena: 2) (Parcial: 1)
Operación pop_at(1):
1. Accede directamente a stacks[1].
2. Llama a pop() sobre Sub-pila 1 -> Extrae 40.
3. Sub-pila 1 queda con [30] (longitud 1).
4. No queda vacía, por lo que el vector exterior no se altera.
Tiempo: O(1) estricto. Cero copias en memoria.
Operación pop_at(1) nuevamente:
1. Accede a stacks[1]. Extrae 30.
2. Sub-pila 1 queda vacía (len == 0).
3. stacks.remove(1) descarta el buffer vacío del Heap y compacta el vector exterior:
Vector Exterior:
+------------+------------+
| [0] Ptr | [1] Ptr |
+-----+------+-----+------+
| |
v v
+----+----+ +----+----+
| 10 | 20 | | 50 | |
+----+----+ +----+----+
4. Implementación Canónica en Rust + Comparativa con Python#
Implementación Canónica en Rust (practica-rust/dia_18.rs)#
//! Día 18: Stack Min (CTCI 3.2, LeetCode 155) y Set of Stacks (CTCI 3.3) en Rust.
//! Compilable y verificable con `rustc --test`.
#[derive(Debug, PartialEq, Eq)]
pub enum StackError {
EmptyStack,
IndexOutOfBounds,
}
/// CTCI 3.2 / LeetCode 155: Pila con seguimiento del mínimo en tiempo O(1) constante.
#[derive(Debug, Default, Clone)]
pub struct MinStack {
stack: Vec<i32>,
min_stack: Vec<(i32, usize)>, // (valor_mínimo, frecuencia)
}
impl MinStack {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, val: i32) {
self.stack.push(val);
match self.min_stack.last_mut() {
Some((current_min, count)) => {
if val < *current_min {
self.min_stack.push((val, 1));
} else if val == *current_min {
*count += 1;
}
}
None => {
self.min_stack.push((val, 1));
}
}
}
pub fn pop(&mut self) -> Result<i32, StackError> {
let val = self.stack.pop().ok_or(StackError::EmptyStack)?;
if let Some((current_min, count)) = self.min_stack.last_mut() {
if val == *current_min {
*count -= 1;
if *count == 0 {
self.min_stack.pop();
}
}
}
Ok(val)
}
pub fn top(&self) -> Result<i32, StackError> {
self.stack.last().copied().ok_or(StackError::EmptyStack)
}
pub fn get_min(&self) -> Result<i32, StackError> {
self.min_stack.last().map(|&(m, _)| m).ok_or(StackError::EmptyStack)
}
pub fn len(&self) -> usize { self.stack.len() }
pub fn is_empty(&self) -> bool { self.stack.is_empty() }
}
/// CTCI 3.3: Set of Stacks (Conjunto de pilas de capacidad limitada).
#[derive(Debug, Clone)]
pub struct SetOfStacks<T> {
stacks: Vec<Vec<T>>,
capacity: usize,
}
impl<T: Clone> SetOfStacks<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0);
Self { stacks: Vec::new(), capacity }
}
pub fn push(&mut self, val: T) {
if self.stacks.is_empty() || self.stacks.last().map_or(false, |s| s.len() >= self.capacity) {
let mut new_stack = Vec::with_capacity(self.capacity);
new_stack.push(val);
self.stacks.push(new_stack);
} else {
self.stacks.last_mut().unwrap().push(val);
}
}
pub fn pop(&mut self) -> Result<T, StackError> {
if self.stacks.is_empty() { return Err(StackError::EmptyStack); }
let val = self.stacks.last_mut().unwrap().pop().ok_or(StackError::EmptyStack)?;
if self.stacks.last().map_or(false, |s| s.is_empty()) {
self.stacks.pop();
}
Ok(val)
}
pub fn pop_at(&mut self, index: usize) -> Result<T, StackError> {
if index >= self.stacks.len() { return Err(StackError::IndexOutOfBounds); }
let val = self.stacks[index].pop().ok_or(StackError::EmptyStack)?;
if self.stacks[index].is_empty() {
self.stacks.remove(index);
}
Ok(val)
}
pub fn num_stacks(&self) -> usize { self.stacks.len() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_min_stack() {
let mut ms = MinStack::new();
ms.push(5);
ms.push(3);
ms.push(7);
ms.push(3);
assert_eq!(ms.get_min(), Ok(3));
assert_eq!(ms.pop(), Ok(3));
assert_eq!(ms.get_min(), Ok(3));
assert_eq!(ms.pop(), Ok(7));
assert_eq!(ms.pop(), Ok(3));
assert_eq!(ms.get_min(), Ok(5));
}
#[test]
fn test_set_of_stacks() {
let mut set = SetOfStacks::new(2);
set.push(10);
set.push(20);
set.push(30);
assert_eq!(set.num_stacks(), 2);
assert_eq!(set.pop_at(0), Ok(20));
assert_eq!(set.pop(), Ok(30));
assert_eq!(set.pop(), Ok(10));
assert_eq!(set.num_stacks(), 0);
}
}
Implementación Canónica en Python (practica/dia_18_stack_min_and_set_of_stacks.py)#
from typing import List
class MinStack:
"""CTCI 3.2 / LC 155: Seguimiento del mínimo en O(1) tiempo con tuplas de frecuencia."""
def __init__(self) -> None:
self._stack: List[int] = []
self._min_stack: List[List[int]] = [] # [min_val, count]
def push(self, val: int) -> None:
self._stack.append(val)
if not self._min_stack or val < self._min_stack[-1][0]:
self._min_stack.append([val, 1])
elif val == self._min_stack[-1][0]:
self._min_stack[-1][1] += 1
def pop(self) -> int:
if not self._stack:
raise IndexError("pop from empty MinStack")
val = self._stack.pop()
if val == self._min_stack[-1][0]:
self._min_stack[-1][1] -= 1
if self._min_stack[-1][1] == 0:
self._min_stack.pop()
return val
def top(self) -> int:
if not self._stack:
raise IndexError("top from empty MinStack")
return self._stack[-1]
def get_min(self) -> int:
if not self._min_stack:
raise IndexError("get_min from empty MinStack")
return self._min_stack[-1][0]
5. Las 3 Trampas Clásicas en Entrevistas#
- La Fuga del Mínimo Duplicado:
- Si no usas un contador de frecuencia o condición
<=al apilar en la auxiliar: al insertar3y luego otro3, el segundo no se apilará. Al hacer un primerpop(), se desapilará el único3registrado, dejando la pila corrupta con un mínimo falso para el3restante. - Sub-pilas Fantasma en SetOfStacks:
- Olvidar eliminar la sub-pila vacía cuando su último elemento es retirado mediante
pop()opop_at(). Dejar listas vacías[]dentro del vector principal provocará que métodos comopeek()o llamadas posteriores fallen con desreferenciación nula o pánico por índice. - Manejo de Errores con Valores Mágicos:
- Nunca retornes un número mágico como
-1si la pila está vacía. El número-1puede ser un dato perfectamente válido insertado por el usuario. Emplea siempreResult<T, StackError>en Rust o una excepción de dominio clara en Python.
6. Preguntas de Comprensión Profunda y Diagnóstico Socrático#
Pregunta 1: ¿Por qué guardar pares (min_val, count) en la pila auxiliar es más eficiente en memoria que almacenar el mínimo en cada nodo de la pila principal?
Respuesta Técnica Detallada: Si almacenas el mínimo dentro de cada nodo de la pila principal (Vec<(T, T)>): La estructura está obligada a consumir memoria adicional para absolutamente todos los elementos, sin importar si el mínimo cambió o no. En un vector de 1,000,000 de enteros de 32 bits, gastarás 8 MB en lugar de 4 MB. Con la pila auxiliar comprimida por frecuencia:
- Si los datos entran en orden estrictamente creciente (
1, 2, 3, ..., 1000000), el mínimo nunca cambia (sigue siendo 1). La pila auxiliar contiene un único nodo:(1, 1000000). El espacio adicional es prácticamente O(1) (16 bytes en total). - Si entran duplicados masivos del mínimo (
5, 5, 5, ..., 5), tampoco se crean nodos nuevos; solo se incrementa un contador entero en registros de CPU.
El ahorro de memoria en entornos con millones de transacciones es de varios órdenes de magnitud.
Pregunta 2: ¿Cómo diseñarías una pila que permita push, pop, get_min y get_max todos en tiempo estrictamente constante O(1)?
Respuesta Técnica Detallada: Se replica simétricamente la arquitectura de la pila auxiliar:
- Mantenemos la pila principal
stack: Vec<i32>. - Mantenemos una pila auxiliar de mínimos
min_stack: Vec<(i32, usize)>gobernada por la regla de no-crecimiento (val ≤ current_min). - Mantenemos una pila auxiliar de máximos
max_stack: Vec<(i32, usize)>gobernada por la regla de no-decrecimiento (val ≥ current_max).
- En
push(val): Comprobamos sivalcalifica como nuevo mínimo y/o como nuevo máximo de forma independiente. - En
pop(): Comparamos el valor extraído con los topes demin_stackymax_stack, decrementando sus contadores y desapilando cuando el contador llegue a cero. get_min()yget_max()simplemente leen el tope de sus respectivas pilas auxiliares en O(1) tiempo y con la misma eficiencia espacial.
Pregunta 3: En SetOfStacks (CTCI 3.3), ¿cuáles son los compromisos técnicos entre implementar pop_at(index) con Rollover frente a la versión sin Rollover?
Respuesta Técnica Detallada: Se trata de un trade-off clásico de ingeniería de software entre densidad de almacenamiento y latencia computacional:
- Sin Rollover (Sparse Stacks):
- Ventaja:
pop_at(index)se ejecuta en tiempo O(1) estricto, ya que solo desapila del sub-vector indicado y no perturba las demás sub-pilas. - Desventaja: Desperdicia memoria si se ejecutan muchos
pop_atintermedios, dejando sub-pilas con pocos elementos (fragmentación interna). - Con Rollover (Dense Stacks):
- Ventaja: Garantiza que todas las sub-pilas intermedias se mantengan al 100% de su capacidad nominal, logrando máxima densidad de memoria sin huecos.
- Desventaja: Para llenar el hueco dejado en la sub-pila i, se debe retirar el elemento del fondo de la sub-pila i + 1 y moverlo a la sub-pila i. Este movimiento se propaga en cascada por todas las sub-pilas posteriores hasta la última, consumiendo tiempo lineal O(N) en el peor de los casos.
Pregunta 4: ¿Por qué en Rust es fundamental llamar a stacks.remove(index) cuando una sub-pila queda vacía tras un pop_at?
Respuesta Técnica Detallada: Si no se elimina la sub-pila vacía:
- Fuga de estado (Zombi Stack): El vector principal contendrá un elemento
Vecde longitud 0 ([]). - Fallas en operaciones posteriores: Si la sub-pila vacía quedó en la última posición, una llamada subsecuente a
peek()opop()consultaráself.stacks.last().unwrap(), intentará desapilar de esa lista vacía y provocará un pánico en tiempo de ejecución (unwrap on None o IndexError). - Invariante rota: La invariante del sistema es que toda sub-pila registrada en el vector contiene al menos 1 elemento (len ≥ 1). Mantener esta invariante garantiza que la comprobación
self.stacks.is_empty()sea la única condición necesaria para determinar si la estructura global está vacía.
Pregunta 5: ¿Existe algún método matemático para implementar MinStack sin utilizar ninguna pila auxiliar de memoria adicional?
Respuesta Técnica Detallada: Sí, mediante el Truco del Diferencial Codificado (Encoded Difference): En lugar de almacenar el valor real x, almacenamos la diferencia entre x y el mínimo anterior en una variable única min_val:
- Al hacer
push(x): - Si la pila está vacía: apilamos $0$ y fijamos min_val = x. - Si x ≥ min_val: apilamos la diferencia positiva Δ = x - min_val (el mínimo no cambia). - Si x < min_val: apilamos la diferencia negativa Δ = x - min_val y actualizamos min_val = x. Como Δ < 0, ¡esta bandera negativa le avisa alpop()que este nodo cambió el mínimo! - Al hacer
pop(): - Si el valor desapilado Δ ≥ 0: el elemento original era min_val + Δ. - Si el valor desapilado Δ < 0: el elemento original era el propio min_val, y restauramos el mínimo anterior haciendo min_val_{anterior} = min_val - Δ.
Desventaja en producción: Riesgo de desbordamiento aritmético de enteros (integer overflow) al calcular x - min_val.
7. Puente a LeetCode y Práctica de Código#
- LeetCode 155 — Min Stack (Medium): - Implementación canónica de la pila con seguimiento del mínimo en O(1).
- LeetCode 1172 — Dinner Plate Stacks (Hard): - Generalización avanzada de CTCI 3.3 (SetOfStacks) con gestión de índices mediante Heap de huecos disponibles.
- LeetCode 716 — Max Stack (Hard):
- Pila con seguimiento del máximo y soporte para
popMax()en tiempo sub-lineal mediante Lista Doblemente Enlazada + TreeMap.
8. Tarea en Papel para Hoy#
Toma una hoja de papel y dibuja el estado en memoria de MinStack (pila principal y pila auxiliar comprimida) tras la siguiente secuencia:
push(10)push(2)push(5)push(2)push(1)pop()(muestra cómo se restaura el mínimo a 2).pop()(muestra cómo el contador de 2 se reduce de 2 a 1 sin desapilar de la auxiliar).
Navegación: