Al cerrar el módulo anterior dejamos una promesa en el aire: existe "otra apuesta" distinta a la del array, una estructura que renuncia a la contigüidad en memoria precisamente para hacer baratas las inserciones y borrados donde el array flaquea. Esa apuesta es la lista enlazada, y este módulo entero está dedicado a ella y a sus variantes. Pero antes de escribir una sola clase necesitamos dar un paso atrás y hacer lo mismo que hicimos con la pila en la lección 01-01: separar el qué (el TDA lista, un contrato de operaciones) del cómo (array contiguo o nodos dispersos). En esta lección definimos ese contrato, entendemos la pieza fundamental de la nueva apuesta —el nodo con su referencia siguiente— y ponemos frente a frente los costes de ambas implementaciones, para decidir con criterio cuál conviene en cada rincón de TaskFlow.
Contenido
- El TDA lista: un contrato, dos implementaciones
- La apuesta del array: contigüidad (repaso estratégico)
- La otra apuesta: nodos dispersos y referencias
- Anatomía de un nodo en Python
- Tabla comparativa de costes: array dinámico vs lista enlazada
- ¿Cuál conviene en TaskFlow?
El TDA lista: un contrato, dos implementaciones
En la lección 01-01 vimos que un Tipo de Dato Abstracto (TDA) define qué operaciones ofrece una estructura sin comprometerse con cómo se implementan por dentro: nuestras clases PilaConLista y PilaConDiccionario cumplían el mismo contrato con interiores completamente distintos. Con las listas ocurre exactamente lo mismo.
El TDA lista es una secuencia ordenada de elementos donde cada elemento ocupa una posición, y que ofrece como mínimo estas operaciones:
- Insertar un elemento (al principio, al final o en una posición intermedia).
- Borrar un elemento (por posición o por valor).
- Buscar un elemento (¿está? ¿en qué posición?).
- Recorrer la secuencia de principio a fin, visitando cada elemento en orden.
- Consultar el elemento de una posición dada y conocer la longitud de la secuencia.
Fíjate en que el contrato no dice ni una palabra sobre memoria, casillas o referencias. Y eso es deliberado, porque hay (al menos) dos formas radicalmente distintas de cumplirlo:
| Implementación A | Implementación B | |
|---|---|---|
| Nombre | Array dinámico (la list de Python) |
Lista enlazada (la construiremos en 02-02) |
| Estrategia | Elementos contiguos en memoria | Nodos dispersos, unidos por referencias |
| Cómo encuentra el elemento i | Fórmula base + i × tamaño |
Saltando de nodo en nodo desde el primero |
| Su punto fuerte | Acceso por índice instantáneo | Insertar/borrar sin desplazar a nadie |
Una advertencia terminológica importante: en Python, la palabra list nombra a la implementación A. Es un nombre algo desafortunado, porque en la literatura de estructuras de datos "lista" suele referirse al TDA, y "lista enlazada" (linked list) a la implementación B. En este curso diremos list de Python o array dinámico para la A, y lista enlazada para la B.
La apuesta del array: contigüidad (repaso estratégico)
No vamos a repetir la lección 01-05, pero sí a condensarla en lo que nos importa para la comparación. El array apuesta todo a la contigüidad:
- Gana: acceso por índice O(1) gracias a la fórmula
base + i × tamaño; recorridos muy rápidos (la memoria contigua es amiga de la caché del procesador);appendO(1) amortizado. - Pierde: insertar o borrar en el principio o en el medio obliga a desplazar todos los elementos posteriores, un coste O(n) que medimos con
timeity que se volvía cuadrático al repetirlo en bucle (la trampa deinsert(0)que vimos al cerrar el módulo 1).
La pregunta que queda flotando es: ¿y si pudiéramos insertar un elemento en medio de la secuencia sin tocar a los demás? Para lograrlo hay que abandonar la idea de que la posición en memoria codifica la posición en la secuencia.
La otra apuesta: nodos dispersos y referencias
La lista enlazada invierte la apuesta. En lugar de exigir que los elementos vivan en casillas consecutivas, permite que cada uno viva donde quiera en la memoria. Pero entonces surge un problema: si los elementos están dispersos, ¿cómo sabemos cuál va después de cuál? La fórmula del array ya no sirve, porque no hay contigüidad que explotar.
La solución es que cada elemento lleve consigo la dirección del siguiente. A ese paquete —el dato más la referencia al siguiente— lo llamamos nodo:
graph LR
subgraph "Lista enlazada: el orden lo dictan las flechas, no la memoria"
A["dato: tarea 1<br>siguiente ─→"] --> B["dato: tarea 2<br>siguiente ─→"]
B --> C["dato: tarea 3<br>siguiente: None"]
end
H[cabeza] --> A
Tres observaciones clave sobre este diagrama:
- La lista solo necesita "agarrar" el primer nodo (lo llamaremos la cabeza, en inglés head). A partir de él, las flechas permiten llegar a todos los demás.
- El último nodo apunta a
None: es la señal de "aquí se acaba la secuencia". - Las flechas son la secuencia. Si en memoria el nodo de la tarea 3 estuviera físicamente antes que el de la tarea 1, daría exactamente igual: el orden lógico lo definen las referencias, no las direcciones.
Y aquí está la jugada maestra. Para insertar una tarea nueva entre la 1 y la 2, no hay que desplazar nada: basta con recablear dos flechas.
graph LR
H[cabeza] --> A["tarea 1"]
A -. "flecha antigua (se elimina)" .-> B["tarea 2"]
A -- "1: ahora apunta a la nueva" --> N["tarea nueva"]
N -- "2: la nueva apunta a la 2" --> B
B --> C["tarea 3<br>siguiente: None"]
Da igual que la lista tenga 3 elementos o 3 millones: el recableado en sí son dos asignaciones, coste O(1). Esa es la esencia de la promesa "inserciones y borrados baratos". (Ojo: llegar hasta el punto de inserción puede tener su propio coste; lo analizaremos con rigor en la próxima lección. No queremos venderte la moto sin la letra pequeña.)
Anatomía de un nodo en Python
En Python no manejamos direcciones de memoria a mano: manejamos referencias, que es exactamente lo que ocurre cada vez que asignas un objeto a una variable. Un nodo es simplemente un objeto con dos atributos:
class Nodo:
"""Una pieza de la lista enlazada: un dato y la referencia al siguiente."""
def __init__(self, dato):
self.dato = dato # el contenido: en TaskFlow, una tarea (dict)
self.siguiente = None # referencia al próximo nodo (None = no hay más)Explicación detallada para quien ve esto por primera vez:
self.datoguarda el elemento en sí. Como Python trabaja con referencias, aquí puede vivir cualquier cosa: un número, una cadena o —en nuestro caso— la tarea-como-dictque fijamos en el módulo 1.self.siguienteguarda otra referencia, esta vez a otro objetoNodo(oNonesi es el último). No hay magia: es la misma mecánica de asignación que usas a diario, puesta al servicio de encadenar objetos.
Podemos encadenar tres tareas de TaskFlow a mano, sin ninguna clase contenedora todavía:
# Tres tareas de TaskFlow (el dict fijado en el módulo 1)
t1 = {"id": 1, "titulo": "Diseñar logo", "prioridad": 2, "estado": "pendiente"}
t2 = {"id": 2, "titulo": "Configurar servidor", "prioridad": 1, "estado": "pendiente"}
t3 = {"id": 3, "titulo": "Escribir documentación", "prioridad": 3, "estado": "pendiente"}
# Creamos los nodos y los enlazamos a mano
cabeza = Nodo(t1)
cabeza.siguiente = Nodo(t2)
cabeza.siguiente.siguiente = Nodo(t3)
# Recorremos siguiendo las flechas hasta encontrar None
actual = cabeza
while actual is not None:
print(actual.dato["titulo"])
actual = actual.siguiente # "avanzar" = seguir la referenciaSalida:
El bucle while de arriba es el gesto más importante de todo el módulo: avanzar por una lista enlazada es reasignar actual = actual.siguiente hasta toparse con None. Todo lo que construyamos en las próximas lecciones —insertar, borrar, buscar— son variaciones de este gesto.
Encadenar nodos a mano es instructivo pero impracticable. En 02-02 encapsularemos toda esta mecánica en una clase ListaEnlazada con las operaciones del TDA; hoy nos basta con entender la pieza.
Tabla comparativa de costes: array dinámico vs lista enlazada
Esta tabla es el mapa del módulo. La columna del array la demostramos en 01-05; la columna de la lista enlazada es, por ahora, un adelanto que la lección 02-02 demostrará operación por operación (y verificará con timeit, como mandan nuestras costumbres):
| Operación | Array dinámico (list) |
Lista enlazada simple | ¿Por qué? |
|---|---|---|---|
Acceso por índice [i] |
O(1) | O(n) | Fórmula directa vs caminar i saltos desde la cabeza |
| Insertar al principio | O(n) | O(1) | Desplazar todo vs recablear la cabeza |
| Insertar al final | O(1) amortizado | O(n) — u O(1) con truco* | append con hueco vs caminar hasta el último nodo |
| Insertar en medio (ya situados) | O(n) | O(1) | Desplazar la mitad vs recablear dos flechas |
| Borrar al principio | O(n) | O(1) | La trampa de pop(0) vs mover la cabeza |
| Buscar por valor | O(n) | O(n) | En ambas hay que mirar elemento a elemento |
| Recorrer entera | O(n) | O(n) | Ambas visitan todo, aunque el array es más amigo de la caché |
| Memoria por elemento | Solo la referencia al dato | Referencia al dato + referencia siguiente |
El nodo paga un extra por cada flecha |
* El "truco" consiste en que la lista guarde también una referencia al último nodo (la cola); lo veremos en 02-02.
Lecturas importantes de la tabla:
- No hay ganador absoluto. Cada estructura gana justo donde la otra pierde: es un intercambio (trade-off), el pan de cada día en estructuras de datos.
- La lista enlazada pierde el superpoder del array: ya no existe fórmula que lleve al elemento
ide un salto. Pedir "el elemento 500.000" significa dar 500.000 saltos. - Buscar por valor es O(n) en ambas: ninguna de las dos resuelve el problema de "encontrar la tarea con id 42" que planteamos en 01-02. Para eso seguirá haciendo falta el índice por id que llegará en el módulo 5.
- La lista enlazada paga un sobrecoste de memoria: cada dato carga con una flecha extra. La dispersión no sale gratis.
¿Cuál conviene en TaskFlow?
Bajemos la tabla a tierra con los escenarios reales de nuestra aplicación:
| Escenario en TaskFlow | Operación dominante | Estructura ganadora |
|---|---|---|
| Mostrar el tablero completo en pantalla | Recorrer | Empate (ligera ventaja del array por caché) |
| "Dame la tarea en la posición 7 de la vista" | Acceso por índice | Array |
| Las tareas urgentes entran siempre por delante | Insertar al principio | Lista enlazada |
| Despachar tareas retirándolas por delante | Borrar al principio | Lista enlazada |
| Reordenar: mover una tarea entre otras dos | Insertar/borrar en medio | Lista enlazada (si ya estamos situados) |
| Añadir tareas siempre por el final | Insertar al final | Array (append amortizado) o enlazada con cola |
La conclusión para TaskFlow es matizada, como casi todo en ingeniería: mientras el tablero solo crecía por el final y se leía en orden, la list de Python era imbatible. Pero el tablero real de un equipo no funciona así: las urgencias entran por delante, las tareas se despachan por delante y se reordenan por el medio constantemente. Ese patrón de uso —muchas inserciones y borrados en posiciones incómodas— es exactamente el terreno donde la lista enlazada brilla, y por eso la elegimos como base del tablero definitivo.
Errores Comunes y Consejos
- Confundir la
listde Python con una lista enlazada. Es el error terminológico número uno. Lalistde Python es un array dinámico (lección 01-05); si en una entrevista te piden "implementa una lista enlazada", responderlista = []es responder a otra pregunta. - Creer que la lista enlazada es "mejor" que el array. No lo es; es mejor para ciertas operaciones y peor para otras. Quien memoriza "lista enlazada = rápida" sin la tabla de costes acaba eligiendo mal. Guarda la tabla de esta lección junto a la de costes de
list/dict/setde 01-04. - Olvidar que
siguientepuede serNone. Escribiractual.siguiente.datosin comprobar antes queactual.siguienteno esNoneprovoca el clásicoAttributeError: 'NoneType' object has no attribute 'dato'. Acostúmbrate desde ya a preguntarte en cada línea: "¿y si aquí ya no hay nodo?". - Perder la cabeza de la lista. Si reasignas la variable que apunta al primer nodo (
cabeza = cabeza.siguientesin querer), el nodo anterior se vuelve inalcanzable y el recolector de basura de Python lo elimina. La cabeza es el único hilo del que pende toda la estructura: trátala con respeto. - Consejo: cuando dudes de un recableado de referencias, dibuja los nodos y las flechas en papel antes de escribir código. Es la técnica que usan hasta los ingenieros veteranos, y en las lecciones siguientes la practicaremos con diagramas mermaid a cada paso.
Ejercicios
Ejercicio 1 — El contrato y sus implementaciones. Sin mirar la tabla, clasifica estas cuatro frases como propias del TDA lista, del array dinámico o de la lista enlazada: (a) "insertar un elemento en la posición i"; (b) "los elementos ocupan casillas contiguas de memoria"; (c) "cada elemento guarda una referencia al siguiente"; (d) "recorrer los elementos en orden". Justifica cada respuesta en una línea.
Ejercicio 2 — Encadenar y recorrer a mano. Usando solo la clase Nodo de esta lección (sin ninguna clase contenedora), construye una cadena con estas cuatro tareas de TaskFlow: "Revisar diseño" (id 10), "Corregir bug login" (id 11), "Desplegar versión" (id 12) y "Cerrar sprint" (id 13), todas con prioridad 2 y estado "pendiente". Después escribe: (a) un bucle que imprima id - titulo de cada tarea; (b) un bucle que cuente cuántos nodos hay, sin usar len de ninguna estructura auxiliar.
Ejercicio 3 — Recablear sin desplazar. Partiendo de la cadena del ejercicio 2, inserta la tarea "Hotfix producción" (id 14, prioridad 1) entre "Corregir bug login" y "Desplegar versión", sin crear ninguna cadena nueva: solo creando un nodo y reasignando las referencias necesarias. ¿Cuántas asignaciones de siguiente has necesitado? ¿Dependería ese número de que la cadena tuviera un millón de nodos?
Soluciones
Solución 1:
- (a) TDA lista: describe una operación del contrato, sin decir cómo se logra por dentro.
- (b) Array dinámico: la contigüidad es la decisión de implementación que da el acceso O(1) por fórmula.
- (c) Lista enlazada: la referencia al siguiente es la decisión de implementación que permite la dispersión.
- (d) TDA lista: recorrer es parte del contrato; ambas implementaciones lo ofrecen (con interiores distintos).
Solución 2:
class Nodo:
def __init__(self, dato):
self.dato = dato
self.siguiente = None
def tarea(id_, titulo):
return {"id": id_, "titulo": titulo, "prioridad": 2, "estado": "pendiente"}
# (Construcción) Creamos los nodos y los enlazamos uno a uno
cabeza = Nodo(tarea(10, "Revisar diseño"))
cabeza.siguiente = Nodo(tarea(11, "Corregir bug login"))
cabeza.siguiente.siguiente = Nodo(tarea(12, "Desplegar versión"))
cabeza.siguiente.siguiente.siguiente = Nodo(tarea(13, "Cerrar sprint"))
# (a) Imprimir id - titulo siguiendo las flechas
actual = cabeza
while actual is not None:
print(f'{actual.dato["id"]} - {actual.dato["titulo"]}')
actual = actual.siguiente
# (b) Contar nodos: mismo recorrido, con un contador
contador = 0
actual = cabeza
while actual is not None:
contador += 1
actual = actual.siguiente
print("Nodos:", contador) # Nodos: 4Observa que ambos apartados usan el mismo patrón de recorrido; solo cambia lo que hacemos al visitar cada nodo. Ese patrón es el corazón de la clase que construiremos en 02-02. Y sí: las cadenas cabeza.siguiente.siguiente.siguiente = ... son horribles — exactamente por eso necesitamos encapsular esto en una clase.
Solución 3:
# 1. Localizamos el nodo tras el que insertar (el de id 11)
nodo_bug = cabeza.siguiente # "Corregir bug login"
# 2. Creamos el nodo nuevo
nuevo = Nodo({"id": 14, "titulo": "Hotfix producción",
"prioridad": 1, "estado": "pendiente"})
# 3. Recableado: primero el nuevo apunta al que venía después...
nuevo.siguiente = nodo_bug.siguiente # asignación 1
# ...y después el anterior apunta al nuevo. ¡El orden importa!
nodo_bug.siguiente = nuevo # asignación 2Han bastado 2 asignaciones de siguiente, y ese número sería idéntico con un millón de nodos: el recableado en sí es O(1). Fíjate en el orden de las dos líneas: si hiciéramos primero nodo_bug.siguiente = nuevo, perderíamos la referencia a "Desplegar versión" y el resto de la cadena quedaría descolgado. Este detalle —el orden del recableado— será protagonista en la próxima lección.
Conclusión
Hemos separado el contrato de sus cumplidores: el TDA lista es una secuencia ordenada con operaciones de insertar, borrar, buscar y recorrer, y tanto el array dinámico como la lista enlazada lo implementan con apuestas opuestas — contigüidad con acceso O(1) por fórmula frente a nodos dispersos unidos por referencias siguiente, donde insertar es recablear dos flechas en vez de desplazar media memoria. La tabla de costes nos dejó claro que no hay ganador absoluto, y el patrón de uso del tablero de TaskFlow (urgencias por delante, reordenaciones por el medio) inclinó la balanza hacia la lista enlazada. Pero hasta ahora solo hemos encadenado nodos a mano, con ese incómodo cabeza.siguiente.siguiente. En la próxima lección haremos las cosas bien: construiremos la clase ListaEnlazada completa —insertar, borrar, buscar, recorrer, __len__, __str__—, analizaremos el Big O de cada operación, y pondremos a la criatura frente a timeit para comprobar que la promesa de las inserciones baratas se cumple donde el array caía: insertando por delante.
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
