Capítulo 5 de 56 · básico
Colas y deques
De qué trata este capítulo
Una cola es la imagen invertida del stack. Donde un stack te entrega el elemento más nuevo, una cola te entrega el más viejo: primero en entrar, primero en salir. Ese es el orden que necesita todo lo que debe atenderse de manera justa, en el orden en que fue llegando: un spooler de impresión, una cola de tareas, la frontera pendiente de una búsqueda en anchura. En este capítulo construimos una cola y a su prima de dos extremos, el deque, sobre una lista doblemente ligada, para que toda operación en los extremos sea O(1); y luego vemos la forma más común en que la gente arruina las colas en Python, y cuánto cuesta.
Un poco de historia
Las colas son más viejas que las computadoras: vienen de las matemáticas de las filas de espera. En 1909 el ingeniero danés Agner Krarup Erlang, que trabajaba para la compañía telefónica de Copenhague, publicó el primer estudio sobre cómo esperan las llamadas a que se libere una línea, y con eso fundó lo que hoy llamamos teoría de colas. La unidad de tráfico telefónico, el erlang, lleva su nombre. Cuando llegaron las computadoras heredaron tanto las matemáticas como la estructura: los sistemas operativos encolan procesos que esperan CPU, las redes encolan paquetes que esperan un enlace, y las impresoras encolan trabajos que esperan la bandeja. La disciplina FIFO aparece en todos lados donde un recurso escaso atiende muchas peticiones, porque atenderlas en orden de llegada es la noción más simple de lo que es justo.
La intuición
Una cola es una fila en un mostrador. Te formas al final; te atienden por el frente; no te puedes meter. El que lleva más tiempo esperando pasa primero. Enqueue agrega al final, dequeue quita del frente, y esa es toda la interfaz.
Un deque — se pronuncia "dec", abreviatura de double-ended queue — relaja la regla y te deja agregar y quitar en ambos extremos. Esa sola generalización lo convierte en la navaja suiza de la familia: usa solo el final y es un stack; usa entrada por atrás y salida por el frente y es una cola. Como un deque puede hacer todo lo que hacen un stack o una cola, es la estructura que la librería estándar realmente incluye, y la que vas a usar en la práctica.
Complejidad: cómo escala
Construida sobre una lista doblemente ligada, cada operación toca únicamente un extremo, así que enqueue, dequeue y las cuatro operaciones del deque son : ligar o desligar un nodo y listo, sin recorrer nada ni mover nada. El espacio es . El puntero prev del capítulo de listas ligadas es lo que hace que sacar por atrás salga tan barato como por el frente; sin él, encontrar la nueva cola significaría recorrer toda la lista.
Aquí no hay recurrencia: el punto es que el costo se mantiene plano. La gráfica de abajo encola n elementos y luego desencola los n, midiendo nuestra cola contra la de la librería; ambas líneas son rectas, porque O(1) por operación significa trabajo total lineal:
En qué es buena y en qué no
Usa una cola siempre que importe el orden de llegada y solo agregues por un extremo y saques por el otro. Igual que con el stack, su restricción es su fuerza: FIFO es fácil de razonar y O(1) de mantener, y la generalización del deque cubre los casos donde necesitas ambos extremos sin perder la garantía de tiempo constante.
Lo que una cola no es: indexable ni buscable. No puedes pedir el quinto de la fila sin caminar hasta él. Y hay una trampa específica de Python que merece su propia advertencia, porque es facilísimo caer en ella y sale carísimo sin hacer ruido:
Los datos, o las entradas
La comparación le da a la cola justo el patrón que expone la trampa: encolar n
elementos y luego desencolar los n. Ahí es exactamente donde list.pop(0) se
vuelve cuadrático, así que hace visible la diferencia entre O(1) y O(n) por
dequeue. La animación usa un guion diminuto escrito a mano, de ocho operaciones
sobre una fila de cinco elementos, porque ahí lo que importa es el orden en que
salen las cosas, no la velocidad.
Constrúyela, una función a la vez
El deque es la base. Empujar al final liga un nodo a la cola:
def push_back(self, value):
"""O(1): link a node onto the tail (the back of the line)."""
node = _Node(value)
node.prev = self._tail
if self._tail is not None:
self._tail.next = node
else:
self._head = node
self._tail = node
self._n += 1
Empujar al frente es el espejo: ligar a la cabeza.
def push_front(self, value):
"""O(1): link a node onto the head (the front of the line)."""
node = _Node(value)
node.next = self._head
if self._head is not None:
self._head.prev = node
else:
self._tail = node
self._head = node
self._n += 1
Sacar del frente quita la cabeza, en O(1), con solo mover el puntero de cabeza: sin recorrer nada, que es toda la ventaja sobre una lista.
def pop_front(self):
"""O(1): remove and return the front. No shifting — just move the head."""
if self._head is None:
raise IndexError("pop from empty deque")
node = self._head
self._head = node.next
if self._head is not None:
self._head.prev = None
else:
self._tail = None
self._n -= 1
return node.value
Y sacar por atrás es la operación que nos compra el puntero prev: quitar la cola igual de barato.
def pop_back(self):
"""O(1): remove and return the back — the payoff of the prev pointer."""
if self._tail is None:
raise IndexError("pop from empty deque")
node = self._tail
self._tail = node.prev
if self._tail is not None:
self._tail.next = None
else:
self._head = None
self._n -= 1
return node.value
La cola entonces es simplemente un deque con la interfaz reducida a FIFO: formarse al final, salir por el frente, nada intermedio.
class Queue:
"""A FIFO queue is just a deque with the operations restricted: you join at
the back and leave from the front, and can't touch anything in between."""
def __init__(self):
self._d = Deque()
def enqueue(self, value):
self._d.push_back(value) # join the back of the line
def dequeue(self):
return self._d.pop_front() # the person who's waited longest goes first
def __len__(self):
return len(self._d)
def to_list(self):
return self._d.to_list()
Míralo funcionar
Aquí tienes una cola como una filita en un mostrador. El verde es el frente, el próximo en ser atendido; el azul es el final, el que acaba de llegar. Avanza paso a paso: los elementos se forman al final (azul), y cada dequeue toma el del frente (verde), el que lleva más tiempo esperando. Fíjate que el orden de salida coincide con el de entrada: A, B, C, D salen exactamente en la secuencia en que llegaron, que es toda la promesa de FIFO:
El código completo
Las dos versiones en un solo lugar; cambia entre ellas. La pestaña "desde cero"
es nuestro Deque y la Queue construida encima. La pestaña de librería es
collections.deque, la estructura doblemente ligada que de verdad vas a usar,
más la versión con list.pop(0), que dejamos únicamente para mostrar qué no
hacer.
"""A queue — first in, first out — and its two-ended cousin the deque, both built
on a doubly linked list so every end operation is O(1).
A queue is the mirror of the stack from the last chapter: instead of taking the
newest item, you take the oldest. That's the ordering you want any time work has
to be served in the order it arrived — a print spooler, a task queue, the frontier
of a breadth-first search.
"""
class _Node:
__slots__ = ("value", "prev", "next")
def __init__(self, value):
self.value = value
self.prev = None
self.next = None
class Deque:
"""A double-ended queue on a doubly linked list: O(1) at BOTH ends. The prev
pointer is what lets us pop from the back as cheaply as the front."""
def __init__(self):
self._head = None
self._tail = None
self._n = 0
# region: push_back
def push_back(self, value):
"""O(1): link a node onto the tail (the back of the line)."""
node = _Node(value)
node.prev = self._tail
if self._tail is not None:
self._tail.next = node
else:
self._head = node
self._tail = node
self._n += 1
# endregion
# region: push_front
def push_front(self, value):
"""O(1): link a node onto the head (the front of the line)."""
node = _Node(value)
node.next = self._head
if self._head is not None:
self._head.prev = node
else:
self._tail = node
self._head = node
self._n += 1
# endregion
# region: pop_front
def pop_front(self):
"""O(1): remove and return the front. No shifting — just move the head."""
if self._head is None:
raise IndexError("pop from empty deque")
node = self._head
self._head = node.next
if self._head is not None:
self._head.prev = None
else:
self._tail = None
self._n -= 1
return node.value
# endregion
# region: pop_back
def pop_back(self):
"""O(1): remove and return the back — the payoff of the prev pointer."""
if self._tail is None:
raise IndexError("pop from empty deque")
node = self._tail
self._tail = node.prev
if self._tail is not None:
self._tail.next = None
else:
self._head = None
self._n -= 1
return node.value
# endregion
def __len__(self):
return self._n
def to_list(self):
out, node = [], self._head
while node is not None:
out.append(node.value)
node = node.next
return out
# region: queue
class Queue:
"""A FIFO queue is just a deque with the operations restricted: you join at
the back and leave from the front, and can't touch anything in between."""
def __init__(self):
self._d = Deque()
def enqueue(self, value):
self._d.push_back(value) # join the back of the line
def dequeue(self):
return self._d.pop_front() # the person who's waited longest goes first
def __len__(self):
return len(self._d)
def to_list(self):
return self._d.to_list()
# endregion
"""collections.deque is the queue and deque you actually use in Python — a doubly
linked list of blocks with O(1) at both ends, exactly like ours but tuned in C.
The instructive comparison is the WRONG one: a plain list used as a queue. `append`
is fine, but taking from the front with `pop(0)` shifts every remaining element
one slot left — O(n) per dequeue, O(n²) to drain the queue. It's the single most
common accidental-quadratic bug in Python, and this chapter's face-off shows it.
"""
from collections import deque
# region: deque_queue
def run_queue_deque(ops):
"""FIFO on a deque: append to the back, popleft from the front. Both O(1)."""
q = deque()
out = []
for op in ops:
if op[0] == "enq":
q.append(op[1])
else:
out.append(q.popleft())
return out
# endregion
# region: list_queue
def run_queue_list(ops):
"""FIFO on a plain list — the anti-pattern. `pop(0)` is O(n) because every
element after index 0 shifts left, so draining n items is O(n^2)."""
q = []
out = []
for op in ops:
if op[0] == "enq":
q.append(op[1])
else:
out.append(q.pop(0))
return out
# endregion
Desde cero vs. librería
Ahora la comparación completa, con el antipatrón de vuelta. Nuestra cola y el
deque son ambos O(1) por operación, así que sus líneas se mantienen planas y
abajo. La línea de list.pop(0) es la lección: se curva hacia arriba con fuerza,
porque vaciar una lista desde el frente es O(n²):
Con 32000 elementos, el deque de la librería terminó en unos 2.2 ms, nuestra cola ligada en 12.5 ms, y el antipatrón de lista en 52.5 ms. Nuestra cola queda en medio por la razón de siempre: es O(1) como el deque, pero cada nodo es un objeto de Python aparte, con el overhead de los punteros y llamadas a métodos por operación, mientras que el deque liga bloques de elementos en C. Lo importante de verdad es cómo la línea de la lista se despega de las otras dos: esa es una diferencia de clase de complejidad, O(n²) contra O(n), y es exactamente el bug del que habla la advertencia de arriba.
A fondo Una estructura, las dos disciplinas
Un deque es la primitiva general, y el stack y la cola son solo deques con sus
operaciones restringidas. Usa nada más push_back y pop_back y tienes un
stack: LIFO. Usa push_back y pop_front y tienes una cola: FIFO. Por eso
collections.deque es el que trae la librería estándar y las otras dos no: una
vez que tienes O(1) en ambos extremos, puedes ser cualquiera de las dos
disciplinas gratis, solo eligiendo de qué extremo sacas. Cuando no estés seguro
de cuál necesitas, un deque te deja las dos opciones abiertas.
Dónde te la vas a encontrar de verdad
Las colas mueven los sistemas que están debajo de tus programas. El scheduler del sistema operativo mantiene los procesos en colas esperando CPU. Los routers de red encolan paquetes; los brokers de mensajes como RabbitMQ y Kafka son colas que rentas por mes. La búsqueda en anchura de los capítulos de grafos es la búsqueda en profundidad con el stack cambiado por una cola: ese solo cambio es la diferencia entre explorar primero lo más cercano y primero lo más profundo. Y cualquier esquema productor/consumidor — un hilo generando trabajo, otro haciéndolo — es una cola entre los dos. Siempre que el trabajo deba atenderse en el orden en que llegó, esta es la estructura.
Puntos clave
Una cola es FIFO — te formas al final, sales por el frente, ambas — y un
deque lo generaliza a O(1) en ambos extremos, que es la razón por la que el deque
es el que te da la librería estándar y la primitiva de la que se tallan el stack y
la cola. Construye cualquiera de ellos sobre una lista doblemente ligada y los
extremos se mantienen baratos; en Python, collections.deque es la versión
afinada que de verdad conviene usar.
Lo único que tienes que llevarte de este capítulo es la advertencia: una lista no
es una cola, porque sacar de su frente es O(n). Usa deque y popleft, y nunca
vas a escribir ese ciclo accidentalmente cuadrático que agarra a todos alguna vez.
Con stacks y colas en la mano ya tienes los dos ordenamientos fundamentales; el
siguiente capítulo ve qué pasa cuando una cola tiene tamaño fijo y debe reutilizar
su espacio: el ring buffer.