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

  1. Las piezas: Nodo y el esqueleto de ListaEnlazada
  2. Insertar al inicio y al final (y el truco de la cola)
  3. Recorrer, __len__ y __str__
  4. Buscar y insertar en una posición
  5. Borrar: el patrón del nodo anterior
  6. Análisis Big O de todas las operaciones
  7. La revancha: timeit contra list insertando por delante
  8. 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ía

Tres decisiones de diseño que conviene entender antes de seguir:

  • cabeza: el único punto de entrada imprescindible. Si es None, 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 cambio len(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 += 1

Paso 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.cabeza es None y el nuevo queda correctamente como último (su siguiente es None).
  • 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 += 1

Sin 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)"
  • yield convierte __iter__ en un generador: entrega un dato, se queda "congelado" y continúa cuando el for pide el siguiente. Para el usuario de la clase, la lista enlazada se recorre igual que una list.
  • __str__ reutiliza el propio __iter__ (nada de duplicar el bucle) y tiene un detalle amable con TaskFlow: si el dato es una tarea-dict, muestra id:titulo en lugar del diccionario completo.
  • La flecha -> None final no es decorativa: recuerda visualmente que el último nodo apunta a None.

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 None

Uso: 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 += 1

Aquí 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 - 1 saltos.
  • 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 None
graph 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.siguiente hace 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 hay del ni liberación manual: soltar todas las referencias basta.
  • Borrar el primero: si anterior sigue siendo None, el nodo a borrar es la cabeza; el puente consiste en mover self.cabeza.
  • Borrar el último: si el condenado era la cola, hay que retrasar self.cola al 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 con self.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 list se cuadruplica (cada insert(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 list es 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 = nuevo antes de nuevo.siguiente = self.cabeza deja 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 (o tamano). Cada método que toca la estructura debe dejar las tres referencias coherentes. Un borrar que no retrasa la cola cuando elimina el último nodo hará que el siguiente insertar_al_final enganche 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 cabeza y cola a None; 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 con for 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 += 1

Comentarios: 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.

© Copyright 2026. Todos los derechos reservados