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

  1. Qué es un conjunto
  2. La interfaz Set y su API
  3. HashSet es un HashMap disfrazado
  4. add devuelve boolean, y eso lo cambia todo
  5. Operaciones de conjunto
  6. HashSet, LinkedHashSet y TreeSet
  7. TreeSet, SortedSet y NavigableSet
  8. El peligro de mutar un elemento guardado
  9. Conjuntos inmutables
  10. contains en un Set frente a una List
  11. Aplicación a BiblioTech
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. 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? List
¿Un repetido es un error o un dato redundante? Set
¿Importa el orden y la posición? List
¿Solo me importa "está o no está"? 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.

  1. La interfaz Set y su API

Set 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á con contains.
  • No hay set(i, e) ni indexOf.
  • 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);
}

  1. HashSet es un HashMap disfrazado

Abre 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")));   // false

La 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 coleccion

Ese ú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]

  1. add devuelve boolean, y eso lo cambia todo

Collection.add devuelve boolean. En una List ese valor es siempre true y nadie lo mira. En un Set es información valiosa:

add devuelve true si el elemento se añadió (era nuevo) y false si 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 aqui

Los 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ó

  1. 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-005

Diferencia 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 copia

Olvidar la copia y destruir el conjunto original es uno de los errores más frecuentes con operaciones de conjunto.

  1. 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 : 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.

  1. TreeSet, SortedSet y NavigableSet

TreeSet 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: OBLIGATORIO

Si 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 unica

A esta propiedad se le llama coherencia con equals, y en 05-09 la estudiarás como parte del contrato de Comparable.

  1. 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 veces

El 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));    // impredecible

Regla: 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

  1. Conjuntos inmutables

Como viste en 05-02, Set.of(...) crea un conjunto inmutable:

Set<String> tiposPrestables = Set.of("Libro", "Revista", "DVD");

Sus propiedades, con una particularidad importante:

  • Inmutable: add, remove y clear lanzan UnsupportedOperationException.
  • No admite null: NullPointerException al crearlo.
  • Rechaza duplicados al construirse con IllegalArgumentException:
Set<String> mal = Set.of("Libro", "Revista", "Libro");
// IllegalArgumentException: duplicate element: Libro

Ese 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 lista

Y Set.copyOf(coleccion) crea una copia inmutable de cualquier colección, descartando duplicados sin protestar (a diferencia de Set.of):

Set<String> instantanea = Set.copyOf(listaConPosiblesRepetidos);   // sin excepcion

  1. contains en un Set frente a una List

Esta 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 comparaciones

Y 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 operaciones

Regla 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.

  1. 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 (usa Collections.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:

  1. Que HashSet, LinkedHashSet y TreeSet dan órdenes distintos con los mismos datos.
  2. Que un elemento sin hashCode correcto permite duplicados en un HashSet.
  3. Que mutar un elemento guardado lo hace inalcanzable e incluso duplicable.
  4. Que un Comparator no total hace que un TreeSet descarte elementos distintos.
  5. Que Set.of con duplicados lanza IllegalArgumentException (coméntalo, no lo ejecutes) mientras que new HashSet<>(lista) los ignora.
  6. La diferencia de tiempo entre List.contains y Set.contains con 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): usa computeIfAbsent y 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

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados