En la lección anterior conociste el contrato del TDA pila: push, pop, peek, esta_vacia y tamano, todas con coste O(1). Ahora vamos a abrir cada operación y verla funcionar paso a paso, con trazas del estado de la pila tras cada movimiento, exactamente como harías al depurar. Además tomaremos una decisión de diseño que todo autor de estructuras debe afrontar (¿qué hacer cuando alguien hace pop sobre una pila vacía?), formularemos los invariantes que toda pila debe cumplir, y aplicaremos todo esto al deshacer de TaskFlow. Para practicar sin esperar a la implementación completa (lección 03-03), usaremos la list de Python como pila provisional. Dominar la mecánica fina de estas operaciones es lo que te permitirá después implementarlas y, sobre todo, razonar con seguridad sobre cualquier algoritmo que use pilas.

Contenido

  1. push: apilar paso a paso
  2. pop: desapilar paso a paso
  3. peek: mirar sin tocar
  4. esta_vacia y tamano: las consultas de estado
  5. El caso de la pila vacía: excepción vs None
  6. Invariantes del TDA pila
  7. Secuencias de operaciones: el deshacer de TaskFlow
  8. Mini-práctica guiada: list como pila provisional

push: apilar paso a paso

push(elemento) coloca elemento en la cima. Es la única forma de entrar en una pila. Veamos una traza partiendo de una pila vacía, apilando acciones del historial de TaskFlow (dibujamos la pila en vertical, con la cima arriba, como crece de verdad):

Operación Estado de la pila (cima arriba) tamano()
(inicio) (vacía) 0
push("crear tarea 7") crear tarea 7 1
push("cambiar prioridad") cambiar prioridad ← cima
crear tarea 7
2
push("marcar en_curso") marcar en_curso ← cima
cambiar prioridad
crear tarea 7
3

Puntos clave:

  • Cada push tapa al elemento anterior: crear tarea 7 sigue ahí, pero ya no es accesible hasta que se retiren los de encima.
  • push nunca falla por estado de la pila: una pila (conceptual) no tiene límite de capacidad. (Existen pilas acotadas —lo veremos en un ejercicio de 03-05 con el límite de historial— pero no forman parte del contrato base.)
  • push no devuelve nada: su efecto es el cambio de estado.

En diagrama, los dos primeros pasos:

flowchart LR
    subgraph P1["Tras push(crear tarea 7)"]
        direction TB
        a1["crear tarea 7 ← cima"]
    end
    subgraph P2["Tras push(cambiar prioridad)"]
        direction TB
        b2["cambiar prioridad ← cima"]
        b1["crear tarea 7"]
        b2 --- b1
    end
    P1 -->|"push(cambiar prioridad)"| P2

pop: desapilar paso a paso

pop() hace dos cosas en una: retira el elemento de la cima y lo devuelve. Continuemos la traza anterior (la pila tenía 3 elementos):

Operación Devuelve Estado de la pila (cima arriba) tamano()
(estado previo) marcar en_curso
cambiar prioridad
crear tarea 7
3
pop() "marcar en_curso" cambiar prioridad
crear tarea 7
2
pop() "cambiar prioridad" crear tarea 7 1
pop() "crear tarea 7" (vacía) 0
pop() ¿? (vacía) 0

Observa dos cosas:

  • Los elementos salen en orden inverso al de entrada: entraron 7→prioridad→en_curso y salieron en_curso→prioridad→7. Esa es la esencia LIFO, y es exactamente el orden que necesita un deshacer.
  • El cuarto pop() es un problema: la pila está vacía y no hay nada que devolver. Ese "¿?" merece su propia sección (la número 5).

Un patrón habitual es vaciar una pila procesando cada elemento:

while not pila_esta_vacia():
    elemento = pop()
    procesar(elemento)

Este bucle procesa los elementos en orden inverso al de inserción. Memoriza el patrón: lo usaremos una y otra vez (deshacer todo, invertir secuencias, evaluar expresiones...).

peek: mirar sin tocar

peek() devuelve el elemento de la cima sin retirarlo. La pila queda exactamente igual:

Operación Devuelve Estado tras la operación
(estado previo) cambiar prioridad
crear tarea 7
peek() "cambiar prioridad" cambiar prioridad
crear tarea 7 (idéntico)
peek() "cambiar prioridad" (idéntico: peek es repetible)
pop() "cambiar prioridad" crear tarea 7

¿Para qué sirve si pop ya devuelve la cima? Para decidir antes de actuar. Dos usos típicos en TaskFlow:

  • La interfaz quiere mostrar "Deshacer: cambiar prioridad" en el botón. Necesita leer la próxima acción sin deshacerla: peek.
  • Un algoritmo quiere desapilar solo si la cima cumple una condición (lo verás en la conversión de expresiones de 03-04): primero peek, se comprueba la condición, y solo entonces pop.

Regla de oro: si tras llamar a una operación de consulta la pila cambió, esa operación está mal implementada. peek, esta_vacia y tamano son observadoras; push y pop son modificadoras.

esta_vacia y tamano: las consultas de estado

  • esta_vacia() devuelve True si tamano() == 0. Es la guardia natural antes de cualquier pop o peek, y la condición de parada del bucle de vaciado.
  • tamano() devuelve el número de elementos. Para que sea O(1), la implementación mantendrá un contador que se incrementa en cada push y se decrementa en cada pop — la misma técnica del atributo tamano de ListaEnlazada en el módulo 2, donde contar recorriendo habría costado O(n).

Aunque parezcan triviales, estas dos operaciones son las que hacen seguro el uso de la pila: casi todo bug con pilas es un pop sin comprobar esta_vacia antes.

El caso de la pila vacía: excepción vs None

¿Qué debe hacer pop() (o peek()) sobre una pila vacía? Es una decisión de diseño, y las dos opciones razonables tienen consecuencias distintas:

Estrategia Comportamiento Ventajas Inconvenientes
Lanzar excepción raise IndexError("pop sobre pila vacía") El error no pasa desapercibido; obliga al llamador a pensar; es lo que hace list.pop() de Python Requiere try/except o comprobación previa
Devolver None return None Código llamador más corto Bug silencioso: si alguien apila None como valor legítimo, no puedes distinguir "pila vacía" de "la cima era None"; el error explota lejos de su causa

En este curso adoptamos la excepción, por dos motivos:

  1. Coherencia con Python: [].pop() lanza IndexError. Nuestra pila se comportará como el resto del lenguaje.
  2. Fallar pronto y fuerte: un None inesperado puede viajar por medio programa antes de romper algo, y entonces el traceback no apunta a la causa. Una excepción en el pop culpable apunta exactamente al error.

El llamador tiene entonces dos estilos correctos, ambos válidos:

# Estilo 1: mirar antes de saltar (LBYL, "Look Before You Leap")
if not historial_vacio():
    accion = desapilar()
    revertir(accion)
else:
    print("Nada que deshacer")

# Estilo 2: pedir perdón en vez de permiso (EAFP, el estilo idiomático en Python)
try:
    accion = desapilar()
    revertir(accion)
except IndexError:
    print("Nada que deshacer")

Lo importante no es qué estilo elijas, sino que la decisión "excepción, no None" quede escrita en el contrato y todos los usuarios de la pila puedan confiar en ella.

Invariantes del TDA pila

Un invariante es una propiedad que se cumple siempre, antes y después de cada operación (ya razonamos así en el módulo 2, cuando manteníamos cabeza, cola y tamano coherentes tras cada inserción). Los invariantes de la pila son el "control de calidad" de cualquier implementación:

  1. tamano() >= 0 en todo momento; y esta_vacia() equivale a tamano() == 0.
  2. Tras push(x): peek() devuelve x, y tamano() ha crecido exactamente en 1.
  3. pop() tras push(x) (sin operaciones intermedias) devuelve x y deja la pila exactamente como estaba antes del push. Es decir: pop deshace push.
  4. peek() no modifica nada: tamano() y el contenido son idénticos antes y después.
  5. Orden LIFO global: si se apilan n elementos y luego se desapilan n, salen en orden exactamente inverso.

Estos invariantes son directamente traducibles a tests. Cuando implementemos las dos pilas de la lección 03-03, cualquiera de ellas debe pasar exactamente las mismas comprobaciones: esa es la prueba práctica de que el TDA es independiente de la implementación.

Secuencias de operaciones: el deshacer de TaskFlow

Juguemos una sesión realista de TaskFlow, todavía con pseudocódigo del contrato. Cada acción del usuario se registra con push; cada pulsación de Deshacer ejecuta pop y revierte. Seguimos la tarea {"id": 7, "titulo": "Revisar presupuesto", "prioridad": 2, "estado": "pendiente"}:

# El usuario... Operación sobre la pila Pila de historial (cima arriba)
1 Crea la tarea 7 push(crear id=7) crear 7
2 Sube prioridad 2→1 push(prioridad 7: 2→1) prioridad 2→1
crear 7
3 Marca en curso push(estado 7: pendiente→en_curso) estado →en_curso
prioridad 2→1
crear 7
4 Pulsa Deshacer pop() → revierte estado a pendiente prioridad 2→1
crear 7
5 Asigna a Ana push(asignar 7 a ana) asignar ana
prioridad 2→1
crear 7
6 Pulsa Deshacer pop() → quita la asignación prioridad 2→1
crear 7
7 Pulsa Deshacer pop() → prioridad vuelve a 2 crear 7
8 Pulsa Deshacer pop() → se elimina la tarea 7 (vacía)
9 Pulsa Deshacer esta_vacia() es True → botón inactivo, no se llama a pop (vacía)

Dos observaciones importantes:

  • En el paso 4 se deshizo el estado y en el 5 el usuario hizo algo nuevo. La acción deshecha no "vuelve": la pila solo registra lo vigente. (¿Y si quisiera rehacerla? Necesitaríamos una segunda pila; esa es exactamente la construcción deshacer/rehacer de la lección 03-04.)
  • Cada acción apilada lleva la información necesaria para revertirla (el valor previo: 2→1, pendiente→en_curso). En 03-03 formalizaremos esto con diccionarios {"tipo": ..., "tarea": ..., "datos_previos": ...}.

Mini-práctica guiada: list como pila provisional

Aún no tenemos nuestra clase Pila (llega en 03-03), pero la list de Python puede ejercer de pila provisional, porque sus operaciones por el final son las adecuadas:

  • lista.append(x) → hace de push → O(1) amortizado.
  • lista.pop() (sin argumento) → hace de pop → O(1).
  • lista[-1] → hace de peek → O(1).
  • len(lista) == 0 → hace de esta_vacia → O(1).

¿Por qué por el FINAL y no por el inicio? Recuerda la trampa del módulo 1: insert(0, x) y pop(0) son O(n), porque una list es un array dinámico y tocar el inicio obliga a desplazar todos los elementos. La cima de nuestra pila provisional debe ser el final de la lista, donde el array trabaja en O(1). Una pila hecha con insert(0)/pop(0) "funciona" pero degrada todas las operaciones a O(n): rompería la tabla de costes del contrato.

Abre un intérprete y reproduce la sesión de TaskFlow de la sección anterior:

historial = []          # pila vacía (provisional, sobre list)

# El usuario trabaja: cada acción se apila con append (= push)
historial.append("crear tarea 7")
historial.append("prioridad 7: 2 -> 1")
historial.append("estado 7: pendiente -> en_curso")

print(len(historial))   # 3  (= tamano)
print(historial[-1])    # 'estado 7: pendiente -> en_curso'  (= peek: sin retirar)

# Pulsa Deshacer: pop() retira y devuelve la cima
accion = historial.pop()
print(f"Deshaciendo: {accion}")   # Deshaciendo: estado 7: pendiente -> en_curso
print(historial[-1])              # 'prioridad 7: 2 -> 1'  (nueva cima)

# Deshacer todo con el patrón de vaciado
while len(historial) > 0:         # (= not esta_vacia)
    print(f"Deshaciendo: {historial.pop()}")
# Deshaciendo: prioridad 7: 2 -> 1
# Deshaciendo: crear tarea 7

historial.pop()                   # IndexError: pop from empty list

Explicación línea a línea:

  • historial = [] crea la pila vacía. La lista vacía es nuestra pila vacía.
  • Los tres append apilan en orden; tras ellos, la cima (final de la lista) es la acción más reciente.
  • historial[-1] lee la cima sin retirarla: es nuestro peek. Ojo: sobre una lista vacía, [-1] también lanza IndexError, coherente con nuestra decisión de diseño.
  • historial.pop() sin argumento retira del final: LIFO garantizado y O(1).
  • El bucle while es el patrón de vaciado: imprime las acciones en orden inverso al que ocurrieron, que es justo el orden correcto de un "deshacer todo".
  • El último pop() sobre la lista vacía lanza IndexError: Python ya implementa la estrategia de excepción que elegimos.

Esta pila provisional es totalmente funcional, pero tiene un defecto: no protege el contrato. Nada impide que otro programador haga historial.insert(0, x) o historial[3] y rompa la disciplina LIFO. Encapsular la lista dentro de una clase que solo exponga las cinco operaciones es precisamente el trabajo de la próxima lección.

Errores Comunes y Consejos

  • Hacer pop sin comprobar antes (o sin try/except): el clásico IndexError en producción. Toda llamada a pop/peek debe estar protegida por esta_vacia() o por un except IndexError.
  • Usar pop(0) o insert(0, x) sobre la list-pila: funciona, pero convierte O(1) en O(n). La cima vive en el final de la lista. Si dudas, vuelve a la tabla de costes de list del módulo 1.
  • Usar pop cuando solo querías consultar: si después de "mirar" la cima la necesitas de nuevo, has destruido información. Consulta = peek (lista[-1]); extracción = pop.
  • Devolver None en pila vacía "para simplificar": acabas con if resultado is not None repartidos por todo el código y bugs cuando None es un valor válido. Excepción y punto.
  • Olvidar que pop devuelve el elemento: escribir pila.pop() y luego intentar leer la cima con peek para saber "qué se desapiló" — ya es tarde, era el valor de retorno del propio pop.
  • Consejo: cuando escribas o depures secuencias de operaciones, dibuja la traza en una tabla como las de esta lección (operación → devuelve → estado). Dos minutos de tabla ahorran veinte de depurador.

Ejercicios

Ejercicio 1: traza completa

Partiendo de una pila vacía, construye la tabla de traza (operación, valor devuelto, estado de la pila, tamaño) de esta secuencia:

push(10)
push(20)
peek()
push(30)
pop()
pop()
push(40)
peek()
pop()
pop()
pop()

Indica en qué paso (si lo hay) se lanza IndexError según nuestra decisión de diseño.

Ejercicio 2: el "deshacer todo" seguro

Usando una list como pila provisional, escribe una función deshacer_todo(historial) que reciba la pila de acciones (lista de cadenas) y devuelva una lista con los mensajes "Deshaciendo: <accion>" en el orden correcto de deshacer, dejando la pila vacía. La función no debe fallar nunca, ni siquiera si recibe la pila ya vacía. Escribe dos versiones: una con estilo LBYL (comprobar antes) y otra con estilo EAFP (try/except).

Ejercicio 3: detectar la implementación rota

Un compañero ha escrito estas "operaciones de pila" sobre list. Señala todos los errores respecto al contrato y a los invariantes de esta lección, y corrígelos:

def push(pila, elemento):
    pila.insert(0, elemento)

def pop(pila):
    if len(pila) == 0:
        return None
    return pila.pop(0)

def peek(pila):
    return pila.pop(0)

Soluciones

Solución 1:

Paso Operación Devuelve Pila (cima arriba) Tamaño
1 push(10) 10 1
2 push(20) 20, 10 2
3 peek() 20 20, 10 2
4 push(30) 30, 20, 10 3
5 pop() 30 20, 10 2
6 pop() 20 10 1
7 push(40) 40, 10 2
8 peek() 40 40, 10 2
9 pop() 40 10 1
10 pop() 10 (vacía) 0
11 pop() IndexError (vacía) 0

El paso 11 lanza IndexError: la pila está vacía y elegimos excepción, no None.

Solución 2:

# Versión LBYL: comprobar antes de desapilar
def deshacer_todo(historial):
    mensajes = []
    while len(historial) > 0:          # guardia: solo pop si no está vacía
        accion = historial.pop()       # retira la cima (la más reciente)
        mensajes.append(f"Deshaciendo: {accion}")
    return mensajes

# Versión EAFP: intentar y capturar la excepción
def deshacer_todo_eafp(historial):
    mensajes = []
    while True:
        try:
            accion = historial.pop()
        except IndexError:             # pila agotada: terminamos
            break
        mensajes.append(f"Deshaciendo: {accion}")
    return mensajes

Ambas devuelven los mensajes en orden inverso a la inserción (el orden correcto de deshacer) y dejan historial vacía. Con una pila ya vacía, el while de la primera no llega a ejecutarse y el try de la segunda rompe el bucle en la primera vuelta: ninguna falla.

Solución 3: hay cuatro errores.

  1. push usa insert(0, ...): apila por el inicio de la lista → O(n) por desplazamiento del array. Rompe el coste del contrato.
  2. pop usa pop(0): mismo problema, O(n). (Curiosamente el conjunto "insertar y borrar por el inicio" mantiene el orden LIFO, así que funciona... lentísimo. El bug de rendimiento es el más traicionero porque los tests de corrección no lo cazan.)
  3. pop devuelve None en pila vacía: contradice la decisión de diseño; debe dejar que la excepción salte (o lanzarla explícitamente).
  4. peek hace pop(0): ¡retira el elemento! Viola el invariante 4 (peek no modifica). Debe ser solo lectura.

Versión corregida:

def push(pila, elemento):
    pila.append(elemento)        # cima = final de la lista: O(1)

def pop(pila):
    return pila.pop()            # retira del final: O(1); IndexError si vacía

def peek(pila):
    return pila[-1]              # solo lectura: O(1); IndexError si vacía

Conclusión

Ya dominas la mecánica completa del TDA pila: push tapa la cima anterior, pop retira y devuelve en un solo gesto, peek observa sin modificar, y esta_vacia/tamano hacen seguro todo lo demás. Has tomado una decisión de diseño razonada (excepción IndexError en pila vacía, como hace la propia list de Python), has formulado los invariantes que cualquier implementación deberá cumplir, y has trazado sesiones reales del deshacer de TaskFlow operación a operación. Además ya tienes una pila provisional funcionando con list —siempre por el final, nunca por el inicio— aunque le falta lo esencial: una frontera que impida saltarse el contrato. En la próxima lección construiremos esa frontera dos veces: una clase Pila sobre list y una PilaEnlazada sobre los nodos del módulo 2, mediremos ambas con timeit y montaremos el HistorialAcciones real de TaskFlow.

© Copyright 2026. Todos los derechos reservados