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.
| Operación | Lista + sort + pop(0) | Heap (heapq) |
|---|---|---|
| Agendar | O(n log n) si reordena todo | O(log n) |
| Extraer mínimo | O(n) por desplazar (pop(0)) | O(log n) |
| Ver próximo | O(1) pero exige orden previo | O(1): fel[0] |
| Con n=80 000 | Segundos por evento: inviable | Milisegundos: rutina |
03 · Formato canónico
Cinco campos en orden sagrado
- 1t (float).
Tiempo del evento. Manda siempre: lo más temprano sale primero.
- 2prio (int).
Regla de empates: 0 salidas, 1 llegadas, 2 fin de simulación. Se fija una vez por tipo.
- 3seq (int).
Contador global que crece: garantiza desempate total y orden FIFO entre gemelos. Nunca se reutiliza.
- 4tipo + 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
| Tipo | Prio | Por qué |
|---|---|---|
| Salida / fin | 0 | Libera antes de ocupar: evita rechazos fantasmas |
| Llegada | 1 | Entra después de liberar |
| Fin de simulación (stop) | 2 | Cierra 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.
O(n²) frente a O(n log n)
lista ≈ n²/2 · heap ≈ n·log₂n · mismo mínimo
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
- Con n=100, ¿cuántas veces más barato es el heap? ¿Y con n=2000?
- Si cada operación costara 1 µs, ¿cuánto tardaría cada método con n=2000?
- ¿Por qué el mínimo coincide aunque las tripas sean distintas?
Ver respuestas sugeridas
- Con 100: ~7×; con 2000: ~90×. La brecha crece con n porque una es cuadrática y la otra casi lineal.
- 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.
- 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.