Skip to content

Estructuras de Datos Dinámicas en Java

¡Hola, futuros arquitectos de software! En la unidad anterior, trabajamos con arrays, que son como trenes de mercancías: increíblemente eficientes para transportar una cantidad fija de vagones en una vía recta. Sabes exactamente cuántos vagones tienes, y llegar al quinto vagón es súper rápido. Pero, ¿qué pasa si a mitad de camino necesitas añadir un vagón extra? O quitar uno del medio... ¡Tienes que parar todo el tren, desmontarlo y volverlo a montar! Es rígido y poco práctico para situaciones cambiantes.

Ahora, vamos a aparcar el tren y a coger las llaves de un coche en una gran ciudad. Con un coche, puedes ir donde quieras, cambiar de ruta, recoger a más gente por el camino y no tienes un número fijo de asientos para todo tu viaje. Esta flexibilidad es la esencia de las estructuras de datos dinámicas.

En esta unidad, nos sumergiremos en el Java Collections Framework (JCF), que es como el concesionario de vehículos de Java. Nos ofrece un catálogo impresionante de "vehículos" para gestionar nuestros datos: desde las versátiles "furgonetas" ArrayList y los "trenes de carretera" LinkedList, hasta los "almacenes con direcciones únicas" HashMap. Aprenderemos a elegir el vehículo correcto para cada tipo de carga (datos) y cada tipo de viaje (operación). ¡Abróchate el cinturón, que arrancamos!



1. Introducción a las estructuras de datos dinámicas

Cuando el volumen de datos que una aplicación debe manejar crece, las variables simples y los arrays estáticos se quedan cortos. Imagina gestionar un único pedido en una tienda online: podrías usar unas cuantas variables. Pero, ¿qué ocurre cuando tienes que gestionar miles de pedidos simultáneamente, cada uno con su cliente, su lista de productos y su estado? Aquí es donde entran en juego las estructuras de datos dinámicas.

Concepto de Colección

Definición: Colección (Collection)

Una colección es un objeto que agrupa múltiples elementos en una sola unidad. Representa un conjunto de objetos que, a priori, tienen alguna relación entre sí. Piénsalo como una "caja" inteligente diseñada para almacenar y manipular otros objetos de forma eficiente.

El Java Collections Framework (JCF) es la librería estándar de Java que nos proporciona un conjunto de interfaces y clases para trabajar con estas colecciones. Es una de las APIs más importantes y utilizadas del lenguaje.

Diferencias Respecto a Arrays

Los arrays, como vimos en la UD4, son la estructura de datos más básica. Las colecciones son una evolución que soluciona muchas de sus limitaciones.

Característica Arrays (String[]) Colecciones (List<String>)
Tamaño Fijo. Se define en la creación y no se puede cambiar. Dinámico. Crece o decrece automáticamente según se añaden o eliminan elementos.
Funcionalidad Básica. Ofrece acceso por índice, pero pocas operaciones integradas. Rica. Incluye métodos para añadir, eliminar, buscar, ordenar, etc.
Tipo de Datos Puede almacenar tipos primitivos (int, char) y objetos. Solo almacena objetos. Para primitivos, usa las clases Wrapper (Integer, Character).
Flexibilidad Baja. Modificar (insertar/eliminar en medio) es ineficiente. Alta. Proporciona implementaciones optimizadas para diferentes casos de uso.

Tipos Fundamentales

El JCF nos ofrece una variedad de estructuras, cada una con sus propias fortalezas. Estas son las más importantes:

  • Listas (List): Colecciones ordenadas de elementos. Permiten duplicados y el acceso a los elementos es por su posición (índice). Son como un estante de libros numerado.
  • Colas (Queue): Colecciones diseñadas para mantener elementos antes de su procesamiento. Siguen el principio FIFO (First-In, First-Out), como la cola del supermercado.
  • Pilas (Stack): Colecciones que siguen el principio LIFO (Last-In, First-Out). El último elemento en entrar es el primero en salir, como una pila de platos.
  • Conjuntos (Set): Colecciones que no permiten elementos duplicados. Son como una bolsa de canicas únicas.
  • Mapas (Map): Colecciones que almacenan pares clave-valor. Cada clave es única y se usa para recuperar su valor asociado. Son como un diccionario.
  • Árboles (Tree): Estructuras jerárquicas no lineales donde cada elemento (nodo) puede tener hijos. Son excelentes para almacenar datos ordenados y realizar búsquedas eficientes.
  • Grafos (Graph): Estructuras no lineales compuestas por nodos y las conexiones (aristas) entre ellos. Perfectas para modelar redes (sociales, de carreteras, etc.).
  • Tablas Hash (Hash Table): Estructuras que usan una función "mágica" (hash) para asignar a cada elemento una ubicación única, permitiendo inserciones y búsquedas increíblemente rápidas (promedio O(1)). Son la base de HashSet y HashMap.

Reflexiona

  1. ¿Por qué un ArrayList es más flexible que un array tradicional para gestionar la lista de la compra?
  2. Si estás desarrollando el historial de "Deshacer" (Ctrl+Z) de un editor de texto, ¿qué estructura de datos te parece más adecuada y por qué?
  3. ¿Qué estructura de datos usarías para almacenar los contactos de tu teléfono, donde cada nombre está asociado a un número?
  4. ¿Por qué las colecciones en Java no pueden almacenar tipos primitivos como int directamente?



2. Interfaces Base de Colecciones en Java

El Java Collections Framework (JCF) está brillantemente diseñado en torno a un conjunto de interfaces. Esto significa que aprendes a programar contra un "contrato" (List, Set, Map), y puedes cambiar la implementación concreta (ArrayList, HashSet) con un impacto mínimo en tu código.

Interfaz Collection<E>: La Raíz de Todo

Casi todas las colecciones en Java (excepto los Mapas) heredan de la interfaz Collection<E>. Esta interfaz define el comportamiento más básico que cualquier colección debe tener.

Definición: Interfaz Collection<E>

Es la interfaz raíz de la jerarquía de colecciones. Define las operaciones fundamentales como añadir, eliminar, y consultar elementos, sin especificar detalles como el orden o si se permiten duplicados. La E es un parámetro de tipo genérico, que se sustituye por la clase concreta de objetos que queremos almacenar (ej: Collection<String>, Collection<Alumno>).

Operaciones Principales

Método Descripción
boolean add(E elemento) Añade un elemento a la colección. Devuelve true si la colección cambió.
boolean remove(Object objeto) Elimina una única instancia del objeto especificado.
int size() Devuelve el número de elementos en la colección.
boolean isEmpty() Devuelve true si la colección no tiene elementos.
boolean contains(Object objeto) Devuelve true si la colección contiene el elemento especificado.
void clear() Elimina todos los elementos de la colección.
Object[] toArray() Convierte la colección en un array de objetos.
Iterator<E> iterator() Devuelve un iterador para recorrer los elementos de la colección (lo veremos en detalle más adelante).

Decidir qué Colección Utilizar

Antes de empezar a programar, hazte estas tres preguntas clave. La respuesta te guiará hacia la colección correcta como si fuera un GPS.

  1. ¿Necesito mantener el orden?
    • Sí: Necesitas una List (ArrayList, LinkedList).
    • No: Un Set (HashSet) puede ser más eficiente.
  2. ¿Necesito almacenar elementos duplicados?
    • Sí: Una List es tu opción.
    • No: Debes usar un Set.
  3. ¿Necesito acceder a elementos por una clave única en lugar de por su posición?
    • Sí: Necesitas un Map (HashMap, TreeMap).
graph TD
    Start{Inicio: ¿Qué necesito?} --> Q1{¿Necesito<br>duplicados?};
    Q1 -- Sí --> Q2{¿Necesito<br>orden?};
    Q1 -- No --> UseSet[Usa un Set: HashSet, TreeSet];

    Q2 -- Sí, por inserción o acceso posicional --> UseList[Usa una List: ArrayList, LinkedList];
    Q2 -- Sí, por orden natural o personalizado --> UseTreeSet[Considera un TreeSet o una List ordenada];
    Q2 -- No --> UseSet;

    Start --> Q3{¿Necesito<br>pares Clave-Valor?};
    Q3 -- Sí --> UseMap[Usa un Map: HashMap, TreeMap];
    Q3 -- No --> Q1;

Reflexiona

  1. Estás creando una baraja de cartas para un juego. No puede haber cartas repetidas. ¿Qué interfaz de colección principal usarías?
  2. Necesitas almacenar el historial de páginas web visitadas por un usuario, en el orden en que las visitó. ¿Qué interfaz es la más adecuada?
  3. Para una agenda, necesitas asociar el NIF de una persona (que es único) con su objeto Persona completo. ¿Qué estructura elegirías?
  4. ¿Por qué crees que el método add() devuelve un boolean en lugar de void? ¿En qué tipo de colección podría devolver false?



3. Listas en Java (List<E>)

Las listas son, con diferencia, la colección más común y versátil que usarás. Son la evolución natural de los arrays, pero con superpoderes.

3.1 Concepto y Características

Definición: Interfaz List<E>

Una List es una subinterfaz de Collection que representa una colección ordenada de elementos (también conocida como secuencia). A diferencia de los Set, las listas permiten elementos duplicados.

Sus características clave son:

  • Elementos Ordenados: Las listas mantienen el orden de inserción. El primer elemento que añades estará en la posición 0, el segundo en la 1, y así sucesivamente.
  • Permiten Duplicados: Puedes añadir el mismo objeto a una lista varias veces.
  • Acceso Posicional (por Índice): Puedes acceder, modificar o eliminar elementos especificando su posición numérica (índice), igual que en un array.

La interfaz List hereda todos los métodos de Collection y añade otros nuevos basados en el índice:

Método Descripción
E get(int index) Devuelve el elemento en la posición especificada.
E set(int index, E element) Reemplaza el elemento en la posición index con element.
void add(int index, E element) Inserta un elemento en la posición index, desplazando los siguientes.
E remove(int index) Elimina el elemento en la posición index.
int indexOf(Object o) Devuelve el índice de la primera aparición del objeto, o -1 si no está.
int lastIndexOf(Object o) Devuelve el índice de la última aparición del objeto, o -1 si no está.

3.2 ArrayList

Es la implementación más común de la interfaz List. Internamente, usa un array dinámico.

Bajo el capó de un ArrayList

Un ArrayList empieza con un array de un tamaño inicial (por ejemplo, 10). Cuando intentas añadir el 11º elemento, la clase crea un nuevo array más grande (normalmente un 50% más grande), copia todos los elementos del array viejo al nuevo y luego descarta el viejo. Este proceso de "redimensionamiento" es automático, pero puede tener un coste de rendimiento si añades muchísimos elementos uno a uno.

Uso básico: ArrayList es excelente para acceso rápido a elementos por su índice (get) y para recorrer la lista, pero es menos eficiente para inserciones o eliminaciones en medio de la lista, ya que requiere desplazar todos los elementos posteriores.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class EjemploArrayList {
    public static void main(String[] args) {
        // Buena práctica: programar contra la interfaz List
        List<String> listaCompra = new ArrayList<>();

        // 1. Añadir elementos (add)
        listaCompra.add("Leche");
        listaCompra.add("Pan");
        listaCompra.add("Manzanas");
        listaCompra.add(1, "Huevos"); // Añade en una posición específica

        System.out.println("Lista de la compra: " + listaCompra);

        // 2. Acceder a un elemento (get)
        String segundoElemento = listaCompra.get(1);
        System.out.println("El segundo elemento es: " + segundoElemento);

        // 3. Modificar un elemento (set)
        listaCompra.set(0, "Leche desnatada");
        System.out.println("Lista actualizada: " + listaCompra);

        // 4. Eliminar un elemento (remove)
        listaCompra.remove("Pan"); // Por objeto
        listaCompra.remove(2);   // Por índice

        // 5. Ordenación con Collections.sort()
        Collections.sort(listaCompra);
        System.out.println("Lista ordenada: " + listaCompra);
    }
}
+Info

-> Ampliación ArrayList


3.3 LinkedList

Es la otra gran implementación de List. Internamente, usa una lista doblemente enlazada.

Bajo el capó de un LinkedList

No usa un array. Cada elemento es un "nodo" que contiene el dato y dos punteros: uno al nodo anterior y otro al siguiente. Para añadir o quitar un elemento en medio, solo hay que cambiar un par de punteros. Para acceder al elemento en la posición 500, tiene que empezar desde el principio (o el final) y seguir los punteros 500 veces.

Uso básico: LinkedList es extremadamente eficiente para añadir o eliminar elementos al principio, al final o en medio de la lista. Sin embargo, el acceso a un elemento por su índice (get(i)) es mucho más lento que en un ArrayList, ya que tiene que recorrer la lista desde el principio.

Además de List, LinkedList también implementa las interfaces Queue (cola) y Deque (cola de doble extremo), lo que le da métodos adicionales para comportarse como una pila o una cola.

import java.util.LinkedList;

public class EjemploLinkedList {
    public static void main(String[] args) {
        LinkedList<String> playlist = new LinkedList<>();

        // Métodos de Deque: añadir al principio y al final
        playlist.addFirst("Bohemian Rhapsody - Queen");
        playlist.addLast("Stairway to Heaven - Led Zeppelin");
        playlist.addFirst("Hotel California - Eagles");

        System.out.println("Playlist: " + playlist);

        // Métodos de Deque: eliminar del principio y del final
        String primera = playlist.removeFirst();
        System.out.println("Reproduciendo ahora: " + primera);

        String ultima = playlist.removeLast();
        System.out.println("Quitando la última: " + ultima);

        System.out.println("Playlist restante: " + playlist);

        // Método de Queue: mirar el primer elemento sin quitarlo
        System.out.println("Siguiente en la cola: " + playlist.peek());
    }
}
+Info

-> Ampliación LinkedList

Reflexiona

  1. Estás creando una aplicación para gestionar una lista de tareas. Los usuarios añadirán tareas principalmente al final y las consultarán a menudo por su posición. ¿Usarías ArrayList o LinkedList? ¿Por qué?
  2. Ahora imagina una aplicación que gestiona el historial de un navegador web. Los usuarios pueden ir "atrás" y "adelante". ¿Qué estructura te parece más adecuada?
  3. ¿Qué crees que es más rápido: lista.get(50000) en un ArrayList de 100.000 elementos o en un LinkedList del mismo tamaño? ¿Y lista.add(50000, "nuevoElemento")?
  4. El método remove(Object o) en una List elimina la primera ocurrencia del objeto. ¿Cómo harías para eliminar todas las ocurrencias de un objeto en un ArrayList?



4. Pilas (Stack) y Colas (Queue)

Las pilas y las colas son colecciones especializadas, diseñadas para procesar elementos en un orden específico. Son fundamentales en muchos algoritmos e incluso en el funcionamiento interno de los sistemas operativos.

Lifo vs fifo

4.1 Clase Stack (LIFO)

Una pila funciona bajo el principio LIFO (Last-In, First-Out): el último elemento que entra es el primero que sale.

Definición: Pila (Stack)

Es una estructura de datos que permite dos operaciones principales: push (empujar), para agregar un elemento en la cima, y pop (sacar), para eliminar el elemento de la cima. Es como una pila de platos: siempre coges el de arriba.

En Java, existe una clase Stack heredada, pero se considera obsoleta.

La clase Stack está obsoleta

La clase java.util.Stack es antigua (desde Java 1.0) y extiende Vector, que es una clase sincronizada y lenta. La recomendación moderna es usar una implementación de la interfaz Deque (como ArrayDeque o LinkedList) para modelar una pila.

Las operaciones principales son:
* push(E item): Añade un elemento a la cima de la pila.
* E pop(): Elimina y devuelve el elemento de la cima. Lanza EmptyStackException si la pila está vacía.
* E peek(): Devuelve el elemento de la cima sin eliminarlo.
* boolean empty(): Devuelve true si la pila está vacía.
* int search(Object o): Busca un objeto y devuelve su distancia desde la cima (1-based).

4.2 Colas (Queue y Deque) (FIFO)

Una cola funciona bajo el principio FIFO (First-In, First-Out): el primer elemento que entra es el primero que sale, como la cola para pagar en una tienda.

Definición: Cola (Queue)

Es una estructura de datos para mantener elementos antes de procesarlos. Las operaciones principales son add u offer (encolar) para agregar un elemento al final, y remove o poll (desencolar) para eliminar el elemento del principio.

La interfaz Deque (pronunciado "deck", por "Double Ended Queue") es aún más versátil, ya que permite añadir y quitar elementos de ambos extremos, por lo que puede funcionar perfectamente como una pila (usando push y pop) o como una cola.

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Queue;

public class EjemploPilasYColas {
    public static void main(String[] args) {
        System.out.println("--- Ejemplo de Pila (con Deque) ---");
        Deque<String> historialNavegador = new ArrayDeque<>();

        historialNavegador.push("google.com"); // Equivale a addFirst()
        historialNavegador.push("github.com");
        historialNavegador.push("wikipedia.org");

        System.out.println("Página actual: " + historialNavegador.peek()); // wikipedia.org

        String paginaAtras = historialNavegador.pop(); // Equivale a removeFirst()
        System.out.println("Volviendo atrás... ahora en: " + paginaAtras);
        System.out.println("Página actual: " + historialNavegador.peek()); // github.com

        System.out.println("\n--- Ejemplo de Cola (con LinkedList) ---");
        Queue<String> colaImpresion = new LinkedList<>();

        colaImpresion.offer("Documento1.pdf"); // Equivale a add() o addLast()
        colaImpresion.offer("Foto.jpg");
        colaImpresion.offer("Contrato.docx");

        System.out.println("Cola de impresión: " + colaImpresion);

        String imprimiendo = colaImpresion.poll(); // Equivale a remove() o removeFirst()
        System.out.println("Imprimiendo ahora: " + imprimiendo);
        System.out.println("Cola restante: " + colaImpresion);
    }
}

graph TD
    subgraph "Pila (LIFO)"
        direction TB
        A["push(A)"] --> B["push(B)"] --> C["push(C)"]
        C -- pop() --> D("Sale C")
        B -- pop() --> E("Sale B")
    end

    subgraph "Cola (FIFO)"
        direction LR
        X["offer(X)"] --> Y["offer(Y)"] --> Z["offer(Z)"]
        X -- poll() --> W("Sale X")
        Y -- poll() --> V("Sale Y")
    end


+Info

-> Ampliación Queue y Deque

Reflexiona

  1. Además del historial del navegador o la función "deshacer", ¿en qué otra situación del mundo real o de la informática se te ocurre que una pila (LIFO) sería útil?
  2. Las colas se usan masivamente en sistemas operativos para gestionar tareas. ¿Puedes pensar por qué el principio FIFO es el más justo y adecuado para, por ejemplo, una cola de impresión?
  3. La interfaz Deque tiene métodos como offerFirst y pollFirst, y también push y pop. ¿Cuál es la principal diferencia entre poll y pop? (Pista: ¿qué pasa si la colección está vacía?).
  4. Si quieres implementar una pila, ¿por qué ArrayDeque es generalmente preferible a LinkedList? (Pista: investiga la eficiencia de añadir/quitar elementos al principio/final en ambas).



5. Conjuntos (Set<E>)

Los conjuntos son colecciones que modelan la abstracción matemática de un conjunto: una agrupación de elementos únicos y sin un orden particular.

Definición: Interfaz Set<E>

Es una subinterfaz de Collection que representa una colección que no permite elementos duplicados. Si intentas añadir un elemento que ya existe (según su método equals()), la operación simplemente no tendrá efecto y el método add() devolverá false.

Hay tres implementaciones principales:

  • HashSet: Es la más común. No garantiza ningún orden en los elementos. Es extremadamente rápida para añadir, eliminar y comprobar si un elemento existe (rendimiento de tiempo constante, O(1), en promedio).
  • LinkedHashSet: Como un HashSet, pero mantiene el orden de inserción. Es ligeramente más lenta que HashSet.
  • TreeSet: Mantiene los elementos en un orden natural (o un orden personalizado). Es más lenta que HashSet (rendimiento logarítmico, O(log n)), pero te permite recorrer los elementos de forma ordenada.

HashSet y la importancia de equals() y hashCode()

HashSet utiliza una tabla hash para almacenar sus elementos. Para determinar si un elemento es "duplicado", no solo usa equals(), sino que depende críticamente de hashCode().

¡El Contrato es Ley!

Para que un HashSet (y un HashMap) funcione correctamente con tus propios objetos, tu clase DEBE sobrescribir equals() y hashCode() siguiendo estas reglas: 1. Si objeto1.equals(objeto2) es true, entonces objeto1.hashCode() debe ser igual a objeto2.hashCode(). 2. El hashCode() de un objeto debe ser consistente (devolver el mismo valor) mientras no se modifiquen los campos usados en equals().

Si no lo haces, te encontrarás con que el HashSet permite "duplicados" o no encuentra objetos que sabes que están ahí.

Ejemplo con HashSet y Alumno

Veamos qué pasa cuando la clase Alumno no tiene equals() y hashCode() correctos, y qué pasa cuando sí los tiene.

// Escenario 1: Clase Alumno SIN equals() y hashCode() correctos
// Usará la implementación de Object, que compara referencias de memoria.
public class AlumnoMal {
    private String nia;
    private String nombre;
    // ... constructor, getters ...
}

Set<AlumnoMal> claseMal = new HashSet<>();
claseMal.add(new AlumnoMal("111A", "Ana"));
claseMal.add(new AlumnoMal("111A", "Ana")); // ¡Debería ser un duplicado!

System.out.println(claseMal.size()); // Imprime 2. ¡ERROR! Permitió un duplicado.

// Escenario 2: Clase Alumno CON equals() y hashCode() correctos (basados en NIA)
public class AlumnoBien {
    private String nia;
    private String nombre;
    // ... constructor, getters ...

    @Override
    public int hashCode() {
        return Objects.hash(nia);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        AlumnoBien other = (AlumnoBien) obj;
        return Objects.equals(nia, other.nia);
    }
}

Set<AlumnoBien> claseBien = new HashSet<>();
claseBien.add(new AlumnoBien("222B", "Juan"));
claseBien.add(new AlumnoBien("222B", "Juan")); // Duplicado

System.out.println(claseBien.size()); // Imprime 1. ¡CORRECTO! No permitió el duplicado.
+Info

-> Ampliación HashSet

TreeSet y la Ordenación

TreeSet mantiene los elementos ordenados. Para ello, los objetos que almacena deben implementar la interfaz Comparable (orden natural) o debes pasarle un Comparator al crearlo.

Consistencia entre compareTo y equals

Para TreeSet, la unicidad se determina usando compareTo() (o compare()), no equals(). Si a.compareTo(b) devuelve 0, TreeSet considera que los objetos son iguales y no añadirá el segundo. Es una buena práctica que el orden definido por compareTo sea "consistente con equals". Esto significa que a.compareTo(b) == 0 debería ser verdad si y solo si a.equals(b) es verdad. Si no es así, TreeSet y HashSet se comportarán de forma diferente para la misma clase.

// Suponiendo que Alumno implementa Comparable por nombre
Set<String> nombresOrdenados = new TreeSet<>();
nombresOrdenados.add("Carlos");
nombresOrdenados.add("Ana");
nombresOrdenados.add("Beatriz");

System.out.println(nombresOrdenados); // Salida: [Ana, Beatriz, Carlos]
+Info

-> Ampliación TreeSet

Reflexiona

  1. Si quieres una colección que no permita duplicados y que, al recorrerla, te devuelva los elementos en el mismo orden en que los insertaste, ¿qué implementación de Set usarías?
  2. En el Escenario 1 del AlumnoMal, ¿por qué HashSet considera que los dos objetos son diferentes?
  3. ¿Qué pasaría si metes un objeto en un HashSet, luego modificas un campo de ese objeto que se usa en el cálculo de hashCode(), y finalmente intentas hacer miSet.contains(miObjeto)? ¿Lo encontraría?
  4. ¿Por qué TreeSet no permite elementos null?



6. Mapas (Map<K, V>)

A diferencia de las colecciones anteriores que almacenan elementos individuales, un mapa almacena asociaciones entre pares clave-valor.

Definición: Interfaz Map<K, V>

Un Map es un objeto que mapea claves (K, de Key) a valores (V, de Value). Cada clave debe ser única; no puede haber claves duplicadas. Cada clave puede mapear a, como máximo, un valor. Es como un diccionario: la palabra (clave) es única y tiene una definición (valor) asociada.

La interfaz Map no extiende la interfaz Collection.

Implementaciones Principales

  • HashMap: La implementación más común. Usa una tabla hash. No garantiza ningún orden. Permite una clave null y múltiples valores null. Ofrece rendimiento O(1) para get() y put().
  • LinkedHashMap: Mantiene el orden de inserción de las claves.
  • TreeMap: Mantiene las claves ordenadas (orden natural o por Comparator).

Métodos Principales de Map

Método Descripción
V put(K key, V value) Asocia la clave key con el valor value. Si la clave ya existía, reemplaza el valor antiguo y lo devuelve.
V get(Object key) Devuelve el valor asociado a la clave, o null si la clave no existe.
V remove(Object key) Elimina el mapeo para una clave.
boolean containsKey(Object key) Devuelve true si el mapa contiene un mapeo para la clave especificada.
boolean containsValue(Object value) Devuelve true si el mapa mapea una o más claves al valor especificado.
int size() Devuelve el número de pares clave-valor.
Set<K> keySet() Devuelve un Set con todas las claves del mapa.
Collection<V> values() Devuelve una Collection con todos los valores del mapa.
Set<Map.Entry<K,V>> entrySet() Devuelve un Set de objetos Map.Entry que representan cada par clave-valor.

Ejemplo: Contando la Frecuencia de Palabras

Un uso clásico de HashMap es contar la frecuencia de elementos.

import java.util.HashMap;
import java.util.Map;

public class FrecuenciaPalabras {
    public static void main(String[] args) {
        String texto = "hola mundo hola a todos y todas en el mundo";
        Map<String, Integer> frecuencias = new HashMap<>();

        String[] palabras = texto.split(" ");

        for (String palabra : palabras) {
            // getOrDefault es muy útil: si la clave no existe, devuelve el valor por defecto (0)
            int contador = frecuencias.getOrDefault(palabra, 0);
            frecuencias.put(palabra, contador + 1);
        }

        System.out.println("Frecuencia de palabras:");
        // Recorrer con entrySet() es la forma más eficiente
        for (Map.Entry<String, Integer> entry : frecuencias.entrySet()) {
            System.out.println("'" + entry.getKey() + "': " + entry.getValue());
        }
    }
}

Ejemplo Avanzado: Map<Alumno, ArrayList<Calificacion>>

Los mapas son increíblemente potentes para modelar datos complejos. Imagina que queremos almacenar las calificaciones de varios módulos para cada alumno.

public class Calificacion {
    private String modulo;
    private List<Double> notas;
    // ... constructor, getters, etc.
}

public class Alumno {
    // ... con equals y hashCode basados en NIA
}

// En nuestro programa principal
Map<Alumno, List<Calificacion>> expediente = new TreeMap<>(); // TreeMap para tener los alumnos ordenados

// ... código para rellenar el mapa ...
+Info

-> Ampliación HashMap

Reflexiona

  1. ¿Por qué Map no hereda de Collection? (Pista: add(E element) no tiene sentido para un Map).
  2. Si usas un objeto de tu propia clase como clave en un HashMap, ¿qué métodos de esa clase son absolutamente críticos para que el mapa funcione correctamente?
  3. Explica las tres formas de recorrer un Map (keySet, values, entrySet) y cuál consideras más eficiente si necesitas tanto la clave como el valor en cada iteración.
  4. ¿Qué pasaría si intentas hacer miMapa.put(null, "valor"); en un HashMap? ¿Y en un TreeMap? Investígalo.



7. Iteradores

¿Cómo recorremos una colección de forma segura y estandarizada, especialmente si queremos modificarla durante el recorrido? La respuesta son los iteradores.

7.1 Interfaz Iterator<E>

Definición: Iterator<E>

Es un objeto que permite recorrer una colección elemento a elemento. Proporciona una forma unificada de acceder a los elementos secuencialmente, sin exponer la estructura interna de la colección.

Proporciona tres métodos clave: * boolean hasNext(): Devuelve true si hay más elementos por recorrer. * E next(): Devuelve el siguiente elemento y avanza el "cursor" del iterador. * void remove(): Elimina de la colección el último elemento devuelto por next(). Este es el único modo seguro de modificar una colección mientras se itera sobre ella.

List<String> lista = new ArrayList<>(List.of("A", "B", "C", "D"));
Iterator<String> it = lista.iterator();

while (it.hasNext()) {
    String elemento = it.next();
    System.out.println(elemento);
    if (elemento.equals("B")) {
        it.remove(); // ¡Forma correcta de eliminar!
    }
}
System.out.println("Lista final: " + lista); // [A, C, D]

ConcurrentModificationException

Si intentas modificar una colección (añadir o quitar elementos) usando los métodos de la propia colección mientras la recorres con un bucle for-each o un iterador, Java lanzará una ConcurrentModificationException. Siempre debes usar iterator.remove().

+Info

-> Ampliación Iterator

7.2 Interfaz Iterable<E>

Esta es una interfaz muy simple, pero fundamental. Solo tiene un método: iterator().

Iterable y el bucle for-each

Cualquier clase que implemente la interfaz Iterable<E> puede ser utilizada en un bucle for-each (for (Elemento e : miColeccion)). Todas las colecciones del JCF implementan Iterable.

Podemos hacer que nuestras propias clases sean "iterables":

public class Grupo implements Iterable<Alumno> {
    private String nombre;
    private List<Alumno> alumnos;

    public Grupo(String nombre) {
        this.nombre = nombre;
        this.alumnos = new ArrayList<>();
    }

    public void addAlumno(Alumno alumno) {
        alumnos.add(alumno);
    }

    // Implementación del método de Iterable
    @Override
    public Iterator<Alumno> iterator() {
        // Simplemente delegamos al iterador de nuestra lista interna
        return alumnos.iterator();
    }
}

// Ahora podemos hacer esto:
Grupo daw1 = new Grupo("1º DAW");
daw1.addAlumno(new Alumno("Ana", 10));
daw1.addAlumno(new Alumno("Luis", 8));

for (Alumno alumno : daw1) { // ¡Funciona gracias a Iterable!
    System.out.println(alumno.getNombre());
}
+Info

-> Ampliación Iterable

7.3 Interfaz ListIterator<E>

Es un subtipo de Iterator específico para Lists, que añade muchísima más potencia.

  • Permite el recorrido bidireccional (hasPrevious(), previous()).
  • Permite modificar la lista durante la iteración (add(), set()).
  • Permite conocer el índice del elemento siguiente y anterior (nextIndex(), previousIndex()).
Característica Iterator ListIterator
Dirección Hacia adelante Hacia adelante y hacia atrás
Operaciones hasNext(), next(), remove() Todo lo de Iterator más hasPrevious(), previous(), add(), set()
Colecciones Todas las de Collection Solo para List

Reflexiona

  1. ¿Por qué iterator.remove() es seguro mientras que collection.remove() no lo es durante una iteración?
  2. Implementa la interfaz Iterable en la clase Autor del ejemplo N:M para que se pueda iterar sobre los libros que ha escrito.
  3. Usando un ListIterator, recorre una lista de números del final al principio e imprime cada número.
  4. ¿Por qué no existe un SetIterator con las mismas funcionalidades que ListIterator?



8. Ordenación y Comparación

A menudo, necesitamos que nuestras colecciones estén ordenadas. Java nos ofrece dos interfaces para definir criterios de ordenación: Comparable y Comparator.

8.1 Interface Comparable<T>

Esta interfaz se usa para definir el orden natural de los objetos de una clase.

Definición: Comparable<T>

Una clase que implementa Comparable<T> está diciendo: "Yo sé cómo compararme con otros objetos de mi mismo tipo". Implementa un único método, compareTo(T otro), que devuelve: * Un int negativo si this objeto es "menor que" otro. * Cero si this objeto es "igual a" otro en términos de orden. * Un int positivo si this objeto es "mayor que" otro.

public class Alumno implements Comparable<Alumno> {
    private String nombre;
    private int edad;

    // ... Constructor, getters, etc. ...

    // Orden natural: por edad, de menor a mayor. Si la edad es la misma, por nombre.
    @Override
    public int compareTo(Alumno otro) {
        int comparacionEdad = Integer.compare(this.edad, otro.edad);
        if (comparacionEdad != 0) {
            return comparacionEdad;
        } else {
            return this.nombre.compareTo(otro.nombre);
        }
    }
}

8.2 Interfaz Comparator<T>

¿Y si queremos ordenar los alumnos por nota a veces, y por nombre otras? Comparable solo define un orden natural. Para múltiples criterios de ordenación, o para ordenar clases que no puedes modificar, usamos Comparator.

Definición: Comparator<T>

Un Comparator es un objeto cuya única función es comparar otros dos objetos. Implementa un método compare(T o1, T o2). La lógica de devolución es la misma que en compareTo.

import java.util.Comparator;

// Un comparador para ordenar Alumnos por nota, de mayor a menor.
public class AlumnoPorNotaComparator implements Comparator<Alumno> {
    @Override
    public int compare(Alumno a1, Alumno a2) {
        return Integer.compare(a2.getNota(), a1.getNota()); // Orden descendente
    }
}

// Uso:
List<Alumno> clase = ...;
Collections.sort(clase); // Usa el orden natural de Alumno (Comparable)
Collections.sort(clase, new AlumnoPorNotaComparator()); // Usa el criterio del Comparator

Clases Anónimas y Lambdas

Desde Java 8, es muy común crear Comparators "al vuelo" usando clases anónimas o, de forma mucho más concisa, expresiones lambda.

// Ordenar por nombre, alfabéticamente
clase.sort((a1, a2) -> a1.getNombre().compareTo(a2.getNombre()));

// O usando métodos de referencia, ¡aún más corto!
clase.sort(Comparator.comparing(Alumno::getNombre));
+Info

-> Ampliación Comparator y Comparable



9. Estructuras de Datos Clásicas (Conceptos)

El JCF es una abstracción de alto nivel. Es útil entender brevemente las estructuras de datos "clásicas" que hay debajo, ya que son la base de la informática.

9.1 Listas Enlazadas

Una lista enlazada consiste en nodos. Cada nodo contiene un dato y un "puntero" o referencia al siguiente nodo de la secuencia. En una lista doblemente enlazada (como la LinkedList de Java), cada nodo tiene también un puntero al nodo anterior. Son muy eficientes para inserciones y eliminaciones, pero lentas para el acceso aleatorio.

9.2 Árboles

Son estructuras jerárquicas. Comienzan en un nodo raíz. Cada nodo puede tener cero o más nodos hijos. Los nodos sin hijos se llaman hojas. Un tipo muy común es el Árbol Binario de Búsqueda (BST), donde cada nodo tiene como máximo dos hijos, y todos los valores del subárbol izquierdo son menores que el nodo, y los del subárbol derecho son mayores. Esto permite búsquedas muy rápidas (O(log n)), pero solo si el árbol está balanceado (no degenera en una lista).

9.3 Grafos

Un grafo es un conjunto de vértices (o nodos) y un conjunto de aristas (o conexiones) que los unen. Se usan para modelar todo tipo de redes: sociales (amigos), de transporte (carreteras entre ciudades), de internet (routers conectados), etc. Pueden ser dirigidos (las conexiones tienen un sentido) o no dirigidos, y ponderados (cada conexión tiene un "coste" o "peso") o no ponderados.

9.4 Tablas Hash

Son la magia detrás de HashSet y HashMap. Una tabla hash es básicamente un array. Para almacenar un objeto, se le aplica una función hash que convierte el objeto en un número entero. Este número (o su módulo respecto al tamaño del array) se usa como índice para decidir en qué "cubo" o bucket del array guardar el objeto. * Colisiones: Ocurre cuando dos objetos diferentes producen el mismo hash. Se gestionan, por ejemplo, almacenando una lista de objetos en ese bucket. * Una buena función hash distribuye los objetos de manera uniforme, minimizando las colisiones y garantizando un rendimiento cercano a O(1). Una mala función hash puede hacer que el rendimiento degenere a O(n).



Aplicación en el Mundo Real

  • ArrayList: Es tu caballo de batalla para casi cualquier lista donde el acceso por índice es frecuente (ej: mostrar una lista de productos en una web).
  • LinkedList: Ideal para implementar colas, pilas, o listas donde hay constantes inserciones/eliminaciones en los extremos (ej: una playlist de música donde puedes añadir canciones al principio o al final).
  • HashSet: Perfecto para saber rápidamente si un elemento ya ha sido procesado o si existe en un gran conjunto de datos, sin importar el orden (ej: comprobar si un email ya está registrado).
  • HashMap: La estructura más usada para mapear un identificador único a un objeto complejo (ej: un Map<Integer, Usuario> para cachear usuarios por su ID y acceder a ellos instantáneamente).
  • TreeSet/TreeMap: Se usan cuando necesitas mantener los datos constantemente ordenados (ej: un ranking de puntuaciones de un juego).

Para Saber Más

  1. Documentación Oficial de Oracle sobre el Collections Framework: The Java™ Tutorials - Collections - La guía de referencia completa y oficial.
  2. Visualgo - Visualizing data structures and algorithms: https://visualgo.net/en - Una herramienta interactiva increíble para visualizar cómo funcionan internamente las listas enlazadas, árboles, tablas hash y otras estructuras.
  3. Baeldung: A Guide to the Java Collections Framework: Java Collections Framework Guide - Un tutorial muy completo que cubre todas las colecciones principales con ejemplos prácticos.



EJERCICIOS


=> Ejercicios de Estructuras de Datos Dinámicas