Esta guía complementa la práctica de Listas. Cubre el TAD abstracto, las tres implementaciones de la cátedra, el TAD Iterador y casos de uso con análisis de complejidad.
Una lista es una secuencia ordenada de elementos donde el orden importa: cada elemento ocupa una posición (ordinal), y las operaciones respetan esa posición.
El TAD define qué se puede hacer con una lista, sin decir cómo está guardada internamente.
| Operación | Firma | Intención |
|---|---|---|
| Crear | Lista l_crear() |
Devuelve una lista vacía lista para usar. |
| ¿Vacía? | bool l_es_vacia(Lista) |
Indica si la lista no contiene ningún elemento. |
| Longitud | int l_longitud(Lista) |
Devuelve la cantidad de elementos en la lista. |
| Agregar | bool l_agregar(Lista, TipoElemento) |
Inserta un elemento al final de la lista. Devuelve false si la operación no puede realizarse. |
| Insertar | bool l_insertar(Lista, TipoElemento, int pos) |
Inserta un elemento en la posición pos, desplazando hacia adelante los siguientes. Si pos supera la longitud, agrega al final. |
| Borrar | bool l_borrar(Lista, int clave) |
Elimina todos los elementos con la clave indicada. Devuelve false si no encontró ninguno. |
| Eliminar | bool l_eliminar(Lista, int pos) |
Elimina el elemento en la posición pos. |
| Buscar | TipoElemento l_buscar(Lista, int clave) |
Devuelve el primer elemento cuya clave coincide, o NULL si no existe. |
| Recuperar | TipoElemento l_recuperar(Lista, int pos) |
Devuelve el elemento en la posición pos sin eliminarlo, o NULL si pos es inválida. |
Las posiciones en esta cátedra empiezan en 1 (no en 0). El primer elemento está en la posición 1, el último en la posición l_longitud(lista).
listas_arreglos.c)struct ListaRep {
TipoElemento *valores; // arreglo contiguo en heap
int cantidad; // cantidad de elementos actuales
};
Los elementos se almacenan en celdas contiguas de memoria. El campo cantidad actúa como centinela: todo lo que está en valores[0..cantidad-1] es válido.
índice: [0] [1] [2] [3] ... [999]
valores: e1 e2 e3 -- --
↑ ↑
inicio cantidad = 3
calloc(TAMANIO_MAXIMO, ...) al crear la lista; el tamaño es fijo.valores[pos - 1].valores[cantidad]).l_recuperar y l_agregar son O(1): acceso directo sin recorrer nada.TAMANIO_MAXIMO elementos, la lista no crece.listas_punteros.c)struct Nodo {
TipoElemento datos;
struct Nodo *siguiente; // puntero al nodo siguiente
};
struct ListaRep {
struct Nodo *inicio; // puntero al primer nodo
int cantidad;
};
Cada elemento vive en un nodo independiente en el heap. Los nodos se enlazan mediante punteros.
inicio
↓
[e1 | →] → [e2 | →] → [e3 | NULL]
malloc al momento de insertar; se libera con free al borrar.TAMANIO_MAXIMO por consistencia con las otras implementaciones).pos siempre hay que recorrer desde el inicio.l_recuperar, l_insertar y l_eliminar son O(n): hay que recorrer hasta la posición.l_agregar también es O(n): hay que llegar al último nodo para enlazar.siguiente.listas_cursores.c)struct Nodo {
TipoElemento datos;
int siguiente; // índice del siguiente nodo (NULO = -1)
};
struct ListaRep {
struct Nodo *cursor; // arreglo estático de nodos
int inicio; // índice del primer nodo lógico
int libre; // índice del primer nodo disponible
int cantidad;
};
La implementación usa un único arreglo cursor de struct Nodo. Cada nodo tiene dos campos: el TipoElemento y el índice siguiente.
El arreglo cursor simula el heap: en lugar de punteros reales se usan índices enteros. Sobre ese mismo arreglo conviven dos encadenamientos lógicos:
inicio), con los nodos que hoy pertenecen a la lista;libre), con los nodos disponibles.cursor[] (un solo arreglo de nodos):
índice: 0 1 2 3 4 ...
nodo: [e1, sig=2] [--, sig=4] [e2, sig=3] [e3, sig=-1] [--, ...]
inicio = 0
libre = 1
Al crear la lista, todos los nodos se encadenan en la lista de libres (0 -> 1 -> 2 -> ... -> NULO) y inicio queda en NULO.
libre = 0 → 1 → 2 → 3 → ... → 999 → NULO
int siguiente.malloc/free por elemento.malloc/free por elemento: asignación y liberación en O(1).l_recuperar, l_insertar y l_eliminar son O(n) como en punteros.l_agregar es O(n): debe recorrer hasta el último nodo para enlazar.l_crear es O(TAMANIO_MAXIMO).$n$ = cantidad de elementos en la lista. El análisis asume peor caso.
| Operación | Arreglo | Punteros | Cursores | Notas |
|---|---|---|---|---|
l_crear |
$O(N)$ | $O(1)$ | $O(N)$ | Arreglos y cursores alocan $N$ celdas; punteros sólo la cabecera. |
l_es_vacia |
$O(1)$ | $O(1)$ | $O(1)$ | Compara cantidad == 0. |
l_longitud |
$O(1)$ | $O(1)$ | $O(1)$ | El contador cantidad se mantiene actualizado. |
l_agregar |
$O(1)$ | $O(n)$ | $O(n)$ | Arreglos: inserta en valores[cantidad]. Punteros/cursores: recorre hasta el último nodo. |
l_borrar |
$O(n)$ | $O(n)$ | $O(n)$ | Recorre toda la lista para eliminar todas las ocurrencias; desplaza o reenlaza. |
l_buscar |
$O(n)$ | $O(n)$ | $O(n)$ | Búsqueda lineal por clave. |
l_insertar |
$O(n)$ | $O(n)$ | $O(n)$ | Arreglos: desplaza $n - pos$ elementos. Punteros/cursores: recorre hasta pos - 1. |
l_eliminar |
$O(n)$ | $O(n)$ | $O(n)$ | Arreglos: desplaza $n - pos$ elementos. Punteros/cursores: recorre hasta pos - 1. |
l_recuperar |
$O(1)$ | $O(n)$ | $O(n)$ | Arreglos: acceso directo valores[pos-1]. Punteros/cursores: recorre hasta pos. |
Nota sobre $N$: en los casos de
l_crear, $N$ es el tamaño máximo fijo del arreglo (TAMANIO_MAXIMO = 1000), no la cantidad de elementos actuales.
l_recuperar en O(1).l_agregar).Recorrer una lista elemento por elemento es una necesidad muy frecuente. Sin el iterador, la opción más directa sería:
for (int i = 1; i <= l_longitud(lista); i++) {
TipoElemento e = l_recuperar(lista, i);
// procesar e
}
Esto funciona, pero tiene un problema de complejidad:
l_recuperar es O(1), así que el bucle completo es O(n). Correcto.l_recuperar es O(n), así que el bucle completo es O(n²). Muy ineficiente.El Iterador resuelve esto encapsulando el recorrido de forma eficiente: recuerda en qué punto de la lista está y avanza un paso en O(1), sin importar la implementación.
Iterador iterador(Lista lista); // crea un iterador apuntando al primer elemento
bool hay_siguiente(Iterador iter); // true si quedan elementos por visitar
TipoElemento siguiente(Iterador iter); // devuelve el elemento actual y avanza
Iterador iter = iterador(lista);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
// procesar e
}
free(iter);
El bucle completo es O(n) para las tres implementaciones.
struct IteradorRep {
Lista lista;
int posicionActual; // índice en el arreglo (empieza en 0)
};
Iterador iterador(Lista lista) {
Iterador iter = malloc(sizeof(struct IteradorRep));
iter->lista = lista;
iter->posicionActual = 0;
return iter;
}
bool hay_siguiente(Iterador iter) {
return iter->posicionActual < iter->lista->cantidad;
}
TipoElemento siguiente(Iterador iter) {
return iter->lista->valores[iter->posicionActual++];
}
El estado del iterador es simplemente un entero que avanza de 0 a cantidad - 1.
struct IteradorRep {
struct Nodo *posicionActual; // puntero al nodo actual
};
Iterador iterador(Lista lista) {
Iterador iter = malloc(sizeof(struct IteradorRep));
iter->posicionActual = lista->inicio;
return iter;
}
bool hay_siguiente(Iterador iter) {
return iter->posicionActual != NULL;
}
TipoElemento siguiente(Iterador iter) {
TipoElemento actual = iter->posicionActual->datos;
iter->posicionActual = iter->posicionActual->siguiente;
return actual;
}
El estado del iterador es un puntero que sigue la cadena de nodos.
Ejemplo rápido de avance del iterador (punteros):
inicio -> [A | * ] -> [B | * ] -> [C | NULL]
posicionActual = inicio (apunta al nodo con A).siguiente: devuelve A y avanza al nodo con B.siguiente: devuelve B y avanza al nodo con C.siguiente: devuelve C y avanza a NULL.hay_siguiente devuelve false porque posicionActual == NULL.struct IteradorRep {
Lista lista;
int posicionActual; // índice en el arreglo cursor
};
Iterador iterador(Lista lista) {
Iterador iter = malloc(sizeof(struct IteradorRep));
iter->lista = lista;
iter->posicionActual = lista->inicio; // índice del primer nodo lógico
return iter;
}
bool hay_siguiente(Iterador iter) {
return iter->posicionActual != NULO; // NULO = -1
}
TipoElemento siguiente(Iterador iter) {
TipoElemento actual = iter->lista->cursor[iter->posicionActual].datos;
iter->posicionActual = iter->lista->cursor[iter->posicionActual].siguiente;
return actual;
}
El estado del iterador es un entero que sigue la cadena de índices, igual que el puntero en la versión de punteros pero usando índices en lugar de direcciones.
Ejemplo rápido de avance del iterador (cursores):
inicio = 5
cursor[5] = [dato=A, siguiente=9]
cursor[9] = [dato=B, siguiente=2]
cursor[2] = [dato=C, siguiente=-1]
posicionActual = 5.siguiente: devuelve A y deja posicionActual = 9.siguiente: devuelve B y deja posicionActual = 2.siguiente: devuelve C y deja posicionActual = -1.hay_siguiente devuelve false porque -1 representa NULO.| Operación | Arreglo | Punteros | Cursores |
|---|---|---|---|
iterador(lista) |
$O(1)$ | $O(1)$ | $O(1)$ |
hay_siguiente(iter) |
$O(1)$ | $O(1)$ | $O(1)$ |
siguiente(iter) |
$O(1)$ | $O(1)$ | $O(1)$ |
| Recorrido completo | $O(n)$ | $O(n)$ | $O(n)$ |
Objetivo: recorrer la lista e imprimir cada elemento.
void imprimir_lista(Lista lista) {
Iterador iter = iterador(lista);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
printf("%d\n", e->clave);
}
free(iter);
}
Complejidad:
| Implementación | Complejidad |
|---|---|
| Arreglo | $O(n)$ |
| Punteros | $O(n)$ |
| Cursores | $O(n)$ |
El iterador garantiza O(n) en todos los casos. Sin iterador, usando l_recuperar en un for, punteros y cursores serían O(n²).
Objetivo: encontrar el elemento con mayor clave en la lista.
TipoElemento maximo(Lista lista) {
if (l_es_vacia(lista)) return NULL;
Iterador iter = iterador(lista);
TipoElemento max = siguiente(iter);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
if (e->clave > max->clave) {
max = e;
}
}
free(iter);
return max;
}
Complejidad:
| Implementación | Complejidad |
|---|---|
| Arreglo | $O(n)$ |
| Punteros | $O(n)$ |
| Cursores | $O(n)$ |
Búsqueda lineal: hay que visitar todos los elementos en el peor caso.
Objetivo: contar cuántas veces aparece una clave determinada.
int contar_ocurrencias(Lista lista, int clave) {
int contador = 0;
Iterador iter = iterador(lista);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
if (e->clave == clave) contador++;
}
free(iter);
return contador;
}
Complejidad:
| Implementación | Complejidad |
|---|---|
| Arreglo | $O(n)$ |
| Punteros | $O(n)$ |
| Cursores | $O(n)$ |
Siempre O(n): hay que recorrer toda la lista porque puede haber duplicados al final.
Objetivo: insertar un elemento en la posición correcta para que la lista quede ordenada.
void insertar_ordenado(Lista lista, TipoElemento nuevo) {
int pos = 1;
for (int i = 1; i <= l_longitud(lista); i++) {
TipoElemento e = l_recuperar(lista, i);
if (e->clave <= nuevo->clave) {
pos = i + 1;
} else {
break;
}
}
l_insertar(lista, nuevo, pos);
}
Complejidad:
| Implementación | Búsqueda de posición | Inserción | Total |
|---|---|---|---|
| Arreglo | $O(n)$ | $O(n)$ | $O(n)$ |
| Punteros | $O(n^2)$ | $O(n)$ | $O(n^2)$ |
| Cursores | $O(n^2)$ | $O(n)$ | $O(n^2)$ |
En arreglos l_recuperar es O(1), por lo que el bucle de búsqueda es O(n). En punteros y cursores, cada llamada a l_recuperar es O(n), lo que hace que el bucle sea O(n²).
Versión optimizada con iterador (O(n) en todas las implementaciones):
void insertar_ordenado_v2(Lista lista, TipoElemento nuevo) {
int pos = 1;
Iterador iter = iterador(lista);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
if (e->clave <= nuevo->clave) pos++;
else break;
}
free(iter);
l_insertar(lista, nuevo, pos);
}
| Implementación | Complejidad |
|---|---|
| Arreglo | $O(n)$ |
| Punteros | $O(n)$ |
| Cursores | $O(n)$ |
La búsqueda de posición pasa a ser O(n) en todos los casos al usar el iterador. La inserción sigue siendo O(n) por el desplazamiento o recorrido hasta la posición.
Objetivo: dada una lista, construir una nueva con los mismos elementos en orden inverso.
Lista invertir_lista(Lista original) {
Lista invertida = l_crear();
Iterador iter = iterador(original);
while (hay_siguiente(iter)) {
TipoElemento e = siguiente(iter);
l_insertar(invertida, e, 1); // inserta siempre al principio
}
free(iter);
return invertida;
}
Análisis: el bucle ejecuta l_longitud(original) veces. Dentro del bucle, l_insertar en posición 1 tiene distinta complejidad según implementación:
Complejidad:
| Implementación | Complejidad |
|---|---|
| Arreglo | $O(n^2)$ |
| Punteros | $O(n)$ |
| Cursores | $O(n)$ |
Este caso ilustra una situación donde punteros y cursores superan claramente a arreglos: las inserciones repetidas al inicio tienen un costo muy diferente.