Unidad 4. Estructuras de datos dinámicas
Objetivos de la Unidad
- Comprender las limitaciones de los arrays estáticos frente a las estructuras dinámicas.
- Conocer la arquitectura del Java Collection Framework (JCF) y sus interfaces principales.
- Manejar
ArrayListpara almacenar, recorrer, buscar y ordenar colecciones de objetos. - Utilizar
HashSetpara garantizar unicidad de elementos. - Utilizar
HashMappara asociar pares clave-valor y resolver problemas de acceso directo. - Comprender el autoboxing/unboxing y los tipos genéricos (
<T>).
1. Introducción: Limitaciones del Almacenamiento Estático
1.1. Estructuras Estáticas (Arrays)
En el desarrollo de software profesional, la gestión de datos rara vez es predecible. Hasta ahora, hemos utilizado Arrays, que son estructuras de almacenamiento estático. Esto implica que su tamaño se define en tiempo de compilación o inicialización y no puede modificarse durante la ejecución.
-
Ventajas:
- Rendimiento extremo: Acceso directo a memoria y muy ligeros.
- Simplicidad: Ideales cuando sabemos exactamente cuántos datos vamos a manejar (ej: días de la semana, meses del año).
-
El problema del Array:
- Rigidez: Si declaramos
int[] datos = new int[10];, estamos limitados a 10 elementos. No se pueden redimensionar. Si te quedas corto, el programa falla (ArrayIndexOutOfBoundsException). - Ineficiencia: Si solo usamos 2 posiciones, desperdiciamos memoria reservada para las otras 8.
- Complejidad de gestión: Insertar un elemento en medio requiere mover manualmente todos los elementos posteriores.
- Rigidez: Si declaramos
En programación, la forma en que almacenamos y organizamos los datos determina la eficiencia y la flexibilidad de nuestras aplicaciones.
1.2. Estructuras Dinámicas (Colecciones)
Para resolver estos problemas, Java proporciona el Java Collection Framework (JCF). Este conjunto de clases e interfaces permite gestionar grupos de objetos de forma dinámica, adaptando el uso de memoria a las necesidades del programa en tiempo de ejecución.
-
Ventajas:
- Flexibilidad total: Crecen y decrecen según la necesidad.
- Potencia: Incluyen algoritmos ya programados para buscar, ordenar, barajar, invertir, etc.
- Abstracción: Nos olvidamos de la gestión de memoria de bajo nivel.
-
Desventajas:
- Mayor consumo de memoria: Tienen una pequeña sobrecarga (overhead) por ser objetos complejos.
- Tipado: Solo trabajan con Objetos (requieren Wrappers para tipos primitivos), lo que añade un pequeñísimo coste de procesamiento.
2. Java Collection Framework (JCF)
Java no nos deja solos con la tarea de programar estructuras dinámicas. Nos ofrece el Java Collection Framework (JCF), una arquitectura unificada para representar y manipular colecciones.
El JCF no es solo "una lista de clases". Es un diseño de ingeniería de software basado en tres pilares:
- Interfaces: Definen QUÉ se puede hacer (el contrato). Ej:
List,Set,Map. Nos permiten programar contra abstracciones (ej: "necesito una Lista", sin importar si esArrayListoLinkedList). - Implementaciones: Son las clases concretas que definen CÓMO se hace. Ej:
ArrayList,HashSet. Cada una tiene características de rendimiento diferentes. - Algoritmos: Métodos polimórficos reutilizables (ordenar, buscar, copiar) que funcionan sobre las interfaces.
2.1 La Jerarquía Principal
La interfaz raíz es Collection, de la que descienden las principales familias (aunque Map no hereda de ella):
List(Listas): Define una colección ordenada que permite elementos duplicados.Set(Conjuntos): Colección no ordenada (generalmente), NO permiten duplicados.HashSet(Mapas): Almacenan elementos individuales que asocia una clave con un valor. Las claves no pueden estar duplicadas.Queue(Colas): Estructuras FIFO (First In, First Out).Stack(Pilas): Estructuras LIFO (Last In, First Out).SortedSet: Conjuntos ordenados.
2.2. Métodos de la Interfaz Collection
El JCF está diseñado en base a Interfaces, que definen el comportamiento, y Clases, que implementan ese comportamiento.
La interfaz raíz es Collection, que define las operaciones básicas comunes a casi todas las estructuras de datos dinámicas (excepto los Mapas, que tienen su propia jerarquía).
Cualquier clase que implemente Collection (como ArrayList o HashSet) garantiza la disponibilidad de estos métodos:
| Método | Descripción Técnica |
|---|---|
int size() |
Retorna el número de elementos almacenados. |
boolean isEmpty() |
Retorna true si la colección no contiene elementos. |
boolean add(E e) |
Asegura que la colección contiene el elemento especificado. Retorna true si la colección cambió. |
boolean remove(Object o) |
Elimina una instancia del elemento especificado si está presente. |
boolean contains(Object o) |
Retorna true si la colección contiene el elemento especificado. |
void clear() |
Elimina todos los elementos de la colección. |
2.3. Algoritmos de la clase Collections (¡No confundir con Collection!)
Java proporciona una clase de utilidad llamada Collections (plural, con "s" al final) que contiene métodos estáticos muy potentes para operar sobre las colecciones. Son como la "navaja suiza" del framework.
Estos son los tres más utilizados:
Collections.sort(List lista): Ordena una lista en orden ascendente (natural). Si es de números, de menor a mayor; si es de Strings, alfabéticamente.Collections.max(Collection col): Busca y devuelve el elemento máximo de la colección.Collections.min(Collection col): Busca y devuelve el elemento mínimo.
3. Fundamentos: Clases Wrapper y Tipos Genéricos
Antes de abordar las colecciones, debemos comprender una restricción fundamental de la arquitectura de Java:
Cuidado
Las Colecciones en Java solo pueden almacenar referencias a OBJETOS, no tipos primitivos.
Esto significa que no podemos crear un ArrayList directamente de tipos int, double o boolean. Para solucionar esto, Java utiliza las Clases Wrapper (Envoltorios).
3.1. Clases Wrapper (Envoltorios)
Cada tipo primitivo tiene una clase correspondiente que "envuelve" su valor en un objeto. Esto permite que los valores primitivos sean tratados como objetos y almacenados en colecciones.
| Tipo Primitivo | Clase Wrapper (Objeto) |
|---|---|
int |
Integer |
double |
Double |
char |
Character |
boolean |
Boolean |
3.2. Autoboxing y Unboxing
Java facilita la conversión entre primitivos y Wrappers mediante dos mecanismos automáticos del compilador:
- Autoboxing: Conversión automática de un tipo primitivo a su objeto Wrapper correspondiente.
- Unboxing: Conversión automática de un objeto Wrapper a su tipo primitivo.
4. Listas: Secuencias Ordenadas
La interfaz List es, con diferencia, la estructura más utilizada en el día a día del desarrollo. Si vienes del mundo de los arrays, la Lista es su evolución natural y supervitaminada.
4.1. ¿Qué es una Lista?
Una Lista es una colección ordenada (una secuencia). Imagínala como una "Lista de la Compra" o una "Lista de Reproducción" de Spotify:
- El Orden Importa: Si añades "Pan", luego "Leche" y luego "Huevos", se guardan en ese orden exacto. El "Pan" es el primero (índice 0), la "Leche" el segundo (índice 1), etc.
- Admite Duplicados: Puedes tener "Leche" dos veces en tu lista si necesitas comprar dos cartones. A la lista no le importa que el contenido sea igual, son dos elementos distintos en posiciones distintas.
- Acceso por Posición: Puedes decir "dame el tercer elemento" (
get(2)) o "borra el primer elemento" (remove(0)).
4.2. ¿Para qué la usamos?
Usamos listas siempre que necesitemos agrupar elementos y nos importe el orden en que están, o necesitemos acceder a ellos por su posición numérica.
- Una lista de alumnos en una clase.
- El historial de páginas visitadas en un navegador.
- Los mensajes de un chat (ordenados cronológicamente).
4.3. Implementaciones: ArrayList vs LinkedList
Aunque hay varias, en el 99% de los casos usarás una de estas dos:
ArrayList: Internamente usa un array. Es rapidísima para leer (get), pero si insertas algo en medio, tiene que mover todos los elementos siguientes un paso a la derecha. Es la opción por defecto.LinkedList: Internamente es una cadena de nodos enlazados. Es rápida insertando en medio, pero lenta para leer porque tiene que recorrer la cadena nodo a nodo.
4.4. Métodos Principales de List
A continuación, tienes las herramientas que usarás constantemente. Supongamos una lista llamada lista.
| Método | Descripción | Ejemplo de Código |
|---|---|---|
add(E elemento) |
Añade el elemento al final de la lista. | lista.add("Java"); |
add(int index, E e) |
Inserta el elemento en la posición indicada, desplazando al resto. | lista.add(0, "Python"); |
get(int index) |
Devuelve el elemento que está en esa posición. | String s = lista.get(1); |
set(int index, E e) |
Sustituye el elemento de esa posición por uno nuevo. | lista.set(1, "C++"); |
remove(int index) |
Elimina el elemento de esa posición y "cierra el hueco". | lista.remove(0); |
remove(Object o) |
Busca el primer elemento igual a ese y lo borra. | lista.remove("Java"); |
size() |
Devuelve el número de elementos actuales. | int n = lista.size(); |
contains(Object o) |
Devuelve true si la lista contiene ese elemento. |
boolean b = lista.contains("C++"); |
indexOf(Object o) |
Devuelve el índice de la primera aparición (o -1 si no está). | int pos = lista.indexOf("C++"); |
clear() |
Vacía la lista por completo. | lista.clear(); |
4.5. Visualizando una Lista
Este diagrama muestra cómo una lista (ArrayList) gestiona sus elementos y sus índices. Observa que los índices siempre son consecutivos (0, 1, 2...).
graph LR
subgraph "Estructura de la Lista"
direction LR
N0[Índice 0: <br>'Ana'] --> N1[Índice 1: <br>'Beto']
N1 --> N2[Índice 2: <br>'Carla']
N2 --> N3[Índice 3: <br>'Dani']
end
style N0 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style N1 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style N2 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style N3 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
4.6. Manejo básico de una lista
- Instanciación de una Lista: Para comenzar a trabajar con listas, lo primero que tenemos que saber es cómo instanciarlas (declararlas).
- Añadir elementos a una lista: Para añadir elementos al final de una lista usaremos el método
add. Ten en cuenta que el wrapper tiene que ser el mismo que usamos en la instanciación.
- Iteración de una lista (recorrer elementos): Usaremos un bucle
forpero de una manera ligeramente distinta a la que hemos visto hasta ahora.for( [tipo_dato] nombre_elemento : nombre_lista ) {}.
Si lanzamos la ejecución del programa, la salida estándar por pantalla es la siguiente:
4.7. Ejemplo Funcional: Gestión de Inventario con ArrayList
A continuación, vamos a ver un ejemplo funcional con la mayoría de métodos de uso de una lista. Queremos un Gestor de inventario que realice las principales operaciones CRUD (Create, Read, Update, Delete):
- Crear: Añadir un elemento a la lista.
- Leer: Accedemos a un elemento de una lista (según posición).
- Actualizar: Modificamos el valor de un elemento en una posición.
- Eliminar: Borramos un elemento. Podemos hacerlo por su valor o por su posición.
También haremos uso de operaciones de utilidad:
isEmpty(): Indica si la lista está vacía o no.size(): Devuelve el tamaño de la listacontains([elemento]): Indica si un elemento está o no en la lista.
🔎 Comprobación
Comprueba el funcionamiento del programa. Copia el código y ejecútalo en tu entorno para comprender cómo funciona.
Vamos a pornerlo en práctica
Vamos a comenzar a crear un ejemplo. Crea un programa con una lista de Videojuegos que implemente y pruebe las operaciones CRUD que hemos visto.
4.8. Operaciones Especiales: Inserción y Borrado Condicional
Antes de ver el ejemplo completo, es importante entender dos operaciones que distinguen a las listas de los arrays:
A. Inserción en medio de la lista
A diferencia de los arrays, donde insertar un dato en medio requiere mover manualmente todos los elementos siguientes, las listas tienen un método mágico: add(int index, E element).
- ¿Qué hace? Hace un hueco en la posición
index, inserta el nuevo dato y desplaza automáticamente todos los elementos posteriores una posición a la derecha. - Ejemplo: Si tienes
[A, C]y haceslista.add(1, "B"), la lista pasa a ser[A, B, C].
B. Borrado Condicional (El peligro del bucle for)
Borrar elementos de una lista mientras la recorres es una de las fuentes de errores más comunes en Java.
- El Problema: Si usas un bucle
forofor-eachy borras un elemento (lista.remove(i)), el tamaño de la lista cambia instantáneamente y los índices se mueven. Esto suele provocar que te saltes elementos o que el programa falle con unaConcurrentModificationException. - La Solución: Para borrar de forma segura mientras recorres, debes usar un Iterador (
Iterator). El iterador es un objeto que sabe "caminar" sobre la lista y tiene un métodoremove()seguro que mantiene la integridad de la estructura.
Ejemplo Funcional: Gestor de Notas
Vamos a ver un programa completo que gestiona las notas de una clase aplicando estos conceptos. Fíjate en cómo insertamos una nota olvidada en medio de la lista y cómo borramos los suspensos de forma segura.
Salida esperada del programa:
5. Conjuntos (Set): Unicidad Garantizada
La interfaz Set modela la abstracción matemática de un conjunto. Su característica definitoria es que NO permite elementos duplicados.
- Sin orden (generalmente): No garantiza que los elementos se guarden en el orden en que se insertaron.
- Sin acceso posicional: No existe el método
get(i). No puedes pedir "el tercer elemento", porque no hay un orden secuencial fijo.
¿Cuándo usar un Set?
Úsalo cuando necesites garantizar unicidad: lista de correos para no enviar spam duplicado, DNI de usuarios, matrículas de coches en un parking, etc.
5.1. Implementaciones Principales
HashSet: Es la más rápida y utilizada. No garantiza ningún orden. Usa una tabla hash interna.TreeSet: Ordena los elementos automáticamente (por orden natural o comparador). Es más lento al insertar porque tiene que ordenar.LinkedHashSet: Mantiene el orden de inserción, pero es un poco más lento queHashSet.
5.2. Visualizando un Conjunto (Diagrama)
En este diagrama vemos cómo funciona un Conjunto (HashSet):
- Los elementos no tienen una posición fija (índice).
- Si intentamos añadir un duplicado (ej: 'Ana'), el conjunto lo rechaza y se mantiene igual.
graph LR
subgraph "Conjunto"
direction TB
E1((Ana))
E2((Beto))
E3((Carla))
end
Nuevo((Ana)) -- "add('Ana')" --> E1
style Nuevo fill:#ffcccc,stroke:#cc0000,stroke-width:2px,stroke-dasharray: 5 5
style E1 fill:#ccffcc,stroke:#009900,stroke-width:2px
linkStyle 0 stroke:#cc0000,stroke-width:2px,color:red;
Nota: La línea discontinua roja indica que la inserción del duplicado es rechazada.
5.3. Métodos Principales de Set
Supongamos un conjunto llamado conjunto.
| Método | Descripción | Ejemplo de Código |
|---|---|---|
add(E elemento) |
Añade el elemento si no existe. Devuelve true si lo añadió, false si ya estaba. |
boolean exito = conjunto.add("Ana"); |
remove(Object o) |
Elimina el elemento si existe. Devuelve true si lo eliminó. |
conjunto.remove("Beto"); |
size() |
Devuelve el número de elementos únicos. | int n = conjunto.size(); |
contains(Object o) |
Devuelve true si el conjunto contiene ese elemento. |
boolean b = conjunto.contains("Carla"); |
isEmpty() |
Devuelve true si el conjunto está vacío. |
boolean b = conjunto.isEmpty(); |
clear() |
Elimina todos los elementos. | conjunto.clear(); |
5.4. Ejemplo Funcional: Invitados a una Fiesta
🔎 Comprobación
Comprueba el funcionamiento del programa. Copia el código y ejecútalo en tu entorno para comprender cómo funciona.
Vamos a pornerlo en práctica
Vamos a crear un programa que use conjuntos. El programa definirá un conjunto de Frutas que implemente y pruebe las operaciones de conjuntos (Set) que hemos visto.
6. Mapas: Estructuras Asociativas (Clave-Valor)
La interfaz Map es una de las estructuras más potentes y utilizadas. A diferencia de las Listas y Conjuntos, un Mapa no guarda elementos sueltos, sino que establece una relación entre dos objetos: una Clave (Key) y un Valor (Value).
6.1. ¿Qué es un Mapa? (La analogía del Diccionario)
Imagina un diccionario de inglés:
- Buscas una palabra (Clave) -> Encuentras su definición (Valor).
- O una agenda telefónica: Buscas un nombre (Clave) -> Encuentras su número (Valor).
- O una base de datos de empleados: Buscas por DNI (Clave) -> Obtienes la ficha del empleado (Valor).
Reglas de Oro de los Mapas:
- Claves Únicas: No puede haber dos claves iguales (no puede haber dos entradas para la palabra "House" en el diccionario). Si añades una clave que ya existe, sobrescribes el valor anterior.
- Valores Repetidos: Sí puede haber valores repetidos (varias personas pueden vivir en la misma ciudad).
- Acceso Rápido: Los mapas están optimizados para encontrar el valor rápidamente si conoces la clave.
6.2. Visualizando un Mapa (Diagrama)
En este diagrama vemos cómo las claves actúan como un índice único para acceder a los valores.
graph LR
subgraph "Claves (Keys)"
K1[1111A]
K2[2222B]
K3[3333C]
end
subgraph "Valores (Values)"
V1[Ana López]
V2[Carlos Ruiz]
V3[Beatriz Aranda]
end
K1 --> V1
K2 --> V2
K3 --> V3
style K1 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style K2 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style K3 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
6.3. Implementación Principal: HashMap
La interfaz Map no hereda de Collection, pero es parte fundamental del framework. Modela el concepto de "diccionario".
- Estructura: Almacena pares
(Key, Value). - Unicidad: Las claves (Keys) son únicas.
- Acceso: Los datos se recuperan por clave.
HashMap es la implementación estándar. No garantiza ningún orden específico de las claves.
6.4. Métodos Principales de Map
Supongamos un mapa llamado mapa que asocia DNI (String) con Nombre (String).
| Método | Descripción | Ejemplo de Código |
|---|---|---|
put(K key, V value) |
Añade una pareja o actualiza el valor si la clave ya existía. | mapa.put("123A", "Pepe"); |
get(Object key) |
Devuelve el valor asociado a la clave, o null si no existe. |
String nombre = mapa.get("123A"); |
remove(Object key) |
Elimina la pareja asociada a esa clave. | mapa.remove("123A"); |
containsKey(Object key) |
Devuelve true si la clave existe en el mapa. |
boolean existe = mapa.containsKey("123A"); |
keySet() |
Devuelve un Set con todas las claves. |
Set<String> claves = mapa.keySet(); |
values() |
Devuelve una Collection con todos los valores. |
Collection<String> nombres = mapa.values(); |
entrySet() |
Devuelve un Set de objetos Map.Entry (parejas clave-valor). Ideal para recorrer. |
Set<Map.Entry<String, String>> pares = mapa.entrySet(); |
size() |
Devuelve el número de parejas almacenadas. | int n = mapa.size(); |
clear() |
Vacía el mapa. | mapa.clear(); |
6.5. Ejemplo Funcional: Censo de Población
🔎 Comprobación
Comprueba el funcionamiento del programa. Copia el código y ejecútalo en tu entorno para comprender cómo funciona.
Vamos a pornerlo en práctica
Vamos a crear un programa que maneje un diccionario, mapa o HashMap que almacene pares clave-valor. El programa definirá un diccionario de Coches que asociará cada matrícula con su modelo de coche. Recuerda que los valores pueden repetirse pero las claves no.