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

  1. El problema: una cola sobre array fijo
  2. La idea circular: aritmética modular
  3. El rompecabezas: ¿llena o vacía?
  4. La clase ColaCircular
  5. Traza visual del avance modular
  6. Ring buffers en el mundo real y relación con ListaCircular
  7. 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 ↑final

Tras 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:

siguiente = (i + 1) % c

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:

final = (frente + tamano) % capacidad   # casilla donde caerá el PRÓXIMO elemento

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._capacidad

Puntos 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 la Cola enlazada). Tampoco hay redimensionados: la memoria se reserva una única vez en __init__.
  • self._datos[self._frente] = None en desencolar: 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.
  • OverflowError al 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óximo final es (1 + 3) % 4 = 0: ha dado la vuelta. Por eso E aterriza en la casilla 0, que A dejó libre. Ninguna casilla se desperdicia, nada se desplaza.
  • Con la cola llena (tamano == 4), frente vale 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: completada

Dos decisiones dignas de comentario:

  • registrar implementa "descartar lo viejo" componiendo el contrato público (esta_llena + desencolar + encolar), sin tocar las tripas de ColaCircular. La política vive en la capa de TaskFlow; la estructura queda genérica y reutilizable.
  • actividad_reciente usa el truco de la rotación completa: desencola cada elemento y lo vuelve a encolar, de modo que tras tamano() 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 escribes self._frente += 1 sin 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 que capacidad.
  • Usar frente == final como 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. Ver frente = 3, tamano = 2 sobre [E][·][·][D] te enseña más que veinte print del 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 0

La 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.

© Copyright 2026. Todos los derechos reservados