Colecciones en Java — teoría con una biblioteca de libros, List, Set y Map (y el Set que decía tener dos veces el mismo libro)

Estaba montando el catálogo de una biblioteca de prueba y quería evitar libros repetidos. «Para eso están los conjuntos», pensé, y en vez de una lista usé un HashSet<Libro>. Para comprobar que funcionaba, añadí Dune dos veces a propósito y pregunté cuántos elementos tenía. Java me contestó esto:

Sin equals ni hashCode: 2 elementos

«¿Dos? ¡Si es el mismo libro, con el mismo ISBN!» Estuve un buen rato culpando a Java, hasta que entendí que el conjunto no estaba roto: yo no le había explicado qué significa que dos libros sean el mismo. Y eso, en las colecciones, lo cambia todo.

Hoy hacemos el mapa completo. Verás qué familias de colecciones hay (List, Set, Map), en qué se diferencian ArrayList y LinkedList, por qué un HashSet necesita equals() y hashCode(), y cómo recorrer y modificar un Map sin que Java proteste. Todo con un mismo ejemplo: la biblioteca. Si vienes de la teoría de iteradores, ya sabes que todas las colecciones se pueden recorrer con un for-each; aquí aprenderás a elegir cuál usar. También te vendrá bien repasar las interfaces y Comparable, porque las colecciones están construidas sobre ellas. Y al terminar, la práctica de colecciones te espera con tres programas completos.

El mapa de las colecciones

Una colección es un objeto que guarda otros objetos. Java las organiza en interfaces, cada una con una promesa distinta:

InterfazQué prometeImplementaciones habituales
ListOrden de inserción, admite repetidos, acceso por posiciónArrayList, LinkedList
SetSin repetidosHashSet, LinkedHashSet, TreeSet
Queue / DequeColas y pilas: se añade por un extremo y se saca por otroArrayDeque, LinkedList, PriorityQueue
MapPares clave → valor, claves sin repetirHashMap, LinkedHashMap, TreeMap

List, Set y Queue heredan de Collection (que a su vez es Iterable, por eso todas funcionan en un for-each). Map va aparte.

Map no es una Collection: guarda pares, no elementos sueltos. Para recorrerlo se le pide una «vista» que sí lo es: sus claves (keySet()), sus valores (values()) o sus pares (entrySet()). Lo verás al final.

Hay una regla de estilo que conviene adoptar desde ya: declara con la interfaz y construye con la clase.

List<Libro> libros = new ArrayList<>();   // el tipo es List; la implementación, ArrayList

Así el resto del programa solo conoce las promesas de List, y cambiar de ArrayList a LinkedList es tocar una línea. Lo comprobamos en la sección siguiente.

List: orden y repetidos

Para los ejemplos usamos un record muy pequeño. Un record es una clase de datos con constructor, getters, equals() y hashCode() ya hechos (de esto último hablamos luego), y aquí le damos un toString() legible:

public record Libro(String isbn, String titulo, String autor) {
    @Override
    public String toString() {
        return titulo + " (" + autor + ")";
    }
}

Una lista guarda los libros en el orden en que los añades, permite repetidos y deja acceder por posición:

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class Listas {
    public static void main(String[] args) {
        List<Libro> libros = new ArrayList<>();
        libros.add(new Libro("L-101", "Dune", "Herbert"));
        libros.add(new Libro("L-102", "Emma", "Austen"));
        libros.add(new Libro("L-103", "Hamlet", "Shakespeare"));
        libros.add(new Libro("L-102", "Emma", "Austen"));   // una lista admite repetidos

        System.out.println("Libros: " + libros);
        System.out.println("Tamaño: " + libros.size());
        System.out.println("El de la posición 2: " + libros.get(2));
        System.out.println("Primera posición de Emma: " + libros.indexOf(new Libro("L-102", "Emma", "Austen")));

        libros.remove(0);   // borra por posición
        System.out.println("Sin el primero: " + libros);

        List<Libro> otra = new LinkedList<>(libros);   // misma interfaz, otra implementación
        otra.add(1, new Libro("L-104", "Macbeth", "Shakespeare"));
        System.out.println("LinkedList con Macbeth en medio: " + otra);
    }
}
Libros: [Dune (Herbert), Emma (Austen), Hamlet (Shakespeare), Emma (Austen)]
Tamaño: 4
El de la posición 2: Hamlet (Shakespeare)
Primera posición de Emma: 1
Sin el primero: [Emma (Austen), Hamlet (Shakespeare), Emma (Austen)]
LinkedList con Macbeth en medio: [Emma (Austen), Macbeth (Shakespeare), Hamlet (Shakespeare), Emma (Austen)]

Entre líneas:

  • Emma está dos veces: una lista no se queja de los repetidos.
  • get(2) y remove(0) trabajan con posiciones (empiezan en 0). Al borrar el primero, todos los demás se corren un hueco.
  • indexOf(...) busca con equals(), y por eso encuentra a Emma aunque le pasemos un Libro recién creado. (Los record comparan por contenido.)
  • Al cambiar a LinkedList solo tocamos la línea del new; el resto del código sigue valiendo, porque List es la misma interfaz. Y add(1, ...) inserta en medio, desplazando lo que había detrás.

ArrayList o LinkedList

Las dos hacen lo mismo de cara al programador, pero por dentro son muy distintas:

ArrayListLinkedList
Por dentroUn array que crece cuando se llenaNodos enlazados entre sí (como en la práctica de iteradores)
get(i)Rápido: va directo a la posiciónLento: recorre nodos hasta llegar a i
Añadir al finalRápidoRápido
Insertar o borrar en medioLento: hay que desplazar los elementosUna vez localizado el nodo, solo redirige dos enlaces (pero localizarlo cuesta)
MemoriaPocaMás: cada elemento lleva su nodo

En la práctica, **usa ArrayList por defecto**. Es más rápida en casi todos los usos reales, y solo merece la pena LinkedList si insertas y borras constantemente en los extremos (y para eso hoy tienes también ArrayDeque, que verás en la práctica).

Las listas guardan objetos, no tipos primitivos. Por eso se escribe List<Integer>, no List<int>: Java convierte int ↔ Integer solo (autoboxing). Tiene una trampa que verás en los ejercicios.

Set: sin repetidos

Si lo que te importa es qué hay y no cuántas veces ni en qué orden, usa un conjunto. Lo bonito es que tiene tres sabores, y la diferencia está solo en el orden con el que lo recorres:

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class Conjuntos {
    public static void main(String[] args) {
        String[] autores = {"King", "Austen", "Shakespeare", "Austen", "Herbert", "King", "du Maurier"};

        Set<String> hash = new HashSet<>();
        Set<String> enlazado = new LinkedHashSet<>();
        Set<String> arbol = new TreeSet<>();

        for (String a : autores) {
            boolean nuevo = hash.add(a);   // add() devuelve false si ya estaba
            enlazado.add(a);
            arbol.add(a);
            if (!nuevo) {
                System.out.println("Repetido, no se añade: " + a);
            }
        }

        System.out.println("HashSet:       " + hash);
        System.out.println("LinkedHashSet: " + enlazado);
        System.out.println("TreeSet:       " + arbol);
        System.out.println("¿Está Austen? " + hash.contains("Austen"));
        System.out.println("Autores distintos: " + hash.size());
    }
}
Repetido, no se añade: Austen
Repetido, no se añade: King
HashSet:       [King, Herbert, Shakespeare, du Maurier, Austen]
LinkedHashSet: [King, Austen, Shakespeare, Herbert, du Maurier]
TreeSet:       [Austen, Herbert, King, Shakespeare, du Maurier]
¿Está Austen? true
Autores distintos: 5
HashSetLinkedHashSetTreeSet
Orden al recorrerloNinguno garantizado (el que salga)El de inserciónOrdenado (orden natural o un Comparator)
Velocidad de add / containsMuy rápidaMuy rápidaAlgo más lenta (crece con el tamaño, pero poco)
Exige a los elementosequals() y hashCode()equals() y hashCode()Comparable o un Comparator

Fíjate en tres cosas del resultado:

  • add() devuelve false cuando el elemento ya estaba, y el conjunto no cambia. Es la forma más cómoda de detectar repetidos.
  • El orden del HashSet parece caprichoso (King, Herbert, Shakespeare…). Ni es aleatorio ni está garantizado: depende de los códigos hash de los elementos y no debes apoyarte en él.
  • TreeSet ordena los String alfabéticamente… pero du Maurier queda al final. Es el orden natural de String: compara por código de carácter y las mayúsculas van antes que las minúsculas.

¿tienes una lista con repetidos y quieres quitarlos? new HashSet<>(lista) lo hace en una línea, aunque pierdes el orden. Si lo necesitas, usa new LinkedHashSet<>(lista).

Map: de la clave al valor

Un Map es como un diccionario: buscas por clave y obtienes el valor. En la biblioteca, la clave natural es el ISBN y el valor, el libro entero. Buscar así es mucho más cómodo (y rápido) que recorrer una lista entera:

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

public class Mapas {
    public static void main(String[] args) {
        Map<String, Libro> catalogo = new HashMap<>();
        catalogo.put("L-101", new Libro("L-101", "Dune", "Herbert"));
        catalogo.put("L-102", new Libro("L-102", "Emma", "Austen"));
        catalogo.put("L-103", new Libro("L-103", "Hamlet", "Shakespeare"));

        System.out.println("Catálogo: " + catalogo);
        System.out.println("get(L-102): " + catalogo.get("L-102"));
        System.out.println("get(L-999): " + catalogo.get("L-999"));   // clave que no existe
        System.out.println("getOrDefault(L-999): "
                + catalogo.getOrDefault("L-999", new Libro("?", "Desconocido", "?")));

        Libro anterior = catalogo.put("L-102", new Libro("L-102", "Emma (2.ª edición)", "Austen"));
        System.out.println("put() sobre una clave existente devuelve el valor anterior: " + anterior);
        System.out.println("Ahora L-102 es: " + catalogo.get("L-102"));

        catalogo.putIfAbsent("L-101", new Libro("L-101", "Otro", "Otro"));   // ya existe: no hace nada
        catalogo.putIfAbsent("L-104", new Libro("L-104", "Macbeth", "Shakespeare"));
        System.out.println("Claves: " + catalogo.keySet());
        System.out.println("¿Existe la clave L-103? " + catalogo.containsKey("L-103"));
        System.out.println("Tamaño: " + catalogo.size());

        Map<String, Libro> ordenado = new TreeMap<>(catalogo);   // lo mismo, pero ordenado por clave
        System.out.println("Ordenado por clave: " + ordenado.keySet());
    }
}
Catálogo: {L-101=Dune (Herbert), L-102=Emma (Austen), L-103=Hamlet (Shakespeare)}
get(L-102): Emma (Austen)
get(L-999): null
getOrDefault(L-999): Desconocido (?)
put() sobre una clave existente devuelve el valor anterior: Emma (Austen)
Ahora L-102 es: Emma (2.ª edición) (Austen)
Claves: [L-104, L-101, L-102, L-103]
¿Existe la clave L-103? true
Tamaño: 4
Ordenado por clave: [L-101, L-102, L-103, L-104]

Los métodos que más vas a usar:

Quiero…MétodoQué devuelve
Guardar o sustituirput(clave, valor)El valor anterior (o null si no había)
Consultarget(clave)El valor, o **null** si no existe
Consultar con valor de repuestogetOrDefault(clave, repuesto)El valor, o el repuesto
Guardar solo si no estabaputIfAbsent(clave, valor)No toca nada si la clave ya existía
Saber si existe una clavecontainsKey(clave)true / false
Cuántas parejas haysize()Un entero

Cosas que merece la pena notar:

  • Una clave no se repite: al hacer put sobre L-102 no aparece una segunda entrada, se sustituye el valor y put te devuelve el antiguo.
  • get de una clave que no existe da null, no una excepción. Si intentas usar ese null como si fuera un número, el programa se cae (en los ejercicios lo ves).
  • Los sabores son los mismos que en Set: HashMap (sin orden garantizado; mira cómo L-104 salió el primero), LinkedHashMap (orden de inserción) y TreeMap (ordenado por clave).

Esperar que un HashMap se imprima en el orden en que metiste las claves. No hay garantía. Si el orden importa, usa LinkedHashMap (el de inserción) o TreeMap (el de las claves).

equals() y hashCode(): cuándo dos libros son el mismo

Volvamos al misterio del principio. HashSet y HashMap funcionan así: para guardar o buscar un elemento, llaman primero a su hashCode() (un número entero) y con él eligen en qué «cubo» mirar; después, dentro de ese cubo, usan equals() para decidir si ya está. Si no les dices cómo se calculan esas dos cosas, Java usa las de Object, que consideran iguales solo a el mismo objeto, no a dos objetos con los mismos datos.

Compáralos a lo bruto: una clase normal sin equals() ni hashCode(), y otra que sí los define a partir del ISBN:

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class Duplicados {

    static class LibroSuelto {
        final String isbn;

        LibroSuelto(String isbn) {
            this.isbn = isbn;
        }
    }

    static class LibroIgual {
        final String isbn;

        LibroIgual(String isbn) {
            this.isbn = isbn;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof LibroIgual otro && isbn.equals(otro.isbn);
        }

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

    public static void main(String[] args) {
        Set<LibroSuelto> sueltos = new HashSet<>();
        sueltos.add(new LibroSuelto("L-101"));
        sueltos.add(new LibroSuelto("L-101"));
        System.out.println("Sin equals ni hashCode: " + sueltos.size() + " elementos");

        Set<LibroIgual> iguales = new HashSet<>();
        iguales.add(new LibroIgual("L-101"));
        iguales.add(new LibroIgual("L-101"));
        System.out.println("Con equals y hashCode:  " + iguales.size() + " elemento");

        System.out.println("¿Contiene un L-101 nuevo? " + sueltos.contains(new LibroSuelto("L-101"))
                + " / " + iguales.contains(new LibroIgual("L-101")));
    }
}
Sin equals ni hashCode: 2 elementos
Con equals y hashCode:  1 elemento
¿Contiene un L-101 nuevo? false / true

El primer conjunto tiene dos «Dune» porque para él son dos objetos distintos; y contains con un libro recién creado da false por la misma razón. En el segundo, con equals() y hashCode() basados en el ISBN, el segundo add se reconoce como repetido.

Hay un contrato que debes cumplir:

  1. Si a.equals(b) es true, entonces a.hashCode() == b.hashCode(). (Siempre juntos: si cambias uno, cambia el otro.)
  2. Lo contrario no hace falta: dos objetos distintos pueden tener el mismo hash (se llama colisión, y es normal).
  3. Los dos métodos deben usar los mismos campos.

Sobrescribir equals() y olvidar hashCode() (o al revés). El código compila y no da error, pero el HashSet y el HashMap se portan mal: dos objetos «iguales» caen en cubos distintos y el conjunto no los detecta. En la práctica lo reproduces paso a paso.

Si tu clase es solo datos, hazla un record. Java escribe equals(), hashCode() y toString() por ti, comparando todos los campos. Si solo quieres que el ISBN identifique el libro, define equals() y hashCode() a mano con ese campo.

Recorrer y modificar un Map

Como Map no es una Collection, para recorrerlo eliges qué quieres ver:

  • keySet() → solo las claves.
  • values() → solo los valores.
  • entrySet() → las parejas (Map.Entry), con getKey() y getValue().

Y cuando además necesitas modificar, hay métodos pensados para ello (merge, removeIf…). Lo probamos con el stock de la biblioteca, título → ejemplares:

import java.util.ConcurrentModificationException;
import java.util.LinkedHashMap;
import java.util.Map;

public class Recorrer {
    public static void main(String[] args) {
        Map<String, Integer> stock = new LinkedHashMap<>();   // título -> ejemplares
        stock.put("Dune", 3);
        stock.put("Emma", 0);
        stock.put("Hamlet", 2);
        stock.put("Macbeth", 0);

        System.out.println("keySet() (solo claves):");
        for (String titulo : stock.keySet()) {
            System.out.println("  " + titulo);
        }

        int total = 0;
        for (int n : stock.values()) {   // values() (solo valores)
            total += n;
        }
        System.out.println("Ejemplares en total: " + total);

        System.out.println("entrySet() (clave y valor a la vez):");
        for (Map.Entry<String, Integer> e : stock.entrySet()) {
            System.out.println("  " + e.getKey() + " -> " + e.getValue());
        }

        try {
            for (String titulo : stock.keySet()) {
                if (stock.get(titulo) == 0) {
                    stock.remove(titulo);   // modificamos el mapa por fuera del recorrido
                }
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("¡Alto ahí!: " + e.getClass().getSimpleName());
            System.out.println("Se quedó a medias: " + stock);
        }

        stock.entrySet().removeIf(e -> e.getValue() == 0);   // la forma segura
        System.out.println("Con removeIf: " + stock);

        stock.merge("Hamlet", 1, Integer::sum);    // ya existe: suma 1
        stock.merge("Rebecca", 1, Integer::sum);   // no existe: la crea con 1
        System.out.println("Tras dos merge: " + stock);
    }
}
keySet() (solo claves):
  Dune
  Emma
  Hamlet
  Macbeth
Ejemplares en total: 5
entrySet() (clave y valor a la vez):
  Dune -> 3
  Emma -> 0
  Hamlet -> 2
  Macbeth -> 0
¡Alto ahí!: ConcurrentModificationException
Se quedó a medias: {Dune=3, Hamlet=2, Macbeth=0}
Con removeIf: {Dune=3, Hamlet=2}
Tras dos merge: {Dune=3, Hamlet=3, Rebecca=1}

Entre líneas:

  • Para recorrer clave y valor a la vez, entrySet() es lo mejor: una sola pasada, sin llamar a get por cada clave.
  • Borrar con stock.remove(...) dentro del for-each provoca ConcurrentModificationException, igual que con las listas: el recorrido y el mapa dejan de ponerse de acuerdo. Y el mapa se queda a medias: Emma se borró, pero Macbeth (también a cero) sigue ahí. (Aquí capturamos la excepción solo para enseñarte cómo queda; en tu código no la captures.)
  • stock.entrySet().removeIf(...) es la forma segura de borrar por condición: Java lo hace por dentro con un iterador, que es exactamente lo que vimos en la teoría de iteradores.
  • merge(clave, 1, Integer::sum) es «suma 1 a lo que haya; si no hay nada, empieza en 1». Es el método estrella para contar: lo verás mucho en la práctica.

Integer::sum es una referencia a método: la función «suma dos enteros». merge la usa cuando la clave ya existía, para combinar el valor viejo con el nuevo.

Errores típicos, de un vistazo

Guardar objetos en un HashSet o usarlos de clave en un HashMap sin equals() y hashCode() coherentes. Aparecen repetidos que no deberían o búsquedas que fallan.

Modificar una colección o un mapa mientras lo recorres con un for-each. ConcurrentModificationException. Usa el iterador o removeIf.

Depender del orden de un HashSet o de un HashMap. No está garantizado. Si importa, LinkedHash... o Tree....

Usar get() de un Map y tratar el resultado como si nunca fuera null. Si la clave no está, get devuelve null: usa getOrDefault o containsKey.

Usar LinkedList «porque insertar es más rápido» y acabar con get(i) dentro de un bucle. Para recorrer por posiciones, ArrayList.

Míralo paso a paso en Python Tutor

Un HashSet es, por dentro, un conjunto de «cubos» donde caen los elementos según su hash. Python Tutor ejecuta el código línea a línea y dibuja variables, arrays y referencias. Elige Java, pega el programa y pulsa Visualize Execution. Aquí tienes una tabla hash casera con cuatro cubos, para que veas el reparto con tus propios ojos:

public class Tutor {
    public static void main(String[] args) {
        String[][] cubos = new String[4][3];
        int[] usados = new int[4];
        String[] titulos = {"Dune", "Emma", "Hamlet", "Dune"};

        for (String t : titulos) {
            int cubo = Math.abs(t.hashCode()) % 4;      // 1. el hash elige el cubo
            boolean repetido = false;
            for (int i = 0; i < usados[cubo]; i++) {    // 2. equals decide dentro del cubo
                if (cubos[cubo][i].equals(t)) {
                    repetido = true;
                }
            }
            if (!repetido) {
                cubos[cubo][usados[cubo]] = t;
                usados[cubo]++;
            }
            System.out.println(t + " -> cubo " + cubo + (repetido ? " (ya estaba)" : " (nuevo)"));
        }
    }
}

Salida esperada:

Dune -> cubo 0 (nuevo)
Emma -> cubo 0 (nuevo)
Hamlet -> cubo 1 (nuevo)
Dune -> cubo 0 (ya estaba)

Qué observar paso a paso:

  1. Al empezar verás cubos, un array de cuatro filas vacías (cada una puede guardar tres títulos), y usados, que cuenta cuántos hay en cada fila.
  2. En la primera vuelta, Dune cae en el cubo 0: Math.abs(t.hashCode()) % 4 da 0. Como el cubo estaba vacío, no hay nada que comparar y se guarda.
  3. Emma también cae en el cubo 0 (una colisión: dos títulos distintos, el mismo cubo). Ahora el bucle interior sí compara con equals contra Dune, ve que no son iguales y la añade a continuación.
  4. Hamlet va al cubo 1. Y el segundo Dune vuelve al cubo 0: el hash lo lleva al sitio correcto y equals lo reconoce como repetido, así que no se guarda otra vez. Eso es exactamente lo que hace un HashSet: **el hash lo lleva al cubo y equals decide**.

Resumen y tabla rápida

Quiero…UsoDetalle
Una secuencia ordenada con repetidosArrayListLa opción por defecto
Elementos sin repetirHashSetRápido, sin orden
Sin repetir y con orden de inserciónLinkedHashSet
Sin repetir y ordenadoTreeSetExige Comparable o Comparator
Buscar por claveHashMapSin orden; LinkedHashMap o TreeMap si importa
Contar aparicionesmapa.merge(clave, 1, Integer::sum)
Borrar por condiciónremoveIf(...)En listas, conjuntos y en entrySet()
Que mis objetos funcionen en HashSet/HashMapequals() + hashCode()O un record

Tres ideas para quedarte:

  1. Elige por la promesa: List si importa el orden o hay repetidos, Set si no puede haberlos, Map si buscas por clave. Y declara siempre con la interfaz.
  2. **HashSet y HashMap dependen de equals() y hashCode():** el hash elige el cubo y equals decide si ya está. Si los dos no van de la mano, aparecen repetidos «invisibles».
  3. **El orden de un Hash... no existe:** si lo necesitas, LinkedHash... (inserción) o Tree... (ordenado).

Y ahora, a practicar

Con esto tienes la teoría completa. En la práctica de colecciones contamos palabras con un Map, probamos qué pasa con tus propios objetos en un HashSet y un HashMap, y montamos colas y pilas de préstamos con Deque y PriorityQueue. Y si quieres repasar antes lo anterior, vuelve a la teoría de iteradores.

Siguiente paso: prácticaColecciones en Java — práctica con un contador de palabras, claves propias y colas de préstamos (y el HashMap que imprimía en un orden que no entendía)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

2 comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *