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
- El nodo doble: dos flechas por nodo
- Cabeza y cola: la estructura y sus invariantes
- Insertar por ambos extremos
- Insertar y borrar O(1) dado el nodo
- Recorrer en ambos sentidos
- Simple vs doble: la balanza completa
- TaskFlow: el historial de tareas navegable
- 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 = 0En 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.anteriores siempreNone(nadie precede al primero).self.cola.siguientees siempreNone(nadie sigue al último).- Si la lista está vacía,
cabezaycolason ambasNone; si tiene un elemento, ambas apuntan al mismo nodo. - Para todo nodo interior
n:n.siguiente.anterior is nyn.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 += 1Fí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 += 1graph LR
A["anterior"] -- "puente siguiente" --> C["siguiente"]
C -- "puente anterior" --> A
A -.-> B["nodo borrado"]
B -.-> C
Detalles que marcan la diferencia:
borrar_nodoconstruye 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 servidorPuntos a saborear:
atras()yadelante()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.actuales la materialización de "la ventaja solo vale si conservas referencias a nodos": el historial vive precisamente de guardar una.- Las comprobaciones
is not Noneen 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 faltab.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 invarianten.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 dejarcabezaycolaaNonea 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 haceHistorialTareasmoviendoself.actualsolo 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)ylist(reversed(lista))) y compara: deben ser exactamente inversas. Si no lo son, hay una flechaanteriortraicionada, 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 sanasEl 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)) # FalseComprobamos 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.
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
