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 Insertar al inicio Insertar al final amort. con tail Borrar (con nodo) Memoria + 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: .insertar_final: — recorre hasta el final. Mejorable guardandocola.buscar,eliminar: .- Acceso por índice: (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 .
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 — 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 . 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()(otop()) — mirar el de arriba sin sacarlo.
Todas .
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 — 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 .
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 . 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 .
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:
- Sliding window: ventana deslizante en arrays.
- BFS bidireccional.
- Simulación de listas en ambos extremos.
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 | ||||
| Lista enlazada simple | con nodo | |||
| Lista doblemente enlazada | con nodo | |||
| Pila (LIFO) | n/a | n/a | ||
| Cola (FIFO) | n/a | n/a | ||
| Deque | extremos |
- amortizado. ** con puntero al final.
2.9 Ejercicios
✏️ Ejercicio 2.1 — Reverse de lista enlazada
Implementá reverse() que invierte una lista enlazada en-place en tiempo y memoria adicional.
Solución
def reverse(lista):
anterior = None
actual = lista.cabeza
while actual is not None:
siguiente = actual.siguiente
actual.siguiente = anterior
anterior = actual
actual = siguiente
lista.cabeza = anterior
Tres punteros (anterior, actual, siguiente) que avanzan una posición por iteración. Es la solución clásica.
✏️ Ejercicio 2.2 — Detectar ciclo
Una lista enlazada puede tener un ciclo (un nodo apunta a un nodo previo). Detectalo con memoria.
Solución
Algoritmo de Floyd (tortuga y liebre):
def tiene_ciclo(cabeza):
lento = rapido = cabeza
while rapido and rapido.siguiente:
lento = lento.siguiente
rapido = rapido.siguiente.siguiente
if lento is rapido:
return True
return False
Dos punteros, uno avanza 1 posición, el otro 2. Si hay ciclo, eventualmente se encuentran. Si no hay, el rápido llega a None.
✏️ Ejercicio 2.3 — Implementar cola con dos pilas
Implementá una cola usando solo dos pilas.
Solución
class ColaConPilas:
def __init__(self):
self.entrada = []
self.salida = []
def enqueue(self, x):
self.entrada.append(x)
def dequeue(self):
if not self.salida:
while self.entrada:
self.salida.append(self.entrada.pop())
if not self.salida:
raise IndexError("cola vacía")
return self.salida.pop()
enqueue siempre va a entrada (). dequeue saca de salida; si está vacía, vuelca toda entrada invertida ( en ese caso, pero amortizado porque cada elemento se mueve solo una vez).
✏️ 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.
Solución
from collections import deque
def maximos_ventana(stream, k):
cola = deque() # guarda índices, valores decrecientes
resultados = []
for i, x in enumerate(stream):
# remover viejos fuera de la ventana
while cola and cola[0] <= i - k:
cola.popleft()
# remover los menores: ya no van a ser máximos
while cola and stream[cola[-1]] < x:
cola.pop()
cola.append(i)
if i >= k - 1:
resultados.append(stream[cola[0]])
return resultados
print(maximos_ventana([1, 3, -1, -3, 5, 3, 6, 7], 3))
# [3, 3, 5, 5, 6, 7]
Algoritmo del monotonous deque — clásico de optimización con O(n) total en vez del O(n·k) naive.
2.10 Para profundizar
- CLRS cap 10.
- Sedgewick & Wayne — listas enlazadas con visualizaciones.
- Python's
collections.deque— doc oficial. - Próximo capítulo: Árboles — la estructura jerárquica más versátil.
Definiciones nuevas: array vs lista enlazada, lista enlazada simple/doble, pila (LIFO), cola (FIFO), deque, BFS, DFS, algoritmo de Floyd, monotonous deque.