En la lección anterior encadenamos nodos a mano y terminamos con expresiones tan incómodas como cabeza.siguiente.siguiente.siguiente. Hoy encapsulamos toda esa mecánica en una clase ListaEnlazada que cumple el contrato del TDA lista: insertar (al inicio, al final y en posición), borrar, buscar, recorrer, y los métodos mágicos __len__ y __str__ para que se comporte como una estructura de Python de pleno derecho. Además de construirla, la someteremos al mismo escrutinio que a todo lo demás en este curso: análisis Big O operación por operación y verificación empírica con timeit, enfrentándola a la list de Python justo donde esta flaqueaba —insertar por delante—. Al final, el tablero de TaskFlow pasará a vivir sobre nuestra nueva estructura.
Contenido
- Las piezas:
Nodoy el esqueleto deListaEnlazada - Insertar al inicio y al final (y el truco de la cola)
- Recorrer,
__len__y__str__ - Buscar y insertar en una posición
- Borrar: el patrón del nodo anterior
- Análisis Big O de todas las operaciones
- La revancha:
timeitcontralistinsertando por delante - TaskFlow v0.2: el tablero como lista enlazada
Las piezas: Nodo y el esqueleto de ListaEnlazada
Partimos de la clase Nodo de la lección anterior, sin cambios, y añadimos la clase contenedora que guardará las referencias estratégicas:
class Nodo:
"""Un dato y la referencia al siguiente nodo."""
def __init__(self, dato):
self.dato = dato
self.siguiente = None
class ListaEnlazada:
"""Lista enlazada simple: implementación del TDA lista con nodos."""
def __init__(self):
self.cabeza = None # referencia al primer nodo (None = lista vacía)
self.cola = None # referencia al último nodo (el "truco" anunciado)
self.tamano = 0 # contador de elementos, mantenido al díaTres decisiones de diseño que conviene entender antes de seguir:
cabeza: el único punto de entrada imprescindible. Si esNone, la lista está vacía.cola: referencia directa al último nodo. No es obligatoria en una lista enlazada, pero sin ella insertar al final exigiría caminar toda la lista (O(n)); con ella será O(1). Es el truco que dejamos anunciado en la tabla de 02-01. El precio: cada operación debe mantenerla actualizada, como veremos.tamano: llevar la cuenta al insertar y borrar cuesta una suma o una resta (O(1)) y a cambiolen(lista)será instantáneo, en lugar de recontar los nodos cada vez. Es el mismo espíritu del coste repartido que vimos con el amortizado: pagar un poquito en cada operación para no pagar mucho de golpe.
Nota sobre la palabra tamano: la escribimos sin eñe a propósito. Aunque Python admite identificadores con ñ, evitar caracteres no ASCII en nombres de código es una convención profesional extendida (y coherente con la higiene de nombres URL-safe que practica este campus).
Insertar al inicio y al final (y el truco de la cola)
Insertar al inicio: el punto fuerte
def insertar_al_inicio(self, dato):
"""Añade un elemento delante de todo. Coste: O(1)."""
nuevo = Nodo(dato)
nuevo.siguiente = self.cabeza # 1: el nuevo apunta al antiguo primero
self.cabeza = nuevo # 2: la cabeza pasa a ser el nuevo
if self.cola is None: # 3: si la lista estaba vacía...
self.cola = nuevo # ...el nuevo también es el último
self.tamano += 1Paso a paso, con el diagrama del recableado:
graph LR
H[cabeza] -- "2: se reasigna" --> N["nuevo"]
N -- "1: nuevo.siguiente" --> A["antiguo primero"]
A --> B["..."]
- Línea 1 (
nuevo.siguiente = self.cabeza): el nodo recién creado agarra al que era el primero. Si la lista estaba vacía,self.cabezaesNoney el nuevo queda correctamente como último (susiguienteesNone). - Línea 2 (
self.cabeza = nuevo): la lista pasa a empezar por el nuevo. El orden de estas dos líneas es sagrado: si reasignáramos la cabeza primero, perderíamos la única referencia al resto de la lista. - Línea 3: caso especial de la lista vacía — el nuevo nodo es a la vez primero y último.
Cuenta las operaciones: dos asignaciones, una comparación, una suma. Ninguna depende de cuántos elementos haya. O(1) de verdad, no amortizado: aquí no hay redimensionados ocasionales como en el append del array.
Insertar al final: la cola en acción
def insertar_al_final(self, dato):
"""Añade un elemento detrás de todo. Coste: O(1) gracias a la cola."""
nuevo = Nodo(dato)
if self.cola is None: # lista vacía: el nuevo es todo
self.cabeza = nuevo
self.cola = nuevo
else:
self.cola.siguiente = nuevo # el antiguo último apunta al nuevo
self.cola = nuevo # la cola pasa a ser el nuevo
self.tamano += 1Sin la referencia cola, este método tendría que caminar desde la cabeza hasta el último nodo (O(n)) solo para engancharle el nuevo. Con ella, dos asignaciones y listo. Es un ejemplo perfecto de cómo una referencia extra bien mantenida cambia la clase de coste de una operación.
Recorrer, __len__ y __str__
El recorrido es el gesto que ya conoces: actual = actual.siguiente hasta None. Lo ofreceremos de la forma más pythónica posible, como generador, para poder escribir for tarea in lista:
def __iter__(self):
"""Permite: for dato in lista. Recorre en orden. Coste: O(n)."""
actual = self.cabeza
while actual is not None:
yield actual.dato # entrega el dato y pausa aquí
actual = actual.siguiente # al pedir el siguiente, avanza
def __len__(self):
"""Permite: len(lista). Coste: O(1) gracias al contador."""
return self.tamano
def __str__(self):
"""Permite: print(lista). Dibuja la cadena de nodos."""
textos = []
for dato in self: # reutilizamos __iter__
if isinstance(dato, dict) and "titulo" in dato:
textos.append(f'[{dato["id"]}:{dato["titulo"]}]')
else:
textos.append(f"[{dato}]")
return (" -> ".join(textos) + " -> None") if textos else "(vacía)"yieldconvierte__iter__en un generador: entrega un dato, se queda "congelado" y continúa cuando elforpide el siguiente. Para el usuario de la clase, la lista enlazada se recorre igual que unalist.__str__reutiliza el propio__iter__(nada de duplicar el bucle) y tiene un detalle amable con TaskFlow: si el dato es una tarea-dict, muestraid:tituloen lugar del diccionario completo.- La flecha
-> Nonefinal no es decorativa: recuerda visualmente que el último nodo apunta aNone.
Buscar y insertar en una posición
Buscar
Buscar por valor no tiene atajos: hay que mirar nodo a nodo. Implementamos una búsqueda por predicado, pensada para tareas ("dame la primera que cumpla esta condición"):
def buscar(self, condicion):
"""Devuelve el primer dato que cumple la condición, o None. Coste: O(n)."""
actual = self.cabeza
while actual is not None:
if condicion(actual.dato):
return actual.dato
actual = actual.siguiente
return NoneUso: lista.buscar(lambda t: t["id"] == 42). Es el mismo O(n) que buscar en una list; la lección 01-02 ya nos enseñó que "mirar todo" no escala, y el módulo 5 traerá el índice por id que lo arregla. La lista enlazada no compite en este terreno.
Insertar en una posición
def insertar_en(self, indice, dato):
"""Inserta el dato en la posición indice (0 = al principio).
Coste: O(n) por el desplazamiento hasta el punto; el recableado es O(1)."""
if indice <= 0:
self.insertar_al_inicio(dato)
return
if indice >= self.tamano:
self.insertar_al_final(dato)
return
anterior = self.cabeza
for _ in range(indice - 1): # caminar hasta el nodo anterior al punto
anterior = anterior.siguiente
nuevo = Nodo(dato)
nuevo.siguiente = anterior.siguiente # 1: el nuevo agarra al desplazado
anterior.siguiente = nuevo # 2: el anterior agarra al nuevo
self.tamano += 1Aquí aparece la letra pequeña que anunciamos en 02-01, y conviene mirarla de frente:
- Llegar al punto de inserción cuesta O(n): hay que caminar
indice - 1saltos. - Recablear cuesta O(1): las dos asignaciones de siempre, en el orden de siempre.
¿Entonces dónde está la ventaja sobre el array, si ambos son O(n) al insertar en medio? En dos sitios. Primero, en los extremos: al principio la lista enlazada es O(1) donde el array es O(n) — esa diferencia la mediremos enseguida. Segundo, en el matiz "si ya estamos situados": cuando un algoritmo recorre la lista y decide insertar o borrar donde ya está (como harás al fusionar listas ordenadas en 02-05), el recableado O(1) se aprovecha de verdad, mientras que el array pagaría el desplazamiento igualmente. El array, en cambio, paga los desplazamientos de memoria siempre, esté donde esté.
Borrar: el patrón del nodo anterior
Para borrar un nodo hay que recablear al que está antes que él... y en una lista enlazada simple los nodos no saben quién les precede (esa carencia la resolverá la lista doble en 02-03). La técnica estándar es avanzar con dos referencias en paralelo: anterior y actual.
def borrar(self, condicion):
"""Borra el primer nodo cuyo dato cumple la condición.
Devuelve el dato borrado, o None si nada la cumple. Coste: O(n)."""
anterior = None
actual = self.cabeza
while actual is not None:
if condicion(actual.dato):
if anterior is None: # era el primero
self.cabeza = actual.siguiente
else:
anterior.siguiente = actual.siguiente # puentear el nodo
if actual is self.cola: # era el último
self.cola = anterior
self.tamano -= 1
return actual.dato
anterior = actual
actual = actual.siguiente
return Nonegraph LR
A["anterior"] -- "puente nuevo" --> C["actual.siguiente"]
A -. "flecha antigua" .-> B["actual (se borra)"]
B -.-> C
Puntos delicados, uno a uno:
- Puentear:
anterior.siguiente = actual.siguientehace que la cadena "salte por encima" del nodo condenado. Nadie apunta ya a él, así que el recolector de basura de Python lo libera. No haydelni liberación manual: soltar todas las referencias basta. - Borrar el primero: si
anteriorsigue siendoNone, el nodo a borrar es la cabeza; el puente consiste en moverself.cabeza. - Borrar el último: si el condenado era la cola, hay que retrasar
self.colaal anterior. Olvidar este caso deja una cola fantasma apuntando a un nodo fuera de la lista — un clásico. - Usamos
is(identidad) y no==para comparar conself.cola: queremos saber si es el mismo nodo, no uno parecido.
Análisis Big O de todas las operaciones
La tabla prometida en 02-01, ya demostrada método a método:
| Operación | ListaEnlazada |
¿Por qué? | list de Python |
|---|---|---|---|
insertar_al_inicio |
O(1) | Recablear la cabeza: 2 asignaciones | O(n) — desplaza todo |
insertar_al_final |
O(1) | Gracias a la referencia cola |
O(1) amortizado |
insertar_en(i, x) |
O(n) | Caminar hasta i (recableado O(1)) |
O(n) — desplaza el resto |
borrar (por delante) |
O(1) | Mover la cabeza | O(n) — la trampa de pop(0) |
borrar (en general) |
O(n) | Caminar hasta el nodo | O(n) |
buscar |
O(n) | Mirar nodo a nodo | O(n) |
Recorrer (__iter__) |
O(n) | Visita cada nodo una vez | O(n) |
len() |
O(1) | Contador mantenido | O(1) |
| Acceso por índice | O(n) | Sin fórmula: caminar i saltos |
O(1) |
La última fila es el precio de la dispersión y no hay que esconderlo: si tu código vive de hacer tablero[i], la lista enlazada es una mala elección. Por eso no hemos implementado __getitem__: dar sintaxis de corchetes a una operación O(n) invita a usarla en bucles y fabricar O(n²) sin darse cuenta.
La revancha: timeit contra list insertando por delante
En 01-05 vimos que llenar una list con insert(0, x) era cuadrático. Repitamos aquel experimento con nuestra estructura en la contienda:
import timeit
def llenar_list_por_delante(n):
lista = []
for i in range(n):
lista.insert(0, i) # desplaza i elementos cada vez
return lista
def llenar_enlazada_por_delante(n):
lista = ListaEnlazada()
for i in range(n):
lista.insertar_al_inicio(i) # recablea 2 referencias cada vez
return lista
for n in (10_000, 50_000, 100_000):
t_list = timeit.timeit(lambda: llenar_list_por_delante(n), number=3)
t_enla = timeit.timeit(lambda: llenar_enlazada_por_delante(n), number=3)
print(f"n={n:>7}: list.insert(0) {t_list:8.3f} s | enlazada {t_enla:8.3f} s")Resultado típico (los números exactos varían según la máquina; la forma no):
n= 10000: list.insert(0) 0.050 s | enlazada 0.012 s n= 50000: list.insert(0) 1.3 s | enlazada 0.06 s n= 100000: list.insert(0) 5.2 s | enlazada 0.12 s
La lectura importa más que las cifras:
- Al duplicar n, la
listse cuadruplica (cadainsert(0)es O(n), el total O(n²)); la enlazada se duplica (cada inserción O(1), el total O(n)). Son las curvas de 01-04 en carne viva. - Con n pequeño la diferencia es anecdótica; con n grande es abismal. La escalabilidad, como en el experimento de 01-02, es lo que separa una elección correcta de una que "funcionaba en mi portátil".
- Honestidad experimental: en operaciones donde la
listes fuerte (recorrer,append, acceso por índice), ganaría ella, y a menudo con ventaja, porque el array contiguo aprovecha la caché y su código interno está escrito en C. Elegir estructura es elegir según la operación dominante, no según la ganadora del último benchmark.
TaskFlow v0.2: el tablero como lista enlazada
Cerramos poniendo la pieza en su sitio. El tablero de TaskFlow, donde las urgencias entran por delante y las tareas se despachan por delante, encuentra por fin su estructura:
tablero = ListaEnlazada()
# Las tareas normales entran por el final (orden de llegada)
tablero.insertar_al_final({"id": 1, "titulo": "Diseñar logo",
"prioridad": 2, "estado": "pendiente"})
tablero.insertar_al_final({"id": 2, "titulo": "Configurar servidor",
"prioridad": 2, "estado": "pendiente"})
# ¡Urgencia! Entra por delante, en O(1)
tablero.insertar_al_inicio({"id": 3, "titulo": "Hotfix producción",
"prioridad": 1, "estado": "pendiente"})
print(tablero)
# [3:Hotfix producción] -> [1:Diseñar logo] -> [2:Configurar servidor] -> None
# Se completa una tarea: la localizamos y la retiramos
hecha = tablero.borrar(lambda t: t["id"] == 3)
print("Completada:", hecha["titulo"]) # Completada: Hotfix producción
print(len(tablero)) # 2
# Informe del tablero: recorrido natural con for
for t in tablero:
print(f'- ({t["prioridad"]}) {t["titulo"]} [{t["estado"]}]')Fíjate en que el código cliente no menciona nodos ni referencias: habla de tareas, tal como manda la separación TDA-implementación de 01-01. Y un anticipo: este patrón de "entrar por un extremo y salir por un extremo" tiene nombres propios —pila y cola— y estructuras dedicadas que construiremos en los módulos 3 y 4, muchas veces montadas exactamente sobre lo que acabas de escribir.
Errores Comunes y Consejos
- Recablear en el orden equivocado. Asignar
self.cabeza = nuevoantes denuevo.siguiente = self.cabezadeja al nuevo nodo apuntándose a sí mismo y pierde el resto de la lista. Regla mnemotécnica: primero el nuevo agarra, después lo agarran a él. - Olvidar actualizar
cola(otamano). Cada método que toca la estructura debe dejar las tres referencias coherentes. Unborrarque no retrasa la cola cuando elimina el último nodo hará que el siguienteinsertar_al_finalenganche el nodo a un fantasma. Escribe un método_comprobar_invariantes()en depuración si te pasa a menudo. - Los casos frontera: lista vacía y un solo elemento. Son la fuente del 80% de los fallos: borrar el único nodo debe dejar
cabezaycolaaNone; insertar en vacía debe fijar ambas. Prueba siempre tus métodos con listas de 0, 1 y 2 elementos antes que con 100. - Iterar con índices por costumbre.
for i in range(len(lista)): hacer_algo(lista_en(i))sería O(n²) en una lista enlazada. Recorre siempre confor dato in lista(nuestro__iter__), que visita cada nodo una sola vez. - Consejo: ante cualquier duda con un recableado, dibuja el antes y el después con cajas y flechas, numera las asignaciones y solo entonces escribe el código. Cinco minutos de papel ahorran una hora de depurador.
Ejercicios
Ejercicio 1 — contar_si. Añade a ListaEnlazada un método contar_si(condicion) que devuelva cuántos datos cumplen la condición, y úsalo para contar cuántas tareas del tablero tienen prioridad == 1. ¿Cuál es su coste Big O y por qué no puede ser mejor?
Ejercicio 2 — insertar_ordenado por prioridad. Añade un método insertar_ordenado(tarea) que inserte la tarea manteniendo el tablero ordenado por prioridad ascendente (prioridad 1 delante). Pista: es una variante de insertar_en, pero en lugar de contar saltos, caminas hasta encontrar el primer nodo con prioridad mayor. Cuida los tres casos: insertar delante, en medio y al final. Indica el Big O.
Ejercicio 3 — El precio del índice. Implementa una función externa elemento_en(lista, i) que devuelva el dato en la posición i de una ListaEnlazada (o None si no existe). Después escribe imprimir_mal(lista) que imprima todos los elementos usando elemento_en en un bucle sobre range(len(lista)), e imprimir_bien(lista) usando for dato in lista. Mide ambas con timeit para n = 2.000 y 4.000 elementos y explica el resultado con Big O.
Soluciones
Solución 1:
def contar_si(self, condicion):
"""Cuenta los datos que cumplen la condición. Coste: O(n)."""
contador = 0
actual = self.cabeza
while actual is not None:
if condicion(actual.dato):
contador += 1
actual = actual.siguiente
return contador
# Uso:
urgentes = tablero.contar_si(lambda t: t["prioridad"] == 1)Es O(n) y no puede ser mejor: para contar cuántos cumplen algo hay que examinarlos todos; ningún recableado ni referencia extra evita mirar cada dato al menos una vez.
Solución 2:
def insertar_ordenado(self, tarea):
"""Inserta manteniendo orden ascendente de prioridad. Coste: O(n)."""
# Caso 1: vacía o la nueva va delante (prioridad menor o igual que la primera)
if self.cabeza is None or tarea["prioridad"] <= self.cabeza.dato["prioridad"]:
self.insertar_al_inicio(tarea)
return
# Casos 2 y 3: caminar hasta el último nodo con prioridad <= a la nueva
nuevo = Nodo(tarea)
anterior = self.cabeza
while (anterior.siguiente is not None
and anterior.siguiente.dato["prioridad"] <= tarea["prioridad"]):
anterior = anterior.siguiente
nuevo.siguiente = anterior.siguiente # recableado habitual
anterior.siguiente = nuevo
if nuevo.siguiente is None: # quedó el último: actualizar cola
self.cola = nuevo
self.tamano += 1Comentarios: la condición del while mira anterior.siguiente (no anterior) porque necesitamos detenernos en el nodo previo al punto de inserción — el patrón del nodo anterior otra vez. El caso "va al final" se resuelve solo: el while termina con anterior.siguiente a None y el recableado engancha al final (sin olvidar la cola). Coste O(n): en el peor caso se camina toda la lista. Este método reaparecerá en el módulo 4 cuando hablemos de colas de prioridad.
Solución 3:
import timeit
def elemento_en(lista, i):
"""Devuelve el dato en la posición i, o None. Coste: O(n)."""
if i < 0:
return None
actual = lista.cabeza
for _ in range(i):
if actual is None:
return None
actual = actual.siguiente
return actual.dato if actual is not None else None
def imprimir_mal(lista):
for i in range(len(lista)):
_ = elemento_en(lista, i) # cada llamada camina desde la cabeza
def imprimir_bien(lista):
for dato in lista: # un único recorrido
_ = dato
for n in (2_000, 4_000):
lista = ListaEnlazada()
for i in range(n):
lista.insertar_al_final(i)
t_mal = timeit.timeit(lambda: imprimir_mal(lista), number=5)
t_bien = timeit.timeit(lambda: imprimir_bien(lista), number=5)
print(f"n={n}: con índices {t_mal:.3f} s | con iterador {t_bien:.4f} s")Resultado típico: al duplicar n, imprimir_bien se duplica (O(n): un recorrido) pero imprimir_mal se cuadruplica (O(n²): la llamada i-ésima camina i saltos, total 0+1+...+(n−1) ≈ n²/2). Es exactamente la misma suma que condenaba a insert(0) en el array, ahora del lado de la lista enlazada: cada estructura tiene su propia forma de fabricar O(n²) si se usa contra su naturaleza.
Conclusión
Ya tienes tu primera estructura construida desde cero y completa: ListaEnlazada, con inserciones O(1) en ambos extremos (cabeza recableada, cola mantenida), borrado con el patrón del nodo anterior, búsqueda por predicado, recorrido como generador y len/print de ciudadano de primera. El análisis Big O quedó demostrado y timeit confirmó la revancha: donde insert(0) condenaba a la list a lo cuadrático, nuestra estructura escala linealmente — a cambio de haber renunciado al acceso por índice O(1), como delató el último ejercicio. El tablero de TaskFlow ya vive sobre ella. Pero le queda una carencia estructural: cada nodo conoce al siguiente e ignora por completo al anterior, lo que nos obligó al baile de dos referencias para borrar y nos impide recorrer hacia atrás. En la próxima lección añadiremos la flecha que falta —anterior— y obtendremos la lista doblemente enlazada: borrados O(1) teniendo el nodo, recorridos en ambos sentidos, y para TaskFlow un historial de tareas navegable hacia delante y hacia atrás.
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
