La lista enlazada simple que construimos en la lección anterior tiene una asimetría incómoda: cada nodo sabe quién viene después, pero ignora por completo quién viene antes. Esa ceguera nos obligó al baile de dos referencias (anterior y actual) para borrar, y hace directamente imposible recorrer la lista hacia atrás. En esta lección añadimos la flecha que falta: cada nodo llevará dos referencias, anterior y siguiente, y obtendremos la lista doblemente enlazada, con borrados e inserciones O(1) cuando ya tenemos el nodo en la mano y recorridos en ambos sentidos. El precio, como siempre, existe y lo pondremos en la balanza. Para TaskFlow, esta estructura es la pieza perfecta para un historial de tareas navegable: avanzar y retroceder por las tareas trabajadas, como el botón "atrás" y "adelante" de un navegador.

Contenido

  1. El nodo doble: dos flechas por nodo
  2. Cabeza y cola: la estructura y sus invariantes
  3. Insertar por ambos extremos
  4. Insertar y borrar O(1) dado el nodo
  5. Recorrer en ambos sentidos
  6. Simple vs doble: la balanza completa
  7. TaskFlow: el historial de tareas navegable
  8. Un puente hacia collections.deque

El nodo doble: dos flechas por nodo

La modificación es pequeña de escribir y grande de consecuencias:

class NodoDoble:
    """Un dato y dos referencias: al nodo anterior y al siguiente."""
    def __init__(self, dato):
        self.dato = dato
        self.anterior = None    # referencia al nodo previo (None = es el primero)
        self.siguiente = None   # referencia al nodo posterior (None = es el último)
graph LR
    H[cabeza] --> A
    A["tarea 1"] -- siguiente --> B["tarea 2"]
    B -- anterior --> A
    B -- siguiente --> C["tarea 3"]
    C -- anterior --> B
    T[cola] --> C

Cada pareja de nodos vecinos está unida por dos flechas, una en cada sentido. De ahí se derivan las dos propiedades nuevas:

  • Desde cualquier nodo se puede caminar hacia atrás (actual = actual.anterior), no solo hacia delante.
  • Cualquier nodo conoce a su vecino previo sin buscarlo: el patrón del nodo anterior de 02-02 deja de ser necesario.

Y la servidumbre nueva: cada operación debe mantener coherentes el doble de flechas. Donde la lista simple recableaba 2 referencias, la doble recablea hasta 4. Ninguna operación cambia de clase de coste por ello (sigue siendo O(1) recablear), pero el código tiene más puntos donde equivocarse, y lo veremos.

Cabeza y cola: la estructura y sus invariantes

class ListaDoblementeEnlazada:
    """Lista doblemente enlazada: recorrible en ambos sentidos."""
    def __init__(self):
        self.cabeza = None    # primer nodo
        self.cola = None      # último nodo
        self.tamano = 0

En la lista simple, la cola era un truco opcional para abaratar insertar_al_final. Aquí es parte esencial del diseño: sin ella no habría por dónde empezar el recorrido hacia atrás. Conviene fijar por escrito los invariantes — las condiciones que todo método debe dejar ciertas al terminar:

  • self.cabeza.anterior es siempre None (nadie precede al primero).
  • self.cola.siguiente es siempre None (nadie sigue al último).
  • Si la lista está vacía, cabeza y cola son ambas None; si tiene un elemento, ambas apuntan al mismo nodo.
  • Para todo nodo interior n: n.siguiente.anterior is n y n.anterior.siguiente is n (las flechas de ida y vuelta se corresponden).

Cuando un método deja las flechas incoherentes, los síntomas aparecen lejos del culpable (un recorrido hacia atrás que se corta, un borrado que resucita nodos). Comprobar los invariantes mentalmente tras escribir cada método es el mejor seguro.

Insertar por ambos extremos

La simetría de la estructura se refleja en el código: los dos métodos son espejo el uno del otro.

    def insertar_al_inicio(self, dato):
        """Coste: O(1)."""
        nuevo = NodoDoble(dato)
        if self.cabeza is None:          # lista vacía
            self.cabeza = nuevo
            self.cola = nuevo
        else:
            nuevo.siguiente = self.cabeza    # 1: nuevo mira al antiguo primero
            self.cabeza.anterior = nuevo     # 2: el antiguo primero mira al nuevo
            self.cabeza = nuevo              # 3: la cabeza se muda
        self.tamano += 1

    def insertar_al_final(self, dato):
        """Coste: O(1)."""
        nuevo = NodoDoble(dato)
        if self.cola is None:            # lista vacía
            self.cabeza = nuevo
            self.cola = nuevo
        else:
            nuevo.anterior = self.cola       # espejo exacto del método anterior
            self.cola.siguiente = nuevo
            self.cola = nuevo
        self.tamano += 1

Fíjate en el paso 2 de insertar_al_inicio, que no existía en la lista simple: el antiguo primer nodo debe devolver la mirada al nuevo (self.cabeza.anterior = nuevo). Es la asignación extra que mantiene el invariante de las flechas correspondidas. Olvidarla no rompe el recorrido hacia delante —el fallo queda agazapado— pero corta el recorrido hacia atrás justo en ese punto: el tipo de error silencioso más difícil de depurar.

Insertar y borrar O(1) dado el nodo

Aquí está el titular de la lección, y hay que leerlo con su letra pequeña: teniendo una referencia al nodo, borrar (o insertar junto a él) es O(1) puro, sin caminar la lista ni buscar al anterior — el nodo ya lo trae consigo.

    def borrar_nodo(self, nodo):
        """Desengancha el nodo dado. Coste: O(1) — no busca, recablea."""
        if nodo.anterior is not None:
            nodo.anterior.siguiente = nodo.siguiente   # puente hacia delante
        else:
            self.cabeza = nodo.siguiente               # era el primero
        if nodo.siguiente is not None:
            nodo.siguiente.anterior = nodo.anterior    # puente hacia atrás
        else:
            self.cola = nodo.anterior                  # era el último
        nodo.anterior = None      # higiene: el nodo suelto no retiene la lista
        nodo.siguiente = None
        self.tamano -= 1
        return nodo.dato

    def insertar_despues(self, nodo, dato):
        """Inserta un dato justo después del nodo dado. Coste: O(1)."""
        if nodo is self.cola:
            self.insertar_al_final(dato)
            return
        nuevo = NodoDoble(dato)
        nuevo.anterior = nodo                 # 1: el nuevo mira a ambos lados
        nuevo.siguiente = nodo.siguiente
        nodo.siguiente.anterior = nuevo       # 2: los vecinos le devuelven la mirada
        nodo.siguiente = nuevo
        self.tamano += 1
graph LR
    A["anterior"] -- "puente siguiente" --> C["siguiente"]
    C -- "puente anterior" --> A
    A -.-> B["nodo borrado"]
    B -.-> C

Detalles que marcan la diferencia:

  • borrar_nodo construye dos puentes, uno por sentido, y cada uno tiene su caso frontera (primero/último). Cuatro ramas, todas necesarias.
  • Las dos líneas de "higiene" (nodo.anterior = None, etc.) evitan que un nodo ya retirado siga reteniendo referencias a la lista viva — y de paso, que alguien lo use como punto de partida de un recorrido fantasma.
  • Compara con la lista simple: allí, borrar exigía haber llegado con la pareja anterior/actual (O(n) de búsqueda previa salvo en la cabeza). Aquí, si guardaste la referencia al nodo (por ejemplo, el nodo del historial donde estás situado), el borrado es instantáneo. La ventaja solo se materializa si conservas referencias a nodos; si siempre empiezas buscando desde la cabeza, la lista doble no te ahorra la búsqueda O(n).

Recorrer en ambos sentidos

    def __iter__(self):
        """Recorrido hacia delante: for dato in lista. Coste: O(n)."""
        actual = self.cabeza
        while actual is not None:
            yield actual.dato
            actual = actual.siguiente

    def __reversed__(self):
        """Recorrido hacia atrás: for dato in reversed(lista). Coste: O(n)."""
        actual = self.cola
        while actual is not None:
            yield actual.dato
            actual = actual.anterior

    def __len__(self):
        return self.tamano

    def __str__(self):
        textos = [f'[{d["id"]}:{d["titulo"]}]' if isinstance(d, dict) and "titulo" in d
                  else f"[{d}]" for d in self]
        return " <-> ".join(textos) if textos else "(vacía)"

__reversed__ es el método mágico que Python invoca al escribir reversed(lista): empezamos por la cola y caminamos por las flechas anterior. En la lista simple este método era sencillamente imposible de escribir con coste razonable (habría que rebuscar el anterior de cada nodo, O(n²), o copiar la lista entera). El separador <-> de __str__ recuerda que ahora las flechas van en ambos sentidos.

Simple vs doble: la balanza completa

Criterio Lista enlazada simple Lista doblemente enlazada
Referencias por nodo 1 (siguiente) 2 (anterior y siguiente)
Memoria extra por nodo Una flecha Dos flechas (~un 30-50% más de sobrecoste por nodo)
Recorrido hacia delante O(n) O(n)
Recorrido hacia atrás Impracticable O(n)
Borrar teniendo el nodo O(n) — hay que localizar al anterior O(1)
Insertar junto a un nodo dado O(1) solo después; antes exige buscar O(1) en ambos lados
Insertar/borrar en extremos O(1) (con cola) O(1)
Flechas a mantener por operación 2 Hasta 4 (más casos frontera)
Complejidad del código Menor Mayor (más puntos de error)

¿Cuándo elegir cada una?

  • Simple: cuando solo se avanza en un sentido y las modificaciones se concentran en los extremos o durante un recorrido hacia delante. Menos memoria, menos código, menos errores posibles.
  • Doble: cuando se necesita navegar hacia atrás, o borrar/mover elementos a partir de referencias guardadas a sus nodos. El historial que viene ahora es el caso canónico.

TaskFlow: el historial de tareas navegable

Cada vez que un miembro del equipo abre una tarea, TaskFlow la anota en su historial. El usuario quiere moverse por ese historial como por las páginas de un navegador: atrás y adelante. Con la lista doble, el historial es la estructura y la posición actual es simplemente una referencia a un nodo:

class HistorialTareas:
    """Historial navegable de tareas visitadas, sobre una lista doble."""
    def __init__(self):
        self.lista = ListaDoblementeEnlazada()
        self.actual = None                    # nodo donde estamos situados

    def visitar(self, tarea):
        """El usuario abre una tarea: se anota al final y nos situamos en ella."""
        self.lista.insertar_al_final(tarea)
        self.actual = self.lista.cola         # el nodo recién creado

    def atras(self):
        """Retrocede una posición, si se puede. Coste: O(1)."""
        if self.actual is not None and self.actual.anterior is not None:
            self.actual = self.actual.anterior
        return self.actual.dato if self.actual else None

    def adelante(self):
        """Avanza una posición, si se puede. Coste: O(1)."""
        if self.actual is not None and self.actual.siguiente is not None:
            self.actual = self.actual.siguiente
        return self.actual.dato if self.actual else None


historial = HistorialTareas()
historial.visitar({"id": 1, "titulo": "Diseñar logo", "prioridad": 2, "estado": "en curso"})
historial.visitar({"id": 2, "titulo": "Configurar servidor", "prioridad": 2, "estado": "en curso"})
historial.visitar({"id": 3, "titulo": "Hotfix producción", "prioridad": 1, "estado": "en curso"})

print(historial.atras()["titulo"])      # Configurar servidor
print(historial.atras()["titulo"])      # Diseñar logo
print(historial.adelante()["titulo"])   # Configurar servidor

Puntos a saborear:

  • atras() y adelante() son O(1) puros: mover una referencia por una flecha. Con una lista simple, atras() habría exigido recorrer desde la cabeza hasta el nodo previo — O(n) por pulsación de botón.
  • self.actual es la materialización de "la ventaja solo vale si conservas referencias a nodos": el historial vive precisamente de guardar una.
  • Las comprobaciones is not None en cadena evitan tanto el historial vacío como salirse por los extremos: en la primera y la última tarea, los botones simplemente no se mueven.

En 02-05 completarás este historial con un matiz clásico de los navegadores: qué pasa con las tareas "adelante" cuando visitas una nueva desde mitad del historial.

Un puente hacia collections.deque

En el módulo 1 quedó sembrada la mención a collections.deque, y ahora ya puedes entender su carné de identidad: deque está implementada en C como una estructura doblemente enlazada por bloques — no un nodo por elemento, sino nodos que son bloques de 64 huecos enlazados entre sí en ambos sentidos. Esa hibridación le da inserciones y borrados O(1) por ambos extremos con mucho menos sobrecoste de memoria por elemento que nuestra lista doble artesanal. Es la respuesta profesional de Python al patrón "entrar y salir por los dos extremos", y será protagonista en el módulo 4 cuando construyamos colas y deques; aquí nos basta con saber que existe y que, por dentro, es pariente directa de lo que acabas de programar.

Errores Comunes y Consejos

  • Actualizar una flecha y olvidar la de vuelta. El error estrella: tras a.siguiente = b, casi siempre falta b.anterior = a. El síntoma es traicionero porque el recorrido hacia delante funciona y el fallo solo aflora al ir hacia atrás. Verifica cada método contra el invariante n.siguiente.anterior is n.
  • Los cuatro casos frontera de borrar_nodo. Primero, último, único elemento y nodo interior tocan ramas distintas. Borrar el único nodo debe dejar cabeza y cola a None a la vez; prueba siempre ese caso.
  • Usar un nodo ya borrado. Si guardaste una referencia a un nodo y luego alguien lo borró, tus flechas apuntan al vacío (o a None, gracias a la higiene del método). En diseños con referencias vivas, define quién es responsable de invalidarlas — como hace HistorialTareas moviendo self.actual solo a través de sus métodos.
  • Pagar la lista doble sin usarla. Si tu código nunca recorre hacia atrás ni guarda referencias a nodos, estás pagando una flecha extra por nodo y el doble de recableados a cambio de nada: vuelve a la lista simple. Elegir la estructura mínima suficiente también es optimizar.
  • Consejo: al depurar, imprime la lista en ambos sentidos (list(lista) y list(reversed(lista))) y compara: deben ser exactamente inversas. Si no lo son, hay una flecha anterior traicionada, y el punto donde divergen te dice cuál.

Ejercicios

Ejercicio 1 — buscar_nodo y borrado por id. Añade a ListaDoblementeEnlazada un método buscar_nodo(condicion) que devuelva el nodo (no el dato) que cumpla la condición, o None. Úsalo junto a borrar_nodo para eliminar del historial la tarea con id 2. ¿Cuál es el coste total de la operación combinada y en qué parte se concentra?

Ejercicio 2 — insertar_antes. Escribe el método insertar_antes(nodo, dato), simétrico de insertar_despues, con coste O(1). Cuidado con el caso en que nodo sea la cabeza. Comprueba con un ejemplo que los invariantes se mantienen imprimiendo la lista en ambos sentidos.

Ejercicio 3 — Detectar la flecha traicionada. Escribe una función verificar(lista) que devuelva True si la lista cumple los invariantes de la lección (cabeza sin anterior, cola sin siguiente, y flechas correspondidas en todos los nodos) y False en caso contrario. Después rompe adrede una flecha anterior de una lista de prueba y comprueba que verificar la detecta.

Soluciones

Solución 1:

    def buscar_nodo(self, condicion):
        """Devuelve el primer NODO cuyo dato cumple la condición. Coste: O(n)."""
        actual = self.cabeza
        while actual is not None:
            if condicion(actual.dato):
                return actual
            actual = actual.siguiente
        return None

# Operación combinada:
nodo = historial.lista.buscar_nodo(lambda t: t["id"] == 2)   # O(n)
if nodo is not None:
    if historial.actual is nodo:                 # no dejar 'actual' colgando
        historial.actual = nodo.anterior or nodo.siguiente
    historial.lista.borrar_nodo(nodo)            # O(1)

El coste total es O(n), pero todo el coste está en la búsqueda; el borrado en sí es O(1). Esta separación es la moraleja de la lección: la lista doble abarata el modificar, no el encontrar. Para abaratar el encontrar hará falta el índice por id del módulo 5 — que, combinado con esta lista (un dict de id → nodo), dará borrados totales en O(1). Nota el detalle de recolocar historial.actual si estaba sobre el nodo borrado: es el error "usar un nodo ya borrado" atajado de raíz.

Solución 2:

    def insertar_antes(self, nodo, dato):
        """Inserta un dato justo antes del nodo dado. Coste: O(1)."""
        if nodo is self.cabeza:
            self.insertar_al_inicio(dato)
            return
        nuevo = NodoDoble(dato)
        nuevo.siguiente = nodo                # el nuevo mira a ambos lados
        nuevo.anterior = nodo.anterior
        nodo.anterior.siguiente = nuevo       # los vecinos le devuelven la mirada
        nodo.anterior = nuevo
        self.tamano += 1

# Comprobación de invariantes:
lista = ListaDoblementeEnlazada()
for x in ("A", "B", "D"):
    lista.insertar_al_final(x)
nodo_d = lista.buscar_nodo(lambda d: d == "D")
lista.insertar_antes(nodo_d, "C")
print(list(lista))             # ['A', 'B', 'C', 'D']
print(list(reversed(lista)))   # ['D', 'C', 'B', 'A']  ← exactamente inversa: flechas sanas

El orden de las cuatro asignaciones sigue la regla de siempre ampliada: primero el nuevo mira a ambos lados, después los vecinos le devuelven la mirada. Y dentro de la segunda parte, nodo.anterior.siguiente = nuevo debe ir antes de nodo.anterior = nuevo, porque la primera línea todavía necesita el valor antiguo de nodo.anterior.

Solución 3:

def verificar(lista):
    """Comprueba los invariantes de la lista doble. Coste: O(n)."""
    if lista.cabeza is None or lista.cola is None:
        return lista.cabeza is None and lista.cola is None   # vacía: ambas a None
    if lista.cabeza.anterior is not None or lista.cola.siguiente is not None:
        return False
    actual = lista.cabeza
    while actual.siguiente is not None:
        if actual.siguiente.anterior is not actual:   # ¿flecha de vuelta correcta?
            return False
        actual = actual.siguiente
    return actual is lista.cola    # el recorrido debe terminar exactamente en la cola

# Sabotaje controlado:
lista = ListaDoblementeEnlazada()
for x in (1, 2, 3):
    lista.insertar_al_final(x)
print(verificar(lista))            # True
lista.cabeza.siguiente.anterior = None    # rompemos una flecha de vuelta
print(verificar(lista))            # False

Comprobamos las flechas con is (identidad de nodos, no igualdad de datos) y rematamos verificando que el recorrido muere en lista.cola — así cazamos también colas fantasma. Una función como esta, llamada tras cada operación en los tests, convierte los errores silenciosos de flechas en fallos ruidosos e inmediatos: exactamente lo que quieres mientras desarrollas.

Conclusión

Con una segunda flecha por nodo, la lista doblemente enlazada elimina las dos limitaciones de la simple: ya se puede recorrer hacia atrás (__reversed__ desde la cola) y, teniendo el nodo, borrar o insertar a su lado es O(1) sin buscar a nadie — a cambio de más memoria por nodo y hasta cuatro flechas que mantener coherentes en cada operación, con sus invariantes y sus casos frontera. El historial navegable de TaskFlow demostró dónde brilla: atras() y adelante() como simples movimientos de una referencia. Y collections.deque quedó presentada como la versión profesional e híbrida de esta idea, esperándonos en el módulo 4. Ahora bien, todas nuestras listas —simples o dobles— comparten un rasgo: tienen un final, ese None que detiene los recorridos. En la próxima lección lo eliminaremos deliberadamente: haremos que el último nodo apunte al primero y obtendremos las listas circulares, perfectas para los turnos rotatorios — en TaskFlow, el reparto cíclico de tareas entre los miembros del equipo — siempre que aprendamos a recorrerlas sin caer en el bucle infinito.

© Copyright 2026. Todos los derechos reservados