En la lección anterior desmontaste el HashMap pieza a pieza: la función hash, las cubetas, las colisiones, el factor de carga, el rehash y —por fin— la razón exacta por la que equals y hashCode tienen que ir siempre juntos. Esa inversión te va a rendir intereses inmediatos, porque un HashSet es literalmente un HashMap con valores ficticios. No es una metáfora didáctica: es su implementación real, y lo verás en el código fuente del JDK.
Un conjunto (Set) modela la idea matemática de conjunto: una colección sin elementos repetidos. Esa única restricción resuelve por sí sola una familia entera de problemas que con listas requieren bucles y comprobaciones: detectar duplicados, comprobar pertenencia, cruzar dos colecciones para saber qué tienen en común o en qué se diferencian. En BiblioTech ya usaste un Set<String> como apaño para no repetir ISBN; al terminar esta lección será una pieza de diseño con todo su potencial desplegado.
También verás la diferencia de rendimiento más espectacular de todo el módulo: buscar en un Set frente a buscar en una List. No es un 20 % mejor. Es cinco órdenes de magnitud.
Contenido
- Qué es un conjunto
- La interfaz
Sety su API HashSetes unHashMapdisfrazadoadddevuelveboolean, y eso lo cambia todo- Operaciones de conjunto
HashSet,LinkedHashSetyTreeSetTreeSet,SortedSetyNavigableSet- El peligro de mutar un elemento guardado
- Conjuntos inmutables
containsen unSetfrente a unaList- Aplicación a BiblioTech
- Errores Comunes y Consejos
- Ejercicios
- Qué es un conjunto
Un Set es una colección con dos propiedades definitorias:
- No admite duplicados. Añadir un elemento que ya está no hace nada.
- No garantiza orden (en el caso de
HashSet). El orden de recorrido es impredecible y puede cambiar.
import java.util.HashSet;
import java.util.Set;
Set<String> isbnCatalogados = new HashSet<>();
isbnCatalogados.add("978-0000000001");
isbnCatalogados.add("978-0000000002");
isbnCatalogados.add("978-0000000001"); // ya estaba: se ignora
System.out.println(isbnCatalogados.size()); // 2, no 3
System.out.println(isbnCatalogados); // orden impredecible¿Qué significa exactamente "ya estaba"? Aquí es donde 03-09 y 05-05 se juntan: dos elementos son el mismo si equals dice que lo son, y para llegar a compararlos el conjunto usa hashCode. Todo lo que aprendiste sobre claves de mapa se aplica exactamente igual a los elementos de un conjunto.
La pregunta que decide entre List y Set:
| Pregunta | Respuesta | Colección |
|---|---|---|
| ¿Puede haber elementos repetidos, y cada uno cuenta? | Sí | List |
| ¿Un repetido es un error o un dato redundante? | Sí | Set |
| ¿Importa el orden y la posición? | Sí | List |
| ¿Solo me importa "está o no está"? | Sí | Set |
Ejemplos claros en BiblioTech:
- El catálogo es una
List: podría haber dos ejemplares del mismo libro y el orden de exhibición importa. - Los ISBN catalogados son un
Set: un ISBN repetido es un error de alta. - Los empleados con préstamos vencidos son un
Set: un empleado con tres préstamos vencidos aparece una vez. - Los avisos ya enviados son un
Set: no queremos enviar dos veces el mismo.
Cuando eliges Set, la unicidad deja de ser algo que tienes que vigilar y pasa a estar garantizada por la estructura. Eso es diseño: el tipo documenta y hace cumplir la regla.
- La interfaz
Set y su API
Set y su APISet extiende Collection y, curiosamente, no añade ni un solo método nuevo. Lo que cambia es el contrato: add puede rechazar, y no hay acceso por índice.
| Método | Qué hace | Complejidad en HashSet |
|---|---|---|
add(E e) |
Añade si no estaba. Devuelve false si ya estaba |
O(1) |
remove(Object o) |
Elimina. Devuelve true si había algo |
O(1) |
contains(Object o) |
¿Está? | O(1) |
size() / isEmpty() |
Cuántos hay | O(1) |
clear() |
Vacía | O(n) |
addAll(Collection c) |
Unión con otra colección | O(m) |
retainAll(Collection c) |
Intersección | O(n) |
removeAll(Collection c) |
Diferencia | O(m) |
containsAll(Collection c) |
¿Es superconjunto? | O(m) |
removeIf(Predicate) |
Elimina los que cumplan | O(n) |
forEach(Consumer) |
Recorre | O(n) |
iterator() |
Recorrido explícito | O(n) |
toArray(T[] a) |
Vuelca a array | O(n) |
Lo que no hay, y es importante notarlo:
- No hay
get(i). Un conjunto no tiene posiciones. Para llegar a un elemento concreto, o lo recorres, o consultas si está concontains. - No hay
set(i, e)niindexOf. - No hay orden en
HashSet. Si necesitas orden, cambias de implementación.
Uso básico:
Set<String> empleadosConAviso = new HashSet<>();
empleadosConAviso.add("EMP-001");
empleadosConAviso.add("EMP-002");
if (empleadosConAviso.contains("EMP-001")) { // O(1)
System.out.println("A Marta ya se le aviso");
}
empleadosConAviso.remove("EMP-002");
System.out.println(empleadosConAviso.size()); // 1
for (String id : empleadosConAviso) { // for-each: la unica forma de recorrer
System.out.println(id);
}
HashSet es un HashMap disfrazado
HashSet es un HashMap disfrazadoAbre java.util.HashSet en el JDK y encontrarás esto:
public class HashSet<E> extends AbstractSet<E> implements Set<E> {
private transient HashMap<E, Object> map; // un HashMap por dentro
// El valor ficticio que se asocia a TODAS las claves
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT) == null; // put devuelve null si la clave era nueva
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
public int size() {
return map.size();
}
public Iterator<E> iterator() {
return map.keySet().iterator();
}
}Un HashSet es un HashMap en el que solo importan las claves. Los elementos del conjunto son las claves del mapa; todos los valores son el mismo objeto centinela PRESENT, que no significa nada y solo existe porque HashMap necesita algo que guardar.
flowchart LR
subgraph HS["HashSet"]
M["map →"]
end
subgraph HM["HashMap interno"]
E1["'978-0000000001' → PRESENT"]
E2["'978-0000000002' → PRESENT"]
E3["'REV-2024-03' → PRESENT"]
end
M --> HM
P["PRESENT<br/>(un unico objeto compartido)"]
E1 -.-> P
E2 -.-> P
E3 -.-> P
De esta identidad se deduce todo lo que necesitas saber sobre HashSet, sin aprender nada nuevo:
Propiedad del HashSet |
Porque el HashMap interno... |
|---|---|
add, contains, remove son O(1) |
...localiza la cubeta calculando el hash |
| No admite duplicados | ...no admite claves duplicadas |
| No garantiza orden | ...no garantiza orden de claves |
Admite un solo null |
...admite una sola clave null |
Exige equals y hashCode correctos |
...los exige en sus claves |
| Los elementos deben ser inmutables | ...sus claves deben serlo |
| Tiene factor de carga y rehash | ...los tiene |
| Una cubeta con 8+ elementos se convierte en árbol | ...lo hace |
Y de ahí sale el aviso central de esta lección: todo lo que rompía un HashMap en 05-05 rompe exactamente igual un HashSet. Un elemento con equals pero sin hashCode entra en el conjunto y no se encuentra nunca, y se pueden meter dos "duplicados" en un conjunto que garantiza unicidad:
// ClaveRota tenia equals pero NO hashCode (05-05)
Set<ClaveRota> conjunto = new HashSet<>();
conjunto.add(new ClaveRota("978-0000000001"));
conjunto.add(new ClaveRota("978-0000000001")); // "igual", pero con otro hash
System.out.println(conjunto.size()); // 2 <-- DUPLICADOS en un Set
System.out.println(conjunto.contains(new ClaveRota("978-0000000001"))); // falseLa misma explicación de 05-05: contains calcula el hash, va a una cubeta que está vacía y devuelve false sin llegar a llamar a equals.
Un HashSet también tiene constructores con capacidad, con la misma semántica y las mismas cuentas que un HashMap:
Set<String> isbn = new HashSet<>(); // por defecto
Set<String> grande = new HashSet<>((int)(50_000 / 0.75f) + 1); // sin rehashes
Set<Material> copia = new HashSet<>(listaDeMateriales); // desde otra coleccionEse último constructor es un idioma muy útil: eliminar duplicados de una lista en una línea.
List<String> conRepetidos = List.of("A", "B", "A", "C", "B");
Set<String> sinRepetidos = new HashSet<>(conRepetidos); // [A, B, C], orden impredecible
// Y si quieres una lista sin duplicados conservando el orden original:
List<String> lista = new ArrayList<>(new LinkedHashSet<>(conRepetidos)); // [A, B, C]
add devuelve boolean, y eso lo cambia todo
add devuelve boolean, y eso lo cambia todoCollection.add devuelve boolean. En una List ese valor es siempre true y nadie lo mira. En un Set es información valiosa:
adddevuelvetruesi el elemento se añadió (era nuevo) yfalsesi ya estaba.
Eso resuelve el problema de "detectar duplicados" sin ninguna comprobación previa:
// SIN aprovecharlo: dos operaciones, dos busquedas
if (isbnCatalogados.contains(isbn)) {
System.out.println("AVISO: ISBN duplicado");
} else {
isbnCatalogados.add(isbn);
}
// APROVECHANDOLO: una operacion, una busqueda
if (!isbnCatalogados.add(isbn)) {
System.out.println("AVISO: ISBN duplicado -> " + isbn);
}La segunda versión no solo es más corta: hace la mitad de trabajo, porque contains seguido de add recorre la misma cubeta dos veces.
Aplicaciones típicas del patrón:
// 1. Procesar cada elemento SOLO la primera vez que aparece
Set<String> yaProcesados = new HashSet<>();
for (Prestamo p : prestamos) {
if (yaProcesados.add(p.getEmpleado().getIdentificador())) {
enviarResumenMensual(p.getEmpleado()); // solo una vez por empleado
}
}
// 2. Detectar el primer duplicado de una lista
Set<String> vistos = new HashSet<>();
for (String isbn : isbnDelFichero) {
if (!vistos.add(isbn)) {
System.out.println("Primera repeticion: " + isbn);
break;
}
}
// 3. Contar cuantos elementos distintos hay
Set<String> distintos = new HashSet<>(todosLosIsbn);
System.out.println("Materiales distintos: " + distintos.size());
// 4. Evitar ciclos infinitos al recorrer relaciones
Set<Material> visitados = new HashSet<>();
// ... if (!visitados.add(actual)) { return; } // ya pasamos por aquiLos métodos hermanos siguen la misma lógica:
| Método | Devuelve true cuando... |
|---|---|
add(e) |
El elemento no estaba y se añadió |
remove(o) |
El elemento estaba y se eliminó |
addAll(c) |
Al menos uno de los elementos era nuevo |
removeAll(c) |
Al menos uno se eliminó |
retainAll(c) |
El conjunto cambió |
- Operaciones de conjunto
Aquí Set demuestra que no es una List capada, sino una estructura con álgebra propia. Las cuatro operaciones clásicas de la teoría de conjuntos tienen su método:
Set<String> conPrestamo = new HashSet<>(Set.of("EMP-001", "EMP-002", "EMP-003"));
Set<String> conRetraso = new HashSet<>(Set.of("EMP-002", "EMP-003", "EMP-005"));flowchart TB
subgraph diagrama["Empleados"]
A["conPrestamo<br/>EMP-001, EMP-002, EMP-003"]
B["conRetraso<br/>EMP-002, EMP-003, EMP-005"]
I["INTERSECCION<br/>EMP-002, EMP-003<br/>(con prestamo Y con retraso)"]
A --- I
B --- I
end
Unión: addAll
Todos los elementos de ambos conjuntos, sin repetir.
Set<String> union = new HashSet<>(conPrestamo); // copia, para no destruir el original
union.addAll(conRetraso);
System.out.println(union); // [EMP-001, EMP-002, EMP-003, EMP-005]Intersección: retainAll
Solo los que están en ambos.
Set<String> interseccion = new HashSet<>(conPrestamo);
interseccion.retainAll(conRetraso);
System.out.println(interseccion); // [EMP-002, EMP-003]Diferencia: removeAll
Los que están en el primero pero no en el segundo.
Set<String> soloConPrestamo = new HashSet<>(conPrestamo);
soloConPrestamo.removeAll(conRetraso);
System.out.println(soloConPrestamo); // [EMP-001]
// Ojo: la diferencia NO es simetrica
Set<String> soloConRetraso = new HashSet<>(conRetraso);
soloConRetraso.removeAll(conPrestamo);
System.out.println(soloConRetraso); // [EMP-005]Subconjunto: containsAll
¿Están todos los del segundo en el primero?
Set<String> algunos = Set.of("EMP-002", "EMP-003");
System.out.println(conPrestamo.containsAll(algunos)); // true: es un subconjunto
System.out.println(conPrestamo.containsAll(conRetraso)); // false: falta EMP-005Diferencia simétrica
No tiene método propio, pero se compone con las anteriores: los que están en uno o en el otro, pero no en ambos.
Set<String> simetrica = new HashSet<>(conPrestamo);
simetrica.addAll(conRetraso); // union
Set<String> comunes = new HashSet<>(conPrestamo);
comunes.retainAll(conRetraso); // interseccion
simetrica.removeAll(comunes); // union menos interseccion
System.out.println(simetrica); // [EMP-001, EMP-005]Tabla resumen
| Operación matemática | Método | Efecto |
|---|---|---|
| Unión (A ∪ B) | a.addAll(b) |
A pasa a contener todo |
| Intersección (A ∩ B) | a.retainAll(b) |
A conserva solo lo común |
| Diferencia (A − B) | a.removeAll(b) |
A pierde lo que estaba en B |
| Subconjunto (B ⊆ A) | a.containsAll(b) |
No modifica nada, solo consulta |
| Disjuntos (A ∩ B = ∅) | Collections.disjoint(a, b) |
true si no comparten nada |
Aviso crítico: las tres primeras modifican el conjunto sobre el que se invocan. Si necesitas conservar los originales —que es casi siempre—, trabaja sobre una copia:
Set<String> resultado = new HashSet<>(original); // COPIA
resultado.retainAll(otro); // se modifica la copiaOlvidar la copia y destruir el conjunto original es uno de los errores más frecuentes con operaciones de conjunto.
HashSet, LinkedHashSet y TreeSet
HashSet, LinkedHashSet y TreeSet| Aspecto | HashSet |
LinkedHashSet |
TreeSet |
|---|---|---|---|
| Estructura interna | HashMap |
LinkedHashMap |
TreeMap (árbol rojo-negro) |
| Orden de recorrido | Ninguno garantizado | Inserción | Ordenado |
add / contains / remove |
O(1) | O(1) | O(log n) |
| Memoria | Menor | +2 referencias por elemento | Mayor |
null permitido |
Uno | Uno | No: NullPointerException |
| Requisito del elemento | equals + hashCode |
equals + hashCode |
Comparable o Comparator |
| Operaciones de rango | No | No | Sí: headSet, ceiling... |
| Cuándo usarlo | Por defecto | Orden reproducible | Orden permanente, rangos |
Los tres en acción sobre los mismos datos:
List<String> entrada = List.of("Revista", "Libro", "DVD", "Libro", "Audiolibro");
Set<String> hash = new HashSet<>(entrada);
Set<String> linked = new LinkedHashSet<>(entrada);
Set<String> arbol = new TreeSet<>(entrada);
System.out.println("HashSet: " + hash); // [DVD, Revista, Libro, Audiolibro] (impredecible)
System.out.println("LinkedHashSet: " + linked); // [Revista, Libro, DVD, Audiolibro] (insercion)
System.out.println("TreeSet: " + arbol); // [Audiolibro, DVD, Libro, Revista] (alfabetico)Los tres tienen 4 elementos: el "Libro" repetido se descartó en los tres casos.
Cuándo usar cada uno:
HashSet: por defecto. Si solo te importa "está o no está", es la respuesta.LinkedHashSet: cuando la salida deba ser reproducible —informes, tests, ficheros generados— o cuando quieras eliminar duplicados conservando el orden original. El sobrecoste es mínimo.TreeSet: cuando necesites recorrer siempre en orden, o consultar rangos y vecinos. Recuerda que pasas de O(1) a O(log n): con un millón de elementos son unas 20 comparaciones por operación, generalmente asumible.
TreeSet, SortedSet y NavigableSet
TreeSet, SortedSet y NavigableSetTreeSet implementa NavigableSet, que extiende SortedSet, que extiende Set. Cada nivel añade operaciones que solo tienen sentido si hay orden.
TreeSet<String> referencias = new TreeSet<>(Set.of(
"978-0000000001", "978-0000000002", "978-0000000003", "DVD-0007", "REV-2024-03"));
// SortedSet: extremos y rangos
System.out.println(referencias.first()); // 978-0000000001
System.out.println(referencias.last()); // REV-2024-03
System.out.println(referencias.headSet("DVD-0007")); // los ESTRICTAMENTE menores
System.out.println(referencias.tailSet("DVD-0007")); // los mayores O IGUALES
System.out.println(referencias.subSet("978-0000000002", "DVD-0007"));
// NavigableSet: vecinos
System.out.println(referencias.ceiling("978-0000000002x")); // el menor >= dado
System.out.println(referencias.floor("978-0000000002x")); // el mayor <= dado
System.out.println(referencias.higher("978-0000000002")); // estrictamente mayor
System.out.println(referencias.lower("978-0000000002")); // estrictamente menor
// NavigableSet: extraer extremos (util como cola de prioridad)
System.out.println(referencias.pollFirst()); // devuelve Y ELIMINA el primero
System.out.println(referencias.pollLast()); // devuelve Y ELIMINA el ultimo
// Recorrido inverso
System.out.println(referencias.descendingSet());| Método | Devuelve |
|---|---|
first() / last() |
El menor / mayor. NoSuchElementException si está vacío |
pollFirst() / pollLast() |
El menor / mayor, eliminándolo. null si está vacío |
headSet(e) |
Vista de los menores que e |
tailSet(e) |
Vista de los mayores o iguales que e |
subSet(a, b) |
Vista del rango [a, b) |
ceiling(e) |
El menor elemento ≥ e, o null |
floor(e) |
El mayor elemento ≤ e, o null |
higher(e) / lower(e) |
Estrictamente mayor / menor, o null |
descendingSet() |
Vista en orden inverso |
Las vistas de rango son vivas: eliminar de un headSet elimina del TreeSet original.
Orden natural o Comparator
TreeSet necesita saber cómo comparar sus elementos. Dos opciones:
// 1. Orden natural: los elementos implementan Comparable
TreeSet<Ficha> porTitulo = new TreeSet<>(); // Ficha implementa Comparable (04-07)
porTitulo.add(new Ficha("Refactorizacion", "Martin Fowler", 1999));
porTitulo.add(new Ficha("Java Efectivo", "Joshua Bloch", 2018));
porTitulo.add(new Ficha("Patrones de Diseno", "Erich Gamma", 1994));
// recorrido: Java Efectivo, Patrones de Diseno, Refactorizacion
// 2. Comparator explicito, retomando 04-06
TreeSet<Material> porTarifa = new TreeSet<>(
Comparator.comparingDouble(Material::getTarifaDiaria)
.thenComparing(Material::getReferencia)); // desempate: OBLIGATORIOSi el elemento no implementa Comparable y no le das un Comparator, la primera inserción lanza ClassCastException.
Y aquí hay una trampa que hay que entender bien. En un TreeSet, la unicidad no la decide equals: la decide el comparador. Dos elementos son "el mismo" si compareTo (o compare) devuelve 0.
TreeSet<Material> porTarifa = new TreeSet<>(
Comparator.comparingDouble(Material::getTarifaDiaria)); // SIN desempate
porTarifa.add(new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018)); // 0.25
porTarifa.add(new Libro("Patrones de Diseno", "Erich Gamma", "978-0000000002", 1994)); // 0.25
System.out.println(porTarifa.size()); // 1 <-- el segundo se descarto!Los dos libros son objetos distintos, con equals distinto, pero su tarifa es la misma, así que el comparador devuelve 0 y el TreeSet los considera duplicados. Es una fuente de pérdidas de datos silenciosas.
Regla imprescindible: el Comparator de un TreeSet debe ser "total", es decir, debe devolver 0 solo para elementos realmente iguales. Añade siempre un criterio de desempate único:
Comparator.comparingDouble(Material::getTarifaDiaria)
.thenComparing(Material::getReferencia) // la referencia es unicaA esta propiedad se le llama coherencia con equals, y en 05-09 la estudiarás como parte del contrato de Comparable.
- El peligro de mutar un elemento guardado
Es el mismo problema de las claves mutables de 05-05, y por la misma razón: los elementos del HashSet son las claves del HashMap interno.
package com.nexussoftware.bibliotech.dominio;
/** Etiqueta mutable. Mala candidata a elemento de un Set. */
public class Etiqueta {
private String nombre; // no final: el problema
public Etiqueta(String nombre) { this.nombre = nombre; }
public void setNombre(String nombre) { this.nombre = nombre; }
@Override public boolean equals(Object o) {
return (o instanceof Etiqueta e) && nombre.equals(e.nombre);
}
@Override public int hashCode() { return nombre.hashCode(); }
@Override public String toString() { return nombre; }
}Etiqueta e = new Etiqueta("java");
Set<Etiqueta> etiquetas = new HashSet<>();
etiquetas.add(e);
System.out.println(etiquetas.contains(e)); // true
e.setNombre("programacion"); // MUTAMOS un elemento YA GUARDADO
System.out.println(etiquetas.contains(e)); // false <-- perdido
System.out.println(etiquetas.size()); // 1 <-- sigue dentro
System.out.println(etiquetas); // [programacion]
System.out.println(etiquetas.remove(e)); // false <-- ni siquiera se puede quitar
etiquetas.add(e); // se anade OTRA VEZ el mismo objeto
System.out.println(etiquetas.size()); // 2 <-- el mismo objeto, dos vecesEl elemento quedó en la cubeta del hash antiguo. contains calcula el hash nuevo, mira en otra cubeta y no lo encuentra. Y como no lo encuentra, add lo vuelve a insertar: ahora el mismo objeto está dos veces en un conjunto, algo que se supone imposible.
En TreeSet el problema es equivalente pero con el orden: si mutas el campo por el que se ordena, el elemento queda en una rama del árbol donde la búsqueda binaria nunca lo va a buscar.
TreeSet<Etiqueta> ordenadas = new TreeSet<>(Comparator.comparing(Etiqueta::toString));
Etiqueta z = new Etiqueta("zzz");
ordenadas.add(new Etiqueta("aaa"));
ordenadas.add(z);
z.setNombre("aab"); // ahora deberia ir el segundo... pero no se recoloca
System.out.println(ordenadas.contains(z)); // impredecibleRegla: los elementos de un Set deben ser inmutables, o al menos deben serlo los campos que intervienen en equals/hashCode (o en el comparador).
Si necesitas cambiar un elemento, la única forma correcta es quitarlo, modificarlo y volver a meterlo:
etiquetas.remove(e); // se quita mientras su hash sigue siendo el correcto
e.setNombre("programacion"); // ahora se puede mutar
etiquetas.add(e); // se reinserta en la cubeta correcta
- Conjuntos inmutables
Como viste en 05-02, Set.of(...) crea un conjunto inmutable:
Sus propiedades, con una particularidad importante:
- Inmutable:
add,removeyclearlanzanUnsupportedOperationException. - No admite
null:NullPointerExceptional crearlo. - Rechaza duplicados al construirse con
IllegalArgumentException:
Set<String> mal = Set.of("Libro", "Revista", "Libro");
// IllegalArgumentException: duplicate element: LibroEse comportamiento sorprende a mucha gente, porque un HashSet normal ignora los duplicados en silencio. Es intencionado: en un literal escrito a mano, un duplicado casi seguro que es un error tuyo, y es mejor que salte al construirlo que descubrirlo más tarde.
- El orden de recorrido está deliberadamente aleatorizado entre ejecuciones, para que nadie escriba código que dependa de él.
Los conjuntos inmutables son ideales para constantes:
public static final Set<String> TIPOS_PRESTABLES = Set.of("Libro", "Revista", "DVD");
public static final Set<String> ESTADOS_FINALES = Set.of("DEVUELTO", "CANCELADO", "PERDIDO");
if (TIPOS_PRESTABLES.contains(m.getTipo())) { ... } // O(1), y nadie puede alterar la listaY Set.copyOf(coleccion) crea una copia inmutable de cualquier colección, descartando duplicados sin protestar (a diferencia de Set.of):
contains en un Set frente a una List
contains en un Set frente a una ListEsta es la comparación que más impresiona de todo el módulo, y merece números.
package com.nexussoftware.bibliotech.presentacion;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ComparativaBusqueda {
public static void main(String[] args) {
final int N = 100_000;
final int CONSULTAS = 10_000;
List<String> lista = new ArrayList<>(N);
Set<String> conjunto = new HashSet<>((int)(N / 0.75f) + 1);
for (int i = 0; i < N; i++) {
String ref = "978-" + String.format("%010d", i);
lista.add(ref);
conjunto.add(ref);
}
// Calentamiento
for (int i = 0; i < 100; i++) { lista.contains("978-0000099999"); }
long t1 = System.nanoTime();
int encontradosLista = 0;
for (int i = 0; i < CONSULTAS; i++) {
if (lista.contains("978-" + String.format("%010d", N - 1))) { encontradosLista++; }
}
long msLista = (System.nanoTime() - t1) / 1_000_000;
long t2 = System.nanoTime();
int encontradosSet = 0;
for (int i = 0; i < CONSULTAS; i++) {
if (conjunto.contains("978-" + String.format("%010d", N - 1))) { encontradosSet++; }
}
long msSet = (System.nanoTime() - t2) / 1_000_000;
System.out.printf("List.contains x%d: %6d ms (%d encontrados)%n",
CONSULTAS, msLista, encontradosLista);
System.out.printf("Set.contains x%d: %6d ms (%d encontrados)%n",
CONSULTAS, msSet, encontradosSet);
System.out.printf("El Set es aproximadamente %d veces mas rapido%n",
msSet == 0 ? msLista : msLista / Math.max(msSet, 1));
}
}Resultado típico:
List.contains x10000: 4180 ms (10000 encontrados) Set.contains x10000: 1 ms (10000 encontrados) El Set es aproximadamente 4180 veces mas rapido
Y en operaciones por segundo:
| Tamaño | List.contains (peor caso) |
Set.contains |
Ventaja |
|---|---|---|---|
| 100 | ~100 comparaciones | 1 operación | 100× |
| 10 000 | ~10 000 | 1 | 10 000× |
| 1 000 000 | ~1 000 000 | 1 | 1 000 000× |
De aquí sale una de las optimizaciones más rentables y sencillas que existen. Este patrón, que aparece constantemente en código real, es O(n×m):
// LENTO: para cada material, recorrer toda la lista de referencias
List<String> referenciasVetadas = cargarVetados(); // 10.000 elementos
for (Material m : catalogo) { // 10.000 materiales
if (referenciasVetadas.contains(m.getReferencia())) { // O(n) cada vez
rechazar(m);
}
}
// Total: 100.000.000 de comparacionesY así queda con una línea cambiada:
// RAPIDO: O(n + m)
Set<String> vetadas = new HashSet<>(cargarVetados()); // conversion O(m), una sola vez
for (Material m : catalogo) {
if (vetadas.contains(m.getReferencia())) { // O(1) cada vez
rechazar(m);
}
}
// Total: 20.000 operacionesRegla profesional: si vas a hacer contains sobre una colección más de unas pocas veces, conviértela primero a Set. El coste de la conversión (O(m), una vez) se amortiza casi de inmediato.
- Aplicación a BiblioTech
Dos aplicaciones que consolidan lo aprendido.
Set<String> de ISBN catalogados
Ya lo usaste en 05-02, pero ahora con toda su lógica:
package com.nexussoftware.bibliotech.servicio;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import com.nexussoftware.bibliotech.dominio.Material;
/** Control de altas duplicadas en el catalogo. */
public class ControlDuplicados {
private final Set<String> referenciasCatalogadas = new HashSet<>();
private final Set<String> rechazadas = new LinkedHashSet<>(); // orden de aparicion
/**
* Intenta dar de alta. El boolean de add hace todo el trabajo:
* true si era nueva, false si ya estaba. Una sola busqueda.
*/
public boolean registrar(Material m) {
if (m == null || m.getReferencia() == null) { return false; }
if (referenciasCatalogadas.add(m.getReferencia())) {
return true; // alta aceptada
}
rechazadas.add(m.getReferencia()); // alta rechazada: la anotamos
return false;
}
public boolean estaCatalogada(String referencia) {
return referenciasCatalogadas.contains(referencia); // O(1)
}
public boolean darDeBaja(String referencia) {
return referenciasCatalogadas.remove(referencia);
}
/** Cuales de las referencias recibidas NO estan catalogadas: diferencia. */
public Set<String> desconocidas(Set<String> referencias) {
Set<String> resultado = new HashSet<>(referencias); // COPIA: no destruimos el parametro
resultado.removeAll(referenciasCatalogadas);
return resultado;
}
/** Cuales SI estan catalogadas: interseccion. */
public Set<String> conocidas(Set<String> referencias) {
Set<String> resultado = new HashSet<>(referencias);
resultado.retainAll(referenciasCatalogadas);
return resultado;
}
/** El fichero recibido, cubre el catalogo entero? */
public boolean cubreTodoElCatalogo(Set<String> referencias) {
return referencias.containsAll(referenciasCatalogadas);
}
public Set<String> intentosRechazados() { return Set.copyOf(rechazadas); }
public int catalogadas() { return referenciasCatalogadas.size(); }
}Cruce de conjuntos: vencidos frente a avisados
Aquí las operaciones de conjunto resuelven en tres líneas lo que con listas serían bucles anidados:
package com.nexussoftware.bibliotech.servicio;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.Comparator;
import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Prestamo;
/** Gestiona a quien hay que avisar por retrasos, sin repetir avisos. */
public class GestorAvisos {
private final Set<Empleado> yaAvisados = new HashSet<>();
/**
* Empleados con al menos un prestamo vencido. Un empleado con tres
* prestamos vencidos aparece UNA vez: eso es exactamente un Set.
*/
public Set<Empleado> conVencidos(List<Prestamo> prestamos, int diaActual) {
Set<Empleado> resultado = new HashSet<>();
for (Prestamo p : prestamos) {
if (!p.estaDevuelto() && p.estaVencido(diaActual)) {
resultado.add(p.getEmpleado()); // los repetidos se descartan solos
}
}
return resultado;
}
/** DIFERENCIA: vencidos menos ya avisados = pendientes de avisar. */
public Set<Empleado> pendientesDeAvisar(List<Prestamo> prestamos, int diaActual) {
Set<Empleado> pendientes = new HashSet<>(conVencidos(prestamos, diaActual));
pendientes.removeAll(yaAvisados);
return pendientes;
}
/** INTERSECCION: avisados que siguen teniendo retrasos = hay que escalar. */
public Set<Empleado> reincidentes(List<Prestamo> prestamos, int diaActual) {
Set<Empleado> reincidentes = new HashSet<>(conVencidos(prestamos, diaActual));
reincidentes.retainAll(yaAvisados);
return reincidentes;
}
/** DIFERENCIA inversa: avisados que ya no deben nada = se puede limpiar su aviso. */
public Set<Empleado> regularizados(List<Prestamo> prestamos, int diaActual) {
Set<Empleado> regularizados = new HashSet<>(yaAvisados);
regularizados.removeAll(conVencidos(prestamos, diaActual));
return regularizados;
}
/** Envia avisos solo a quien no lo haya recibido. Devuelve cuantos envio. */
public int avisar(List<Prestamo> prestamos, int diaActual) {
int enviados = 0;
for (Empleado e : pendientesDeAvisar(prestamos, diaActual)) {
System.out.printf("AVISO a %-16s (%s): tiene material vencido%n",
e.getNombre(), e.getIdentificador());
yaAvisados.add(e);
enviados++;
}
return enviados;
}
/** Limpia los avisos de quien ya devolvio todo. */
public int limpiarRegularizados(List<Prestamo> prestamos, int diaActual) {
Set<Empleado> limpiar = regularizados(prestamos, diaActual);
yaAvisados.removeAll(limpiar);
return limpiar.size();
}
/** Los avisados, ordenados por nombre: TreeSet con Comparator. */
public Set<Empleado> avisadosOrdenados() {
Set<Empleado> ordenados = new TreeSet<>(
Comparator.comparing(Empleado::getNombre)
.thenComparing(Empleado::getIdentificador)); // desempate UNICO
ordenados.addAll(yaAvisados);
return ordenados;
}
}Uso:
Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
Empleado diego = new Empleado("Diego Alonso", "EMP-002");
Empleado nuria = new Empleado("Nuria Vidal", "EMP-003");
List<Prestamo> prestamos = List.of(
new Prestamo(new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018), marta, 100),
new Prestamo(new Libro("Refactorizacion", "Martin Fowler", "978-0000000003", 1999), marta, 101),
new Prestamo(new Libro("Patrones de Diseno", "Erich Gamma", "978-0000000002", 1994), diego, 105),
new Prestamo(new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"), nuria, 140)
);
GestorAvisos gestor = new GestorAvisos();
System.out.println("Con vencidos el dia 130: " + gestor.conVencidos(prestamos, 130).size());
System.out.println("Avisos enviados: " + gestor.avisar(prestamos, 130));
System.out.println("Avisos en un segundo pase: " + gestor.avisar(prestamos, 130));
System.out.println("Reincidentes: " + gestor.reincidentes(prestamos, 135).size());
System.out.print("Avisados (ordenados): ");
gestor.avisadosOrdenados().forEach(e -> System.out.print(e.getNombre() + " "));Con vencidos el dia 130: 2 AVISO a Marta Ruiz (EMP-001): tiene material vencido AVISO a Diego Alonso (EMP-002): tiene material vencido Avisos enviados: 2 Avisos en un segundo pase: 0 Reincidentes: 2 Avisados (ordenados): Diego Alonso Marta Ruiz
Fíjate en el segundo pase: cero avisos, porque la diferencia de conjuntos ya excluye a quien fue avisado. Con listas, eso requeriría un bucle anidado por cada empleado. Y observa que Marta, con dos préstamos vencidos, recibe un aviso: el Set deduplica solo.
Errores Comunes y Consejos
Meter en un Set objetos sin equals/hashCode correctos. Entran y no se encuentran nunca, y el conjunto acepta "duplicados". Es el mismo fallo de 05-05: hashCode elige la cubeta, equals elige el elemento. Si sobrescribes uno, sobrescribe el otro, sobre los mismos campos.
Mutar un elemento ya guardado. Queda en la cubeta del hash antiguo: inalcanzable, imposible de eliminar y susceptible de duplicarse. Usa elementos inmutables, o quita → modifica → vuelve a meter.
Olvidar copiar antes de retainAll o removeAll. Estos métodos modifican el conjunto sobre el que se llaman. new HashSet<>(original) antes de operar, siempre que quieras conservar el original.
Depender del orden de un HashSet. No hay ninguno garantizado y puede cambiar entre ejecuciones o al insertar. Usa LinkedHashSet para orden de inserción o TreeSet para orden natural.
Usar un Comparator no total en un TreeSet. Si dos elementos distintos comparan a 0, el segundo se descarta en silencio. Añade siempre un criterio de desempate único, como la referencia o el identificador.
Esperar que TreeSet use equals. No lo usa: usa compareTo/compare. Un elemento distinto según equals puede ser "el mismo" para el árbol, y viceversa.
Meter null en un TreeSet. NullPointerException: no se puede comparar null. HashSet sí admite uno, pero evítalo igualmente.
Set.of(...) con duplicados. Lanza IllegalArgumentException al construirlo. Si esperas duplicados, usa new HashSet<>(coleccion) o Set.copyOf(coleccion).
Usar List.contains en un bucle. Es el error de rendimiento más rentable de corregir de todo el módulo: convierte la lista a Set una sola vez y pasarás de O(n×m) a O(n+m).
Consejo: elige Set por semántica, no por velocidad. Un Set<String> de ISBN documenta que los ISBN son únicos mejor que cualquier comentario, y el compilador y la estructura lo hacen cumplir. La velocidad es un extra.
Consejo: new LinkedHashSet<>(lista) elimina duplicados conservando el orden. Y new ArrayList<>(new LinkedHashSet<>(lista)) devuelve una lista limpia, en orden, en una línea.
Ejercicios
Ejercicio 1: control de catálogo con conjuntos
Escribe AuditoriaCatalogo que reciba dos conjuntos de referencias —las del catálogo interno y las de un fichero del proveedor— y produzca un informe con:
Set<String> soloEnCatalogo(): las que tenemos y el proveedor no lista (candidatas a baja).Set<String> soloEnProveedor(): las que el proveedor lista y no tenemos (candidatas a alta).Set<String> enAmbos(): las coincidentes.boolean catalogoCompleto(): si el catálogo cubre todo lo del proveedor.boolean sinRelacion(): si no comparten ninguna referencia (usaCollections.disjoint).Set<String> diferenciaSimetrica(): las que están en uno u otro pero no en ambos.String informe(): un resumen formateado.
Ningún método debe modificar los conjuntos recibidos.
Ejercicio 2: las tres implementaciones y sus trampas
Escribe DemostracionConjuntos con un main que demuestre, imprimiendo y explicando:
- Que
HashSet,LinkedHashSetyTreeSetdan órdenes distintos con los mismos datos. - Que un elemento sin
hashCodecorrecto permite duplicados en unHashSet. - Que mutar un elemento guardado lo hace inalcanzable e incluso duplicable.
- Que un
Comparatorno total hace que unTreeSetdescarte elementos distintos. - Que
Set.ofcon duplicados lanzaIllegalArgumentException(coméntalo, no lo ejecutes) mientras quenew HashSet<>(lista)los ignora. - La diferencia de tiempo entre
List.containsySet.containscon 100 000 elementos.
Ejercicio 3: etiquetas de materiales
Amplía BiblioTech con un sistema de etiquetas. Escribe GestorEtiquetas que mantenga un Map<String, Set<String>> (referencia del material → conjunto de etiquetas) y un Map<String, Set<String>> inverso (etiqueta → conjunto de referencias):
void etiquetar(String referencia, String... etiquetas): usacomputeIfAbsenty mantiene los dos mapas coherentes.Set<String> etiquetasDe(String referencia).Set<String> materialesCon(String etiqueta).Set<String> materialesConTodas(String... etiquetas): intersección.Set<String> materialesConAlguna(String... etiquetas): unión.Set<String> todasLasEtiquetas(), ordenadas alfabéticamente.void desetiquetar(String referencia, String etiqueta), limpiando las entradas que queden vacías.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.servicio;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
/** Compara el catalogo interno con el listado de un proveedor. */
public class AuditoriaCatalogo {
private final Set<String> catalogo;
private final Set<String> proveedor;
public AuditoriaCatalogo(Set<String> catalogo, Set<String> proveedor) {
// Copias defensivas (03-07): nadie puede alterar nuestros datos desde fuera,
// y nosotros no alteramos los suyos.
this.catalogo = (catalogo == null) ? Set.of() : Set.copyOf(catalogo);
this.proveedor = (proveedor == null) ? Set.of() : Set.copyOf(proveedor);
}
/** DIFERENCIA catalogo - proveedor. Trabajamos sobre una copia. */
public Set<String> soloEnCatalogo() {
Set<String> resultado = new HashSet<>(catalogo);
resultado.removeAll(proveedor);
return resultado;
}
/** DIFERENCIA proveedor - catalogo. Ojo: la diferencia NO es simetrica. */
public Set<String> soloEnProveedor() {
Set<String> resultado = new HashSet<>(proveedor);
resultado.removeAll(catalogo);
return resultado;
}
/** INTERSECCION. */
public Set<String> enAmbos() {
Set<String> resultado = new HashSet<>(catalogo);
resultado.retainAll(proveedor);
return resultado;
}
/** SUBCONJUNTO: contiene el catalogo todo lo que ofrece el proveedor? */
public boolean catalogoCompleto() {
return catalogo.containsAll(proveedor);
}
/** DISJUNTOS: no comparten ni una referencia. */
public boolean sinRelacion() {
return Collections.disjoint(catalogo, proveedor);
}
/** DIFERENCIA SIMETRICA: union menos interseccion. */
public Set<String> diferenciaSimetrica() {
Set<String> union = new HashSet<>(catalogo);
union.addAll(proveedor);
union.removeAll(enAmbos());
return union;
}
public String informe() {
StringBuilder sb = new StringBuilder();
sb.append("=== Auditoria de catalogo ===\n");
sb.append(String.format("Catalogo interno: %d referencias%n", catalogo.size()));
sb.append(String.format("Listado proveedor: %d referencias%n", proveedor.size()));
// TreeSet solo para que el informe salga siempre en el mismo orden
sb.append(String.format("Coincidentes: %s%n", new TreeSet<>(enAmbos())));
sb.append(String.format("Candidatas a baja: %s%n", new TreeSet<>(soloEnCatalogo())));
sb.append(String.format("Candidatas a alta: %s%n", new TreeSet<>(soloEnProveedor())));
sb.append(String.format("Catalogo completo: %s%n", catalogoCompleto() ? "si" : "no"));
sb.append(String.format("Sin relacion: %s%n", sinRelacion() ? "si" : "no"));
return sb.toString();
}
}Prueba:
Set<String> nuestro = Set.of("978-0000000001", "978-0000000002", "DVD-0007");
Set<String> suyo = Set.of("978-0000000002", "978-0000000003", "REV-2024-03");
System.out.print(new AuditoriaCatalogo(nuestro, suyo).informe());=== Auditoria de catalogo === Catalogo interno: 3 referencias Listado proveedor: 3 referencias Coincidentes: [978-0000000002] Candidatas a baja: [978-0000000001, DVD-0007] Candidatas a alta: [978-0000000003, REV-2024-03] Catalogo completo: no Sin relacion: no
Dos ideas de diseño que trascienden el ejercicio. La primera: cada método copia antes de operar, porque removeAll y retainAll son destructivos; olvidarlo destruiría el catálogo en la primera consulta. La segunda: el TreeSet en informe() solo sirve para que la salida sea reproducible; un HashSet daría el mismo contenido en orden arbitrario, lo que hace imposible comparar informes o escribir tests.
Y observa lo poco que hay que escribir. Sin conjuntos, soloEnCatalogo sería un bucle anidado O(n×m); aquí es una llamada O(m).
Solución 2
package com.nexussoftware.bibliotech.presentacion;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
public class DemostracionConjuntos {
// Elemento SIN hashCode
static class EtiquetaRota {
final String nombre;
EtiquetaRota(String n) { this.nombre = n; }
@Override public boolean equals(Object o) {
return (o instanceof EtiquetaRota e) && Objects.equals(nombre, e.nombre);
}
@Override public String toString() { return nombre; }
}
// Elemento MUTABLE
static class EtiquetaMutable {
String nombre;
EtiquetaMutable(String n) { this.nombre = n; }
@Override public boolean equals(Object o) {
return (o instanceof EtiquetaMutable e) && Objects.equals(nombre, e.nombre);
}
@Override public int hashCode() { return Objects.hash(nombre); }
@Override public String toString() { return nombre; }
}
record Tarifa(String referencia, double euros) { }
public static void main(String[] args) {
ordenes();
sinHashCode();
mutacion();
comparadorNoTotal();
duplicadosAlConstruir();
rendimiento();
}
static void ordenes() {
System.out.println("=== 1. Tres implementaciones, tres ordenes ===");
List<String> entrada = List.of("Revista", "Libro", "DVD", "Libro", "Audiolibro");
System.out.println("Entrada: " + entrada + " (5 elementos, uno repetido)");
System.out.println("HashSet: " + new HashSet<>(entrada) + " orden impredecible");
System.out.println("LinkedHashSet: " + new LinkedHashSet<>(entrada) + " orden de insercion");
System.out.println("TreeSet: " + new TreeSet<>(entrada) + " orden alfabetico");
System.out.println("Los tres tienen 4 elementos: 'Libro' se descarto en los tres.\n");
}
static void sinHashCode() {
System.out.println("=== 2. Elemento sin hashCode ===");
Set<EtiquetaRota> conjunto = new HashSet<>();
conjunto.add(new EtiquetaRota("java"));
conjunto.add(new EtiquetaRota("java")); // "igual" segun equals
System.out.println("size: " + conjunto.size() + " <- DUPLICADOS en un Set");
System.out.println("contains(nueva 'java'): "
+ conjunto.contains(new EtiquetaRota("java")) + " <- no la encuentra");
System.out.println("Causa: hashCode heredado de Object -> cada instancia va a una");
System.out.println("cubeta distinta y equals NUNCA llega a ejecutarse.\n");
}
static void mutacion() {
System.out.println("=== 3. Mutar un elemento guardado ===");
EtiquetaMutable e = new EtiquetaMutable("java");
Set<EtiquetaMutable> conjunto = new HashSet<>();
conjunto.add(e);
System.out.println("contains antes: " + conjunto.contains(e));
e.nombre = "programacion"; // mutamos lo ya guardado
System.out.println("contains despues: " + conjunto.contains(e) + " <- perdido");
System.out.println("remove: " + conjunto.remove(e) + " <- ni se puede quitar");
conjunto.add(e);
System.out.println("size tras re-anadir: " + conjunto.size()
+ " <- el MISMO objeto, dos veces");
System.out.println("Correcto: remove -> mutar -> add.\n");
}
static void comparadorNoTotal() {
System.out.println("=== 4. Comparator no total en TreeSet ===");
// Comparator que solo mira los euros: dos tarifas distintas con el mismo
// importe comparan a 0, y el TreeSet las considera EL MISMO elemento.
Set<Tarifa> malo = new TreeSet<>(Comparator.comparingDouble(Tarifa::euros));
malo.add(new Tarifa("978-0000000001", 0.25));
malo.add(new Tarifa("978-0000000002", 0.25));
System.out.println("Con comparador parcial: size = " + malo.size()
+ " <- se PERDIO un elemento");
Set<Tarifa> bueno = new TreeSet<>(
Comparator.comparingDouble(Tarifa::euros)
.thenComparing(Tarifa::referencia)); // desempate UNICO
bueno.add(new Tarifa("978-0000000001", 0.25));
bueno.add(new Tarifa("978-0000000002", 0.25));
System.out.println("Con desempate: size = " + bueno.size() + " <- correcto");
System.out.println("En TreeSet la unicidad la decide el COMPARADOR, no equals.\n");
}
static void duplicadosAlConstruir() {
System.out.println("=== 5. Set.of frente a new HashSet<>(lista) ===");
List<String> conRepetidos = List.of("Libro", "Revista", "Libro");
// Set.of("Libro", "Revista", "Libro");
// -> IllegalArgumentException: duplicate element: Libro
// Es intencionado: en un literal escrito a mano, un duplicado es un error.
System.out.println("Set.of con duplicados lanzaria IllegalArgumentException");
System.out.println("new HashSet<>(lista): " + new HashSet<>(conRepetidos)
+ " <- los ignora en silencio");
System.out.println("Set.copyOf(lista): " + Set.copyOf(conRepetidos)
+ " <- tambien los ignora\n");
}
static void rendimiento() {
System.out.println("=== 6. contains: List frente a Set ===");
final int N = 100_000, CONSULTAS = 5_000;
List<String> lista = new ArrayList<>(N);
Set<String> conjunto = new HashSet<>((int)(N / 0.75f) + 1);
for (int i = 0; i < N; i++) {
String ref = "978-" + String.format("%010d", i);
lista.add(ref);
conjunto.add(ref);
}
String buscado = "978-" + String.format("%010d", N - 1); // el peor caso: el ultimo
for (int i = 0; i < 50; i++) { lista.contains(buscado); } // calentamiento
long t1 = System.nanoTime();
for (int i = 0; i < CONSULTAS; i++) { lista.contains(buscado); }
long msLista = (System.nanoTime() - t1) / 1_000_000;
long t2 = System.nanoTime();
for (int i = 0; i < CONSULTAS; i++) { conjunto.contains(buscado); }
long msSet = (System.nanoTime() - t2) / 1_000_000;
System.out.printf("List.contains x%d: %6d ms%n", CONSULTAS, msLista);
System.out.printf("Set.contains x%d: %6d ms%n", CONSULTAS, msSet);
System.out.println("La List compara hasta 100.000 veces; el Set calcula UNA cubeta.");
}
}Los seis apartados cuentan la misma historia desde ángulos distintos: un Set solo cumple su promesa si sus elementos respetan el contrato. hashCode ausente, elemento mutable o comparador parcial son tres formas de romperlo, y las tres fallan en silencio: no hay excepción, solo datos duplicados o perdidos. Y el apartado 6 recuerda por qué merece la pena hacerlo bien.
Solución 3
package com.nexussoftware.bibliotech.servicio;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Etiquetado de materiales con doble indice: material -> etiquetas y
* etiqueta -> materiales. Mantener los dos coherentes es el precio de
* poder consultar en ambas direcciones en O(1).
*/
public class GestorEtiquetas {
private final Map<String, Set<String>> etiquetasPorMaterial = new HashMap<>();
private final Map<String, Set<String>> materialesPorEtiqueta = new HashMap<>();
private static String normalizar(String s) {
return (s == null) ? null : s.trim().toLowerCase();
}
public void etiquetar(String referencia, String... etiquetas) {
if (referencia == null || etiquetas == null) { return; }
for (String bruta : etiquetas) {
String etiqueta = normalizar(bruta);
if (etiqueta == null || etiqueta.isEmpty()) { continue; }
// computeIfAbsent crea el Set solo si hace falta y devuelve siempre uno valido
etiquetasPorMaterial.computeIfAbsent(referencia, r -> new HashSet<>()).add(etiqueta);
materialesPorEtiqueta.computeIfAbsent(etiqueta, e -> new HashSet<>()).add(referencia);
// Si la etiqueta ya estaba, add devuelve false y no pasa nada: la unicidad
// la garantiza el propio Set, sin comprobaciones.
}
}
public Set<String> etiquetasDe(String referencia) {
return Set.copyOf(etiquetasPorMaterial.getOrDefault(referencia, Set.of()));
}
public Set<String> materialesCon(String etiqueta) {
return Set.copyOf(materialesPorEtiqueta.getOrDefault(normalizar(etiqueta), Set.of()));
}
/** INTERSECCION sucesiva: los materiales que tienen TODAS las etiquetas. */
public Set<String> materialesConTodas(String... etiquetas) {
if (etiquetas == null || etiquetas.length == 0) { return Set.of(); }
Set<String> resultado = new HashSet<>(materialesCon(etiquetas[0]));
for (int i = 1; i < etiquetas.length; i++) {
resultado.retainAll(materialesCon(etiquetas[i]));
if (resultado.isEmpty()) { break; } // atajo: ya no puede crecer
}
return resultado;
}
/** UNION sucesiva: los materiales que tienen ALGUNA de las etiquetas. */
public Set<String> materialesConAlguna(String... etiquetas) {
Set<String> resultado = new HashSet<>();
if (etiquetas == null) { return resultado; }
for (String e : etiquetas) {
resultado.addAll(materialesCon(e));
}
return resultado;
}
/** TreeSet: siempre en orden alfabetico, sin ordenar a mano. */
public Set<String> todasLasEtiquetas() {
return new TreeSet<>(materialesPorEtiqueta.keySet());
}
/** Quita una etiqueta, limpiando las entradas que queden vacias en AMBOS mapas. */
public void desetiquetar(String referencia, String etiquetaBruta) {
String etiqueta = normalizar(etiquetaBruta);
if (referencia == null || etiqueta == null) { return; }
// Devolver null desde computeIfPresent elimina la entrada del mapa (05-05)
etiquetasPorMaterial.computeIfPresent(referencia, (r, set) -> {
set.remove(etiqueta);
return set.isEmpty() ? null : set;
});
materialesPorEtiqueta.computeIfPresent(etiqueta, (e, set) -> {
set.remove(referencia);
return set.isEmpty() ? null : set;
});
}
public int materialesEtiquetados() { return etiquetasPorMaterial.size(); }
public int etiquetasDistintas() { return materialesPorEtiqueta.size(); }
}Prueba:
GestorEtiquetas g = new GestorEtiquetas();
g.etiquetar("978-0000000001", "java", "buenas-practicas", "avanzado");
g.etiquetar("978-0000000002", "java", "patrones", "diseno", "avanzado");
g.etiquetar("978-0000000003", "refactorizacion", "diseno", "java");
g.etiquetar("REV-2024-03", "java", "actualidad");
g.etiquetar("DVD-0007", "refactorizacion", "video");
System.out.println("Etiquetas de 978-0000000002: " + new TreeSet<>(g.etiquetasDe("978-0000000002")));
System.out.println("Con 'java': " + new TreeSet<>(g.materialesCon("java")));
System.out.println("Con java Y avanzado: " + new TreeSet<>(g.materialesConTodas("java", "avanzado")));
System.out.println("Con video O patrones:" + new TreeSet<>(g.materialesConAlguna("video", "patrones")));
System.out.println("Todas las etiquetas: " + g.todasLasEtiquetas());
g.desetiquetar("DVD-0007", "video");
System.out.println("Tras quitar 'video': " + g.todasLasEtiquetas());Etiquetas de 978-0000000002: [avanzado, diseno, java, patrones] Con 'java': [978-0000000001, 978-0000000002, 978-0000000003, REV-2024-03] Con java Y avanzado: [978-0000000001, 978-0000000002] Con video O patrones:[978-0000000002, DVD-0007] Todas las etiquetas: [actualidad, avanzado, buenas-practicas, diseno, java, patrones, refactorizacion, video] Tras quitar 'video': [actualidad, avanzado, buenas-practicas, diseno, java, patrones, refactorizacion]
Este ejercicio junta todo el módulo hasta aquí. computeIfAbsent de 05-05 construye los mapas de conjuntos sin una sola comprobación de null. El Set interno garantiza que etiquetar dos veces con lo mismo no duplica nada, sin que haya que comprobarlo. retainAll y addAll implementan búsquedas "Y" y "O" que con listas serían bucles anidados. El TreeSet de todasLasEtiquetas mantiene el orden alfabético sin ordenar nada. Y computeIfPresent devolviendo null limpia las entradas huérfanas, evitando que los mapas se llenen de conjuntos vacíos.
El precio de tener dos índices es mantenerlos coherentes: cada etiquetar y cada desetiquetar toca los dos mapas. Es un compromiso deliberado —memoria y disciplina a cambio de consultas O(1) en ambas direcciones— y es exactamente la clase de decisión que se toma a diario en el diseño de sistemas reales.
Conclusión
Ya dominas la otra gran estructura basada en hash, y te ha costado poco porque HashSet es literalmente un HashMap con valores ficticios: lo has visto en su código fuente, con su campo map y su centinela PRESENT. De esa identidad se deduce todo sin aprender nada nuevo: O(1) en add, contains y remove; sin orden garantizado; un solo null; factor de carga, rehash y conversión de cubeta a árbol; y —crucialmente— los mismos requisitos sobre equals y hashCode que las claves de un mapa, con las mismas consecuencias cuando se incumplen: elementos que entran y no se encuentran, y "duplicados" en un conjunto que se supone sin duplicados.
Sabes que un Set no añade métodos a Collection sino contrato, y que la ausencia de get(i) no es una carencia: un conjunto no tiene posiciones. Has aprendido a explotar el detalle que casi nadie mira: add devuelve false si el elemento ya estaba, lo que resuelve en una línea, y con la mitad de trabajo que contains + add, la detección de duplicados, el procesamiento único por clave, el conteo de elementos distintos y la prevención de ciclos.
Manejas el álgebra de conjuntos con soltura: addAll para la unión, retainAll para la intersección, removeAll para la diferencia —que no es simétrica—, containsAll para el subconjunto y Collections.disjoint para comprobar que no comparten nada; con la diferencia simétrica compuesta a partir de las anteriores. Y tienes grabado el aviso que evita el error más frecuente: las tres primeras modifican el conjunto sobre el que se invocan, así que se trabaja siempre sobre una copia.
Conoces las tres implementaciones y su criterio de elección: HashSet por defecto, LinkedHashSet para orden reproducible —y para eliminar duplicados conservando el orden original en una línea—, y TreeSet cuando necesites orden permanente o navegación, con todo su arsenal de first, last, headSet, tailSet, subSet, ceiling, floor, higher, lower, pollFirst y descendingSet. Y sabes lo que más se olvida de TreeSet: la unicidad la decide el comparador, no equals, de modo que un Comparator sin criterio de desempate único descarta elementos distintos en silencio.
Entiendes el peligro de mutar un elemento ya guardado —queda en la cubeta del hash antiguo, no se encuentra, no se puede eliminar y se puede duplicar— y su única solución correcta: quitar, modificar, volver a meter. Conoces los conjuntos inmutables, con la particularidad de que Set.of rechaza duplicados con IllegalArgumentException mientras Set.copyOf los ignora. Y has visto con números la optimización más rentable de todo el módulo: si vas a consultar pertenencia más de unas pocas veces, convierte la lista a Set y pasarás de O(n×m) a O(n+m).
BiblioTech tiene ahora un ControlDuplicados que rechaza altas repetidas en O(1) apoyándose únicamente en el boolean de add, y un GestorAvisos que resuelve con tres operaciones de conjunto —diferencia, intersección y diferencia inversa— lo que con listas serían bucles anidados: a quién hay que avisar, quién reincide y a quién se le puede retirar el aviso. Un empleado con tres préstamos vencidos recibe exactamente un aviso, y un segundo pase no envía ninguno.
En la lección siguiente, Cola y Deque, cambias de familia. Hasta ahora las colecciones respondían a "¿está?" y "¿dónde está?"; ahora responderán a "¿a quién le toca?". Verás la interfaz Queue con su semántica FIFO y sus dos familias de métodos —la que lanza excepción y la que devuelve un valor especial, y cuándo usar cada una—, el Deque como cola de doble extremo, ArrayDeque con su buffer circular y por qué es la opción por defecto hoy (y por qué rechaza los null), la PriorityQueue con su montículo binario y la sorpresa de que su iterador no recorre en orden de prioridad, el patrón productor-consumidor y el recorrido en anchura. Y en BiblioTech, la ColaReservas que escribiste con LinkedList se reescribirá con Deque, y aparecerá una PriorityQueue<Prestamo> que atiende siempre primero al préstamo con más días de retraso.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
