16. Listas Enlazadas#
Las listas enlazadas son estructuras de datos que permiten almacenar una colección de elementos, en posiciones de memoria no necesariamente contiguas. Cada elemento de una lista se almacena en un nodo que contiene un campo de dato y uno o dos punteros a otros nodos.
Los nodos están enlazados entre sí para formar la lista. Son estructuras de datos dinámicas, que pueden crecer a medida que se le agregan datos y decrecer cuando se eliminan.
Las listas enlazadas generalmente se usan como contenedores para definir estructuras de datos más complejas. Existen varios tipos de listas enlazadas, que se utilizan para distintas aplicaciones.
Algunos usos de las listas enlazadas pueden ser:
- Algoritmos de manipulación de texto
Las listas enlazadas son útiles en aplicaciones y algoritmos de procesamiento de texto, como editores de texto, donde la inserción y eliminación de caracteres o líneas se realizan con frecuencia.
- Undo y redo en aplicaciones
Las listas enlazadas dobles son útiles para implementar funciones de deshacer y rehacer en aplicaciones de software, ya que permiten navegar hacia adelante y hacia atrás en el historial de acciones realizadas.
- Listas de reproducción
Algunas aplicaciones utilizan listas circulares para implementar las funciones de avanzar y retroceder en listas de reproducción infinitas.
Las listas que estudiaremos son:
Lista Enlazada Simple (Simple Linked List)
Lista Enlazada Doble (Double Linked List)
Lista Circular (Circular List)
Las listas enlazadas son muy versátiles y su comportamiento se puede adaptar a medida de las necesidades. Algunas de las operaciones típicas que soporta una lista enlazada son:
Head
Devuelve un puntero al primer elemento de la lista.
Tail
Devuelve un puntero al último elemento de la lista.
Size
Devuelve el tamaño de la lista (cantidad de nodos que tiene la lista).
IsEmpty
Devuelve Verdadero si la lista está vacía o Falso en caso contrario.
Prepend
Insertar un elemento en la cabeza de la lista.
Append
Insertar un elemento en la cola de la lista.
InsertAfter
Inserta un elemento a continuación de un elemento dado.
InsertBefore
Inserta un elemento antes de un elemento dado.
RemoveFirst
Elimina el primer elemento de la lista.
RemoveLast
Elimina el último elemento de la lista.
Remove
Elimina la primera aparición del elemento dado.
Find
Devuelve un puntero a la primera aparición de un elemento buscado.
Clear
Elimina todos los elementos de la lista
16.1. Lista Enlazada Simple#
Definición
Una lista enlazada simple es una estructura de datos lineal donde cada nodo de la lista tiene un sucesor, salvo el último. Por definición la lista vacía es la que no contiene datos y su tamaño es 0.
El nodo de una lista enlazada simple tiene dos campos:
- Dato
El valor que se almacena en el nodo.
- Puntero (o enlace)
Una referencia a la dirección de memoria del siguiente nodo en la secuencia.
Figura 16.1 Lista Enlazada Simple#
16.1.1. Búsqueda#
Para buscar un elemento no queda otra alternativa que recorrer toda la lista desde la cabeza de la misma hasta encontrar el elemento o llegar al final de la lista y determinar que no se encuentra. Si encuentra el elemento devuelve un puntero al nodo correspondiente o nulo en caso contrario
1Find(buscado):
2 actual := self.Head()
3 mientras actual != nulo:
4 si actual.Data() == buscado:
5 retornar actual
6 actual = actual.siguiente
7 retornar nulo
16.1.2. Inserción después de un elemento dado#
Supongamos que dada la lista de la figura Lista Enlazada Simple, se quiere insertar un elemento nuevo después de un elemento dado. Para poder lograrlo primero hay que crear un nuevo nodo con el elemento a insertar, buscar el elemento después del cual se quiere insertar y finalmente actualizar los campos siguientes de los nodos:
1InsertAfter(buscado, elemento):
2 nuevo := NuevoNodo(elemento)
3 actual := self.Find(buscado)
4 si actual != nulo:
5 nuevo.SetNext(actual) //Primero hay que enlazar el nuevo nodo a la lista
6 actual.SetNext(nuevo) //Después corregir al siguiente del nodo actual
De esta forma se garantiza la integridad de la lista. Ver figura Inserción en una Lista Enlazada Simple
Figura 16.2 Inserción en una Lista Enlazada Simple#
16.1.3. Eliminación#
Supongamos que dada la lista de la figura Lista Enlazada Simple, se quiere eliminar el elemento E3, para no perder la integridad de la lista se deben seguir los pasos a continuación:
1Remove(buscado):
2 actual := self.Find(buscado)
3 if actual != nulo:
4 if actual == self.Head():
5 self.RemoveFirst
6 return
7 previo := self.Head()
8 mientras previo.Siguiente.Data()
Eventualmente el recolector de basura liberará la memoria que ocupa el nodo E3, ya no es alcanzable (no hay ningún puntero que nos permita llegar a E3). Ver figura Eliminación de un elemento en una Lista Enlazada Simple
Figura 16.3 Eliminación de un elemento en una Lista Enlazada Simple#
16.1.4. Orden de las operaciones#
En la siguiente tabla se muestra el orden que deberían tener las operaciones básicas sobre una lista enlazada simple
Operación |
Orden |
---|---|
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(n)\) |
|
\(O(n)\) |
|
\(O(1)\) |
|
\(O(1)\) |
|
\(O(n)\) |
|
\(O(n)\) |
|
\(O(1)\) |
Las operaciones que requieren recorrer la lista son \(O(n)\). En particular buscar un elemento, insertar o eliminar un elemento en una posición arbitraria requieren recorrer uno a uno cada nodo hasta posicionarse en el nodo adecuado.
Encontrar el predecesor de un elemento dado también es \(O(n)\) ya que hay ingresar a la lista desde la cabeza y comparar si el siguiente de cada nodo es el nodo actual para poder encontrar su predecesor.
16.1.5. Implementación de la lista enlazada simple#
Existen distintas alternativas para implementar una lista. Una alternativa se muestra en la figura Implementación de una Lista Enlazada Simple, con punteros a la cabeza y la cola. Para lograr que las operaciones de insertar y eliminar un elemento de la cola de la lista sean \(O(1)\) se guarda un puntero a la cola de la lista, para poder acceder en tiempo constante al último elemento de la lista. En esta implementación la lista vacía se representa cuando los punteros a la cabeza y la cola son nulos y el tamaño es 0
Figura 16.4 Implementación de una Lista Enlazada Simple, con punteros a la cabeza y la cola#
16.1.6. Implementación en Go#
A continuación una implementación de una lista enlazada simple, donde se definen dos tipos LinkedNode
como un node de lista que contiene un dato y un puntero al siguiente y el tipo LinkedList
. Los datos de la lista son genéricos, pero se piden que sean Comparables entre sí
1package list
2
3// LinkedNode representa un nodo de una lista enlazada simple.
4type LinkedNode[T comparable] struct {
5 data T
6 next *LinkedNode[T]
7}
8
9// NewLinkedListNode crea un nuevo nodo de lista enlazada con el dato especificado.
10//
11// Uso:
12//
13// node := list.NewLinkedListNode(10) // Crea un nuevo nodo con el dato 10.
14//
15// Parámetros:
16// - `data`: el dato a almacenar en el nodo.
17func NewLinkedListNode[T comparable](data T) *LinkedNode[T] {
18 return &LinkedNode[T]{data: data}
19}
20
21// SetData establece el dato almacenado en el nodo.
22//
23// Uso:
24//
25// node.SetData(20) // Establece el dato del nodo a 20.
26//
27// Parámetros:
28// - `data`: el dato a almacenar en el nodo.
29func (n *LinkedNode[T]) SetData(data T) {
30 n.data = data
31}
32
33// Data devuelve el dato almacenado en el nodo.
34//
35// Uso:
36//
37// data := node.Data() // Obtiene el dato almacenado en el nodo.
38//
39// Retorna:
40// - el dato almacenado en el nodo.
41func (n *LinkedNode[T]) Data() T {
42 return n.data
43}
44
45// SetNext establece el nodo siguiente al nodo actual.
46//
47// Uso:
48//
49// node.SetNext(newNode) // Establece el nodo siguiente al nodo actual.
50//
51// Parámetros:
52// - `newNext`: el nodo siguiente al nodo actual.
53func (n *LinkedNode[T]) SetNext(newNext *LinkedNode[T]) {
54 n.next = newNext
55}
56
57// Next devuelve el nodo siguiente al nodo actual.
58//
59// Uso:
60//
61// nextNode := node.Next() // Obtiene el nodo siguiente al nodo actual.
62func (n *LinkedNode[T]) Next() *LinkedNode[T] {
63 return n.next
64}
65
66// HasNext evalúa si el nodo actual tiene asignado un nodo siguiente.
67//
68// Uso:
69//
70// if node.HasNext() {
71// fmt.Println("El nodo tiene un nodo siguiente.")
72// }
73//
74// Retorna:
75// - `true` si el nodo tiene un nodo siguiente; `false` en caso contrario.
76func (n *LinkedNode[T]) HasNext() bool {
77 return n.next != nil
78}
1package list
2
3import "fmt"
4
5// LinkedList se implementa con un nodo que contiene un dato y un puntero al siguiente nodo.
6// Los elementos deben ser de un tipo comparable.
7type LinkedList[T comparable] struct {
8 head *LinkedNode[T]
9 tail *LinkedNode[T]
10 size int
11}
12
13// NewLinkedList crea una nueva lista vacía.
14//
15// Uso:
16//
17// list := list.NewLinkedList[int]() // Crea una nueva lista vacía.
18func NewLinkedList[T comparable]() *LinkedList[T] {
19 return &LinkedList[T]{}
20}
21
22// Head devuelve el primer nodo de la lista.
23//
24// Uso:
25//
26// head := list.Head() // Obtiene el primer nodo de la lista.
27//
28// Retorna:
29// - el primer nodo de la lista.
30func (l *LinkedList[T]) Head() *LinkedNode[T] {
31 return l.head
32}
33
34// Tail devuelve el último nodo de la lista.
35//
36// Uso:
37//
38// tail := list.Tail() // Obtiene el último nodo de la lista.
39//
40// Retorna:
41// - el último nodo de la lista.
42func (l *LinkedList[T]) Tail() *LinkedNode[T] {
43 return l.tail
44}
45
46// Size devuelve el tamaño de la lista.
47//
48// Uso:
49//
50// size := list.Size() // Obtiene el tamaño de la lista.
51//
52// Retorna:
53// - el tamaño de la lista.
54func (l *LinkedList[T]) Size() int {
55 return l.size
56}
57
58// IsEmpty evalúa si la lista está vacía.
59//
60// Uso:
61//
62// empty := list.IsEmpty() // Verifica si la lista está vacía.
63//
64// Retorna:
65// - `true` si la lista está vacía; `false` en caso contrario.
66func (l *LinkedList[T]) IsEmpty() bool {
67 return l.size == 0
68}
69
70// Clear elimina todos los nodos de la lista.
71//
72// Uso:
73//
74// list.Clear() // Elimina todos los nodos de la lista.
75func (l *LinkedList[T]) Clear() {
76 l.head = nil
77 l.tail = nil
78 l.size = 0
79}
80
81// Prepend inserta un dato al inicio de la lista.
82//
83// Uso:
84//
85// list.Prepend(10) // Inserta el dato 10 al inicio de la lista.
86//
87// Parámetros:
88// - `data`: el dato a insertar en la lista.
89func (l *LinkedList[T]) Prepend(data T) {
90 newNode := NewLinkedListNode(data)
91 if l.IsEmpty() {
92 l.tail = newNode
93 } else {
94 newNode.SetNext(l.head)
95 }
96 l.head = newNode
97 l.size++
98}
99
100// Append inserta un dato al final de la lista.
101//
102// Uso:
103//
104// list.Append(10) // Inserta el dato 10 al final de la lista.
105//
106// Parámetros:
107// - `data`: el dato a insertar en la lista.
108func (l *LinkedList[T]) Append(data T) {
109 newNode := NewLinkedListNode(data)
110 if l.IsEmpty() {
111 l.head = newNode
112 } else {
113 l.tail.SetNext(newNode)
114 }
115 l.tail = newNode
116 l.size++
117}
118
119// Find busca un dato en la lista, si lo encuentra devuelve el nodo
120// correspondiente, si no lo encuentra devuelve nil
121//
122// Uso:
123//
124// node := list.Find(10) // Busca el dato 10 en la lista.
125//
126// Parámetros:
127// - `data`: el dato a buscar en la lista.
128//
129// Retorna:
130// - el nodo que contiene el dato; `nil` si el dato no se encuentra.
131func (l *LinkedList[T]) Find(data T) *LinkedNode[T] {
132 for current := l.head; current != nil; current = current.Next() {
133 if current.Data() == data {
134 return current
135 }
136 }
137
138 return nil
139}
140
141// RemoveFirst elimina el primer nodo de la lista.
142//
143// Uso:
144//
145// list.RemoveFirst() // Elimina el primer nodo de la lista.
146func (l *LinkedList[T]) RemoveFirst() {
147 if l.IsEmpty() {
148 return
149 }
150
151 l.head = l.head.Next()
152
153 if l.head == nil {
154 l.tail = nil
155 }
156
157 l.size--
158}
159
160// RemoveLast elimina el último nodo de la lista.
161//
162// Uso:
163//
164// list.RemoveLast() // Elimina el último nodo de la lista.
165func (l *LinkedList[T]) RemoveLast() {
166 if l.IsEmpty() {
167 return
168 }
169
170 if l.Size() == 1 {
171 l.head = nil
172 l.tail = nil
173 } else {
174 current := l.head
175 for current.Next() != l.tail {
176 current = current.Next()
177 }
178 current.SetNext(nil)
179 l.tail = current
180 }
181 l.size--
182}
183
184// Remove elimina un la primera aparición de un dato en la lista.
185//
186// Uso:
187//
188// list.Remove(10) // Elimina la primera aparición del dato 10 en la lista.
189//
190// Parámetros:
191// - `data`: el dato a eliminar de la lista.
192func (l *LinkedList[T]) Remove(data T) {
193 node := l.Find(data)
194
195 if node == nil {
196 return
197 }
198
199 if node == l.head {
200 l.RemoveFirst()
201 return
202 }
203
204 current := l.Head()
205
206 for current.Next() != node {
207 current = current.Next()
208 }
209
210 current.SetNext(node.Next())
211
212 if node == l.tail {
213 l.tail = current
214 }
215 l.size--
216}
217
218// String devuelve una representación en cadena de la lista.
219//
220// Uso:
221//
222// fmt.Println(list) // Imprime la representación en cadena de la lista.
223//
224// Retorna:
225// - una representación en cadena de la lista.
226func (l *LinkedList[T]) String() string {
227 if l.IsEmpty() {
228 return "LinkedList: []"
229 }
230
231 result := "LinkedList: "
232
233 current := l.Head()
234 for {
235 result += fmt.Sprintf("[%v]", current.Data())
236 if !current.HasNext() {
237 break
238 }
239 result += " → "
240 current = current.Next()
241 }
242
243 return result
244}
16.2. Lista Enlazada Doble#
En la lista doble, en cada nodo se mantienen dos punteros, uno al sucesor y otro al predecesor. Esta lista permite avanzar y retroceder una posición en la lista en tiempo constante \(O(1)\). Su principal uso es en aplicaciones que requieran avanzar y retroceder desde cualquier posición como editores de texto o gestores de historiales de navegación. En la siguiente figura se puede ver una representación gráfica.
Figura 16.5 Implementación de una Lista Enlazada Doble, con punteros a la cabeza y la cola#
16.3. Lista Enlazada Circular#
En una lista circular el último nodo se enlaza al primero para poder recorrer en forma continua la lista, se utiliza para modelar colas, gestión de procesos de un sistema operativo y juegos entre otras aplicaciones. En la siguiente figura se puede ver una implementación de una lista circular doble. Las listas circulares se pueden implementar con enlaces dobles o simples. Como el último y el primer elemento de la lista están enlazados solo es necesario mantener un puntero a la cabeza de la lista.
Figura 16.6 Implementación de una Lista Enlazada Circular Doble, con puntero a la cabeza#
16.4. Implementaciones con Centinelas#
Los centinelas son nodos ficticios que no contienen datos, y se agrega uno al principio y otro al final de la lista.
El propósito de estos centinelas es de alguna forma estandarizar las operaciones primitivas de las listas sin tener que contemplar casos especiales como si la lista está vacía (y por lo tanto no hay ningún nodo) o si estoy parado sobre el último elemento, etc.
Figura 16.7 Implementación de una Lista Enlazada Circular Doble con centinelas#
Hay que prestar especial atención a que ahora la lista vacía contendrá al menos dos nodos que se apuntan entre si y no contienen datos como se observa en la siguiente figura
Figura 16.8 Representación de una Lista Enlazada Circular Doble, vacía, con centinelas#
16.5. Ejercicios#
Implementar una lista circular simple, similar a la lista simple dada.
Implementar una lista enlazada doble, similar a la lista simple dada.
Implementar una lista enlazada doble con centinelas.
Analizar pros y contra de cada una de las implementaciones de lista enlazada doble con y sin centinelas.