Todas las colas que llevamos construidas comparten una regla: sale el más antiguo. Pero en TaskFlow hay momentos en que esa regla es injusta al revés: una tarea de prioridad 1 ("el servidor está caído") no puede esperar detrás de veinte tareas rutinarias que llegaron antes. Necesitamos una cola donde desencolar signifique "dame el más prioritario", no "dame el más antiguo". Ese TDA es la cola de prioridad, y cierra el segundo puente del módulo 3: la PilaConMinimo podía consultar el elemento mínimo en O(1), pero no extraerlo; hoy aprenderemos a extraerlo. Compararemos dos implementaciones honestas —lista no ordenada y lista ordenada, reutilizando el insertar_ordenado del módulo 2—, entenderemos sus costes enfrentados, y usaremos la herramienta profesional de Python, heapq, como caja negra, con especial atención a un detalle que separa el código correcto del código traicionero: los empates y la estabilidad.
Contenido
- El TDA cola de prioridad
- Implementación 1: lista no ordenada
- Implementación 2: lista ordenada (reutilizando
insertar_ordenado) - Tabla comparativa y el anuncio del montículo
heapq: la caja negra profesional- Empates y estabilidad: el truco del contador
- TaskFlow: la bandeja de urgencias
El TDA cola de prioridad
El contrato se parece al de la cola FIFO, pero cambia la promesa central:
| Operación | Cola FIFO | Cola de prioridad |
|---|---|---|
encolar(elemento) |
Entra por el final | Entra "donde corresponda" |
desencolar() |
Sale el más antiguo | Sale el más prioritario |
frente() |
Consulta el más antiguo | Consulta el más prioritario |
esta_vacia() / tamano() |
Igual | Igual |
En TaskFlow, prioridad 1 es la máxima, así que "el más prioritario" es el de menor número de prioridad: nuestra cola de prioridad es de mínimos (min-priority queue). Es un convenio frecuente (piensa en "prioridad 1" en soporte técnico) y encaja de fábrica con las herramientas de Python, que también trabajan con mínimos.
Fíjate en que el TDA no dice nada de cómo se logra: solo promete que desencolar devuelve el mínimo. Como siempre desde el módulo 1, un mismo contrato admite implementaciones con costes muy distintos — y esta vez la tensión entre ellas es especialmente instructiva: se puede pagar al entrar o pagar al salir, pero con listas se paga.
Implementación 1: lista no ordenada
La estrategia perezosa: encolar es soltar el elemento al final, sin orden ninguno; desencolar es buscar el mínimo y sacarlo.
class ColaPrioridadNoOrdenada:
"""Encolar O(1); desencolar O(n): busca el mínimo cada vez."""
def __init__(self):
self._elementos = [] # pares (prioridad, tarea), sin orden
def encolar(self, prioridad, tarea):
self._elementos.append((prioridad, tarea)) # O(1): al final y listo
def desencolar(self):
if self.esta_vacia():
raise IndexError("desencolar sobre una cola vacía")
# Buscar la posición del mínimo: recorrido completo, O(n)
mejor = 0
for i in range(1, len(self._elementos)):
if self._elementos[i][0] < self._elementos[mejor][0]:
mejor = i
return self._elementos.pop(mejor)[1] # pop(i) también O(n)
def frente(self):
if self.esta_vacia():
raise IndexError("frente sobre una cola vacía")
return min(self._elementos, key=lambda par: par[0])[1] # O(n)
def esta_vacia(self):
return len(self._elementos) == 0
def tamano(self):
return len(self._elementos)Análisis: encolar es O(1) —imbatible—, pero desencolar recorre toda la lista para localizar el mínimo (O(n)) y encima lo extrae con pop(mejor), que desplaza los elementos posteriores (otra vez O(n), viejo conocido del módulo 1). ¿Cuándo compensa? Cuando se encola muchísimo y se desencola muy poco: por ejemplo, si acumulas miles de candidatos pero solo extraerás unos pocos.
Implementación 2: lista ordenada (reutilizando insertar_ordenado)
La estrategia previsora: mantener la colección siempre ordenada por prioridad, de modo que el mínimo esté listo en el frente. Cuando en el módulo 2 escribimos insertar_ordenado sobre la ListaEnlazada, anunciamos que era "un puente hacia las colas de prioridad". Este es el momento de cruzarlo: si la lista enlazada se mantiene ordenada ascendentemente por prioridad, el más urgente está en la cabeza, y extraerlo es el eliminar_del_principio O(1) de la lección 04-02.
class ColaPrioridadOrdenada:
"""Encolar O(n) (inserción ordenada); desencolar O(1) (la cabeza)."""
def __init__(self):
self._elementos = ListaEnlazada() # ordenada ascendente por prioridad
def encolar(self, prioridad, tarea):
# insertar_ordenado del módulo 2, guardando pares (prioridad, tarea):
# recorre hasta el primer nodo con prioridad MAYOR y se inserta antes.
nuevo = Nodo((prioridad, tarea))
if (self._elementos.cabeza is None
or prioridad < self._elementos.cabeza.dato[0]):
nuevo.siguiente = self._elementos.cabeza # nueva cabeza
self._elementos.cabeza = nuevo
if nuevo.siguiente is None:
self._elementos.cola = nuevo
else:
actual = self._elementos.cabeza
# Avanzar mientras el siguiente exista y no sea menos urgente.
# OJO al <=: los empates quedan DETRÁS de los ya existentes
# (esto preserva el orden de llegada entre iguales: estabilidad).
while (actual.siguiente is not None
and actual.siguiente.dato[0] <= prioridad):
actual = actual.siguiente
nuevo.siguiente = actual.siguiente
actual.siguiente = nuevo
if nuevo.siguiente is None:
self._elementos.cola = nuevo
self._elementos.tamano += 1
def desencolar(self):
if self.esta_vacia():
raise IndexError("desencolar sobre una cola vacía")
return self._elementos.eliminar_del_principio()[1] # O(1)
def frente(self):
if self.esta_vacia():
raise IndexError("frente sobre una cola vacía")
return self._elementos.cabeza.dato[1] # O(1)
def esta_vacia(self):
return self._elementos.tamano == 0
def tamano(self):
return self._elementos.tamanoAhora los costes se invierten: encolar recorre la lista hasta encontrar el hueco (O(n)), pero desencolar y frente son O(1). ¿Cuándo compensa? Cuando desencolas y consultas mucho más de lo que encolas — por ejemplo, un panel que consulta constantemente "¿cuál es la siguiente urgencia?".
Detalle fino que hemos dejado comentado en el código: el <= del bucle hace que una tarea nueva con la misma prioridad que otras existentes se coloque detrás de ellas. Entre iguales, orden de llegada: guardaremos este concepto —estabilidad— para la sección de empates, porque con heapq habrá que ganárselo a pulso.
Tabla comparativa y el anuncio del montículo
| Implementación | encolar |
desencolar |
frente |
¿Cuándo elegirla? |
|---|---|---|---|---|
| Lista no ordenada | O(1) | O(n) | O(n) | Muchas inserciones, pocas extracciones |
Lista ordenada (insertar_ordenado) |
O(n) | O(1) | O(1) | Pocas inserciones, muchas extracciones/consultas |
| Montículo (heap) — módulo 6 | O(log n) | O(log n) | O(1) | El caso general: mezcla de ambas |
La tabla revela un patrón que verás una y otra vez en estructuras de datos: dos soluciones simétricas que pagan O(n) en operaciones opuestas, y una tercera estructura más sofisticada que equilibra: ni O(1) ni O(n), sino O(log n) en ambas. Para hacerte una idea de lo que significa: con n = 1.000.000, O(n) son un millón de pasos y O(log n) son unos 20. Esa estructura es el montículo (heap), un árbol con una propiedad de orden muy astuta... y por eso vive en el módulo 6 (lección 06-07), después de que hayamos aprendido árboles. Aquí no vamos a abrirlo: vamos a usarlo.
heapq: la caja negra profesional
Python trae el montículo de serie en el módulo heapq. Lo usaremos como caja negra: sabemos qué promete (mínimo fuera, O(log n) por operación) sin mirar todavía cómo lo consigue. Es exactamente la disciplina TDA del módulo 1 aplicada a una biblioteca real.
heapq no define una clase: ofrece funciones que operan sobre una list normal a la que mantienen la propiedad de montículo:
import heapq
pendientes = [] # una list corriente hará de montículo
heapq.heappush(pendientes, 3) # encolar: O(log n)
heapq.heappush(pendientes, 1)
heapq.heappush(pendientes, 2)
print(pendientes[0]) # 1 → el mínimo SIEMPRE está en [0] (frente)
print(heapq.heappop(pendientes)) # 1 → desencolar: extrae el mínimo, O(log n)
print(heapq.heappop(pendientes)) # 2
print(heapq.heappop(pendientes)) # 3Reglas de la caja negra:
heappush(lista, elemento)=encolar;heappop(lista)=desencolar(el mínimo);lista[0]=frente;len(lista)=tamano.- La lista solo debe tocarse con funciones de
heapq(o leerse en[0]). Unappendo unsortpor tu cuenta rompe la propiedad interna y el mínimo deja de estar garantizado. - Curiosidad que conecta con el módulo 6: aunque el interior de
pendienteses unalistaparentemente desordenada (¡imprímela!), su estructura codifica un árbol. Que un array pueda "ser" un árbol es una de las ideas bonitas que allí desvelaremos.
¿Y si los elementos no son números sueltos? heapq compara los elementos entre sí con <. Con tuplas, Python compara componente a componente, así que la costumbre es encolar tuplas cuyo primer campo sea la prioridad: (prioridad, tarea). Y aquí aparece una trampa seria.
Empates y estabilidad: el truco del contador
Intentemos encolar tareas (nuestros dict) con su prioridad:
import heapq
urgencias = []
t1 = {"id": 1, "titulo": "Reiniciar servidor", "prioridad": 1, "estado": "pendiente"}
t2 = {"id": 2, "titulo": "Avisar a clientes", "prioridad": 1, "estado": "pendiente"}
heapq.heappush(urgencias, (t1["prioridad"], t1))
heapq.heappush(urgencias, (t2["prioridad"], t2)) # ¡TypeError!TypeError: '<' not supported between instances of 'dict' and 'dict'. ¿Por qué? Ambas tuplas empatan en el primer campo (1 == 1), así que Python pasa a comparar el segundo... y los dict no saben compararse con <. El empate rompe el programa.
Y aunque los elementos fueran comparables, quedaría un problema más sutil: entre dos tareas de prioridad 1, ¿cuál debe salir primero? Lo justo —y lo que espera cualquier usuario— es la que llegó antes: a igual prioridad, FIFO. Esa propiedad se llama estabilidad, y heapq por sí solo no la garantiza (el orden interno del montículo no recuerda llegadas).
El truco canónico resuelve ambas cosas a la vez: encolar tuplas de tres campos, (prioridad, contador, tarea), donde contador es un entero que crece con cada inserción:
- Si las prioridades empatan, se compara el contador, que nunca empata → jamás se llega a comparar los
dict(adiósTypeError). - El contador menor corresponde a la llegada más antigua → los empates salen por orden de llegada (estabilidad garantizada).
import heapq
from itertools import count
urgencias = []
contador = count() # count() genera 0, 1, 2, ... uno nuevo en cada next()
heapq.heappush(urgencias, (t1["prioridad"], next(contador), t1))
heapq.heappush(urgencias, (t2["prioridad"], next(contador), t2)) # ahora sí
prioridad, _, tarea = heapq.heappop(urgencias)
print(tarea["titulo"]) # "Reiniciar servidor": llegó antes que su empateEste patrón (prioridad, contador, elemento) es tan estándar que lo encontrarás tal cual en la documentación oficial de Python y en código de producción. Memorízalo como se memoriza un idiom.
TaskFlow: la bandeja de urgencias
Empaquetemos el patrón en la pieza de TaskFlow de esta lección: la bandeja de urgencias, donde se vuelcan tareas de cualquier procedencia y siempre se atiende la más urgente (recuerda: prioridad 1 = máxima), con empates resueltos por llegada:
import heapq
from itertools import count
class BandejaUrgencias:
"""Cola de prioridad de tareas de TaskFlow sobre heapq.
desencolar() devuelve la tarea de menor número de prioridad;
a igual prioridad, la que se encoló antes (estable).
"""
def __init__(self):
self._monticulo = []
self._contador = count()
def encolar(self, tarea):
entrada = (tarea["prioridad"], next(self._contador), tarea)
heapq.heappush(self._monticulo, entrada) # O(log n)
def desencolar(self):
if self.esta_vacia():
raise IndexError("desencolar sobre una bandeja vacía")
return heapq.heappop(self._monticulo)[2] # O(log n); [2] = tarea
def frente(self):
if self.esta_vacia():
raise IndexError("frente sobre una bandeja vacía")
return self._monticulo[0][2] # O(1)
def esta_vacia(self):
return len(self._monticulo) == 0
def tamano(self):
return len(self._monticulo)
# --- Uso ---
bandeja = BandejaUrgencias()
bandeja.encolar({"id": 4, "titulo": "Actualizar documentación",
"prioridad": 3, "estado": "pendiente"})
bandeja.encolar({"id": 5, "titulo": "Servidor caído",
"prioridad": 1, "estado": "pendiente", "asignada_a": "ana"})
bandeja.encolar({"id": 6, "titulo": "Cliente sin acceso",
"prioridad": 1, "estado": "pendiente", "asignada_a": "luis"})
bandeja.encolar({"id": 7, "titulo": "Revisar estilos CSS",
"prioridad": 2, "estado": "pendiente"})
while not bandeja.esta_vacia():
tarea = bandeja.desencolar()
print(f"prioridad {tarea['prioridad']}: {tarea['titulo']}")
# prioridad 1: Servidor caído ← empate a 1: sale la que llegó antes
# prioridad 1: Cliente sin acceso
# prioridad 2: Revisar estilos CSS
# prioridad 3: Actualizar documentaciónLa salida resume la lección entera: manda la prioridad, no la llegada (la tarea 5 adelanta a la 4); y entre empates manda la llegada (la 5 antes que la 6). El montículo hace ambas cosas en O(log n) por operación, y en el módulo 6 abriremos por fin la caja para ver el árbol que lo hace posible.
Errores Comunes y Consejos
- Encolar
(prioridad, tarea)sin contador: funciona en las pruebas... hasta el primer empate condicts, y entoncesTypeErroren producción. Usa siempre(prioridad, contador, elemento)cuando el elemento no sea comparable (y aunque lo sea: ganas estabilidad). - Confundir el sentido de la prioridad:
heapqextrae el mínimo. Con nuestro convenio (1 = máxima) encaja directo; si tu convenio fuera "número mayor = más urgente", tendrías que encolar(-prioridad, contador, tarea). Documenta el convenio del proyecto y no lo mezcles. - Tocar la lista del montículo por fuera: un
lista.append(x)olista.sort()sobre el montículo rompe su invariante interno silenciosamente: no falla en el momento, falla después, devolviendo mínimos incorrectos. La lista del montículo es territorio exclusivo deheapq(por esoBandejaUrgenciasla esconde tras_). - Reordenar tras cambiar la prioridad de una tarea ya encolada: mutar
tarea["prioridad"]no recoloca su entrada en el montículo. El patrón profesional es encolar una entrada nueva y marcar la vieja como invalidada (o reconstruir la cola si es pequeña). Trabajaremos esta idea en los proyectos del módulo 8. - Consejo: si dudas entre lista ordenada, no ordenada o
heapq, cuenta operaciones: ¿dominan las inserciones, las extracciones o van a la par? La tabla comparativa de esta lección es literalmente una guía de decisión; tenla a mano.
Ejercicios
Ejercicio 1: predecir con empates
Sin ejecutar, indica en qué orden salen los títulos si se encolan en una BandejaUrgencias estas tareas (en este orden) y luego se desencola todo: ("Backup", 2), ("Incendio", 1), ("Informe", 2), ("Rescate", 1). Justifica cada posición con la regla correspondiente (prioridad o estabilidad).
Ejercicio 2: desencolar estable en la lista NO ordenada
La ColaPrioridadNoOrdenada de esta lección no es estable: ante un empate, devuelve el primero que encuentra, lo cual solo es correcto por accidente. Modifica desencolar para que la búsqueda del mínimo use < estricto (no <=) al comparar, y razona por qué eso —dado que append añade al final— garantiza que entre empates sale el más antiguo.
Ejercicio 3: fusionar dos bandejas
Los equipos "web" y "móvil" de TaskFlow mantienen bandejas de urgencias separadas y se fusionan en un solo equipo. Escribe una función fusionar(bandeja_a, bandeja_b) que devuelva una BandejaUrgencias nueva con todas las tareas de ambas, conservando el orden por prioridad. ¿Se conserva también la estabilidad entre bandejas? Razona la respuesta.
Soluciones
Solución 1: Orden de salida: Incendio, Rescate, Backup, Informe.
Incendio(prioridad 1): mínimo global.Rescate(prioridad 1): empata con Incendio, pero Incendio llegó antes (contador menor) → estabilidad.Backup(prioridad 2): menor que 2 ya no queda; empata con Informe y llegó antes.Informe(prioridad 2): último por estabilidad.
Solución 2:
def desencolar(self):
if self.esta_vacia():
raise IndexError("desencolar sobre una cola vacía")
mejor = 0
for i in range(1, len(self._elementos)):
if self._elementos[i][0] < self._elementos[mejor][0]: # < ESTRICTO
mejor = i
return self._elementos.pop(mejor)[1]Razonamiento: append coloca cada elemento nuevo detrás de los anteriores, así que dentro de la lista los empates están en orden de llegada. La búsqueda con < estricto solo cambia de candidato ante un elemento estrictamente mejor; ante un empate se queda con el que ya tenía, que por recorrer de izquierda a derecha es el de índice menor = el más antiguo. Con <= ocurriría lo contrario: se quedaría el último empate, invirtiendo el orden de llegada.
Solución 3:
def fusionar(bandeja_a, bandeja_b):
resultado = BandejaUrgencias()
for bandeja in (bandeja_a, bandeja_b):
while not bandeja.esta_vacia():
resultado.encolar(bandeja.desencolar())
return resultadoEl orden por prioridad se conserva siempre: cada tarea se reencola con su prioridad y el montículo nuevo las ordenará. La estabilidad se conserva dentro de cada bandeja (desencolamos en orden estable y reencolamos en ese mismo orden, con contadores nuevos crecientes), pero entre bandejas no hay garantía histórica: todas las tareas de bandeja_a reciben contadores menores que las de bandeja_b, de modo que en los empates entre bandejas siempre gana la bandeja a, no la tarea que realmente llegó antes en el tiempo. Para una fusión históricamente justa habría que haber guardado una marca de tiempo global en cada tarea. Nota la mejora respecto a versiones ingenuas: la función consume las bandejas originales; si quisieras conservarlas, deberías reencolar también en las originales (rotación) o exponer una copia.
Conclusión
La cola de prioridad cambia la promesa del desencolar: ya no sale el más antiguo, sino el más urgente — en TaskFlow, el de menor número de prioridad. Hemos visto el dilema de las implementaciones con lista (no ordenada: encolar O(1) / desencolar O(n); ordenada con insertar_ordenado: encolar O(n) / desencolar O(1)), y hemos usado la solución equilibrada de la biblioteca estándar, heapq, con el idiom (prioridad, contador, tarea) que evita el TypeError de los empates y garantiza estabilidad: a igual urgencia, orden de llegada. El montículo que hay dentro de heapq —y por qué logra O(log n)— es cita pendiente para la lección 06-07, cuando sepamos de árboles. Nos queda un puente del módulo 3 por cruzar, el primero de todos: aquel HistorialConLimite que necesitaba eficiencia por los dos extremos a la vez. La estructura que lo consigue —la cola doble o deque— y la joya de la biblioteca estándar que la implementa, collections.deque, nos esperan en la próxima lección.
Curso de Estructuras de Datos
Módulo 1: Introducción a las Estructuras de Datos
- ¿Qué son las Estructuras de Datos?
- Importancia de las Estructuras de Datos en la Programación
- Tipos de Estructuras de Datos
- Complejidad Algorítmica y Notación Big O
- Arrays y Memoria: la Base de las Estructuras de Datos
Módulo 2: Listas
- Introducción a las Listas
- Listas Enlazadas
- Listas Doblemente Enlazadas
- Listas Circulares
- Ejercicios con Listas
Módulo 3: Pilas
- Introducción a las Pilas
- Operaciones Básicas con Pilas
- Implementación de Pilas
- Aplicaciones de las Pilas
- Ejercicios con Pilas
Módulo 4: Colas
- Introducción a las Colas
- Operaciones Básicas con Colas
- Colas Circulares
- Colas de Prioridad
- Colas Dobles (Deques)
- Ejercicios con Colas
Módulo 5: Tablas Hash y Diccionarios
- Introducción a las Tablas Hash
- Funciones Hash y Resolución de Colisiones
- Diccionarios y Conjuntos en la Práctica
- Ejercicios con Tablas Hash
Módulo 6: Árboles
- Introducción a los Árboles
- Árboles Binarios
- Recorridos de Árboles
- Árboles Binarios de Búsqueda
- Árboles AVL
- Árboles B
- Montículos (Heaps)
- Ejercicios con Árboles
Módulo 7: Grafos
- Introducción a los Grafos
- Representación de Grafos
- Algoritmos de Búsqueda en Grafos
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Aplicaciones de los Grafos
- Ejercicios con Grafos
