La Cola de la lección anterior crece sin límite: mientras haya memoria, acepta elementos. Pero muchos sistemas trabajan con memoria fija: un buffer de red, el búfer de teclado, el registro de los últimos eventos de una aplicación. Para esos casos existe una implementación clásica y elegantísima: la cola circular (o ring buffer), una cola FIFO montada sobre un array de capacidad fija cuyos índices, al llegar al final, "dan la vuelta" y reutilizan las casillas liberadas por el frente. En esta lección construiremos la clase ColaCircular con aritmética modular, resolveremos el rompecabezas de distinguir "llena" de "vacía", y la aplicaremos a TaskFlow: el registro de los últimos N eventos del sistema. También conectaremos la idea con una vieja conocida: la ListaCircular del módulo 2.
Contenido
- El problema: una cola sobre array fijo
- La idea circular: aritmética modular
- El rompecabezas: ¿llena o vacía?
- La clase
ColaCircular - Traza visual del avance modular
- Ring buffers en el mundo real y relación con
ListaCircular - TaskFlow:
RegistroEventos, los últimos N eventos
El problema: una cola sobre array fijo
Supongamos que solo disponemos de un array de capacidad fija, digamos 5 casillas, y queremos una cola FIFO sobre él. El primer intento ingenuo: encolar avanzando un índice final y desencolar avanzando un índice frente:
encolar(A), encolar(B), encolar(C): desencolar() dos veces:
índices: 0 1 2 3 4 índices: 0 1 2 3 4
[ A ][ B ][ C ][ ][ ] [ · ][ · ][ C ][ ][ ]
↑frente ↑final ↑frente ↑finalTras desencolar A y B, las casillas 0 y 1 están libres... pero final sigue avanzando hacia la derecha. Cuando final llegue a la casilla 4, la cola parecerá "llena" con dos casillas desperdiciadas a la izquierda. Las alternativas malas son dos:
- Desplazar los elementos hacia la izquierda en cada desencolado: eso es exactamente el
pop(0)O(n) que llevamos dos módulos evitando. - Rechazar inserciones aunque haya hueco: desperdicio inaceptable en un buffer.
La solución buena: que final, al pasarse del borde derecho, continúe por la casilla 0. El array deja de ser una tira y se convierte, conceptualmente, en un anillo.
La idea circular: aritmética modular
Para "dar la vuelta" no hace falta magia, basta el operador módulo %. Si la capacidad es c, el índice siguiente a i es:
Con c = 5: después del 3 viene el 4, y después del 4 viene (4 + 1) % 5 = 0. El módulo convierte la recta de índices en un círculo:
graph LR
I0((0)) --> I1((1)) --> I2((2)) --> I3((3)) --> I4((4)) --> I0
¿Te suena? Es la misma idea que la ListaCircular del módulo 2, donde el último nodo apuntaba al primero (ultimo.siguiente) y el RepartidorTareas giraba indefinidamente. Allí el círculo se construía con enlaces entre nodos; aquí se construye con aritmética sobre índices de un array. Mismo concepto, implementación distinta — y esta versión, al ser un array contiguo de tamaño fijo, es más compacta en memoria y más amiga de la caché del procesador, por eso es la elegida en drivers y sistemas embebidos.
El rompecabezas: ¿llena o vacía?
Hay una sutileza famosa. Si representamos la cola solo con los índices frente y final, la condición frente == final es ambigua: ocurre tanto cuando la cola está vacía como cuando está llena (el final ha dado la vuelta completa y ha alcanzado al frente). Hay dos soluciones clásicas:
| Estrategia | Cómo funciona | Coste |
|---|---|---|
| Contador de elementos | Mantener tamano: vacía si tamano == 0, llena si tamano == capacidad |
Un entero extra; código muy claro |
| Casilla libre (sacrificada) | Reservar siempre un hueco: llena si (final + 1) % c == frente |
Se desperdicia una casilla; solo índices |
Nosotros usaremos el contador, que además nos da tamano() gratis (está en el contrato). La estrategia de la casilla sacrificada la verás en código C de bajo nivel, donde evitar un campo extra importa; conviene reconocerla cuando la leas.
Con contador, ni siquiera necesitamos almacenar final: se deduce de frente y tamano:
Menos estado que mantener significa menos invariantes que romper: es un principio de diseño que ya aplicamos en la ListaCircular (solo guardábamos self.ultimo).
La clase ColaCircular
class ColaCircular:
"""Cola FIFO de capacidad fija sobre un array, con índices modulares.
Cumple el contrato de cola (encolar, desencolar, frente, esta_vacia,
tamano) y añade esta_llena() y capacidad(), propios del tamaño fijo.
"""
def __init__(self, capacidad):
if capacidad <= 0:
raise ValueError("la capacidad debe ser positiva")
self._datos = [None] * capacidad # array fijo: se crea de una vez
self._capacidad = capacidad
self._frente = 0 # índice del elemento más antiguo
self._tamano = 0 # nº de elementos ocupados
def encolar(self, elemento):
if self.esta_llena():
raise OverflowError("encolar sobre una cola llena")
final = (self._frente + self._tamano) % self._capacidad
self._datos[final] = elemento # escribe en la casilla del final
self._tamano += 1
def desencolar(self):
if self.esta_vacia():
raise IndexError("desencolar sobre una cola vacía")
elemento = self._datos[self._frente]
self._datos[self._frente] = None # liberar la referencia (higiene)
self._frente = (self._frente + 1) % self._capacidad # avance modular
self._tamano -= 1
return elemento
def frente(self):
if self.esta_vacia():
raise IndexError("frente sobre una cola vacía")
return self._datos[self._frente]
def esta_vacia(self):
return self._tamano == 0
def esta_llena(self):
return self._tamano == self._capacidad
def tamano(self):
return self._tamano
def capacidad(self):
return self._capacidadPuntos que merecen explicación detallada:
- Todo es O(1), sin letra pequeña: encolar y desencolar hacen una escritura, una suma y un módulo. No hay desplazamientos (el gran pecado de
ColaLenta) ni nodos que crear (a diferencia de laColaenlazada). Tampoco hay redimensionados: la memoria se reserva una única vez en__init__. self._datos[self._frente] = Noneendesencolar: no es obligatorio para que funcione, pero si la casilla retiene la referencia al objeto, Python no puede liberarlo de memoria hasta que la casilla se sobrescriba. En colas de rotación lenta eso mantiene vivos objetos "fantasma". Es el mismo tipo de higiene que las referencias colgantes del módulo 2.OverflowErroral llenarse: la cola fija debe decidir su política de saturación. Lanzar excepción es la política honesta por defecto; en el ejemplo de TaskFlow veremos la otra política habitual (descartar lo más antiguo). Lo importante es que sea una decisión explícita.
Traza visual del avance modular
Sigamos una ColaCircular(4) operación a operación. Marcamos F bajo el índice frente y calculamos final = (frente + tamano) % 4 (casilla del próximo encolado):
| Operación | Array [0][1][2][3] |
frente | tamano | final (próximo) | Devuelve |
|---|---|---|---|---|---|
| (inicial) | [ · ][ · ][ · ][ · ] |
0 | 0 | 0 | — |
encolar(A) |
[ A ][ · ][ · ][ · ] |
0 | 1 | 1 | — |
encolar(B) |
[ A ][ B ][ · ][ · ] |
0 | 2 | 2 | — |
encolar(C) |
[ A ][ B ][ C ][ · ] |
0 | 3 | 3 | — |
desencolar() |
[ · ][ B ][ C ][ · ] |
1 | 2 | 3 | A |
encolar(D) |
[ · ][ B ][ C ][ D ] |
1 | 3 | 0 | — |
encolar(E) |
[ E ][ B ][ C ][ D ] |
1 | 4 | — (llena) | — |
desencolar() |
[ E ][ · ][ C ][ D ] |
2 | 3 | 1 | B |
desencolar() |
[ E ][ · ][ · ][ D ] |
3 | 2 | 1 | C |
Las dos filas en negrita cuentan la historia completa:
- Tras
encolar(D), el próximofinales(1 + 3) % 4 = 0: ha dado la vuelta. Por esoEaterriza en la casilla 0, que A dejó libre. Ninguna casilla se desperdicia, nada se desplaza. - Con la cola llena (
tamano == 4),frentevale 1 y la casilla del "final" coincidiría con él: exactamente la ambigüedad que el contador resuelve.
Observa también que el orden FIFO se conserva perfectamente aunque físicamente E esté "antes" que B en el array: el orden lógico lo dictan frente y el avance modular, no la posición física. Es la distinción TDA/implementación del módulo 1 en su versión más gráfica.
Ring buffers en el mundo real y relación con ListaCircular
El nombre profesional de esta estructura es ring buffer (buffer circular) y aparece en cuanto rascas cualquier sistema:
- Buffers de teclado y de red: el hardware produce bytes a su ritmo; el software los consume al suyo. Un ring buffer de tamaño fijo absorbe la diferencia sin pedir memoria jamás (crucial en un driver, donde no se puede "pedir más memoria").
- Audio y vídeo en streaming: el reproductor lee del frente mientras la descarga escribe por el final; si la descarga se adelanta demasiado, espera (cola llena); si se retrasa, el reproductor espera (cola vacía).
- Logs de últimos eventos: journals del sistema, cajas negras de aviónica, el historial "últimos N" de cualquier aplicación: se sobrescribe lo viejo automáticamente.
Comparativa con su prima del módulo 2:
ListaCircular (módulo 2) |
ColaCircular (esta lección) |
|
|---|---|---|
| Circularidad lograda con | Enlace del último nodo al primero | Operador % sobre índices |
| Capacidad | Ilimitada (crece nodo a nodo) | Fija (array reservado de antemano) |
| Memoria | Un Nodo por elemento, dispersa |
Contigua y compacta |
| Uso típico | Rotación infinita (round-robin del RepartidorTareas) |
Buffer productor/consumidor de tamaño acotado |
TaskFlow: RegistroEventos, los últimos N eventos
En TaskFlow queremos un panel de "actividad reciente": los últimos 100 eventos del sistema (tarea creada, completada, reasignada...). No queremos guardarlos todos —para eso ya habrá una base de datos—, solo los N más recientes, con memoria constante. Es el caso de uso perfecto del ring buffer con política de descartar lo más antiguo:
class RegistroEventos:
"""Guarda los últimos N eventos de TaskFlow con memoria fija.
Política de saturación: al llenarse, el evento más antiguo se descarta
para dejar sitio al nuevo (a diferencia del OverflowError por defecto).
"""
def __init__(self, maximo=100):
self._eventos = ColaCircular(maximo)
def registrar(self, tarea, accion):
if self._eventos.esta_llena():
self._eventos.desencolar() # descarta el más antiguo: O(1)
self._eventos.encolar({
"tarea_id": tarea["id"],
"titulo": tarea["titulo"],
"accion": accion,
})
def actividad_reciente(self):
"""Devuelve los eventos del más antiguo al más reciente (O(n))."""
recientes = []
for _ in range(self._eventos.tamano()):
evento = self._eventos.desencolar()
recientes.append(evento)
self._eventos.encolar(evento) # rota la cola una vuelta completa
return recientes
# --- Uso ---
registro = RegistroEventos(maximo=3) # 3 para verlo con poco ruido
t1 = {"id": 1, "titulo": "Diseñar logo", "prioridad": 2, "estado": "pendiente"}
t2 = {"id": 2, "titulo": "Configurar CI", "prioridad": 1, "estado": "pendiente"}
registro.registrar(t1, "creada")
registro.registrar(t2, "creada")
registro.registrar(t2, "en curso")
registro.registrar(t2, "completada") # ¡lleno! descarta "t1 creada"
for e in registro.actividad_reciente():
print(f"tarea {e['tarea_id']}: {e['accion']}")
# tarea 2: creada
# tarea 2: en curso
# tarea 2: completadaDos decisiones dignas de comentario:
registrarimplementa "descartar lo viejo" componiendo el contrato público (esta_llena+desencolar+encolar), sin tocar las tripas deColaCircular. La política vive en la capa de TaskFlow; la estructura queda genérica y reutilizable.actividad_recienteusa el truco de la rotación completa: desencola cada elemento y lo vuelve a encolar, de modo que trastamano()vueltas la cola queda exactamente como estaba. Es la manera de recorrer una cola respetando su contrato (sin acceder al array interno).
Quizá recuerdes que el HistorialConLimite del módulo 3 hacía algo parecido ("quedarme con los últimos k") pagando un pop(0) O(n). El RegistroEventos ya lo hace todo en O(1)... a costa de fijar la capacidad de antemano. En la lección 04-05 veremos la tercera vía, la más pythónica: deque(maxlen=k).
Errores Comunes y Consejos
- Olvidar el
%en algún avance: si escribesself._frente += 1sin módulo, la cola funciona de maravilla... hasta la primera vuelta, y entonces indexa fuera del array (IndexError) o lee basura. Los errores "de segunda vuelta" son traicioneros porque las pruebas cortas no los detectan: prueba siempre con más operaciones quecapacidad. - Usar
frente == finalcomo prueba de vacía con solo dos índices: es la ambigüedad llena/vacía. Decide estrategia (contador o casilla sacrificada) y sé consecuente; con contador, ni compares índices. - Confundir capacidad con tamaño:
capacidad()es cuántos caben;tamano()es cuántos hay. En el código de saturación (esta_llena) se comparan entre sí, y mezclarlos produce colas que "se llenan" a la mitad. - Elegir mal la política de saturación: ¿excepción, descartar lo nuevo o descartar lo viejo? Para un buffer de comandos de un usuario, perder lo nuevo suele ser peor; para un registro de actividad, perder lo viejo es justo lo deseado. No hay política universal: documenta la elegida.
- Consejo: al depurar una cola circular, imprime siempre la tripleta
(frente, tamano, capacidad)junto al array. Verfrente = 3, tamano = 2sobre[E][·][·][D]te enseña más que veinteprintdel array solo.
Ejercicios
Ejercicio 1: traza con vuelta completa
Sobre una ColaCircular(3), ejecuta en orden: encolar(1), encolar(2), desencolar(), encolar(3), encolar(4), desencolar(), desencolar(), encolar(5). Construye la tabla de traza (array, frente, tamano, valor devuelto) tras cada operación. ¿En qué casilla física acaba el 5 y por qué?
Ejercicio 2: ultimo_encolado
Añade a ColaCircular un método ultimo_encolado() que devuelva (sin extraer) el elemento más reciente, lanzando IndexError si la cola está vacía. Cuidado: la casilla del último elemento no es (frente + tamano) % capacidad (esa es la del próximo). Hazlo en O(1).
Ejercicio 3: redimensionar conservando el orden
Escribe un método redimensionar(nueva_capacidad) que sustituya el array interno por uno de la nueva capacidad, conservando los elementos en orden FIFO y dejando frente = 0. Debe rechazar con ValueError una capacidad menor que tamano(). Pista: no copies casillas por posición física; recoloca siguiendo el orden lógico, con índices modulares desde el frente antiguo.
Soluciones
Solución 1:
| Operación | [0][1][2] |
frente | tamano | Devuelve |
|---|---|---|---|---|
encolar(1) |
[1][·][·] |
0 | 1 | — |
encolar(2) |
[1][2][·] |
0 | 2 | — |
desencolar() |
[·][2][·] |
1 | 1 | 1 |
encolar(3) |
[·][2][3] |
1 | 2 | — |
encolar(4) |
[4][2][3] |
1 | 3 | — |
desencolar() |
[4][·][3] |
2 | 2 | 2 |
desencolar() |
[4][·][·] |
0 | 1 | 3 |
encolar(5) |
[4][5][·] |
0 | 2 | — |
El 4 cayó en la casilla 0 porque (1 + 2) % 3 = 0 (primera vuelta), y el 5 cae en la casilla 1 porque, con frente = 0 y tamano = 1, el próximo final es (0 + 1) % 3 = 1. El orden lógico (frente→final) es 4, 5, aunque el 4 esté físicamente antes: el FIFO lo dictan los índices, no la posición.
Solución 2:
def ultimo_encolado(self):
if self.esta_vacia():
raise IndexError("ultimo_encolado sobre una cola vacía")
# El próximo hueco es (frente + tamano) % capacidad;
# el último ocupado es la casilla ANTERIOR a ese hueco:
indice = (self._frente + self._tamano - 1) % self._capacidad
return self._datos[indice]El - 1 dentro del módulo maneja también el caso de la vuelta: con frente = 2, tamano = 1, capacidad = 3, el último está en (2 + 1 - 1) % 3 = 2, correcto. En Python, incluso (0 - 1) % 3 da 2 (el módulo de Python nunca es negativo), así que la fórmula es segura.
Solución 3:
def redimensionar(self, nueva_capacidad):
if nueva_capacidad < self._tamano:
raise ValueError("no caben los elementos actuales")
nuevos = [None] * nueva_capacidad
for i in range(self._tamano):
# i-ésimo elemento en orden lógico, empezando por el frente:
nuevos[i] = self._datos[(self._frente + i) % self._capacidad]
self._datos = nuevos
self._capacidad = nueva_capacidad
self._frente = 0 # el orden lógico queda "desenrollado" desde 0La clave es el índice (self._frente + i) % self._capacidad: recorre los elementos en orden FIFO real, aunque estén partidos en dos tramos físicos (final del array + principio). Copiar el array tal cual (self._datos[:]) sería un error: mezclaría casillas vacías en medio y rompería el orden.
Conclusión
La cola circular resuelve el problema de la cola sobre memoria fija: dos índices que avanzan con aritmética modular ((i + 1) % capacidad), un contador que deshace la ambigüedad llena/vacía, y todas las operaciones en O(1) sin desplazar jamás un elemento. Es la versión "de array" de la idea que ya vimos con enlaces en la ListaCircular, y es la estructura de los ring buffers que sostienen drivers, streaming y registros de actividad — incluido nuestro RegistroEventos de los últimos N eventos de TaskFlow. Hasta ahora, todas nuestras colas comparten un dogma: sale el más antiguo. Pero en TaskFlow hay tareas con prioridad 1 que no pueden esperar su turno detrás de veinte tareas rutinarias. Toca romper el dogma con cabeza: en la próxima lección, la cola de prioridad, donde desencolar significa "dame el más urgente" — y donde por fin extraeremos aquello que el min-stack del módulo 3 solo podía mirar.
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
