Listas enlazadas, pilas y colas

"Un array es un libro: andás directo a cualquier página. Una lista enlazada es una caza del tesoro: cada lugar te dice dónde está el siguiente."

Qué vas a aprender en este capítulo

Tres estructuras lineales que están en la base de casi todo lo demás. Vas a aprender la lista enlazada y por qué a veces gana sobre el array común; las pilas (LIFO) que aparecen en parsers, undo, recursión interna; y las colas (FIFO) que aparecen en BFS, schedulers, mensajería. Vas a implementarlas desde cero y vas a entender los trade-offs que cada una hace.

2.1 Array vs lista enlazada

💡 Intuición

Un array guarda los elementos en posiciones consecutivas de memoria. Para acceder al elemento N, hacés dirección_base + N * tamaño. Constante. Como un libro: vas directo a la página.

Una lista enlazada guarda cada elemento en un nodo que tiene un puntero al siguiente. Los nodos pueden estar dispersos por la memoria. Para acceder al N, tenés que recorrer N saltos. Como una caza del tesoro: cada pista te lleva a la siguiente.

Trade-off central:

Array Lista enlazada
Acceso por índice O(1)O(1) O(n)O(n)
Insertar al inicio O(n)O(n) O(1)O(1)
Insertar al final O(1)O(1) amort. O(1)O(1) con tail
Borrar (con nodo) O(n)O(n) O(1)O(1)
Memoria O(n)O(n) O(n)O(n) + overhead por nodo

Ningún ganador absoluto. Cada estructura sirve para distintos patrones de acceso.

Cuándo lista enlazada:

  • Insertar/borrar en cualquier punto (tenés el nodo) sin mover datos.
  • No necesitás acceso aleatorio.
  • Tamaño cambia mucho.

Cuándo array:

  • Acceso aleatorio frecuente.
  • Memoria contigua importa (caches del CPU).
  • Tamaño relativamente estable.

2.2 Lista enlazada simple

📐 Fundamento

class Nodo:
    def __init__(self, dato, siguiente=None):
        self.dato = dato
        self.siguiente = siguiente


class ListaEnlazada:
    def __init__(self):
        self.cabeza = None
        self.tamano = 0

    def insertar_inicio(self, x):
        self.cabeza = Nodo(x, self.cabeza)
        self.tamano += 1

    def insertar_final(self, x):
        nuevo = Nodo(x)
        if self.cabeza is None:
            self.cabeza = nuevo
        else:
            actual = self.cabeza
            while actual.siguiente is not None:
                actual = actual.siguiente
            actual.siguiente = nuevo
        self.tamano += 1

    def buscar(self, x):
        actual = self.cabeza
        while actual is not None:
            if actual.dato == x:
                return actual
            actual = actual.siguiente
        return None

    def eliminar(self, x):
        actual = self.cabeza
        anterior = None
        while actual is not None and actual.dato != x:
            anterior = actual
            actual = actual.siguiente
        if actual is None:
            return False
        if anterior is None:
            self.cabeza = actual.siguiente
        else:
            anterior.siguiente = actual.siguiente
        self.tamano -= 1
        return True

    def __len__(self):
        return self.tamano

    def __iter__(self):
        actual = self.cabeza
        while actual is not None:
            yield actual.dato
            actual = actual.siguiente

Visualización:

cabeza → [A | →] → [B | →] → [C | →] → None

Análisis:

  • insertar_inicio: O(1)O(1).
  • insertar_final: O(n)O(n) — recorre hasta el final. Mejorable guardando cola.
  • buscar, eliminar: O(n)O(n).
  • Acceso por índice: O(n)O(n) (no implementado arriba pero sería igual).

Optimización: guardar puntero al final.

class ListaEnlazada:
    def __init__(self):
        self.cabeza = None
        self.cola = None
        self.tamano = 0

    def insertar_final(self, x):
        nuevo = Nodo(x)
        if self.cola is None:
            self.cabeza = self.cola = nuevo
        else:
            self.cola.siguiente = nuevo
            self.cola = nuevo
        self.tamano += 1

Ahora insertar_final es O(1)O(1).

2.3 Lista doblemente enlazada

📐 Fundamento

Cada nodo tiene puntero al anterior y al siguiente.

class NodoDoble:
    def __init__(self, dato):
        self.dato = dato
        self.anterior = None
        self.siguiente = None


class ListaDoble:
    def __init__(self):
        self.cabeza = None
        self.cola = None
        self.tamano = 0

    def insertar_final(self, x):
        nuevo = NodoDoble(x)
        if self.cola is None:
            self.cabeza = self.cola = nuevo
        else:
            nuevo.anterior = self.cola
            self.cola.siguiente = nuevo
            self.cola = nuevo
        self.tamano += 1

    def eliminar_nodo(self, nodo):
        """Elimina el nodo dado en O(1)."""
        if nodo.anterior:
            nodo.anterior.siguiente = nodo.siguiente
        else:
            self.cabeza = nodo.siguiente
        if nodo.siguiente:
            nodo.siguiente.anterior = nodo.anterior
        else:
            self.cola = nodo.anterior
        self.tamano -= 1

Ventaja clave: eliminar un nodo dado es O(1)O(1) — sin recorrer.

Trade-off: doble memoria por nodo (dos punteros).

Aplicación: caché LRU usa lista doble + dict para insertar/eliminar/mover-a-frente en O(1)O(1). Una de las estructuras más usadas en sistemas reales.

Python's deque (collections.deque) es una lista doble optimizada. La mayoría de las veces, usá deque en vez de implementar tu propia.

2.4 Pila (Stack — LIFO)

📐 Fundamento

LIFO = Last In, First Out. El último que entró es el primero que sale. Como una pila de platos.

Operaciones esenciales:

  • push(x) — agregar arriba.
  • pop() — sacar el de arriba.
  • peek() (o top()) — mirar el de arriba sin sacarlo.

Todas O(1)O(1).

Implementación con array:

class Pila:
    def __init__(self):
        self.items = []

    def push(self, x):
        self.items.append(x)

    def pop(self):
        if not self.items:
            raise IndexError("pila vacía")
        return self.items.pop()

    def peek(self):
        if not self.items:
            raise IndexError("pila vacía")
        return self.items[-1]

    def __len__(self):
        return len(self.items)

En Python, una list ya es una pila eficiente: append para push, pop() para pop. No necesitás implementar tu propia salvo por motivos didácticos.

Implementación con lista enlazada: push/pop al frente.

class PilaEnlazada:
    def __init__(self):
        self.cabeza = None
        self._n = 0

    def push(self, x):
        self.cabeza = Nodo(x, self.cabeza)
        self._n += 1

    def pop(self):
        if self.cabeza is None:
            raise IndexError("pila vacía")
        x = self.cabeza.dato
        self.cabeza = self.cabeza.siguiente
        self._n -= 1
        return x

Aplicaciones

Validar paréntesis:

def balanceados(s):
    pila = []
    pares = {")": "(", "]": "[", "}": "{"}
    for c in s:
        if c in "([{":
            pila.append(c)
        elif c in ")]}":
            if not pila or pila.pop() != pares[c]:
                return False
    return not pila

print(balanceados("({[]})"))    # True
print(balanceados("([)]"))       # False

Otras aplicaciones:

  • Llamadas a función (call stack).
  • Undo / Redo.
  • Evaluación de expresiones (parsers, calculadoras).
  • DFS (profundidad).
  • Backtracking.

2.5 Cola (Queue — FIFO)

📐 Fundamento

FIFO = First In, First Out. El primero que entró es el primero que sale. Como una cola de gente.

Operaciones:

  • enqueue(x) — agregar al final.
  • dequeue() — sacar del frente.

Trampa con array: dequeue() con pop(0) es O(n)O(n) — Python tiene que mover todo. Inadecuado para colas.

Solución: deque (double-ended queue, collections.deque):

from collections import deque

cola = deque()
cola.append("a")        # enqueue: O(1)
cola.append("b")
cola.append("c")
print(cola.popleft())   # dequeue: O(1) → "a"

deque es una lista doblemente enlazada interna optimizada — append y popleft son ambos O(1)O(1).

Implementación con lista enlazada:

class Cola:
    def __init__(self):
        self.cabeza = None
        self.cola_ptr = None
        self._n = 0

    def enqueue(self, x):
        nuevo = Nodo(x)
        if self.cola_ptr is None:
            self.cabeza = self.cola_ptr = nuevo
        else:
            self.cola_ptr.siguiente = nuevo
            self.cola_ptr = nuevo
        self._n += 1

    def dequeue(self):
        if self.cabeza is None:
            raise IndexError("cola vacía")
        x = self.cabeza.dato
        self.cabeza = self.cabeza.siguiente
        if self.cabeza is None:
            self.cola_ptr = None
        self._n -= 1
        return x

Aplicaciones

  • BFS (búsqueda en anchura).
  • Schedulers del SO (Round Robin).
  • Sistemas de mensajería (RabbitMQ, Kafka).
  • Buffers entre productor y consumidor.
  • Impresoras, peticiones HTTP.

Cola con prioridad (vista rápida)

A veces querés sacar el más prioritario, no el más viejo.

import heapq

cola = []
heapq.heappush(cola, (3, "tarea normal"))
heapq.heappush(cola, (1, "URGENTE"))
heapq.heappush(cola, (2, "importante"))

while cola:
    prio, tarea = heapq.heappop(cola)
    print(prio, tarea)
# 1 URGENTE
# 2 importante
# 3 tarea normal

heapq implementa un heap binario — operaciones en O(logn)O(\log n). Lo veremos en el cap 3.

2.6 Deque (cola de doble extremo)

Un deque combina pila y cola: podés agregar/quitar de ambos extremos en O(1)O(1).

from collections import deque

d = deque()
d.append(1)         # al final
d.appendleft(2)      # al inicio
d.pop()              # del final
d.popleft()          # del inicio

Aplicaciones:

2.7 Aplicación clásica: BFS

🛠️ En la práctica

BFS (Breadth-First Search) explora un grafo nivel por nivel, usando una cola.

from collections import deque

def bfs(grafo, inicio):
    """Recorre todos los nodos alcanzables desde 'inicio'."""
    visitados = {inicio}
    cola = deque([inicio])
    orden = []

    while cola:
        nodo = cola.popleft()
        orden.append(nodo)
        for vecino in grafo.get(nodo, []):
            if vecino not in visitados:
                visitados.add(vecino)
                cola.append(vecino)
    return orden


# Grafo: ciudades del Salvador conectadas
ciudades = {
    "San Miguel": ["Usulutan", "La Union", "San Salvador"],
    "Usulutan": ["San Miguel", "San Salvador"],
    "La Union": ["San Miguel"],
    "San Salvador": ["San Miguel", "Usulutan", "Santa Ana"],
    "Santa Ana": ["San Salvador"],
}

print(bfs(ciudades, "San Miguel"))
# ['San Miguel', 'Usulutan', 'La Union', 'San Salvador', 'Santa Ana']

BFS encuentra el camino más corto (en cantidad de saltos) en grafos no ponderados. Si quisieras encontrar la ruta entre San Miguel y Santa Ana en mínimos saltos, BFS la da.

DFS con pila vs BFS con cola. Cambia solo la estructura — el algoritmo es casi idéntico. Eso te muestra la potencia conceptual de pila/cola.

2.8 Resumen visual

Estructura Acceso aleatorio Insertar inicio Insertar fin Borrar
Array O(1)O(1) O(n)O(n) O(1)O(1)^* O(n)O(n)
Lista enlazada simple O(n)O(n) O(1)O(1) O(1)O(1)^{**} O(1)O(1) con nodo
Lista doblemente enlazada O(n)O(n) O(1)O(1) O(1)O(1) O(1)O(1) con nodo
Pila (LIFO) n/a n/a O(1)O(1) O(1)O(1)
Cola (FIFO) n/a n/a O(1)O(1) O(1)O(1)
Deque O(n)O(n) O(1)O(1) O(1)O(1) O(1)O(1) extremos

2.9 Ejercicios

✏️ Ejercicio 2.1 — Reverse de lista enlazada

Implementá reverse() que invierte una lista enlazada en-place en O(n)O(n) tiempo y O(1)O(1) memoria adicional.

✏️ Ejercicio 2.2 — Detectar ciclo

Una lista enlazada puede tener un ciclo (un nodo apunta a un nodo previo). Detectalo con O(1)O(1) memoria.

✏️ Ejercicio 2.3 — Implementar cola con dos pilas

Implementá una cola usando solo dos pilas.

✏️ Ejercicio 2.4 — Mejor calificación reciente

Tenés un stream de calificaciones que llegan una por una. Para cada nueva calificación, devolvé la máxima de las últimas K.

2.10 Para profundizar


Definiciones nuevas: array vs lista enlazada, lista enlazada simple/doble, pila (LIFO), cola (FIFO), deque, BFS, DFS, algoritmo de Floyd, monotonous deque.