Simulación de Eventos Discretos · Tema 14

Lista de eventos futuros

La estructura que implementa el calendario — un heap que siempre entrega lo próximo en O(log n) sin reordenar todo.

01 · Punto de partida

Del papel ordenado a la máquina ordenada

En el Tema 13 la agenda era una lista con sort tras cada append y pop(0) para sacar lo próximo. Con 8 eventos funciona; con 80 000 (un año de banco) cada pop(0) corre 80 000 lugares y cada sort reordena todo: la simulación se pasa más tiempo ordenando que simulando.

La solución es la lista de eventos futuros (FEL) como cola de prioridad (heap): una estructura que mantiene “lo mínimo arriba” sin ordenar todo. push y pop cuestan O(log n) y el próximo siempre está en fel[0].

  • ¿Cuánto cuesta sacar lo próximo con lista vs. heap?
  • ¿Qué formato evita comparar diccionarios y romper el programa?
  • ¿Cómo se resuelven empates sin azar del programa?
  • ¿Cómo se mira lo próximo sin sacarlo y cómo se cancela?

02 · Definición

Heap, tupla y contador: el trío inseparable

▲

Heap

Árbol donde el padre es ≤ hijos: el mínimo siempre arriba. Python lo da con heapq sobre una lista.

( )

Tupla clave

(t, prioridad, contador, tipo, datos): se compara de izquierda a derecha y nunca llega a comparar dicts.

#

Contador

Entero creciente por inserción: desempata todo lo demás y da FIFO entre iguales. Sin él, dos (5.0, salida) compararían dicts y fallarían.

Lista ordenada frente a heap con n eventos.
OperaciónLista + sort + pop(0)Heap (heapq)
AgendarO(n log n) si reordena todoO(log n)
Extraer mínimoO(n) por desplazar (pop(0))O(log n)
Ver próximoO(1) pero exige orden previoO(1): fel[0]
Con n=80 000Segundos por evento: inviableMilisegundos: rutina

03 · Formato canónico

Cinco campos en orden sagrado

tiempo→prioridad→contador→tipo y datos (no comparan)
  1. 1
    t (float).

    Tiempo del evento. Manda siempre: lo más temprano sale primero.

  2. 2
    prio (int).

    Regla de empates: 0 salidas, 1 llegadas, 2 fin de simulación. Se fija una vez por tipo.

  3. 3
    seq (int).

    Contador global que crece: garantiza desempate total y orden FIFO entre gemelos. Nunca se reutiliza.

  4. 4
    tipo + datos.

    Viajan al final porque nunca deben compararse: dos dicts no tienen orden y lanzarían TypeError.

Peek (ver sin sacar)
fel[0] es lo próximo sin extraerlo: sirve para chequear fin (“¿lo próximo supera T?”) sin avanzar.
Cancelación perezosa
Borrar en un heap es caro: se marca el evento como inválido (datos["ok"]=False) y al extraerlo se descarta sin procesar.
No iterar ordenado
El heap solo garantiza el mínimo arriba; el resto está desordenado a propósito. Para listar ordenado hay que hacer sorted(fel) (solo para depurar).
Calendario vs. FEL
Calendario = idea de agenda (Tema 13); FEL = heap que la implementa (este tema). Misma información, distinta máquina.

04 · Ejemplos

La misma agenda, máquina nueva

Prioridades recomendadas por tipo.
TipoPrioPor qué
Salida / fin0Libera antes de ocupar: evita rechazos fantasmas
Llegada1Entra después de liberar
Fin de simulación (stop)2Cierra después de procesar lo de su hora

05 · Representación en Python

heapq en cinco líneas

Todo el Tema 14 cabe en heappush / heappop / fel[0] más un contador global. El segundo bloque muestra el desempate que salva al programa.

Python en tu navegador. El primero agenda y extrae; el segundo empata a propósito. Quitá el contador y mirá el error.

import heapq, itertools
fel, seq = [], itertools.count()
def agendar(t, prio, tipo, datos=None):
    heapq.heappush(fel, (t, prio, next(seq), tipo, datos or {}))

agendar(4.0, 1, "llegada", {"id": "C2"})
agendar(2.0, 1, "llegada", {"id": "C1"})
agendar(5.0, 0, "salida", {"caja": 1})
print("próximo sin sacar:", fel[0][3], fel[0][0])
while fel:
    t, prio, _, tipo, datos = heapq.heappop(fel)
    print(round(t, 1), tipo, datos)
import heapq, itertools
fel, seq = [], itertools.count()
# Mismo tiempo, distinta prioridad: sale primero aunque llegó después
heapq.heappush(fel, (5.0, 1, next(seq), "llegada", {"id": "C9"}))
heapq.heappush(fel, (5.0, 0, next(seq), "salida", {"caja": 1}))
print([heapq.heappop(fel)[3] for _ in range(len(fel))])  # ['salida', 'llegada']

06 · Exploración

Laboratorio: por qué el heap gana

Elegí cuántos eventos n tendrá la agenda. La barra gris estima el trabajo de la lista (pop(0) desplaza todo: ~n²/2 movimientos) y la lima el del heap (~n·log₂n). Con n=100 empatan a ojo; con n=2000 la gris se vuelve una torre. Debajo, la agenda generada muestra que ambas entregan lo mismo: solo cambia el costo.

EXPERIMENTO 14

O(n²) frente a O(n log n)

lista ≈ n²/2 · heap ≈ n·log₂n · mismo mínimo

Los resultados numéricos aparecen debajo.
Ops lista (desplaza)0
Ops heap (log)0
Veces más barato0×
Próximo (ambos)—

Mueve n para ver la brecha.

Barras en escala proporcional (la gris crece cuadrática, la lima casi lineal). La agenda determinista de n tiempos confirma que el mínimo coincide.

Preguntas para explorar

  1. Con n=100, ¿cuántas veces más barato es el heap? ¿Y con n=2000?
  2. Si cada operación costara 1 µs, ¿cuánto tardaría cada método con n=2000?
  3. ¿Por qué el mínimo coincide aunque las tripas sean distintas?
Ver respuestas sugeridas
  1. Con 100: ~7×; con 2000: ~90×. La brecha crece con n porque una es cuadrática y la otra casi lineal.
  2. Lista ≈2 s (2 000 000 ops), heap ≈22 ms (22 000 ops): de “se cuelga” a “ni se nota”. En simulaciones de millones es la diferencia entre días y minutos.
  3. Porque ambas implementan el mismo contrato (extraer el mínimo); el heap solo cambia cómo lo encuentra, no qué entrega.

07 · Comprensión

Confusiones frecuentes

«El heap está ordenado»

Solo el mínimo está garantizado arriba. Recorrerlo esperando orden creciente muestra un desorden legal. Para depurar usá sorted(fel), nunca el orden interno.

«El contador es decoración»

Sin contador, dos eventos gemelos comparan dicts y el programa muere con TypeError justo en la corrida larga. Es el campo más barato y el que más bugs evita.

«La prioridad puede ser el tipo string»

Ordenar por “llegada” < “salida” pone la regla en manos del alfabeto. La prioridad es un int explícito que vos fijás por política.

«Borrar del medio es gratis»

En un heap borrar arbitrario es caro: se marca inválido y se filtra al extraer (cancelación perezosa). Diseñá con eso desde el inicio.

08 · Práctica guiada

Ejercicios con Python

Ejercicio 1: peek sin sacar

Mirá lo próximo dos veces y comprobá que no se gasta.

import heapq
fel = [(2.0, 1, 0, "llegada", {}), (5.0, 0, 1, "salida", {})]
heapq.heapify(fel)
print("peek:", fel[0][:2], "| tamaño:", len(fel))
print("peek otra vez:", fel[0][:2], "| tamaño:", len(fel))
Ver solución razonada

fel[0] es (2.0, llegada) ambas veces con tamaño 2: mirar no extrae. Así se chequea “¿lo próximo supera T?” sin avanzar.

Ejercicio 2: romperlo sin contador

¿Qué pasa con dos gemelos sin seq?

import heapq
fel = [(5.0, "llegada", {"id": "A"}), (5.0, "llegada", {"id": "B"})]
try:
    heapq.heapify(fel)
    print(heapq.heappop(fel))
except TypeError as e:
    print("TypeError:", str(e)[:60])
Ver solución

TypeError: '<' not supported between 'dict' and 'dict': al empatar tiempo y tipo, Python intenta comparar dicts. El contador lo evita.

Ejercicio 3: cancelación perezosa

Marcá un futuro como inválido y filtralo al extraer.

Ver una posible respuesta
import heapq, itertools
fel, seq = [], itertools.count()
heapq.heappush(fel, (4.0, 1, next(seq), "llegada", {"id": "C5", "ok": True}))
ev = (6.0, 1, next(seq), "llegada", {"id": "C6", "ok": True})
heapq.heappush(fel, ev)
ev[4]["ok"] = False  # C6 abandona: se cancela
while fel:
    t, _, _, tipo, datos = heapq.heappop(fel)
    if not datos.get("ok", True):
        print("descarto", datos["id"])
        continue
    print("proceso", datos["id"])

C6 se descarta al salir del heap sin procesarse: borrar es marcar + filtrar, no remover del medio.

09 · Síntesis

Ideas para recordar

  • FEL = heap del calendario: (t, prio, seq, tipo, datos), mínimo siempre arriba.
  • push/pop/fel[0] en O(log n); lista + sort + pop(0) en O(n²): con miles, solo el heap vive.
  • Contador obligatorio: desempata todo y evita comparar dicts.
  • Peek con fel[0]; cancelar marcando inválido y filtrando al extraer.
  • El heap cambia el costo, no el resultado: el mínimo coincide.

En el próximo tema haremos la inicialización del modelo: reloj en cero, estado inicial y primeros futuros.