TAD Lista: Estáticas, Dinámicas y Enlazadas

Desde acá el curso cambia de tema. Ya sabés modelar objetos y, en la lección Arrays de Objetos: Guardar y Recorrer Muchas Instancias, ya guardaste muchos en un array con su contador de cantidad. Ahora vas a formalizar esa idea y —esto es lo importante— a elegir la organización correcta según lo que vayas a hacer con los datos.

Empecemos con una pregunta que parece tonta: si ya existe ArrayList, ¿para qué implementar una lista a mano?

Porque ArrayList es rapidísimo para algunas cosas y desastroso para otras, y si no sabés por qué, vas a elegir mal. Implementarla una vez es lo que te enseña esa diferencia para siempre.


1. Qué es un TAD

Un Tipo Abstracto de Dato es la separación entre dos cosas que solemos mezclar:

  • La especificación: qué operaciones ofrece y qué garantiza cada una. El qué.
  • La implementación: cómo se guardan los datos en memoria y cómo se ejecuta cada operación. El cómo.
Un mismo TAD Lista resuelto por tres implementaciones distintas TAD Lista — la especificación (el QUÉ) agregar(dato) · eliminar(dato) · obtener(indice) tamaño() · estaVacia() · contiene(dato) No dice absolutamente nada sobre cómo se guardan los datos. Arreglo fijo obtener(i) es instantáneo pero no puede crecer nunca ArrayList arreglo que se recrea más grande al llenarse el de uso general Lista enlazada nodos sueltos unidos por referencias; insertar al inicio es instantáneo El código que usa la lista habla con la especificación. Cambiar de implementación no lo obliga a cambiar ni una línea.
El TAD es el contrato; las tres cajas de abajo son formas distintas de cumplirlo, con costos completamente diferentes.

En Java el TAD se escribe como una interfaz —justo lo que viste en la lección Clases Abstractas, Interfaces y Organización del Código—:

public interface Lista<T> {
    void agregar(T dato);
    boolean eliminar(T dato);
    T obtener(int indice);
    int tamanio();
    boolean estaVacia();
}

Quien programa contra Lista<T> no sabe ni le importa si adentro hay un arreglo o una cadena de nodos. Esa ignorancia es exactamente el objetivo.


2. Dos formas de guardar lo mismo, con costos opuestos

Memoria contigua de un arreglo frente a los nodos dispersos de una lista enlazada Lista estática — un arreglo: memoria contigua, tamaño fijo 10 20 30 40 libre libre [0] [1] [2] [3] [4] [5] Como las direcciones son consecutivas, la JVM calcula dónde está el elemento 3 con una cuenta: obtener(i) es instantáneo. Pero insertar al principio obliga a correr todo lo demás una posición a la derecha. Lista enlazada — nodos sueltos por el Heap, unidos por referencias cabeza 10 20 30 null Los nodos pueden estar en cualquier parte del Heap. Para llegar al tercero hay que pasar por los dos anteriores: obtener(i) es lento. Pero insertar al principio es solo cambiar una referencia.
Ninguna de las dos es mejor. Son inversas: lo que una hace instantáneo, la otra lo hace caro.

Quedate con esta idea, porque es la que ordena todo lo que viene:

El arreglo paga la inserción para que la lectura sea gratis. La lista enlazada paga la lectura para que la inserción sea gratis.


3. El nodo: la clase más importante de la lección

Un nodo es un objeto minúsculo con dos cosas: un dato y una referencia al nodo siguiente.

public class Nodo<T> {
    T dato;
    Nodo<T> siguiente;   // ← una referencia a otro Nodo, del mismo tipo

    public Nodo(T dato) {
        this.dato = dato;
        this.siguiente = null;   // por defecto no apunta a nadie
    }
}

Esa línea Nodo<T> siguiente; es la que suele trabar a todo el mundo: una clase que se referencia a sí misma. No hay ninguna recursión infinita ahí. Acordate de la lección Fundamentos de POO: Clases, Objetos y Atributos: un atributo de tipo objeto no guarda el objeto, guarda una referencia (o null). Un nodo no contiene a otro nodo: sabe dónde encontrarlo.


4. Lista enlazada simple, paso a paso

La lista completa se reduce a una sola referencia: la que apunta al primer nodo.

public class ListaEnlazada<T> {
    private Nodo<T> cabeza;   // si es null, la lista está vacía
    private int tamanio;

    public boolean estaVacia() { return cabeza == null; }
    public int tamanio() { return tamanio; }
}

agregarAlInicio: el baile de referencias

Estas tres líneas son el corazón de toda la estructura, y el orden entre ellas no es negociable:

Los tres estados de la lista al insertar un nodo al inicio 1. Nodo<T> nuevo = new Nodo<>(5); → el nodo nace aislado, sin tocar la lista cabeza 10 20 null 5 nuevo 2. nuevo.siguiente = cabeza; → el nodo nuevo engancha la cadena vieja cabeza (sigue acá) 5 10 20 null 3. cabeza = nuevo; → recién ahora la lista reconoce al nodo como su primero cabeza 5 10 20 null Ningún elemento se movió.
Los pasos 2 y 3 no se pueden invertir: si primero hacés cabeza = nuevo, perdés la única referencia al resto de la lista y se la lleva el recolector de basura.
public void agregarAlInicio(T dato) {
    Nodo<T> nuevo = new Nodo<>(dato);
    nuevo.siguiente = cabeza;   // 2. el nuevo engancha lo que había
    cabeza = nuevo;             // 3. la lista adopta al nuevo como primero
    tamanio++;
}

Si invertís las dos últimas líneas, cabeza pasa a apuntar al nodo nuevo antes de que nadie guarde dónde estaba el viejo primero. Esa referencia se pierde, y con ella toda la lista. Compila perfecto. Se rompe en silencio.

Recorrer: el patrón que vas a repetir toda tu vida

public void mostrar() {
    Nodo<T> actual = cabeza;          // un puntero temporal, nunca movemos cabeza
    while (actual != null) {
        System.out.print(actual.dato + " → ");
        actual = actual.siguiente;    // el paso que evita el bucle infinito
    }
    System.out.println("null");
}

Nunca uses cabeza como variable de recorrido. Si la movés, perdés el principio de la lista y no hay vuelta atrás. Siempre una variable auxiliar.

Agregar al final

public void agregarAlFinal(T dato) {
    Nodo<T> nuevo = new Nodo<>(dato);
    if (cabeza == null) {             // caso especial: lista vacía
        cabeza = nuevo;
        tamanio++;
        return;
    }
    Nodo<T> actual = cabeza;
    while (actual.siguiente != null) {   // ojo: siguiente != null, no actual != null
        actual = actual.siguiente;       // hay que caminar TODA la lista
    }
    actual.siguiente = nuevo;
    tamanio++;
}

Fijate la diferencia con el recorrido anterior: acá la condición es actual.siguiente != null, porque queremos quedarnos parados en el último nodo, no pasarnos a null. Es un error clásico.

Y notá el costo: agregar al final recorre la lista entera. Es la debilidad de la enlazada simple, y se resuelve guardando también una referencia cola.


5. Variantes: doble y circular

Lista simplemente enlazada, doblemente enlazada y circular Simple — cada nodo conoce solo al siguiente. Se recorre en un único sentido. A B C null Doble — cada nodo conoce al siguiente y al anterior. Se recorre en los dos sentidos. A B C Circular — el último apunta al primero. No hay null, así que el recorrido no termina solo. A B C vuelve al primero En la circular, la condición de corte no puede ser != null: hay que parar al volver al nodo de partida.
Cada variante paga memoria extra por un recorrido más flexible. La doble gasta una referencia más por nodo; la circular no gasta nada, pero cambia cómo se recorre.

En la doblemente enlazada, el nodo suma una referencia hacia atrás:

public class NodoDoble<T> {
    T dato;
    NodoDoble<T> siguiente;
    NodoDoble<T> anterior;   // el que la vuelve reversible
}

Con eso podés recorrer para atrás y, sobre todo, eliminar un nodo teniendo solo ese nodo, sin recorrer nada para encontrar al anterior. Es lo que usa LinkedList de Java internamente.

En la circular, el último apunta al primero. Sirve para turnos rotativos, buffers y reproductores en modo repetición. El recorrido cambia de forma:

// En una lista circular, esto sería un bucle infinito:
// while (actual != null) { ... }

Nodo<T> actual = cabeza;
do {
    System.out.print(actual.dato + " → ");
    actual = actual.siguiente;
} while (actual != cabeza);   // el corte es volver al punto de partida

6. La tabla que decide

OperaciónArreglo / ArrayListLista enlazada
obtener(i) por índiceO(1) — una cuentaO(n) — hay que caminar
Insertar al inicioO(n) — corre todo a la derechaO(1) — dos asignaciones
Insertar al finalO(1) amortizadoO(n), u O(1) si guardás cola
Insertar en el medioO(n) por el corrimientoO(n) por la búsqueda
Eliminar el primeroO(n)O(1)
Buscar un valorO(n)O(n)
Memoria por elementosolo el datodato + una referencia por nodo

Y la conclusión práctica, que es la que importa:

En el 95 % de los casos usá ArrayList. Los recorridos secuenciales sobre memoria contigua son muchísimo más rápidos de lo que la tabla sugiere, porque el procesador precarga bloques enteros de memoria contigua en su caché. Una LinkedList con nodos dispersos pierde esa ventaja por completo.

LinkedList gana cuando insertás y eliminás constantemente en los extremos y casi nunca accedés por índice. Ese caso existe —lo vas a ver en la próxima lección, con pilas y colas— pero es minoritario.


7. Errores frecuentes

ErrorQué pasaCómo se arregla
cabeza = nuevo; antes de nuevo.siguiente = cabeza;Se pierde la referencia al resto de la lista y el recolector se lleva todo. Compila sin quejarse.Primero enganchar, después mover cabeza.
Recorrer moviendo cabeza en vez de una variable auxiliarLa lista queda truncada o vacía después de un simple recorrido.Nodo<T> actual = cabeza; y mover actual.
Olvidar actual = actual.siguiente; dentro del whileBucle infinito: el programa se cuelga sin ningún error.El avance es parte del bucle, no un detalle opcional.
Usar while (actual != null) cuando querés el último nodoTerminás en null y el NullPointerException llega en la línea siguiente.while (actual.siguiente != null).
No contemplar la lista vacíaNullPointerException al tocar cabeza.siguiente sobre cabeza == null.Chequear cabeza == null al principio de cada operación.
Olvidar actualizar tamaniotamanio() miente y todo lo que dependa de él falla.Modificar el contador en el mismo método que modifica la estructura.
Usar while (actual != null) en una lista circularBucle infinito garantizado: nunca hay null.do { ... } while (actual != cabeza);.

8. Ejercicio práctico guiado

Desafío: eliminar(T dato) en la lista enlazada simple

Implementá eliminar de forma que:

  1. Devuelva true si eliminó algo y false si el dato no estaba.
  2. Funcione cuando la lista está vacía.
  3. Funcione cuando el elemento a borrar es la cabeza.
  4. Funcione cuando está en el medio o al final.
  5. Actualice tamanio correctamente.

Pensá los cuatro casos antes de escribir. Ahí está toda la dificultad del ejercicio.

Ver solución sugerida
public class ListaEnlazada<T> {
    private Nodo<T> cabeza;
    private int tamanio;

    public void agregarAlInicio(T dato) {
        Nodo<T> nuevo = new Nodo<>(dato);
        nuevo.siguiente = cabeza;
        cabeza = nuevo;
        tamanio++;
    }

    public boolean eliminar(T dato) {
        // CASO 1: lista vacía. Sin esto, la línea siguiente explota.
        if (cabeza == null) {
            return false;
        }

        // CASO 2: el elemento buscado es la cabeza.
        // Es distinto porque no hay un nodo "anterior" al que reengancharle.
        if (java.util.Objects.equals(cabeza.dato, dato)) {
            cabeza = cabeza.siguiente;   // la lista arranca en el segundo
            tamanio--;
            return true;
        }

        // CASO 3 y 4: medio o final.
        // Nos paramos SIEMPRE un nodo antes del candidato, porque para
        // desenganchar un nodo hay que modificar el 'siguiente' del anterior.
        Nodo<T> anterior = cabeza;
        while (anterior.siguiente != null) {
            if (java.util.Objects.equals(anterior.siguiente.dato, dato)) {
                anterior.siguiente = anterior.siguiente.siguiente;   // el salto
                tamanio--;
                return true;
            }
            anterior = anterior.siguiente;
        }

        // Recorrimos todo y no estaba.
        return false;
    }

    public void mostrar() {
        Nodo<T> actual = cabeza;
        StringBuilder sb = new StringBuilder();
        while (actual != null) {
            sb.append(actual.dato).append(" → ");
            actual = actual.siguiente;
        }
        System.out.println(sb.append("null").append("  (tamaño ").append(tamanio).append(")"));
    }

    public static void main(String[] args) {
        ListaEnlazada<Integer> lista = new ListaEnlazada<>();
        lista.agregarAlInicio(30);
        lista.agregarAlInicio(20);
        lista.agregarAlInicio(10);
        lista.mostrar();                                    // 10 → 20 → 30 → null  (tamaño 3)

        System.out.println(lista.eliminar(20));  // true  — caso medio
        lista.mostrar();                                    // 10 → 30 → null  (tamaño 2)

        System.out.println(lista.eliminar(10));  // true  — caso cabeza
        lista.mostrar();                                    // 30 → null  (tamaño 1)

        System.out.println(lista.eliminar(99));  // false — no estaba
        System.out.println(lista.eliminar(30));  // true  — último elemento
        lista.mostrar();                                    // null  (tamaño 0)

        System.out.println(lista.eliminar(1));   // false — lista vacía
    }
}

Las dos claves de esta solución.

La primera: nos paramos en el nodo anterior al que queremos borrar, nunca en el nodo mismo. En una lista simple, desde un nodo no hay forma de llegar al que lo precede, y sin el anterior no podés reenganchar la cadena. Por eso la condición es anterior.siguiente.dato y no actual.dato.

La segunda: Objects.equals(a, b) en lugar de a.equals(b), porque tolera que el dato guardado sea null sin lanzar NullPointerException.

Y fijate que “eliminar” nunca borra nada: solo deja de apuntarlo. Sin ninguna referencia que lo alcance, el recolector de basura se lo lleva. En Java no se libera memoria a mano.


Para llevarte

  • Un TAD separa el qué (la especificación) del cómo (la implementación). En Java el qué se escribe como una interfaz.
  • Arreglo y lista enlazada tienen costos inversos: uno paga la inserción para que la lectura sea gratis, el otro al revés.
  • Un nodo es un objeto con un dato y una referencia a otro nodo. No lo contiene: sabe dónde está.
  • En agregarAlInicio, el orden de las dos asignaciones no es negociable: enganchar primero, mover cabeza después.
  • Recorré siempre con una variable auxiliar; mover cabeza destruye la lista.
  • Los cuatro casos de toda operación son: lista vacía, primer elemento, elemento del medio, elemento inexistente.
  • La doble permite ir para atrás y borrar sin buscar al anterior; la circular no tiene null y cambia la condición de corte.
  • En producción, ArrayList casi siempre. La memoria contigua le gana a la teoría gracias a la caché del procesador.