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.
Tabla de Contenidos
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:
| Interfaz | Qué promete | Implementaciones habituales |
|---|---|---|
List | Orden de inserción, admite repetidos, acceso por posición | ArrayList, LinkedList |
Set | Sin repetidos | HashSet, LinkedHashSet, TreeSet |
Queue / Deque | Colas y pilas: se añade por un extremo y se saca por otro | ArrayDeque, LinkedList, PriorityQueue |
Map | Pares clave → valor, claves sin repetir | HashMap, 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:
Emmaestá dos veces: una lista no se queja de los repetidos.get(2)yremove(0)trabajan con posiciones (empiezan en 0). Al borrar el primero, todos los demás se corren un hueco.indexOf(...)busca conequals(), y por eso encuentra aEmmaaunque le pasemos unLibrorecién creado. (Losrecordcomparan por contenido.)- Al cambiar a
LinkedListsolo tocamos la línea delnew; el resto del código sigue valiendo, porqueListes la misma interfaz. Yadd(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:
ArrayList | LinkedList | |
|---|---|---|
| Por dentro | Un array que crece cuando se llena | Nodos enlazados entre sí (como en la práctica de iteradores) |
get(i) | Rápido: va directo a la posición | Lento: recorre nodos hasta llegar a i |
| Añadir al final | Rápido | Rápido |
| Insertar o borrar en medio | Lento: hay que desplazar los elementos | Una vez localizado el nodo, solo redirige dos enlaces (pero localizarlo cuesta) |
| Memoria | Poca | Má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
HashSet | LinkedHashSet | TreeSet | |
|---|---|---|---|
| Orden al recorrerlo | Ninguno garantizado (el que salga) | El de inserción | Ordenado (orden natural o un Comparator) |
Velocidad de add / contains | Muy rápida | Muy rápida | Algo más lenta (crece con el tamaño, pero poco) |
| Exige a los elementos | equals() y hashCode() | equals() y hashCode() | Comparable o un Comparator |
Fíjate en tres cosas del resultado:
add()devuelvefalsecuando el elemento ya estaba, y el conjunto no cambia. Es la forma más cómoda de detectar repetidos.- El orden del
HashSetparece 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. TreeSetordena losStringalfabéticamente… perodu Maurierqueda al final. Es el orden natural deString: 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étodo | Qué devuelve |
|---|---|---|
| Guardar o sustituir | put(clave, valor) | El valor anterior (o null si no había) |
| Consultar | get(clave) | El valor, o **null** si no existe |
| Consultar con valor de repuesto | getOrDefault(clave, repuesto) | El valor, o el repuesto |
| Guardar solo si no estaba | putIfAbsent(clave, valor) | No toca nada si la clave ya existía |
| Saber si existe una clave | containsKey(clave) | true / false |
| Cuántas parejas hay | size() | Un entero |
Cosas que merece la pena notar:
- Una clave no se repite: al hacer
putsobreL-102no aparece una segunda entrada, se sustituye el valor yputte devuelve el antiguo. getde una clave que no existe danull, no una excepción. Si intentas usar esenullcomo 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ómoL-104salió el primero),LinkedHashMap(orden de inserción) yTreeMap(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:
- Si
a.equals(b)estrue, entoncesa.hashCode() == b.hashCode(). (Siempre juntos: si cambias uno, cambia el otro.) - Lo contrario no hace falta: dos objetos distintos pueden tener el mismo hash (se llama colisión, y es normal).
- 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), congetKey()ygetValue().
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 agetpor cada clave. - Borrar con
stock.remove(...)dentro delfor-eachprovocaConcurrentModificationException, igual que con las listas: el recorrido y el mapa dejan de ponerse de acuerdo. Y el mapa se queda a medias:Emmase borró, peroMacbeth(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:
- Al empezar verás
cubos, un array de cuatro filas vacías (cada una puede guardar tres títulos), yusados, que cuenta cuántos hay en cada fila. - En la primera vuelta,
Dunecae en el cubo 0:Math.abs(t.hashCode()) % 4da 0. Como el cubo estaba vacío, no hay nada que comparar y se guarda. Emmatambién cae en el cubo 0 (una colisión: dos títulos distintos, el mismo cubo). Ahora el bucle interior sí compara conequalscontraDune, ve que no son iguales y la añade a continuación.Hamletva al cubo 1. Y el segundoDunevuelve al cubo 0: el hash lo lleva al sitio correcto yequalslo reconoce como repetido, así que no se guarda otra vez. Eso es exactamente lo que hace unHashSet: **el hash lo lleva al cubo yequalsdecide**.
Resumen y tabla rápida
| Quiero… | Uso | Detalle |
|---|---|---|
| Una secuencia ordenada con repetidos | ArrayList | La opción por defecto |
| Elementos sin repetir | HashSet | Rápido, sin orden |
| Sin repetir y con orden de inserción | LinkedHashSet | |
| Sin repetir y ordenado | TreeSet | Exige Comparable o Comparator |
| Buscar por clave | HashMap | Sin orden; LinkedHashMap o TreeMap si importa |
| Contar apariciones | mapa.merge(clave, 1, Integer::sum) | |
| Borrar por condición | removeIf(...) | En listas, conjuntos y en entrySet() |
Que mis objetos funcionen en HashSet/HashMap | equals() + hashCode() | O un record |
Tres ideas para quedarte:
- Elige por la promesa:
Listsi importa el orden o hay repetidos,Setsi no puede haberlos,Mapsi buscas por clave. Y declara siempre con la interfaz. - **
HashSetyHashMapdependen deequals()yhashCode():** el hash elige el cubo yequalsdecide si ya está. Si los dos no van de la mano, aparecen repetidos «invisibles». - **El orden de un
Hash...no existe:** si lo necesitas,LinkedHash...(inserción) oTree...(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.
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

2 comentarios