Ir al contenido
wannacode

Lista enlazada

En resumen: Una lista enlazada es una cadena de nodos, cada uno apuntando al siguiente: con head y tail, añadir por los dos extremos y quitar al principio son O(1), mientras que el acceso por índice es O(n).

Problema

La cola que frena el envío

El servidor envía correos desde una cola. Los nuevos entran por detrás, los urgentes (un restablecimiento de contraseña) por delante, y el proceso de envío toma siempre el primero. El código usa la lista normal:

var emails = new List<string>();
emails.Add("factura");
emails.Add("boletín");
emails.Add("informe");
emails.Insert(0, "contraseña");
string next = emails[0];
emails.RemoveAt(0);
Console.WriteLine("Enviando: " + next);
Console.WriteLine("Quedan: " + string.Join(", ", emails));
Salida
Enviando: contraseña
Quedan: factura, boletín, informe

El lunes por la mañana, el boletín para todos los clientes llena la cola con 100 000 correos. El proceso envía más despacio que de costumbre, luego casi se detiene, y los correos de restablecimiento de contraseña llegan con minutos de retraso.

La causa: la lista es un arreglo dinámico. Sacar el primero mueve todos los demás un lugar hacia delante, y uno urgente al principio los mueve todos hacia atrás. Enviar 100 000 correos uno tras otro son 99 999 + 99 998 + … + 1 = 4 999 950 000 movimientos, solo para sacar el primero.

La idea

Nodos, head y tail

El nodo y la inserción al principio ya los viste en la lección «Pila, cola y lista enlazada»: cada elemento es un objeto aparte con un valor y una flecha Next al siguiente. Aquí escribes la clase entera y calculas cuánto cuesta cada operación.

La lista guarda tres cosas:

  • head: el primer nodo. Todo recorrido empieza ahí.
  • tail: el último nodo. Con él, añadir al final no recorre toda la lista.
  • el tamaño, para no contarlo cada vez.

Los dos extremos tienen que ser correctos después de cada operación. La lista vacía tiene head y tail iguales a null. Una lista con un nodo tiene head y tail apuntando al mismo nodo. Casi todo error en una lista enlazada es un extremo olvidado: quitas el último, y tail sigue apuntando a él.

Míralo

Tu propia lista enlazada

Aquí está la clase entera. Los nodos no se mueven nunca: cada operación cambia solo flechas.

class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
class SimpleLinkedList
{
private Node head;
private Node tail;
private int size;
public int Count => size;
public void AddFirst(string value)
{
var node = new Node(value);
node.Next = head;
head = node;
if (tail == null)
{
tail = node;
}
size++;
}
public void AddLast(string value)
{
var node = new Node(value);
if (tail == null)
{
head = node;
}
else
{
tail.Next = node;
}
tail = node;
size++;
}
public string RemoveFirst()
{
if (head == null)
{
throw new InvalidOperationException("la lista está vacía");
}
string value = head.Value;
head = head.Next;
if (head == null)
{
tail = null;
}
size--;
return value;
}
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = n.Next;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}
public int Find(string value)
{
int index = 0;
for (Node n = head; n != null; n = n.Next)
{
if (n.Value == value)
{
return index;
}
index++;
}
return -1;
}
public string Get(int index)
{
if (index < 0 || index >= size)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
Node n = head;
for (int i = 0; i < index; i++)
{
n = n.Next;
}
return n.Value;
}
public override string ToString()
{
var values = new List<string>();
for (Node n = head; n != null; n = n.Next)
{
values.Add(n.Value);
}
return string.Join(" → ", values);
}
}

Mira qué flechas cambian en cada operación:

class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
private Node head;
private Node tail;
private int size;
Paso a paso, en palabras
  1. Tres correos, cada uno en su propio nodo: el valor y Next, la flecha al siguiente. head apunta al primero, tail al último.
  2. AddFirst(«contraseña»): un nodo nuevo, su Next apunta al antiguo primero, y head pasa a él. Dos flechas, nada se mueve: O(1).
  3. AddLast(«invitación»): tail ya conoce al último, así que no se recorre nada. Su Next apunta al nodo nuevo, y tail se mueve: O(1).
  4. RemoveFirst(): head pasa al nodo siguiente. Ya nada apunta a «contraseña», y su memoria se libera sola.
  5. Remove(«boletín»): los nodos no tienen índice. n parte de head y avanza hasta encontrarlo; prev se queda un nodo atrás. Eso es O(n).
  6. prev.Next = n.Next: una flecha rodea «boletín». «informe» y «invitación» no se mueven.
  7. En los extremos: dos flechas, O(1). En el medio: una flecha, pero a ese lugar se llega paso a paso, O(n).
var emails = new SimpleLinkedList();
emails.AddLast("factura");
emails.AddLast("boletín");
emails.AddLast("informe");
emails.AddFirst("contraseña");
emails.AddLast("invitación");
Console.WriteLine(emails);
Console.WriteLine("Enviando: " + emails.RemoveFirst());
emails.Remove("boletín");
Console.WriteLine(emails);
Console.WriteLine("Posición del informe: " + emails.Find("informe"));
Console.WriteLine("Correos: " + emails.Count);
Salida
contraseña → factura → boletín → informe → invitación
Enviando: contraseña
factura → informe → invitación
Posición del informe: 1
Correos: 3
RemoveFirst en una lista vacía lanza InvalidOperationException, como LinkedList<T>.RemoveFirst. ToString con override es lo que imprime Console.WriteLine(emails). Get es un método y no un indexador [i]: así se ve que llega al elemento paso a paso. Por eso LinkedList<T> tampoco tiene indexador.

Pruébalo

Predice la salida

Siete operaciones seguidas. Fíjate cuál es el último después de quitar:

class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
class SimpleLinkedList
{
private Node head;
private Node tail;
private int size;
public int Count => size;
public void AddFirst(string value)
{
var node = new Node(value);
node.Next = head;
head = node;
if (tail == null)
{
tail = node;
}
size++;
}
public void AddLast(string value)
{
var node = new Node(value);
if (tail == null)
{
head = node;
}
else
{
tail.Next = node;
}
tail = node;
size++;
}
public string RemoveFirst()
{
if (head == null)
{
throw new InvalidOperationException("la lista está vacía");
}
string value = head.Value;
head = head.Next;
if (head == null)
{
tail = null;
}
size--;
return value;
}
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = n.Next;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}
public int Find(string value)
{
int index = 0;
for (Node n = head; n != null; n = n.Next)
{
if (n.Value == value)
{
return index;
}
index++;
}
return -1;
}
public string Get(int index)
{
if (index < 0 || index >= size)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
Node n = head;
for (int i = 0; i < index; i++)
{
n = n.Next;
}
return n.Value;
}
public override string ToString()
{
var values = new List<string>();
for (Node n = head; n != null; n = n.Next)
{
values.Add(n.Value);
}
return string.Join(" → ", values);
}
}
var emails = new SimpleLinkedList();
emails.AddFirst("informe");
emails.AddFirst("factura");
emails.AddLast("invitación");
emails.RemoveFirst();
emails.AddFirst("contraseña");
emails.Remove("invitación");
emails.AddLast("boletín");
Console.WriteLine(emails);
Console.WriteLine("Correos: " + emails.Count);
Respuesta

La idea

Qué cuesta qué: arreglo contra lista enlazada

Operación Arreglo dinámico Lista enlazada
leer por índice O(1) O(n)
añadir al principio O(n) O(1)
añadir al final O(1) amortizado O(1) con tail
quitar el primero O(n) O(1)
quitar el último O(1) O(n), doblemente enlazada: O(1)
en el medio O(n): mover O(n): caminar hasta el lugar

La última fila es la trampa. Reenlazar en el medio es O(1) por sí solo, pero al lugar llegas desde head, paso a paso. La lista enlazada gana en el medio solo si ya tienes el nodo, por ejemplo porque acabas de encontrarlo con una tabla hash.

También hay un coste en memoria. El arreglo guarda los valores (o las referencias) uno junto a otro. Cada nodo es un objeto aparte: el valor, la flecha y la cabecera que el entorno de ejecución guarda para cada objeto. Con valores pequeños, un nodo ocupa varias veces más memoria que un lugar del arreglo.

Míralo

Mídelo

Insertar al principio: el arreglo dinámico de la lección anterior contra la lista enlazada. Contamos las escrituras de elementos: en el arreglo el nuevo y cada movimiento, en la lista solo el nuevo nodo. Las flechas que cambian son dos por cada inserción y no crecen con n.

  • Insertar al principio O(n²)
  • Lista enlazada: al principio O(n)
Algoritmon = 10n = 1000n = 10.000n = 100.000
Insertar al principio63501.51650.021.376≈ 5.002.137.600
Lista enlazada: al principio10100010.000100.000
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
class SimpleLinkedList
{
private Node head;
private Node tail;
private int size;
public int Count => size;
public void AddFirst(string value)
{
var node = new Node(value);
node.Next = head;
head = node;
if (tail == null)
{
tail = node;
}
size++;
}
public void AddLast(string value)
{
var node = new Node(value);
if (tail == null)
{
head = node;
}
else
{
tail.Next = node;
}
tail = node;
size++;
}
public string RemoveFirst()
{
if (head == null)
{
throw new InvalidOperationException("la lista está vacía");
}
string value = head.Value;
head = head.Next;
if (head == null)
{
tail = null;
}
size--;
return value;
}
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = n.Next;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}
public int Find(string value)
{
int index = 0;
for (Node n = head; n != null; n = n.Next)
{
if (n.Value == value)
{
return index;
}
index++;
}
return -1;
}
public string Get(int index)
{
if (index < 0 || index >= size)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
Node n = head;
for (int i = 0; i < index; i++)
{
n = n.Next;
}
return n.Value;
}
public override string ToString()
{
var values = new List<string>();
for (Node n = head; n != null; n = n.Next)
{
values.Add(n.Value);
}
return string.Join(" → ", values);
}
}

Con 10 000 correos, el arreglo hace 50 021 376 escrituras y la lista enlazada 10 000.

Ahora al revés: leer cada elemento por índice, get(0), get(1) y así hasta el final. Contamos los lugares a los que llegas: en el arreglo get(i) es un salto, y en la lista pasa por i + 1 nodos.

  • Arreglo: get(i) para cada i O(n)
  • Lista enlazada: get(i) para cada i O(n²)
Algoritmon = 10n = 1000n = 10.000n = 100.000
Arreglo: get(i) para cada i10100010.000100.000
Lista enlazada: get(i) para cada i55500.50050.005.000≈ 5.000.500.000
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
class SimpleLinkedList
{
private Node head;
private Node tail;
private int size;
public int Count => size;
public void AddFirst(string value)
{
var node = new Node(value);
node.Next = head;
head = node;
if (tail == null)
{
tail = node;
}
size++;
}
public void AddLast(string value)
{
var node = new Node(value);
if (tail == null)
{
head = node;
}
else
{
tail.Next = node;
}
tail = node;
size++;
}
public string RemoveFirst()
{
if (head == null)
{
throw new InvalidOperationException("la lista está vacía");
}
string value = head.Value;
head = head.Next;
if (head == null)
{
tail = null;
}
size--;
return value;
}
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = n.Next;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}
public int Find(string value)
{
int index = 0;
for (Node n = head; n != null; n = n.Next)
{
if (n.Value == value)
{
return index;
}
index++;
}
return -1;
}
public string Get(int index)
{
if (index < 0 || index >= size)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
Node n = head;
for (int i = 0; i < index; i++)
{
n = n.Next;
}
return n.Value;
}
public override string ToString()
{
var values = new List<string>();
for (Node n = head; n != null; n = n.Next)
{
values.Add(n.Value);
}
return string.Join(" → ", values);
}
}

Con 10 000 elementos, el arreglo hace 10 000 saltos y la lista enlazada 50 005 000 pasos. Un bucle por índice sobre una lista enlazada es O(n²). Recórrela con el bucle por las flechas (o con for-each), como hace ToString: eso es O(n).

Más precisamente, para curiosos

Los mismos números en tu lenguaje, con contadores en la clase:

class Node
{
public string Value;
public Node Next;
public Node(string value)
{
Value = value;
}
}
class SimpleLinkedList
{
private Node head;
private Node tail;
private int size;
public long Writes { get; private set; }
public long Steps { get; private set; }
public int Count => size;
public void AddFirst(string value)
{
var node = new Node(value);
Writes++;
node.Next = head;
head = node;
if (tail == null)
{
tail = node;
}
size++;
}
public void AddLast(string value)
{
var node = new Node(value);
if (tail == null)
{
head = node;
}
else
{
tail.Next = node;
}
tail = node;
size++;
}
public string RemoveFirst()
{
if (head == null)
{
throw new InvalidOperationException("la lista está vacía");
}
string value = head.Value;
head = head.Next;
if (head == null)
{
tail = null;
}
size--;
return value;
}
public bool Remove(string value)
{
Node prev = null;
Node n = head;
while (n != null && n.Value != value)
{
prev = n;
n = n.Next;
}
if (n == null)
{
return false;
}
if (prev == null)
{
head = n.Next;
}
else
{
prev.Next = n.Next;
}
if (n == tail)
{
tail = prev;
}
size--;
return true;
}
public int Find(string value)
{
int index = 0;
for (Node n = head; n != null; n = n.Next)
{
if (n.Value == value)
{
return index;
}
index++;
}
return -1;
}
public string Get(int index)
{
if (index < 0 || index >= size)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
Node n = head;
Steps++;
for (int i = 0; i < index; i++)
{
n = n.Next;
Steps++;
}
return n.Value;
}
public override string ToString()
{
var values = new List<string>();
for (Node n = head; n != null; n = n.Next)
{
values.Add(n.Value);
}
return string.Join(" → ", values);
}
}
foreach (int n in new[] { 10, 1000, 100000 })
{
var list = new SimpleLinkedList();
for (int i = 0; i < n; i++)
{
list.AddFirst((1001 + i).ToString());
}
Console.WriteLine($"al principio, n = {n}: {list.Writes} escrituras");
}
foreach (int n in new[] { 10, 1000 })
{
var list = new SimpleLinkedList();
for (int i = 0; i < n; i++)
{
list.AddLast((1001 + i).ToString());
}
for (int i = 0; i < n; i++)
{
list.Get(i);
}
Console.WriteLine($"get(i) para cada i, n = {n}: {list.Steps} pasos");
}
Salida
al principio, n = 10: 10 escrituras
al principio, n = 1000: 1000 escrituras
al principio, n = 100000: 100000 escrituras
get(i) para cada i, n = 10: 55 pasos
get(i) para cada i, n = 1000: 500500 pasos

Bench cuenta operaciones, no tiempo. En el reloj, la diferencia al recorrer es aún mayor a favor del arreglo por la caché: el arreglo es un bloque, los nodos están dispersos y cada siguiente puede no estar en la caché. Aquí eso no se mide, solo se explica en la lección sobre el arreglo dinámico.

Pruébalo

Encuentra el error

Un compañero ha escrito AddLast. Tres correos añadidos al final, y la lista sale así:

var emails = new SimpleLinkedList();
emails.AddLast("factura");
emails.AddLast("boletín");
emails.AddLast("informe");
Console.WriteLine(emails);
Console.WriteLine("Correos: " + emails.Count);
Salida
factura → informe
Correos: 3

El tamaño es 3, y los correos son dos. ¿En qué línea está el error?

Elige la línea con el error.

Pruébalo

Completa

Remove ha encontrado el nodo n y el anterior, prev. ¿A dónde tiene que apuntar prev ahora?

public bool Remove(string value)
{
    Node prev = null;
    Node n = head;
    while (n != null && n.Value != value)
    {
        prev = n;
        n = n.Next;
    }
    if (n == null)
    {
        return false;
    }
    if (prev == null)
    {
        head = n.Next;
    }
    else
    {
        prev.Next = ;
    }
    if (n == tail)
    {
        tail = prev;
    }
    size--;
    return true;
}

La idea

La lista doblemente enlazada

Una lista simplemente enlazada no puede quitar el último con rapidez: necesita el penúltimo, y hacia él no apunta nada desde atrás. Una lista doblemente enlazada guarda en cada nodo dos flechas, Next y Prev. Entonces los dos extremos son O(1), y un nodo que ya tienes se quita con cuatro flechas, sin buscar el anterior.

El coste es una flecha más en cada nodo y más reenlaces en cada operación.

LinkedList<T> en C# es doblemente enlazada. Los nodos se ven: AddLast devuelve un LinkedListNode<T> con Next y Previous, y Remove(node) quita el nodo en O(1).

La idea

Dónde la lista enlazada gana de verdad

Para una cola normal, la integrada es mejor. Queue<T> es un arreglo que mantiene los dos extremos sin mover nada. Cómo funciona está en la lección Pila y cola. La lista enlazada gana cuando ya tienes el nodo y tienes que sacarlo o moverlo:

  • Una caché LRU. La tienda guarda en memoria los últimos productos abiertos y echa el que se abrió hace más tiempo. Una tabla hash encuentra el nodo del producto en O(1), y una lista doblemente enlazada lo pasa al final. El más antiguo está siempre al principio.
  • Historial con atrás y adelante. El navegador y el editor guardan los pasos en una lista doblemente enlazada: atrás y adelante son una flecha.
  • Las listas en el núcleo del sistema operativo. Los procesos y los búferes llevan sus propias flechas y se sacan de la lista en O(1).

Aquí tienes un LRU con las herramientas integradas, los últimos tres productos abiertos:

class Recent
{
private readonly int limit;
private readonly LinkedList<string> order = new();
private readonly Dictionary<string, LinkedListNode<string>> nodes = new();
public Recent(int limit)
{
this.limit = limit;
}
public void Open(string product)
{
if (nodes.TryGetValue(product, out var node))
{
order.Remove(node);
}
else if (order.Count == limit)
{
nodes.Remove(order.First.Value);
order.RemoveFirst();
}
nodes[product] = order.AddLast(product);
}
public override string ToString()
{
return string.Join(", ", order);
}
}
var recent = new Recent(3);
foreach (string product in new[] { "laptop", "mouse", "monitor", "laptop", "teclado" })
{
recent.Open(product);
Console.WriteLine(recent);
}
Salida
laptop
laptop, mouse
laptop, mouse, monitor
mouse, monitor, laptop
monitor, laptop, teclado
Aquí está la forma clásica, a mano: Dictionary encuentra el nodo, y LinkedList<string> lo saca y lo cuelga al final. Las dos cosas son O(1).

Ejercicio

Una lista doblemente enlazada

Resuelve la tarea en tu editor.

Quitar por los dos extremos. Haz una DoublyLinkedList: el nodo tiene también Prev. Escribe AddFirst, AddLast, RemoveFirst y RemoveLast, todas en O(1). Mantén correctos los dos extremos: cuando la lista queda vacía, head y tail son ambos null.

Añade los tres correos al final y el de la contraseña al principio. Imprime la lista, quita el último y el primero, imprímela otra vez. Quita dos más por el final, imprime el tamaño y después añade la invitación al final:

Salida
contraseña → factura → boletín → informe
Último: informe
Primero: contraseña
factura → boletín
Correos: 0
invitación

Más tareas

  • El medio con dos punteros. Encuentra el nodo del medio en una sola pasada: un puntero avanza un paso cada vez, el otro dos. Cuando el rápido llega al final, el lento está en el medio.
  • Un ciclo en la lista. Un error ha colgado el último nodo de uno de los anteriores. Detéctalo con los mismos dos punteros: si hay un ciclo, el rápido alcanza al lento.
  • Quitar todas las apariciones. RemoveAll(string value) quita en una pasada cada nodo con ese valor y devuelve cuántos quitó. No olvides tail.
  • LRU a mano. Haz la caché LRU de la tarjeta de arriba con tu propia lista doblemente enlazada y un Dictionary del producto al nodo.

Test

Ponte a prueba

1. Una lista enlazada tiene 1000 nodos y solo guardas head. ¿Cuántos nodos recorres para leer el elemento con índice 999?
2. ¿Para qué hace falta tail, si head ya sostiene toda la lista?
3. En una lista simplemente enlazada con head y tail, ¿cuánto cuesta quitar el último?
4. Bench muestra que insertar al principio hace n operaciones en una lista enlazada y unas n²/2 en un arreglo dinámico. ¿Por qué el arreglo suele ser más rápido al recorrer?
5. Una caché LRU guarda los últimos 1000 productos abiertos y echa el que se abrió hace más tiempo. ¿Qué hace que cada apertura sea O(1)?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·