Módulo 02: Genéricos y colecciones avanzadas
Objetivos del módulo
- Escribir clases, interfaces y métodos genéricos, y entender la inferencia de tipos
- Usar tipos acotados (
<T extends Comparable<? super T>>) y cotas múltiples - Dominar los comodines
?,? extendsy? supery aplicar el principio PECS - Comprender el borrado de tipos y sus consecuencias: tipos crudos, arrays genéricos, limitaciones
- Implementar estructuras de datos genéricas propias que se puedan recorrer con for-each
- Conocer el funcionamiento interno de
HashMapy sus implicaciones enequalsyhashCode - Usar
TreeMap,NavigableMap,NavigableSet,LinkedHashMap(como caché LRU),EnumMapyEnumSet - Distinguir colecciones inmutables, no modificables y de tamaño fijo
- Aprovechar las colecciones secuenciadas de Java 21
- Construir comparadores complejos con
Comparatory usar las utilidades deCollectionsyArrays - Elegir la colección adecuada según su complejidad algorítmica
1. Repaso: por qué existen los genéricos
En el curso de Fundamentos usaste List<String> y Map<String, Integer>. Recordemos qué problema resuelven. Antes de Java 5 las colecciones guardaban Object:
List lista = new ArrayList(); // sin genéricos (Java 1.4)
lista.add("Ana");
lista.add(42); // nadie lo impide
String s = (String) lista.get(1); // ClassCastException... en ejecución
📘 Concepto: Los genéricos permiten parametrizar clases, interfaces y métodos con tipos.
List<String>es “una lista deString”: el compilador comprueba que solo entren cadenas y te ahorra los casts. Los errores pasan de ejecución a compilación, que es donde son baratos de corregir.
Vocabulario que usaremos en todo el módulo:
| Término | Ejemplo | Significado |
|---|---|---|
| Tipo genérico | List<E> | Clase o interfaz con parámetros de tipo |
| Parámetro de tipo | E, T, K, V | La “variable” que representa un tipo |
| Argumento de tipo | String en List<String> | El tipo concreto con el que se usa |
| Tipo parametrizado | List<String> | El genérico con sus argumentos |
| Tipo crudo (raw) | List | El genérico usado sin argumentos (solo por compatibilidad) |
| Comodín | List<?>, List<? extends Number> | “Algún tipo” desconocido, con o sin cota |
Convenciones de nombres de los parámetros de tipo: T (tipo), E (elemento de una colección), K y V (clave y valor), R (resultado), N (número), y S, U para el segundo y tercer tipo.
2. Clases y métodos genéricos
2.1. Clases e interfaces genéricas
Una clase (o record, o interfaz) declara sus parámetros de tipo tras el nombre, como record Par<A, B>(A primero, B segundo) del ejemplo de la sección 2.2. Las interfaces genéricas son muy habituales en la biblioteca estándar: Comparable<T>, Iterable<T>, Comparator<T>, Function<T, R>… Al implementarlas puedes fijar el tipo o mantenerlo genérico:
interface Repositorio<T, ID> {
Optional<T> buscarPorId(ID id);
List<T> buscarTodos();
void guardar(T entidad);
}
class RepositorioEmpleados implements Repositorio<Empleado, Integer> { ... } // tipo fijado
class RepositorioEnMemoria<T, ID> implements Repositorio<T, ID> { ... } // sigue siendo genérico
💡 Consejo: Esa interfaz
Repositorio<T, ID>no es un ejemplo inventado: es casi exactamente laCrudRepository<T, ID>de Spring Data que usarás en el módulo 19.
2.2. Métodos genéricos e inferencia
Un método genérico declara sus propios parámetros de tipo antes del tipo de retorno, y puede estar en una clase que no sea genérica:
static <T> T ultimo(List<T> lista) { ... }
El compilador infiere T a partir de los argumentos o del tipo de destino, así que casi nunca hay que indicarlo. Cuando hace falta, se usa un testigo de tipo: MetodosGenericos.<Number>repetir(1, 2).
💻 Ejemplo: Métodos genéricos, inferencia y tipos acotados.
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class MetodosGenericos {
// Clase genérica con dos parámetros de tipo
record Par<A, B>(A primero, B segundo) {
<C> Par<A, C> conSegundo(C nuevo) { // método genérico dentro de una clase genérica
return new Par<>(primero, nuevo);
}
}
// Método genérico: <T> va antes del tipo de retorno
static <T> T ultimo(List<T> lista) {
return lista.get(lista.size() - 1);
}
static <T> List<T> repetir(T valor, int veces) {
List<T> resultado = new ArrayList<>();
for (int i = 0; i < veces; i++) resultado.add(valor);
return resultado;
}
// Tipo acotado: T debe ser comparable consigo mismo
static <T extends Comparable<T>> T maximo(Collection<T> elementos) {
T max = null;
for (T e : elementos) {
if (max == null || e.compareTo(max) > 0) max = e;
}
return max;
}
// Varias cotas: una clase (como mucho) y después interfaces, separadas por &
static <T extends Number & Comparable<T>> double rango(List<T> numeros) {
T min = numeros.getFirst(), max = numeros.getFirst();
for (T n : numeros) {
if (n.compareTo(min) < 0) min = n;
if (n.compareTo(max) > 0) max = n;
}
return max.doubleValue() - min.doubleValue(); // doubleValue() viene de Number
}
static <T, R> List<R> transformar(List<T> origen, Function<T, R> f) {
List<R> destino = new ArrayList<>();
for (T t : origen) destino.add(f.apply(t));
return destino;
}
public static void main(String[] args) {
// Inferencia: el compilador deduce T a partir de los argumentos
String s = ultimo(List.of("a", "b", "c"));
Integer n = ultimo(List.of(1, 2, 3));
System.out.println(s + " " + n);
// Inferencia por el tipo de destino (target typing)
List<String> vacia = List.of();
List<Double> ceros = repetir(0.0, 3);
System.out.println(vacia + " " + ceros);
// Testigo de tipo explícito (rara vez necesario)
List<Number> numeros = MetodosGenericos.<Number>repetir(1, 2);
numeros.add(2.5);
System.out.println("Testigo explícito: " + numeros);
System.out.println("Máximo: " + maximo(List.of("pera", "manzana", "uva")));
System.out.println("Máximo: " + maximo(List.of(7, 42, 3)));
System.out.println("Rango: " + rango(List.of(4, 18, 9, 1)));
System.out.println("Longitudes: " + transformar(List.of("Java", "Kotlin"), String::length));
var p = new Par<>("Ana", 6500); // Par<String, Integer>
Par<String, Boolean> p2 = p.conSegundo(true);
System.out.println(p + " -> " + p2);
Map<String, List<Par<String, Integer>>> complejo = Map.of("ventas", List.of(p));
System.out.println(complejo);
}
}
c 3
[] [0.0, 0.0, 0.0]
Testigo explícito: [1, 1, 2.5]
Máximo: uva
Máximo: 42
Rango: 17.0
Longitudes: [4, 6]
Par[primero=Ana, segundo=6500] -> Par[primero=Ana, segundo=true]
{ventas=[Par[primero=Ana, segundo=6500]]}
Fíjate en el operador diamante <> (new Par<>("Ana", 6500), new ArrayList<>()): desde Java 7 el compilador deduce los argumentos de tipo del constructor. Y en List<String> vacia = List.of() el tipo se infiere del destino, no de los argumentos (no hay ninguno).
2.3. Tipos acotados
Sin cota, de un T solo puedes usar los métodos de Object. Con <T extends Comparable<T>> le dices al compilador “T es algo comparable”, y por eso dentro del método puedes llamar a compareTo.
| Declaración | Significado | Qué puedes hacer con un T |
|---|---|---|
<T> | Cualquier tipo | Métodos de Object |
<T extends Number> | Number o un subtipo | doubleValue(), intValue()… |
<T extends Comparable<T>> | Comparable consigo mismo | compareTo(T) |
<T extends Comparable<? super T>> | Comparable consigo mismo o mediante un supertipo | compareTo(T); es la forma correcta (sección 3.4) |
<T extends Number & Comparable<T>> | Ambas cosas a la vez | Métodos de los dos |
⚠️ Importante: En los parámetros de tipo,
extendssignifica “es subtipo de” tanto para clases como para interfaces (no existeimplementsen este contexto). En una cota múltiple, la clase (si la hay) va primero:<T extends Number & Comparable<T>>, nunca al revés.
3. Comodines y el principio PECS
3.1. Los genéricos son invariantes
Esta es la clave para entender los comodines: aunque Integer sea un subtipo de Number, List<Integer> no es un subtipo de List<Number>.
import java.util.ArrayList;
import java.util.List;
public class Invarianza {
public static void main(String[] args) {
// Los arrays son covariantes: un Integer[] ES un Number[]... y eso es peligroso
Number[] array = new Integer[3];
try {
array[0] = 3.14; // compila, pero falla en ejecución
} catch (ArrayStoreException e) {
System.out.println("Array covariante: " + e);
}
// Los genéricos son invariantes: List<Integer> NO es List<Number>
List<Integer> enteros = new ArrayList<>();
// List<Number> numeros = enteros; // NO compila: el error se detecta antes
List<? extends Number> lectura = enteros; // con comodín sí
System.out.println("Comodín: " + lectura.size());
}
}
Array covariante: java.lang.ArrayStoreException: java.lang.Double
Comodín: 0
¿Por qué esa restricción? Si List<Integer> fuera un List<Number>, podrías meter un Double en una lista de enteros, exactamente lo que pasa con el array (que falla en ejecución). Los genéricos lo impiden en compilación. Si descomentas las líneas problemáticas, este es el error real:
ErrorComodin.java:7: error: incompatible types: List<Integer> cannot be converted to List<Number>
List<Number> numeros = enteros;
^
ErrorComodin.java:9: error: incompatible types: int cannot be converted to CAP#1
lectura.add(5);
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Number from capture of ? extends Number
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
2 errors
El segundo error es interesante: en una List<? extends Number> no se puede añadir ni siquiera un entero. CAP#1 es el nombre que el compilador da al “tipo desconocido que es subtipo de Number”: podría ser Double, y entonces un Integer no cabría.
3.2. Los tres comodines
| Comodín | Se lee | Acepta | Puedes leer como | Puedes añadir |
|---|---|---|---|---|
List<?> | Lista de algo | Cualquier List<X> | Object | Solo null |
List<? extends Number> | Lista de algún subtipo de Number | List<Number>, List<Integer>, List<Double>… | Number | Solo null |
List<? super Integer> | Lista de algún supertipo de Integer | List<Integer>, List<Number>, List<Object> | Object | Integer (y subtipos) |
3.3. PECS: Producer Extends, Consumer Super
📘 Concepto: La regla PECS (Joshua Bloch, Effective Java) dice cuándo usar cada comodín en un parámetro: si el parámetro es un productor del que solo lees elementos de tipo
T, usa? extends T. Si es un consumidor en el que solo escribes elementos de tipoT, usa? super T. Si haces las dos cosas, no uses comodín.
Figura 1. PECS: los datos salen de un productor (extends) y entran en un consumidor (super).
💻 Ejemplo: Los tres comodines y PECS en acción.
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class Comodines {
// ? extends Number: acepta List<Integer>, List<Double>... Solo LEEMOS (productor)
static double sumar(Collection<? extends Number> numeros) {
double total = 0;
for (Number n : numeros) total += n.doubleValue();
// numeros.add(1); // NO compila: no sabemos de qué subtipo concreto es la lista
return total;
}
// ? super Integer: acepta List<Integer>, List<Number>, List<Object>. Solo ESCRIBIMOS (consumidor)
static void rellenar(List<? super Integer> destino, int hasta) {
for (int i = 1; i <= hasta; i++) destino.add(i);
Object o = destino.get(0); // leer solo devuelve Object
}
// ? a secas: no importa el tipo; solo usamos métodos que no dependen de él
static void imprimir(Collection<?> c) {
System.out.println(c.size() + " elementos: " + c);
}
// PECS en acción: origen produce T, destino consume T
static <T> void copiar(List<? extends T> origen, List<? super T> destino) {
for (T t : origen) destino.add(t);
}
// La firma "de libro" del máximo: admite tipos cuya comparación está definida en un supertipo
static <T extends Comparable<? super T>> T maximo(List<? extends T> lista) {
T max = lista.getFirst();
for (T t : lista) if (t.compareTo(max) > 0) max = t;
return max;
}
static class Animal implements Comparable<Animal> {
final String nombre; final int peso;
Animal(String nombre, int peso) { this.nombre = nombre; this.peso = peso; }
public int compareTo(Animal o) { return Integer.compare(peso, o.peso); }
public String toString() { return nombre + "(" + peso + ")"; }
}
static class Perro extends Animal { // Perro es Comparable<Animal>, no Comparable<Perro>
Perro(String nombre, int peso) { super(nombre, peso); }
}
public static void main(String[] args) {
List<Integer> enteros = List.of(1, 2, 3);
List<Double> reales = List.of(1.5, 2.5);
System.out.println("Suma enteros: " + sumar(enteros));
System.out.println("Suma reales: " + sumar(reales));
List<Number> numeros = new ArrayList<>();
List<Object> objetos = new ArrayList<>();
rellenar(numeros, 3);
rellenar(objetos, 2);
System.out.println(numeros + " " + objetos);
imprimir(List.of("a", "b"));
imprimir(enteros);
List<Object> destino = new ArrayList<>(List.of("inicio"));
copiar(enteros, destino);
System.out.println("Copiado: " + destino);
List<Perro> perros = List.of(new Perro("Rex", 30), new Perro("Toby", 12), new Perro("Lua", 21));
System.out.println("Más pesado: " + maximo(perros)); // con <T extends Comparable<T>> NO compilaría
// La API estándar usa PECS por todas partes
List<Integer> l = new ArrayList<>(List.of(5, 10, 4, 100));
Collections.addAll(l, 9, 7); // addAll(Collection<? super T> c, T... e)
Comparator<Object> porTexto = Comparator.comparing(Object::toString);
l.sort(porTexto); // sort(Comparator<? super E> c)
System.out.println("Ordenada como texto: " + l);
}
}
Suma enteros: 6.0
Suma reales: 4.0
[1, 2, 3] [1, 2]
2 elementos: [a, b]
3 elementos: [1, 2, 3]
Copiado: [inicio, 1, 2, 3]
Más pesado: Rex(30)
Ordenada como texto: [10, 100, 4, 5, 7, 9]
3.4. ¿Por qué Comparable<? super T>?
Perro hereda compareTo(Animal) de Animal: es un Comparable<Animal>, no un Comparable<Perro>. Con la firma ingenua <T extends Comparable<T>>, maximo(perros) no compila, porque Perro no cumple Perro extends Comparable<Perro>. Con <T extends Comparable<? super T>> basta con que Perro sea comparable con algún supertipo de sí mismo, y Animal lo es. Por la misma razón es la firma de Collections.max, Collections.sort y Stream.sorted:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
Un caso real que verás en el curso: LocalDate implementa Comparable<ChronoLocalDate> (una interfaz que es supertipo suyo), no Comparable<LocalDate>. Sin el ? super, no podrías calcular el máximo de una lista de fechas con tu propio método.
💡 Consejo: PECS solo se aplica a parámetros. No uses comodines en tipos de retorno: obligarías a quien llama a trabajar con
List<? extends Algo>, que es incómodo. Devuelve tipos concretos (List<T>).
4. Borrado de tipos (type erasure)
📘 Concepto: Los genéricos de Java existen solo en compilación. El compilador comprueba los tipos, inserta los casts necesarios y luego borra la información genérica: en el bytecode,
List<String>yList<Integer>son simplementeList, y cadaTse sustituye por su cota (o porObject). Se diseñó así en Java 5 para que el código genérico fuera compatible con todo el código anterior.
Figura 2. Lo que escribes frente a lo que ejecuta la JVM.
💻 Ejemplo: El borrado de tipos, en vivo.
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.IntFunction;
import java.util.function.Supplier;
public class Borrado {
static class Caja<T extends Comparable<T>> {
private T valor;
void guardar(T v) { valor = v; }
T sacar() { return valor; }
}
// No se puede hacer "new T()" ni "new T[n]": se pasa una fábrica
static <T> List<T> crearVarios(Supplier<T> fabrica, int n) {
List<T> lista = new ArrayList<>();
for (int i = 0; i < n; i++) lista.add(fabrica.get());
return lista;
}
static <T> T[] aArray(List<T> lista, IntFunction<T[]> generador) {
T[] array = generador.apply(lista.size());
for (int i = 0; i < lista.size(); i++) array[i] = lista.get(i);
return array;
}
// Un Class<T> conserva el tipo en ejecución ("type token")
static <T> T convertir(Object o, Class<T> tipo) {
if (tipo.isInstance(o)) return tipo.cast(o);
throw new IllegalArgumentException(o + " no es " + tipo.getSimpleName());
}
@SuppressWarnings({"rawtypes", "unchecked"})
static void contaminar(List lista) { // tipo crudo: el compilador ya no comprueba nada
lista.add(42);
}
public static void main(String[] args) throws Exception {
List<String> textos = new ArrayList<>();
List<Integer> numeros = new ArrayList<>();
System.out.println("¿Misma clase? " + (textos.getClass() == numeros.getClass()));
System.out.println("Clase en ejecución: " + textos.getClass().getName());
// Tras el borrado, T se sustituye por su cota (Comparable) o por Object
for (Method m : Caja.class.getDeclaredMethods()) {
System.out.println(" " + m.getName() + " -> " + Arrays.toString(m.getParameterTypes())
+ " devuelve " + m.getReturnType().getSimpleName());
}
// Contaminación del montón (heap pollution) con tipos crudos
List<String> nombres = new ArrayList<>(List.of("Ana"));
contaminar(nombres);
System.out.println("La lista contiene: " + nombres); // [Ana, 42] ¡sin error!
try {
String s = nombres.get(1); // aquí el cast oculto falla
System.out.println(s);
} catch (ClassCastException e) {
System.out.println("ClassCastException: " + e.getMessage());
}
System.out.println(crearVarios(StringBuilder::new, 2).size() + " StringBuilder creados");
String[] array = aArray(List.of("x", "y"), String[]::new);
System.out.println("Array: " + Arrays.toString(array) + " de tipo " + array.getClass().getSimpleName());
System.out.println("convertir: " + convertir("hola", String.class).toUpperCase());
try {
convertir(3, String.class);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
¿Misma clase? true
Clase en ejecución: java.util.ArrayList
guardar -> [interface java.lang.Comparable] devuelve void
sacar -> [] devuelve Comparable
La lista contiene: [Ana, 42]
ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
2 StringBuilder creados
Array: [x, y] de tipo String[]
convertir: HOLA
3 no es String
4.1. Consecuencias del borrado
Como el tipo no existe en ejecución, el compilador rechaza todo lo que lo necesitaría. Este fichero no compila:
import java.util.List;
public class ErrorBorrado<T> {
T crear() { return new T(); }
boolean esLista(Object o) { return o instanceof List<String>; }
void procesar(List<String> l) { }
void procesar(List<Integer> l) { }
static T compartido;
}
ErrorBorrado.java:7: error: name clash: procesar(List<Integer>) and procesar(List<String>) have the same erasure
void procesar(List<Integer> l) { }
^
ErrorBorrado.java:8: error: non-static type variable T cannot be referenced from a static context
static T compartido;
^
ErrorBorrado.java:4: error: unexpected type
T crear() { return new T(); }
^
required: class
found: type parameter T
where T is a type-variable:
T extends Object declared in class ErrorBorrado
ErrorBorrado.java:5: error: Object cannot be safely cast to List<String>
boolean esLista(Object o) { return o instanceof List<String>; }
^
4 errors
| No puedes… | Por qué | Alternativa |
|---|---|---|
new T() | No se sabe qué clase instanciar | Pasar un Supplier<T> (StringBuilder::new) o un Class<T> |
new T[n] | El array necesita su tipo en ejecución | IntFunction<T[]> (String[]::new), Object[] con cast, o usar List<T> |
o instanceof List<String> | En ejecución solo existe List | o instanceof List<?> |
Sobrecargar m(List<String>) y m(List<Integer>) | Tras el borrado tienen la misma firma | Nombres distintos |
Campos static de tipo T | T es de cada instancia; lo estático es de la clase | Un método genérico estático |
Tipos primitivos: List<int> | El borrado lleva a Object | Envoltorios (List<Integer>) o streams primitivos (IntStream) |
Capturar catch (T e) o crear excepciones genéricas | La JVM necesita la clase exacta | Jerarquía de excepciones normal |
4.2. Tipos crudos y heap pollution
Un tipo crudo (List a secas) desactiva todas las comprobaciones. Existe solo para poder usar código anterior a Java 5. El ejemplo muestra el peligro: contaminar mete un Integer en una List<String> sin ningún error y el fallo aparece lejos, al hacer nombres.get(1), en una línea que parece correcta. A esto se le llama contaminación del montón (heap pollution).
⚠️ Importante: Nunca uses tipos crudos en código nuevo. Si el compilador te da advertencias
uncheckedorawtypes(con-Xlint:alllas verás todas), corrígelas en lugar de silenciarlas.@SuppressWarnings("unchecked")solo está justificado cuando demuestras que el cast es seguro, como en la pila de la sección siguiente.
4.3. Genéricos y arrays
Los arrays y los genéricos se llevan mal: los arrays son covariantes y conocen su tipo en ejecución (reificados); los genéricos son invariantes y se borran. Por eso no existe new List<String>[10] y los métodos con T... (varargs genéricos) producen la advertencia possible heap pollution (que se silencia con @SafeVarargs cuando el método no guarda ni expone el array).
💡 Consejo: Regla práctica de Effective Java: prefiere listas a arrays en código genérico. Si necesitas un array internamente (por rendimiento, como
ArrayList), guarda unObject[]y haz el cast al leer, justificándolo.
5. Una estructura de datos genérica propia: la pila
Vamos a implementar una pila (LIFO) genérica basada en un array que crece, que se pueda recorrer con for-each (implementando Iterable<E>) y que use PECS en sus métodos masivos.
import java.util.Arrays;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
/** Pila genérica (LIFO) basada en un array que crece. Recorrible con for-each. */
public class Pila<E> implements Iterable<E> {
private static final int CAPACIDAD_INICIAL = 4;
private Object[] elementos = new Object[CAPACIDAD_INICIAL]; // no se puede crear un E[]
private int tamano;
private int modificaciones; // para detectar cambios al iterar
public void apilar(E e) {
Objects.requireNonNull(e, "La pila no admite null");
if (tamano == elementos.length) {
elementos = Arrays.copyOf(elementos, tamano * 2); // duplica la capacidad
}
elementos[tamano++] = e;
modificaciones++;
}
@SuppressWarnings("unchecked") // seguro: solo guardamos E
public E desapilar() {
if (estaVacia()) throw new NoSuchElementException("Pila vacía");
E e = (E) elementos[--tamano];
elementos[tamano] = null; // evita fugas de memoria
modificaciones++;
return e;
}
@SuppressWarnings("unchecked")
public E cima() {
if (estaVacia()) throw new NoSuchElementException("Pila vacía");
return (E) elementos[tamano - 1];
}
public boolean estaVacia() { return tamano == 0; }
public int tamano() { return tamano; }
/** Apila todos los elementos de otra colección: PECS, la origen "produce" E. */
public void apilarTodos(Iterable<? extends E> origen) {
for (E e : origen) apilar(e);
}
/** Vacía la pila en un destino que "consume" E. */
public void volcarEn(java.util.Collection<? super E> destino) {
while (!estaVacia()) destino.add(desapilar());
}
@Override
public Iterator<E> iterator() { // de la cima a la base
return new Iterator<>() {
private int indice = tamano - 1;
private final int esperadas = modificaciones;
@Override
public boolean hasNext() { return indice >= 0; }
@Override
@SuppressWarnings("unchecked")
public E next() {
if (esperadas != modificaciones) throw new ConcurrentModificationException();
if (!hasNext()) throw new NoSuchElementException();
return (E) elementos[indice--];
}
};
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("[");
for (E e : this) sb.append(sb.length() > 1 ? ", " : "").append(e);
return sb.append("] <- cima a la izquierda").toString();
}
public static void main(String[] args) {
Pila<String> pila = new Pila<>();
for (String s : "uno dos tres cuatro cinco".split(" ")) pila.apilar(s);
System.out.println(pila + " tamaño=" + pila.tamano());
System.out.println("cima=" + pila.cima() + ", desapilo " + pila.desapilar());
for (String s : pila) System.out.print(s.toUpperCase() + " "); // gracias a Iterable
System.out.println();
Pila<Number> numeros = new Pila<>();
numeros.apilarTodos(java.util.List.of(1, 2, 3)); // List<Integer> -> Pila<Number>
numeros.apilar(4.5);
java.util.List<Object> destino = new java.util.ArrayList<>();
numeros.volcarEn(destino); // Pila<Number> -> List<Object>
System.out.println("Volcado: " + destino + ", ¿vacía? " + numeros.estaVacia());
try {
for (String s : pila) pila.apilar(s + "!");
} catch (ConcurrentModificationException e) {
System.out.println("Modificar mientras se recorre: " + e.getClass().getSimpleName());
}
}
}
[cinco, cuatro, tres, dos, uno] <- cima a la izquierda tamaño=5
cima=cinco, desapilo cinco
CUATRO TRES DOS UNO
Volcado: [4.5, 3, 2, 1], ¿vacía? true
Modificar mientras se recorre: ConcurrentModificationException
Decisiones de diseño que merece la pena entender:
Object[]en lugar deE[]: por el borrado no se puede crear unE[]. Los casts(E)son seguros porque soloapilar(E)mete elementos; por eso, y solo por eso, se suprime la advertencia.elementos[tamano] = nullal desapilar: si no, el array seguiría referenciando el objeto y el recolector de basura no podría liberarlo (una fuga de memoria clásica, descrita en Effective Java).- Crecimiento ×2: duplicar la capacidad hace que apilar sea O(1) amortizado (como en
ArrayList, que crece un 50 %). modificaciones: el iterador es fail-fast, como los de la biblioteca estándar: si la pila cambia mientras se recorre, lanzaConcurrentModificationExceptionen lugar de dar resultados incoherentes.- PECS: gracias a
Iterable<? extends E>yCollection<? super E>, unaPila<Number>acepta unaList<Integer>y se vuelca en unaList<Object>.
💡 Consejo: En código real no implementes tu propia pila: usa
ArrayDeque<E>(push,pop,peek), que es más rápida que la vieja claseStack(sincronizada y heredera deVector). Implementar estructuras es un ejercicio excelente para entender los genéricos y las colecciones, que es justo lo que pretendemos aquí. En el ejercicio 2 harás una lista enlazada.
6. HashMap por dentro
HashMap es probablemente la estructura más usada de Java (y HashSet es un HashMap cuyos valores se ignoran). Entender cómo funciona explica por qué equals y hashCode deben ser coherentes y por qué nunca hay que modificar una clave.
Figura 3. Tabla de *buckets de un HashMap con capacidad 16.*
El funcionamiento, paso a paso:
- Internamente hay un array de buckets (
table), con capacidad inicial 16 y siempre potencia de 2. put(clave, valor)calculaclave.hashCode(), lo mezcla (h ^ (h >>> 16), para que los bits altos también influyan) y obtiene el índice con(capacidad - 1) & hash(equivale al resto de dividir entre la capacidad, pero más rápido).- Si el bucket está vacío, guarda ahí el par. Si no, recorre los nodos del bucket comparando con
equals: si encuentra la clave, sustituye el valor; si no, añade un nodo (colisión). - Si un bucket acumula 8 o más nodos (y la tabla tiene al menos 64 posiciones), lo convierte en un árbol rojo-negro para que la búsqueda pase de O(n) a O(log n).
- Cuando el número de elementos supera capacidad × factor de carga (0,75 por defecto), duplica la tabla y redistribuye todo (rehash).
💻 Ejemplo: Los cálculos reales de
HashMap, una clave con unhashCodepésimo y el error de modificar una clave.
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class HashMapInterno {
// Réplica del cálculo que hace java.util.HashMap para elegir el bucket
static int hash(Object clave) {
int h = clave.hashCode();
return h ^ (h >>> 16); // mezcla los 16 bits altos con los bajos
}
static int bucket(Object clave, int capacidad) {
return (capacidad - 1) & hash(clave); // equivale a hash % capacidad si capacidad es potencia de 2
}
// Clave con un hashCode pésimo: todas colisionan
record ClaveMala(String id) {
@Override public int hashCode() { return 7; }
}
// Clave mutable: un error clásico
static class Dni {
String numero;
Dni(String numero) { this.numero = numero; }
@Override public boolean equals(Object o) { return o instanceof Dni d && d.numero.equals(numero); }
@Override public int hashCode() { return Objects.hash(numero); }
}
public static void main(String[] args) {
String[] claves = {"Ana", "Luis", "Marta", "Pedro", "Laura", "Javier", "Carmen", "David"};
System.out.println("Clave hashCode() bucket (capacidad 16)");
for (String c : claves) {
System.out.printf("%-8s %11d %2d%n", c, c.hashCode(), bucket(c, 16));
}
// Rendimiento con colisiones: todas las claves en el mismo bucket
Map<ClaveMala, Integer> malo = new HashMap<>();
Map<String, Integer> bueno = new HashMap<>();
int n = 20_000;
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) malo.put(new ClaveMala("k" + i), i);
for (int i = 0; i < n; i++) malo.get(new ClaveMala("k" + i));
long t1 = System.nanoTime();
for (int i = 0; i < n; i++) bueno.put("k" + i, i);
for (int i = 0; i < n; i++) bueno.get("k" + i);
long t2 = System.nanoTime();
System.out.println("hashCode constante más lento que String: " + ((t1 - t0) > (t2 - t1) * 5));
// Si modificas una clave después de insertarla, el mapa ya no la encuentra
Set<Dni> dnis = new HashSet<>();
Dni d = new Dni("12345678Z");
dnis.add(d);
d.numero = "87654321X"; // cambia su hashCode
System.out.println("contains(d) tras modificarla: " + dnis.contains(d));
System.out.println("tamaño: " + dnis.size() + " (el elemento sigue ahí, perdido)");
}
}
Clave hashCode() bucket (capacidad 16)
Ana 65972 5
Luis 2379923 7
Marta 74114091 1
Pedro 76990316 10
Laura 73193391 3
Javier -2083209097 3
Carmen 2011238818 3
David 65805908 8
hashCode constante más lento que String: true
contains(d) tras modificarla: false
tamaño: 1 (el elemento sigue ahí, perdido)
“Laura”, “Javier” y “Carmen” caen en el bucket 3: con 8 claves y 16 posiciones las colisiones son inevitables, y no pasa nada mientras sean pocas. Con un hashCode constante, en cambio, todas colisionan y el mapa degenera.
6.1. El contrato de equals y hashCode
| Regla | Consecuencia si no se cumple |
|---|---|
Si a.equals(b), entonces a.hashCode() == b.hashCode() | Dos claves “iguales” van a buckets distintos: duplicados y búsquedas que fallan |
hashCode debe ser estable mientras el objeto está en el mapa | Si la clave cambia, queda en un bucket que ya no le corresponde: contains da false |
Dos objetos distintos pueden tener el mismo hashCode | Es una colisión, legal; pero cuantas menos, mejor rendimiento |
⚠️ Importante: Usa como claves de
HashMapy elementos deHashSetobjetos inmutables:String, envoltorios,LocalDate, records con componentes inmutables o enums. Los records generanequalsyhashCodecorrectos automáticamente; en una clase normal, genéralos con el IDE o conObjects.equalsyObjects.hash.
💡 Consejo: Si sabes cuántos elementos vas a meter, dimensiona el mapa y evitarás redimensionados: desde Java 19 existe
HashMap.newHashMap(1000), que calcula la capacidad necesaria para 1000 elementos con el factor de carga por defecto.
7. Mapas y conjuntos ordenados: TreeMap, NavigableMap, NavigableSet
📘 Concepto:
TreeMapyTreeSetguardan los elementos ordenados (orden natural o unComparator) en un árbol rojo-negro, con operaciones en O(log n). ImplementanNavigableMapyNavigableSet, que añaden búsquedas por proximidad (“la mayor clave menor o igual que…”) y vistas de rangos.
💻 Ejemplo: Tramos de IRPF simplificados y fechas de pedidos de la base de datos EMPRESA.
import java.time.LocalDate;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.TreeMap;
import java.util.TreeSet;
public class Navegables {
public static void main(String[] args) {
// Tramos de IRPF simplificados: desde qué base se aplica cada tipo
NavigableMap<Integer, Double> tramos = new TreeMap<>();
tramos.put(0, 19.0);
tramos.put(12_450, 24.0);
tramos.put(20_200, 30.0);
tramos.put(35_200, 37.0);
tramos.put(60_000, 45.0);
for (int base : new int[]{9_000, 20_200, 41_000, 250_000}) {
// floorEntry: la mayor clave <= base
var tramo = tramos.floorEntry(base);
System.out.printf("Base %,7d € -> tramo desde %,6d € al %.0f %%%n", base, tramo.getKey(), tramo.getValue());
}
System.out.println("ceilingKey(20_000) = " + tramos.ceilingKey(20_000)); // menor clave >= 20000
System.out.println("higherKey(20_200) = " + tramos.higherKey(20_200)); // estrictamente mayor
System.out.println("lowerKey(20_200) = " + tramos.lowerKey(20_200)); // estrictamente menor
System.out.println("headMap(20_200) = " + tramos.headMap(20_200)); // claves < 20200
System.out.println("tailMap(35_200) = " + tramos.tailMap(35_200, true));
System.out.println("subMap = " + tramos.subMap(12_450, true, 35_200, false));
System.out.println("descendingMap = " + tramos.descendingMap());
// NavigableSet: fechas de pedidos de la base de datos EMPRESA
NavigableSet<LocalDate> fechas = new TreeSet<>();
for (String f : new String[]{"2026-01-12", "2026-02-09", "2026-03-03", "2026-04-21",
"2026-05-30", "2026-06-15", "2026-07-01", "2026-07-08", "2026-07-10"}) {
fechas.add(LocalDate.parse(f));
}
LocalDate hoy = LocalDate.of(2026, 6, 1);
System.out.println("Último pedido antes del " + hoy + ": " + fechas.lower(hoy));
System.out.println("Primer pedido desde el " + hoy + ": " + fechas.ceiling(hoy));
System.out.println("Pedidos del segundo trimestre: "
+ fechas.subSet(LocalDate.of(2026, 4, 1), true, LocalDate.of(2026, 6, 30), true));
System.out.println("pollFirst() = " + fechas.pollFirst() + ", quedan " + fechas.size());
}
}
Base 9.000 € -> tramo desde 0 € al 19 %
Base 20.200 € -> tramo desde 20.200 € al 30 %
Base 41.000 € -> tramo desde 35.200 € al 37 %
Base 250.000 € -> tramo desde 60.000 € al 45 %
ceilingKey(20_000) = 20200
higherKey(20_200) = 35200
lowerKey(20_200) = 12450
headMap(20_200) = {0=19.0, 12450=24.0}
tailMap(35_200) = {35200=37.0, 60000=45.0}
subMap = {12450=24.0, 20200=30.0}
descendingMap = {60000=45.0, 35200=37.0, 20200=30.0, 12450=24.0, 0=19.0}
Último pedido antes del 2026-06-01: 2026-05-30
Primer pedido desde el 2026-06-01: 2026-06-15
Pedidos del segundo trimestre: [2026-04-21, 2026-05-30, 2026-06-15]
pollFirst() = 2026-01-12, quedan 8
| Método | Devuelve | Para la clave k… |
|---|---|---|
floorKey(k) / floorEntry(k) | La mayor clave ≤ k | “¿En qué tramo cae?” |
ceilingKey(k) / ceilingEntry(k) | La menor clave ≥ k | “¿Cuál es el siguiente?” |
lowerKey(k) / higherKey(k) | Estrictamente < / > k | |
firstKey(), lastKey(), pollFirstEntry(), pollLastEntry() | Extremos (los poll además los eliminan) | |
headMap(k), tailMap(k, incl), subMap(a, inclA, b, inclB) | Vistas de un rango | Reflejan los cambios del mapa original |
descendingMap(), descendingKeySet() | Vista en orden inverso |
En NavigableSet los métodos se llaman floor, ceiling, lower, higher, headSet, tailSet, subSet, pollFirst, pollLast y descendingSet.
⚠️ Importante: En un
TreeMapoTreeSetla igualdad la decide el comparador, noequals: sicompare(a, b) == 0, se consideran el mismo elemento y uno sustituye al otro. UnTreeSetordenado solo por apellido perdería a los hermanos. Añade siempre un criterio de desempate (thenComparing).
Fíjate en el formato %,7d: la coma pide separador de miles, que en español es el punto (20.200).
8. LinkedHashMap y la caché LRU
LinkedHashMap es un HashMap que además mantiene una lista doblemente enlazada de sus entradas, así que recuerda un orden: por defecto, el de inserción; con el tercer parámetro del constructor a true, el de acceso (cada get mueve la entrada al final). Si además sobrescribes removeEldestEntry, tienes una caché LRU (Least Recently Used) en una docena de líneas:
import java.util.LinkedHashMap;
import java.util.Map;
/** Caché LRU (Least Recently Used): al llenarse, expulsa la entrada usada hace más tiempo. */
public class CacheLru<K, V> extends LinkedHashMap<K, V> {
private final int capacidad;
public CacheLru(int capacidad) {
super(16, 0.75f, true); // true = orden de ACCESO (no de inserción)
this.capacidad = capacidad;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> masAntigua) {
boolean expulsar = size() > capacidad; // se llama después de cada put
if (expulsar) System.out.println(" expulsado: " + masAntigua.getKey());
return expulsar;
}
public static void main(String[] args) {
CacheLru<Integer, String> cache = new CacheLru<>(3);
cache.put(1001, "Talleres Martín");
cache.put(1002, "Construcciones Levante");
cache.put(1003, "María José Herrera");
System.out.println(cache.keySet());
cache.get(1001); // 1001 pasa a ser la más reciente
System.out.println("tras get(1001): " + cache.keySet());
cache.put(1004, "Academia Nova"); // se expulsa la menos usada: 1002
System.out.println(cache.keySet());
// Con orden de inserción (lo normal) el get no cambia el orden
Map<String, Integer> insercion = new LinkedHashMap<>();
insercion.put("b", 2); insercion.put("a", 1); insercion.put("c", 3);
insercion.get("b");
System.out.println("LinkedHashMap normal: " + insercion);
}
}
[1001, 1002, 1003]
tras get(1001): [1002, 1003, 1001]
expulsado: 1002
[1003, 1001, 1004]
LinkedHashMap normal: {b=2, a=1, c=3}
💡 Consejo: Esta caché no es segura entre hilos. En aplicaciones reales con concurrencia se usan bibliotecas como Caffeine (que también usa Spring Boot) o la caché de Hibernate (módulo 16). Pero el mecanismo es exactamente este.
9. EnumMap y EnumSet
Cuando las claves son constantes de un enum, usa EnumMap: internamente es un array indexado por ordinal(), más rápido y compacto que un HashMap, y recorre las claves en el orden de declaración del enum. EnumSet representa un conjunto de constantes con un campo de bits.
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
public class EnumMapDemo {
enum Estado { PENDIENTE, ENVIADO, ENTREGADO, CANCELADO }
record Pedido(int id, Estado estado) {}
public static void main(String[] args) {
List<Pedido> pedidos = List.of(new Pedido(5001, Estado.ENTREGADO), new Pedido(5008, Estado.CANCELADO),
new Pedido(5020, Estado.ENVIADO), new Pedido(5021, Estado.ENVIADO),
new Pedido(5022, Estado.PENDIENTE), new Pedido(5023, Estado.PENDIENTE),
new Pedido(5024, Estado.PENDIENTE), new Pedido(5019, Estado.ENTREGADO));
// EnumMap: claves de un enum, internamente un array indexado por ordinal()
Map<Estado, Integer> recuento = new EnumMap<>(Estado.class);
for (Estado e : Estado.values()) recuento.put(e, 0); // así salen también los ceros
for (Pedido p : pedidos) recuento.merge(p.estado(), 1, Integer::sum);
System.out.println(recuento); // siempre en orden de declaración
// EnumSet: conjunto de constantes, internamente un campo de bits
EnumSet<Estado> abiertos = EnumSet.of(Estado.PENDIENTE, Estado.ENVIADO);
EnumSet<Estado> cerrados = EnumSet.complementOf(abiertos);
EnumSet<Estado> hastaEnviado = EnumSet.range(Estado.PENDIENTE, Estado.ENVIADO);
System.out.println("abiertos=" + abiertos + " cerrados=" + cerrados + " rango=" + hastaEnviado);
long pendientesDeServir = pedidos.stream().filter(p -> abiertos.contains(p.estado())).count();
System.out.println("Pedidos abiertos: " + pendientesDeServir);
}
}
{PENDIENTE=3, ENVIADO=2, ENTREGADO=2, CANCELADO=1}
abiertos=[PENDIENTE, ENVIADO] cerrados=[ENTREGADO, CANCELADO] rango=[PENDIENTE, ENVIADO]
Pedidos abiertos: 5
Map.merge(clave, valor, función) es la forma idiomática de contar o acumular: si la clave no existe, guarda valor; si existe, guarda función(valorActual, valor). Sus parientes son computeIfAbsent (crear el valor si falta, ideal para Map<K, List<V>>), computeIfPresent, compute, getOrDefault y putIfAbsent.
10. Colecciones inmutables, no modificables y de tamaño fijo
Tres conceptos que se confunden a menudo:
| Tipo | Cómo se crea | ¿Se puede modificar? | ¿Refleja cambios del original? | ¿Admite null? |
|---|---|---|---|---|
| Inmutable | List.of, Set.of, Map.of, List.copyOf, Stream.toList(), Collectors.toUnmodifiableList() | No | No (no hay original o es una copia) | No (salvo Stream.toList()) |
| Vista no modificable | Collections.unmodifiableList(lista) | No, a través de la vista | Sí | Sí |
| Tamaño fijo | Arrays.asList(...) | set sí; add y remove no | Sí, está respaldada por el array | Sí |
| Modificable | new ArrayList<>(...), Collectors.toList() (en la práctica) | Sí | — | Sí |
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Inmutables {
static void probar(String nombre, List<String> lista) {
try {
lista.add("X");
System.out.println(nombre + ": se puede modificar");
} catch (UnsupportedOperationException e) {
System.out.println(nombre + ": UnsupportedOperationException");
}
}
public static void main(String[] args) {
List<String> original = new ArrayList<>(List.of("Ana", "Luis"));
List<String> vista = Collections.unmodifiableList(original); // VISTA no modificable
List<String> copia = List.copyOf(original); // COPIA inmutable
List<String> fija = java.util.Arrays.asList("a", "b"); // tamaño fijo
probar("unmodifiableList", vista);
probar("List.copyOf", copia);
probar("Arrays.asList", fija);
fija.set(0, "z"); // set sí funciona en asList
System.out.println("Arrays.asList tras set: " + fija);
original.add("Marta"); // cambiamos el original
System.out.println("original=" + original);
System.out.println("vista =" + vista + " <- refleja el cambio");
System.out.println("copia =" + copia + " <- no cambia");
// List.of y compañía no admiten null
try {
List.of("a", null);
} catch (NullPointerException e) {
System.out.println("List.of(null): NullPointerException");
}
// Map.of no admite claves repetidas
try {
Map.of("a", 1, "a", 2);
} catch (IllegalArgumentException e) {
System.out.println("Map.of repetida: " + e.getMessage());
}
// Desde streams
List<String> inm1 = Stream.of("x", "y").toList(); // Java 16
List<String> inm2 = Stream.of("x", "y").collect(Collectors.toUnmodifiableList());
List<String> mod = Stream.of("x", "y").collect(Collectors.toList()); // ArrayList en la práctica
probar("Stream.toList()", inm1);
probar("toUnmodifiableList", inm2);
probar("Collectors.toList()", mod);
// Inmutabilidad superficial: la lista no cambia, pero sus elementos sí
List<StringBuilder> builders = List.of(new StringBuilder("hola"));
builders.get(0).append(" mundo");
System.out.println("Superficial: " + builders);
}
}
unmodifiableList: UnsupportedOperationException
List.copyOf: UnsupportedOperationException
Arrays.asList: UnsupportedOperationException
Arrays.asList tras set: [z, b]
original=[Ana, Luis, Marta]
vista =[Ana, Luis, Marta] <- refleja el cambio
copia =[Ana, Luis] <- no cambia
List.of(null): NullPointerException
Map.of repetida: duplicate key: a
Stream.toList(): UnsupportedOperationException
toUnmodifiableList: UnsupportedOperationException
Collectors.toList(): se puede modificar
Superficial: [hola mundo]
💡 Consejo: Patrón profesional para que una clase no exponga su estado interno: guarda una copia defensiva al recibir la colección (
this.lineas = List.copyOf(lineas)) y devuelve la lista inmutable en el getter. En un record, hazlo en el constructor compacto. Así nadie puede modificar el objeto desde fuera.
⚠️ Importante: Las colecciones de
List.ofy similares no garantizan un orden de iteración enSet.ofyMap.of(de hecho, cambia entre ejecuciones de la JVM). No dependas de él; si necesitas orden, usaLinkedHashSet/LinkedHashMapoTreeSet/TreeMap.
11. Colecciones secuenciadas (Java 21)
📘 Concepto: Java 21 (JEP 431) añadió tres interfaces a la jerarquía de colecciones para las que tienen un orden de encuentro definido:
SequencedCollection(congetFirst,getLast,addFirst,addLast,removeFirst,removeLastyreversed),SequencedSetySequencedMap(confirstEntry,lastEntry,putFirst,putLast,pollFirstEntry,pollLastEntry,reversed,sequencedKeySet,sequencedValuesysequencedEntrySet).
Figura 4. Las interfaces secuenciadas dentro de la jerarquía de colecciones.
Antes, “dame el primero y el último” se escribía de forma distinta en cada colección:
| Operación | Antes de Java 21 | Java 21 |
|---|---|---|
Primero de un List | lista.get(0) | lista.getFirst() |
Último de un List | lista.get(lista.size() - 1) | lista.getLast() |
Último de un LinkedHashSet | Recorrerlo entero | conjunto.getLast() |
Primera entrada de un LinkedHashMap | mapa.entrySet().iterator().next() | mapa.firstEntry() |
| Recorrer al revés | ListIterator o copiar e invertir | for (var e : c.reversed()) |
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedCollection;
import java.util.SequencedMap;
import java.util.SequencedSet;
import java.util.TreeMap;
import java.util.TreeSet;
public class Secuenciadas {
// Un método que sirve para cualquier colección con orden definido
static <E> String extremos(SequencedCollection<E> c) {
return c.getClass().getSimpleName() + ": primero=" + c.getFirst() + ", último=" + c.getLast();
}
public static void main(String[] args) {
List<String> lista = new ArrayList<>(List.of("b", "c"));
Deque<String> cola = new ArrayDeque<>(List.of("b", "c"));
SequencedSet<String> conjunto = new LinkedHashSet<>(List.of("b", "c"));
TreeSet<String> ordenado = new TreeSet<>(List.of("c", "a", "b"));
for (SequencedCollection<String> c : List.<SequencedCollection<String>>of(lista, cola, conjunto, ordenado)) {
System.out.println(extremos(c));
}
lista.addFirst("a");
lista.addLast("d");
conjunto.addFirst("a"); // en un LinkedHashSet, coloca el elemento al principio
conjunto.addFirst("c"); // si ya existía, lo MUEVE al principio
System.out.println("lista=" + lista + " conjunto=" + conjunto);
// reversed() devuelve una VISTA invertida, no una copia
List<String> alReves = lista.reversed();
lista.add("e");
System.out.println("vista invertida: " + alReves);
System.out.println("removeFirst=" + lista.removeFirst() + ", removeLast=" + lista.removeLast() + " -> " + lista);
try {
ordenado.addFirst("z"); // en un conjunto ordenado no tiene sentido
} catch (UnsupportedOperationException e) {
System.out.println("TreeSet.addFirst: UnsupportedOperationException");
}
// SequencedMap
SequencedMap<String, Integer> stock = new LinkedHashMap<>();
stock.put("Ratón", 200);
stock.put("Teclado", 60);
stock.putFirst("Monitor", 35);
stock.putLast("Ratón", 190); // ya existía: se actualiza y pasa al final
System.out.println(stock);
System.out.println("firstEntry=" + stock.firstEntry() + " lastEntry=" + stock.lastEntry());
System.out.println("sequencedKeySet().reversed()=" + stock.sequencedKeySet().reversed());
System.out.println("pollLastEntry=" + stock.pollLastEntry() + " -> " + stock);
SequencedMap<Integer, String> porId = new TreeMap<>(java.util.Map.of(103, "Pedro", 100, "Ana", 101, "Luis"));
System.out.println("TreeMap invertido: " + porId.reversed());
}
}
ArrayList: primero=b, último=c
ArrayDeque: primero=b, último=c
LinkedHashSet: primero=b, último=c
TreeSet: primero=a, último=c
lista=[a, b, c, d] conjunto=[c, a, b]
vista invertida: [e, d, c, b, a]
removeFirst=a, removeLast=e -> [b, c, d]
TreeSet.addFirst: UnsupportedOperationException
{Monitor=35, Teclado=60, Ratón=190}
firstEntry=Monitor=35 lastEntry=Ratón=190
sequencedKeySet().reversed()=[Ratón, Teclado, Monitor]
pollLastEntry=Ratón=190 -> {Monitor=35, Teclado=60}
TreeMap invertido: {103=Pedro, 101=Luis, 100=Ana}
⚠️ Importante:
reversed()es una vista: la lista invertida ve el elemento"e"añadido después. Y las operaciones que no tienen sentido en una colección ordenada por contenido (addFirsten unTreeSet) lanzanUnsupportedOperationException.HashSetyHashMapno son secuenciados porque su orden no está definido.
12. Comparator avanzado
En Fundamentos implementaste Comparable y escribiste algún Comparator. Desde Java 8, Comparator tiene métodos de fábrica y de composición que permiten expresar cualquier criterio de ordenación en una línea (las lambdas y las referencias a método se estudian a fondo en el módulo 03).
| Método | Ejemplo | Efecto |
|---|---|---|
comparing(extractor) | comparing(Empleado::nombre) | Ordena por la clave extraída (que debe ser Comparable) |
comparing(extractor, comparador) | comparing(Empleado::nombre, collator) | Ordena la clave con otro comparador |
comparingInt/Long/Double(extractor) | comparingDouble(Empleado::salario) | Igual, sin autoboxing |
thenComparing(...) | .thenComparing(Empleado::id) | Criterio de desempate |
reversed() | comparingDouble(...).reversed() | Invierte todo el comparador anterior |
reverseOrder() / naturalOrder() | thenComparing(Empleado::salario, reverseOrder()) | Orden natural inverso / directo |
nullsFirst(c) / nullsLast(c) | nullsLast(naturalOrder()) | Coloca los null al principio o al final |
Map.Entry.comparingByKey() / comparingByValue() | Map.Entry.<String, Integer>comparingByValue().reversed() | Para ordenar entradas de mapas |
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
public class Comparadores {
record Empleado(int id, String nombre, String puesto, double salario, Integer departamento) {}
static void mostrar(String titulo, List<Empleado> lista) {
System.out.println("-- " + titulo);
for (Empleado e : lista) {
System.out.printf(" %-7s %-16s %7.0f dep=%s%n", e.nombre(), e.puesto(), e.salario(), e.departamento());
}
}
public static void main(String[] args) {
List<Empleado> empleados = new ArrayList<>(List.of(
new Empleado(110, "Carmen", "VENDEDOR", 2200, 20),
new Empleado(120, "Pablo", "ANALISTA", 3500, 30),
new Empleado(111, "David", "VENDEDOR", 2100, 20),
new Empleado(160, "Tomás", "BECARIO", 900, null),
new Empleado(121, "Raquel", "PROGRAMADOR", 2800, 30),
new Empleado(141, "Álvaro", "ANALISTA", 2700, 50),
new Empleado(112, "Elena", "VENDEDOR", 2300, 20)));
// 1. Por una clave
empleados.sort(Comparator.comparing(Empleado::nombre));
mostrar("comparing(nombre) [ojo con la Á]", empleados);
// 2. Por varias claves: puesto ascendente y, a igual puesto, salario descendente
empleados.sort(Comparator.comparing(Empleado::puesto)
.thenComparing(Empleado::salario, Comparator.reverseOrder()));
mostrar("puesto, luego salario desc", empleados);
// 3. Versiones primitivas (evitan el autoboxing) y reversed()
empleados.sort(Comparator.comparingDouble(Empleado::salario).reversed());
mostrar("comparingDouble(salario).reversed()", empleados.subList(0, 3));
// 4. Claves que pueden ser null: nullsFirst / nullsLast
empleados.sort(Comparator.comparing(Empleado::departamento, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparingInt(Empleado::id));
mostrar("departamento con nulls al final, luego id", empleados);
// 5. Comparar texto con reglas del idioma (tildes)
var collator = java.text.Collator.getInstance(java.util.Locale.of("es", "ES"));
empleados.sort(Comparator.comparing(Empleado::nombre, collator));
System.out.println("-- con Collator: " + empleados.stream().map(Empleado::nombre).toList());
// 6. Comparadores sobre entradas de mapas
Map<String, Integer> ventas = Map.of("Carmen", 4, "David", 2, "Elena", 3);
var ranking = new ArrayList<>(ventas.entrySet());
ranking.sort(Map.Entry.<String, Integer>comparingByValue().reversed());
System.out.println("-- ranking de pedidos: " + ranking);
// 7. Usar un comparador para máximos y mínimos
Empleado mejorPagado = empleados.stream().max(Comparator.comparingDouble(Empleado::salario)).orElseThrow();
System.out.println("-- mejor pagado: " + mejorPagado.nombre());
}
}
-- comparing(nombre) [ojo con la Á]
Carmen VENDEDOR 2200 dep=20
David VENDEDOR 2100 dep=20
Elena VENDEDOR 2300 dep=20
Pablo ANALISTA 3500 dep=30
Raquel PROGRAMADOR 2800 dep=30
Tomás BECARIO 900 dep=null
Álvaro ANALISTA 2700 dep=50
-- puesto, luego salario desc
Pablo ANALISTA 3500 dep=30
Álvaro ANALISTA 2700 dep=50
Tomás BECARIO 900 dep=null
Raquel PROGRAMADOR 2800 dep=30
Elena VENDEDOR 2300 dep=20
Carmen VENDEDOR 2200 dep=20
David VENDEDOR 2100 dep=20
-- comparingDouble(salario).reversed()
Pablo ANALISTA 3500 dep=30
Raquel PROGRAMADOR 2800 dep=30
Álvaro ANALISTA 2700 dep=50
-- departamento con nulls al final, luego id
Carmen VENDEDOR 2200 dep=20
David VENDEDOR 2100 dep=20
Elena VENDEDOR 2300 dep=20
Pablo ANALISTA 3500 dep=30
Raquel PROGRAMADOR 2800 dep=30
Álvaro ANALISTA 2700 dep=50
Tomás BECARIO 900 dep=null
-- con Collator: [Álvaro, Carmen, David, Elena, Pablo, Raquel, Tomás]
-- ranking de pedidos: [Carmen=4, Elena=3, David=2]
-- mejor pagado: Pablo
Puntos a observar:
- “Álvaro” queda el último con
comparing(Empleado::nombre):String.compareTocompara códigos Unicode y laÁ(U+00C1) va después de todas las letras sin tilde. Para ordenar texto como espera una persona, usa unCollatordel idioma (punto 5). reversed()invierte toda la cadena que tiene a su izquierda. Si quieres invertir solo un criterio, pásaleComparator.reverseOrder()a esethenComparing(punto 2).List.sortes estable: los elementos que el comparador considera iguales conservan su orden relativo. Por eso puedes ordenar en varias pasadas o confiar en el orden previo para los empates.- Sin
nullsLast, comparar undepartamentonulo lanzaríaNullPointerException. Map.Entry.<String, Integer>comparingByValue()necesita el testigo de tipo porque, al encadenar.reversed(), el compilador no puede inferir los tipos del destino.
13. Utilidades de Collections y Arrays
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Random;
public class Utilidades {
public static void main(String[] args) {
// ----- Collections -----
List<Integer> l = new ArrayList<>(List.of(5, 3, 8, 1, 9, 3, 7));
System.out.println("max=" + Collections.max(l) + " min=" + Collections.min(l)
+ " frecuencia(3)=" + Collections.frequency(l, 3));
Collections.sort(l);
System.out.println("ordenada " + l + " binarySearch(8)=" + Collections.binarySearch(l, 8));
Collections.reverse(l);
System.out.println("reverse " + l);
Collections.rotate(l, 2);
System.out.println("rotate 2 " + l);
Collections.swap(l, 0, 1);
System.out.println("swap " + l);
Collections.shuffle(l, new Random(42)); // semilla fija: resultado reproducible
System.out.println("shuffle " + l);
System.out.println("nCopies " + Collections.nCopies(3, "-"));
System.out.println("disjoint " + Collections.disjoint(List.of(1, 2), List.of(3, 4)));
List<String> nombres = new ArrayList<>();
Collections.addAll(nombres, "Ana", "Luis", "Marta");
System.out.println("addAll " + nombres + " vacía=" + Collections.emptyList());
// ----- Arrays -----
int[] numeros = {5, 3, 8, 1, 9};
Arrays.sort(numeros);
System.out.println("Arrays.sort " + Arrays.toString(numeros)
+ " binarySearch(8)=" + Arrays.binarySearch(numeros, 8));
int[] copia = Arrays.copyOfRange(numeros, 1, 4);
System.out.println("copyOfRange " + Arrays.toString(copia));
int[] cuadrados = new int[6];
Arrays.setAll(cuadrados, i -> i * i);
System.out.println("setAll " + Arrays.toString(cuadrados));
double[] notas = new double[4];
Arrays.fill(notas, 5.0);
System.out.println("fill " + Arrays.toString(notas));
int[] a = {1, 2, 3, 4}, b = {1, 2, 9, 4};
System.out.println("equals=" + Arrays.equals(a, b) + " mismatch=" + Arrays.mismatch(a, b)
+ " compare=" + Arrays.compare(a, b));
int[][] matriz = {{1, 2}, {3, 4}};
System.out.println("deepToString " + Arrays.deepToString(matriz)
+ " deepEquals=" + Arrays.deepEquals(matriz, new int[][]{{1, 2}, {3, 4}}));
System.out.println("suma con stream: " + Arrays.stream(numeros).sum());
String[] palabras = {"pera", "Uva", "manzana", "kiwi"};
Arrays.sort(palabras, String.CASE_INSENSITIVE_ORDER);
System.out.println("sin mayúsculas: " + Arrays.toString(palabras));
Arrays.sort(palabras, 0, 2, java.util.Comparator.comparing(String::length));
System.out.println("rango 0-2 por longitud: " + Arrays.toString(palabras));
}
}
max=9 min=1 frecuencia(3)=2
ordenada [1, 3, 3, 5, 7, 8, 9] binarySearch(8)=5
reverse [9, 8, 7, 5, 3, 3, 1]
rotate 2 [3, 1, 9, 8, 7, 5, 3]
swap [1, 3, 9, 8, 7, 5, 3]
shuffle [9, 3, 7, 1, 5, 8, 3]
nCopies [-, -, -]
disjoint true
addAll [Ana, Luis, Marta] vacía=[]
Arrays.sort [1, 3, 5, 8, 9] binarySearch(8)=3
copyOfRange [3, 5, 8]
setAll [0, 1, 4, 9, 16, 25]
fill [5.0, 5.0, 5.0, 5.0]
equals=false mismatch=2 compare=-1
deepToString [[1, 2], [3, 4]] deepEquals=true
suma con stream: 26
sin mayúsculas: [kiwi, manzana, pera, Uva]
rango 0-2 por longitud: [kiwi, manzana, pera, Uva]
Además de lo que muestra el ejemplo, Collections ofrece los envoltorios unmodifiableX, synchronizedX y checkedX, y Arrays tiene parallelSort para arrays enormes. Recuerda que el == y el equals de un array comparan referencias: para comparar contenido usa Arrays.equals o Arrays.deepEquals.
⚠️ Importante:
Collections.binarySearchyArrays.binarySearchsobre datos no ordenados devuelven resultados sin sentido (sin error). Si no encuentran el elemento, devuelven-(punto de inserción) - 1, un número negativo.
14. Rendimiento de las colecciones
La complejidad algorítmica (notación O grande) indica cómo crece el coste de una operación con el número de elementos n:
| Colección | get(i) | add (al final) | add(0, e) / addFirst | remove | contains | Orden |
|---|---|---|---|---|---|---|
ArrayList | O(1) | O(1) amortizado | O(n) | O(n) | O(n) | Inserción |
LinkedList | O(n) | O(1) | O(1) | O(1) con iterador, O(n) buscando | O(n) | Inserción |
ArrayDeque | — | O(1) amortizado | O(1) | O(1) en extremos | O(n) | Inserción |
HashSet / HashMap | — | O(1) promedio | — | O(1) | O(1) promedio | Ninguno |
LinkedHashSet / LinkedHashMap | — | O(1) | — | O(1) | O(1) | Inserción (o acceso) |
TreeSet / TreeMap | — | O(log n) | — | O(log n) | O(log n) | Ordenado |
EnumSet / EnumMap | — | O(1) | — | O(1) | O(1) | Declaración del enum |
PriorityQueue | — | O(log n) | — | O(log n) (poll) | O(n) | Montículo: el menor primero |
💻 Ejemplo: Una medición sencilla (no es un benchmark riguroso: para eso existe JMH) que confirma la tabla.
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class Rendimiento {
static long ms(Runnable r) {
long t = System.nanoTime();
r.run();
return (System.nanoTime() - t) / 1_000_000;
}
public static void main(String[] args) {
int n = 50_000;
List<Integer> arrayList = new ArrayList<>();
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < n; i++) { arrayList.add(i); linkedList.add(i); }
// Acceso por índice: O(1) frente a O(n)
long tArray = ms(() -> { long s = 0; for (int i = 0; i < n; i++) s += arrayList.get(i); });
long tLinked = ms(() -> { long s = 0; for (int i = 0; i < n; i++) s += linkedList.get(i); });
System.out.println("get(i) x " + n + ": ArrayList " + tArray + " ms, LinkedList " + tLinked + " ms");
// Insertar al principio: O(n) frente a O(1)
long tArrayIni = ms(() -> { for (int i = 0; i < n; i++) arrayList.addFirst(i); });
long tLinkedIni = ms(() -> { for (int i = 0; i < n; i++) linkedList.addFirst(i); });
System.out.println("addFirst x " + n + ": ArrayList " + tArrayIni + " ms, LinkedList " + tLinkedIni + " ms");
// Búsqueda: O(n) en lista, O(1) en HashSet, O(log n) en TreeSet
Set<Integer> hash = new HashSet<>(arrayList);
Set<Integer> tree = new TreeSet<>(arrayList);
int busquedas = 2_000;
long tLista = ms(() -> { for (int i = 0; i < busquedas; i++) arrayList.contains(-i); });
long tHash = ms(() -> { for (int i = 0; i < busquedas; i++) hash.contains(-i); });
long tTree = ms(() -> { for (int i = 0; i < busquedas; i++) tree.contains(-i); });
System.out.println("contains x " + busquedas + ": ArrayList " + tLista + " ms, HashSet " + tHash
+ " ms, TreeSet " + tTree + " ms");
}
}
get(i) x 50000: ArrayList 103 ms, LinkedList 35449 ms
addFirst x 50000: ArrayList 10203 ms, LinkedList 188 ms
contains x 2000: ArrayList 20235 ms, HashSet 0 ms, TreeSet 76 ms
Los milisegundos dependen totalmente del equipo y de su carga (esta ejecución se hizo en una máquina muy ocupada), pero las proporciones no engañan: LinkedList.get(i) es cientos de veces más lento que en ArrayList, addFirst es al revés, y buscar en un HashSet es prácticamente instantáneo frente a recorrer una lista.
💡 Consejo: Guía rápida de elección:
ArrayListpor defecto para listas;ArrayDequepara pilas y colas;HashMap/HashSetpara buscar por clave;LinkedHashMapsi importa el orden de inserción;TreeMap/TreeSetsi necesitas orden o búsquedas por rango;EnumMap/EnumSetcon enums.LinkedListcasi nunca es la mejor opción en la práctica: su supuesta ventaja (insertar en medio) exige llegar antes a esa posición, que es O(n), y sus nodos dispersos en memoria aprovechan mal la caché del procesador.
15. Ejercicios
Ejercicio 1: Métodos genéricos con cotas
Escribe dos métodos genéricos estáticos con las firmas más flexibles posibles:
estaOrdenada(lista): indica si una lista está ordenada de menor a mayor según el orden natural de sus elementos. Debe funcionar conList<LocalDate>.claveDelMaximo(mapa): devuelve la clave asociada al mayor valor de unMap<K, V>(onullsi está vacío).
Pruébalos con los presupuestos de los departamentos de EMPRESA y con fechas de alta de empleados.
Ver solución
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public class Ej1Genericos {
/** ¿Está la lista ordenada de menor a mayor? */
static <T extends Comparable<? super T>> boolean estaOrdenada(List<? extends T> lista) {
for (int i = 1; i < lista.size(); i++) {
if (lista.get(i - 1).compareTo(lista.get(i)) > 0) return false;
}
return true;
}
/** Clave asociada al mayor valor de un mapa (null si el mapa está vacío). */
static <K, V extends Comparable<? super V>> K claveDelMaximo(Map<K, V> mapa) {
K mejor = null;
V max = null;
for (Map.Entry<K, V> e : mapa.entrySet()) {
if (max == null || e.getValue().compareTo(max) > 0) {
max = e.getValue();
mejor = e.getKey();
}
}
return mejor;
}
public static void main(String[] args) {
System.out.println(estaOrdenada(List.of(1, 3, 3, 8)) + " " + estaOrdenada(List.of("b", "a")));
// LocalDate implementa Comparable<ChronoLocalDate>, un SUPERTIPO: por eso hace falta "? super T"
System.out.println(estaOrdenada(List.of(LocalDate.of(2025, 1, 15), LocalDate.of(2026, 7, 10))));
Map<String, Double> presupuestos = Map.of("DIRECCIÓN", 500000.0, "VENTAS", 350000.0, "INFORMÁTICA", 420000.0);
System.out.println("Mayor presupuesto: " + claveDelMaximo(presupuestos));
Map<Integer, LocalDate> altas = Map.of(100, LocalDate.of(2010, 1, 15), 160, LocalDate.of(2025, 9, 1));
System.out.println("Alta más reciente: empleado " + claveDelMaximo(altas));
System.out.println("Mapa vacío: " + claveDelMaximo(Map.<String, Integer>of()));
}
}
true false
true
Mayor presupuesto: DIRECCIÓN
Alta más reciente: empleado 160
Mapa vacío: null
Comparable<? super T> es imprescindible para LocalDate: con <T extends Comparable<T>> la llamada con fechas no compilaría. El parámetro List<? extends T> es un productor (solo leemos), así que también acepta listas de subtipos.
Ejercicio 2: Lista enlazada genérica
Implementa una ListaEnlazada<E> simplemente enlazada que implemente Iterable<E>, con referencias a la cabeza y a la cola, y los métodos add(e) (al final, en O(1)), addFirst(e), get(indice), remove(indice), removeIf(Predicate<? super E>), size() y toString() con el formato [a -> b -> c]. Valida los índices con Objects.checkIndex.
Ver solución
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.function.Predicate;
/** Lista simplemente enlazada genérica. */
public class ListaEnlazada<E> implements Iterable<E> {
// Nodo: clase anidada estática y genérica (no necesita acceder a la lista)
private static final class Nodo<E> {
E valor;
Nodo<E> siguiente;
Nodo(E valor, Nodo<E> siguiente) { this.valor = valor; this.siguiente = siguiente; }
}
private Nodo<E> cabeza;
private Nodo<E> cola;
private int tamano;
public void addFirst(E e) {
cabeza = new Nodo<>(e, cabeza);
if (cola == null) cola = cabeza;
tamano++;
}
public void add(E e) { // al final, O(1) gracias a la referencia a la cola
Nodo<E> nuevo = new Nodo<>(e, null);
if (cola == null) cabeza = cola = nuevo;
else { cola.siguiente = nuevo; cola = nuevo; }
tamano++;
}
public E get(int indice) { // O(n): hay que recorrer
return nodo(indice).valor;
}
public E remove(int indice) {
Objects.checkIndex(indice, tamano);
E eliminado;
if (indice == 0) {
eliminado = cabeza.valor;
cabeza = cabeza.siguiente;
if (cabeza == null) cola = null;
} else {
Nodo<E> anterior = nodo(indice - 1);
eliminado = anterior.siguiente.valor;
anterior.siguiente = anterior.siguiente.siguiente;
if (anterior.siguiente == null) cola = anterior;
}
tamano--;
return eliminado;
}
/** Elimina todos los elementos que cumplen la condición. */
public int removeIf(Predicate<? super E> condicion) {
int antes = tamano;
while (cabeza != null && condicion.test(cabeza.valor)) { cabeza = cabeza.siguiente; tamano--; }
if (cabeza == null) { cola = null; return antes; }
Nodo<E> actual = cabeza;
while (actual.siguiente != null) {
if (condicion.test(actual.siguiente.valor)) { actual.siguiente = actual.siguiente.siguiente; tamano--; }
else actual = actual.siguiente;
}
cola = actual;
return antes - tamano;
}
public int size() { return tamano; }
private Nodo<E> nodo(int indice) {
Objects.checkIndex(indice, tamano);
Nodo<E> n = cabeza;
for (int i = 0; i < indice; i++) n = n.siguiente;
return n;
}
@Override
public Iterator<E> iterator() {
return new Iterator<>() {
private Nodo<E> actual = cabeza;
public boolean hasNext() { return actual != null; }
public E next() {
if (actual == null) throw new NoSuchElementException();
E v = actual.valor;
actual = actual.siguiente;
return v;
}
};
}
@Override
public String toString() {
StringJoiner sj = new StringJoiner(" -> ", "[", "]");
for (E e : this) sj.add(String.valueOf(e));
return sj.toString();
}
public static void main(String[] args) {
ListaEnlazada<String> productos = new ListaEnlazada<>();
productos.add("Ratón");
productos.add("Teclado");
productos.add("Monitor");
productos.addFirst("Portátil");
System.out.println(productos + " tamaño=" + productos.size() + " get(2)=" + productos.get(2));
System.out.println("remove(1)=" + productos.remove(1) + " -> " + productos);
ListaEnlazada<Integer> numeros = new ListaEnlazada<>();
for (int i = 1; i <= 10; i++) numeros.add(i);
System.out.println("eliminados pares: " + numeros.removeIf(n -> n % 2 == 0) + " -> " + numeros);
numeros.add(99); // la cola sigue bien enlazada
int suma = 0;
for (int n : numeros) suma += n; // for-each gracias a Iterable
System.out.println(numeros + " suma=" + suma);
try {
numeros.get(50);
} catch (IndexOutOfBoundsException e) {
System.out.println("get(50): " + e.getMessage());
}
}
}
[Portátil -> Ratón -> Teclado -> Monitor] tamaño=4 get(2)=Teclado
remove(1)=Ratón -> [Portátil -> Teclado -> Monitor]
eliminados pares: 5 -> [1 -> 3 -> 5 -> 7 -> 9]
[1 -> 3 -> 5 -> 7 -> 9 -> 99] suma=124
get(50): Index 50 out of bounds for length 6
La parte delicada es mantener cola coherente: al borrar el último nodo (en remove y en removeIf) hay que actualizarla, o el siguiente add enlazaría con un nodo que ya no está en la lista. Por eso la prueba añade el 99 después de removeIf. Nodo es static porque no necesita acceder a la instancia de la lista: así cada nodo no guarda una referencia oculta a ella.
Ejercicio 3: Caché LRU con estadísticas
Crea una clase genérica Cache<K, V> con capacidad máxima, que reciba en el constructor una función “cargadora” (Function<? super K, ? extends V>) para calcular los valores ausentes, expulse el elemento usado hace más tiempo al llenarse y cuente aciertos y fallos. Úsala para simular consultas a la tabla productos con capacidad 3 y la secuencia de peticiones 1, 6, 1, 9, 1, 10, 6, 1, 9.
Ver solución
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
public class Ej3Cache {
/** Caché LRU genérica que calcula los valores ausentes y lleva estadísticas. */
static class Cache<K, V> {
private final Map<K, V> datos;
private final Function<? super K, ? extends V> cargador; // PECS: consume K, produce V
private int aciertos, fallos;
Cache(int capacidad, Function<? super K, ? extends V> cargador) {
this.cargador = cargador;
this.datos = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> e) {
return size() > capacidad;
}
};
}
V get(K clave) {
V v = datos.get(clave);
if (v != null) { aciertos++; return v; }
fallos++;
v = cargador.apply(clave);
datos.put(clave, v);
return v;
}
String estadisticas() {
int total = aciertos + fallos;
return "aciertos=%d fallos=%d tasa=%.0f%% contenido=%s".formatted(
aciertos, fallos, total == 0 ? 0.0 : 100.0 * aciertos / total, datos.keySet());
}
}
// Simula una consulta lenta a la tabla productos
static final Map<Integer, String> PRODUCTOS = Map.of(1, "Portátil UltraBook 14", 2, "Portátil Gaming 16",
6, "Ratón inalámbrico", 9, "Monitor 24 FHD", 10, "Monitor 27 4K");
static String consultarBaseDeDatos(Integer id) {
System.out.println(" (consulta a la BD del producto " + id + ")");
return PRODUCTOS.getOrDefault(id, "desconocido");
}
public static void main(String[] args) {
Cache<Integer, String> cache = new Cache<>(3, Ej3Cache::consultarBaseDeDatos);
int[] peticiones = {1, 6, 1, 9, 1, 10, 6, 1, 9};
for (int id : peticiones) {
System.out.println("get(" + id + ") = " + cache.get(id));
}
System.out.println(cache.estadisticas());
}
}
(consulta a la BD del producto 1)
get(1) = Portátil UltraBook 14
(consulta a la BD del producto 6)
get(6) = Ratón inalámbrico
get(1) = Portátil UltraBook 14
(consulta a la BD del producto 9)
get(9) = Monitor 24 FHD
get(1) = Portátil UltraBook 14
(consulta a la BD del producto 10)
get(10) = Monitor 27 4K
(consulta a la BD del producto 6)
get(6) = Ratón inalámbrico
get(1) = Portátil UltraBook 14
(consulta a la BD del producto 9)
get(9) = Monitor 24 FHD
aciertos=3 fallos=6 tasa=33% contenido=[6, 1, 9]
En lugar de heredar de LinkedHashMap (como en la sección 8), aquí la caché contiene uno (composición), creado como clase anónima: así la clase pública solo expone get y estadisticas, y nadie puede llamar a put o remove saltándose la lógica. La firma Function<? super K, ? extends V> aplica PECS a una función: consume claves (super) y produce valores (extends); así acepta, por ejemplo, una Function<Object, String> para una Cache<Integer, CharSequence>.
Ejercicio 4: Reservas de una sala con TreeMap
Implementa una SalaReuniones que guarde las reservas (record Reserva(LocalTime inicio, LocalTime fin, String responsable)) en un NavigableMap por hora de inicio y ofrezca: reservar(inicio, fin, quien) que rechace las que se solapen (usa floorEntry y ceilingEntry: solo pueden solapar la reserva anterior y la siguiente), entre(desde, hasta) con las reservas de una franja, y primerHuecoDesde(hora).
Ver solución
import java.time.LocalTime;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
public class Ej4Reservas {
record Reserva(LocalTime inicio, LocalTime fin, String responsable) {}
static class SalaReuniones {
// Clave: hora de inicio. El TreeMap mantiene las reservas ordenadas
private final NavigableMap<LocalTime, Reserva> reservas = new TreeMap<>();
boolean reservar(LocalTime inicio, LocalTime fin, String quien) {
if (!fin.isAfter(inicio)) throw new IllegalArgumentException("Intervalo vacío");
// Solo pueden solapar la reserva anterior más cercana y la siguiente
Map.Entry<LocalTime, Reserva> anterior = reservas.floorEntry(inicio);
Map.Entry<LocalTime, Reserva> siguiente = reservas.ceilingEntry(inicio);
if (anterior != null && anterior.getValue().fin().isAfter(inicio)) return false;
if (siguiente != null && siguiente.getKey().isBefore(fin)) return false;
reservas.put(inicio, new Reserva(inicio, fin, quien));
return true;
}
/** Reservas que empiezan en [desde, hasta). */
NavigableMap<LocalTime, Reserva> entre(LocalTime desde, LocalTime hasta) {
return reservas.subMap(desde, true, hasta, false);
}
LocalTime primerHuecoDesde(LocalTime hora) {
LocalTime candidata = hora;
Map.Entry<LocalTime, Reserva> previa = reservas.floorEntry(candidata);
if (previa != null && previa.getValue().fin().isAfter(candidata)) candidata = previa.getValue().fin();
for (Reserva r : reservas.tailMap(candidata, true).values()) {
if (r.inicio().isAfter(candidata)) break; // hay hueco antes de r
candidata = r.fin();
}
return candidata;
}
}
public static void main(String[] args) {
SalaReuniones sala = new SalaReuniones();
String[][] peticiones = {
{"09:00", "10:00", "Ana"}, {"10:00", "11:30", "Luis"}, {"09:30", "10:30", "Marta"},
{"12:00", "13:00", "Pedro"}, {"11:00", "12:00", "Laura"}, {"11:30", "12:00", "Javier"}};
for (String[] p : peticiones) {
boolean ok = sala.reservar(LocalTime.parse(p[0]), LocalTime.parse(p[1]), p[2]);
System.out.printf("%-6s %s-%s %s%n", p[2], p[0], p[1], ok ? "reservada" : "RECHAZADA (solapa)");
}
System.out.println("Mañana (9:00-12:00): " + sala.entre(LocalTime.of(9, 0), LocalTime.of(12, 0)).values()
.stream().map(Reserva::responsable).toList());
System.out.println("Primer hueco desde las 9:15: " + sala.primerHuecoDesde(LocalTime.of(9, 15)));
}
}
Ana 09:00-10:00 reservada
Luis 10:00-11:30 reservada
Marta 09:30-10:30 RECHAZADA (solapa)
Pedro 12:00-13:00 reservada
Laura 11:00-12:00 RECHAZADA (solapa)
Javier 11:30-12:00 reservada
Mañana (9:00-12:00): [Ana, Luis, Javier]
Primer hueco desde las 9:15: 13:00
Gracias al orden del TreeMap, comprobar el solapamiento es O(log n) en lugar de recorrer todas las reservas. Las reservas que “se tocan” (Luis empieza a las 10:00, justo cuando acaba Ana) no se consideran solapadas porque se compara con isAfter/isBefore estrictos. Y el primer hueco desde las 9:15 son las 13:00 porque las reservas encadenadas de Ana, Luis, Javier y Pedro cubren sin interrupción hasta esa hora.
Ejercicio 5: Ranking de clientes con Comparator
Ordena los clientes de EMPRESA por comercial asignado (los que no tienen comercial, al final), dentro de cada comercial por límite de crédito descendente y, a igualdad, por nombre según las reglas del español. Muestra después las ciudades distintas ordenadas alfabéticamente con Collator.
Ver solución
import java.text.Collator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public class Ej5Ranking {
record Cliente(int id, String nombre, String ciudad, double limiteCredito, Integer comercial) {}
public static void main(String[] args) {
List<Cliente> clientes = new ArrayList<>(List.of(
new Cliente(1001, "Talleres Martín S.L.", "Valencia", 10000, 110),
new Cliente(1002, "Construcciones Levante S.A.", "Alicante", 25000, 110),
new Cliente(1004, "Academia Nova", "Madrid", 8000, 111),
new Cliente(1007, "Hotel Mar Azul S.L.", "Palma", 15000, 111),
new Cliente(1009, "Laura Benítez Sanz", "Bilbao", 1000, null),
new Cliente(1010, "Instituto Tecnológico Sur", "Granada", 20000, 112),
new Cliente(1012, "Diseños Creativos S.L.", "Barcelona", 7000, 110),
new Cliente(1013, "Carlos Ibáñez Rey", "Valencia", 1500, null),
new Cliente(1014, "Farmacia Central", "Oviedo", 4000, 113)));
Collator es = Collator.getInstance(Locale.of("es", "ES"));
// Comercial (los null al final), dentro de cada comercial por crédito descendente y luego por nombre
Comparator<Cliente> orden = Comparator
.comparing(Cliente::comercial, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Comparator.comparingDouble(Cliente::limiteCredito).reversed())
.thenComparing(Cliente::nombre, es);
clientes.sort(orden);
for (Cliente c : clientes) {
System.out.printf("%-5s %8.0f %s%n", c.comercial() == null ? "-" : c.comercial(), c.limiteCredito(), c.nombre());
}
// Ciudades distintas ordenadas según el idioma español
List<String> ciudades = clientes.stream().map(Cliente::ciudad).distinct().sorted(es).toList();
System.out.println(ciudades);
}
}
110 25000 Construcciones Levante S.A.
110 10000 Talleres Martín S.L.
110 7000 Diseños Creativos S.L.
111 15000 Hotel Mar Azul S.L.
111 8000 Academia Nova
112 20000 Instituto Tecnológico Sur
113 4000 Farmacia Central
- 1500 Carlos Ibáñez Rey
- 1000 Laura Benítez Sanz
[Alicante, Barcelona, Bilbao, Granada, Madrid, Oviedo, Palma, Valencia]
La clave está en el segundo criterio: thenComparing(Comparator.comparingDouble(...).reversed()) invierte solo ese criterio, porque el reversed() se aplica al comparador interno. Si hubieras escrito .thenComparingDouble(Cliente::limiteCredito).reversed(), habrías invertido también el orden de los comerciales (y los null habrían pasado al principio). Collator implementa Comparator<Object>, por eso sirve tanto en thenComparing como en sorted.
Ejercicio 6: Contador de frecuencias genérico
Escribe una clase Contador<T> que cuente apariciones de elementos con Map.merge, con los métodos anadir(T), anadirTodos(Iterable<? extends T>), frecuencia(Object), masFrecuentes(int n) (las n entradas más frecuentes; a igualdad, la que apareció antes) y copiarDistintosEn(Collection<? super T>). Úsalo con los oficios de los empleados de EMPRESA y con los productos de unas líneas de pedido.
Ver solución
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Cuenta cuántas veces aparece cada elemento. */
public class Contador<T> {
private final Map<T, Integer> cuentas = new LinkedHashMap<>(); // conserva el orden de aparición
public void anadir(T elemento) {
cuentas.merge(elemento, 1, Integer::sum);
}
public void anadirTodos(Iterable<? extends T> elementos) { // productor: extends
for (T e : elementos) anadir(e);
}
public int frecuencia(Object elemento) {
return cuentas.getOrDefault(elemento, 0);
}
/** Los n más frecuentes; a igual frecuencia, el que apareció antes. */
public List<Map.Entry<T, Integer>> masFrecuentes(int n) {
List<Map.Entry<T, Integer>> lista = new ArrayList<>(cuentas.entrySet());
lista.sort(Map.Entry.<T, Integer>comparingByValue().reversed()); // sort es estable
return Collections.unmodifiableList(lista.subList(0, Math.min(n, lista.size())));
}
/** Copia los elementos distintos en un destino: consumidor, super. */
public void copiarDistintosEn(Collection<? super T> destino) {
destino.addAll(cuentas.keySet());
}
public static void main(String[] args) {
Contador<String> oficios = new Contador<>();
oficios.anadirTodos(List.of("DIRECTOR GENERAL", "DIRECTOR", "DIRECTOR", "DIRECTOR", "DIRECTOR", "DIRECTOR",
"VENDEDOR", "VENDEDOR", "VENDEDOR", "VENDEDOR", "ADMINISTRATIVO", "ANALISTA", "PROGRAMADOR",
"PROGRAMADOR", "PROGRAMADOR", "TÉCNICO SISTEMAS", "CONTABLE", "ADMINISTRATIVO", "DISEÑADOR",
"ANALISTA", "OPERARIO", "OPERARIO", "ADMINISTRATIVO", "BECARIO"));
System.out.println("Top 3: " + oficios.masFrecuentes(3));
System.out.println("BECARIO: " + oficios.frecuencia("BECARIO") + ", PILOTO: " + oficios.frecuencia("PILOTO"));
Contador<Integer> productos = new Contador<>();
productos.anadirTodos(List.of(6, 9, 13, 6, 10, 9, 6, 13, 9, 10, 10, 6));
List<Object> distintos = new ArrayList<>();
productos.copiarDistintosEn(distintos);
System.out.println("Productos más pedidos: " + productos.masFrecuentes(2) + " distintos=" + distintos);
}
}
Top 3: [DIRECTOR=5, VENDEDOR=4, ADMINISTRATIVO=3]
BECARIO: 1, PILOTO: 0
Productos más pedidos: [6=4, 9=3] distintos=[6, 9, 13, 10]
El desempate “el que apareció antes” sale gratis de dos propiedades que has visto en el módulo: LinkedHashMap conserva el orden de inserción y List.sort es estable. frecuencia recibe Object (como Map.get o Collection.contains) porque preguntar por un elemento de otro tipo no es un error: simplemente la respuesta es 0.
Ejercicio 7 (propuesto): Cola de prioridad de tareas
Modela tareas como record Tarea(String titulo, int prioridad, LocalDateTime creada) y gestiona una PriorityQueue<Tarea> que atienda primero las de mayor prioridad y, a igualdad, las más antiguas. Implementa un método genérico static <T> List<T> extraerN(PriorityQueue<T> cola, int n) que saque las n primeras. Comprueba qué pasa si recorres la cola con un for-each en lugar de con poll() (¿salen ordenadas?).
Ejercicio 8 (propuesto): Repositorio<T, ID> en memoria
Define la interfaz genérica Repositorio<T, ID> de la sección 2.1 (con guardar, buscarPorId devolviendo Optional<T>, buscarTodos, borrar y contar) e impleméntala con un HashMap en RepositorioMemoria<T, ID>, que recibe en el constructor una Function<T, ID> para obtener el identificador de cada entidad. Úsala con records Empleado y Departamento de EMPRESA.
Ejercicio 9 (propuesto): Índice invertido
Construye un Map<String, NavigableSet<Integer>> que, para cada palabra del nombre de los productos de EMPRESA, guarde los identificadores de los productos que la contienen (por ejemplo, "portátil" → [1, 2, 3]). Usa computeIfAbsent, normaliza las palabras a minúsculas y permite buscar productos que contengan todas las palabras de una consulta (intersección de conjuntos con retainAll).
Ejercicio 10 (propuesto): Inmutabilidad profunda
Crea un record Pedido(int id, List<LineaPedido> lineas) que sea realmente inmutable: copia defensiva en el constructor compacto con List.copyOf y LineaPedido también como record. Escribe un main que demuestre que ni modificando la lista original ni la devuelta por lineas() se altera el pedido. Explica qué pasaría si LineaPedido tuviera un campo StringBuilder.
Ejercicio 11 (propuesto): Estadísticas por departamento con EnumMap
Define enum Departamento { DIRECCION, VENTAS, INFORMATICA, CONTABILIDAD, MARKETING, LOGISTICA, INVESTIGACION } y, con los empleados de EMPRESA, construye un EnumMap<Departamento, DoubleSummaryStatistics> con las estadísticas de salario de cada departamento (incluidos los vacíos). Muéstralo en orden de declaración y después en orden de salario medio descendente.
Ejercicio 12 (propuesto): Pila con capacidad máxima y genéricos acotados
Crea PilaAcotada<E extends Comparable<? super E>> a partir de la Pila de la sección 5, con una capacidad máxima fija (lanza IllegalStateException al superarla) y un método E maximo() que devuelva el mayor elemento en O(1). Pista: mantén una segunda pila con los máximos parciales.
Resumen
| Concepto | Sintaxis | Idea clave |
|---|---|---|
| Clase genérica | class Caja<T> { ... } | Parámetros de tipo tras el nombre |
| Método genérico | static <T> T ultimo(List<T> l) | <T> antes del tipo de retorno; el compilador lo infiere |
| Tipo acotado | <T extends Comparable<? super T>> | Permite usar los métodos de la cota |
| Cota múltiple | <T extends Number & Comparable<T>> | La clase primero, luego interfaces |
| Comodín | List<?> | Lista de algo desconocido |
| Productor | List<? extends T> | Solo leer: Producer Extends |
| Consumidor | List<? super T> | Solo escribir: Consumer Super |
| Borrado de tipos | List<String> → List | Sin new T(), new T[], instanceof List<String> |
| Tipos crudos | List | Prohibidos en código nuevo: heap pollution |
Iterable<E> | Iterator<E> iterator() | Permite el for-each en tus estructuras |
HashMap | Buckets, hashCode + equals | O(1); claves inmutables y contrato coherente |
TreeMap / NavigableMap | floorKey, ceilingEntry, subMap | Ordenado, O(log n), búsquedas por proximidad |
| Caché LRU | new LinkedHashMap<>(16, 0.75f, true) + removeEldestEntry | Orden de acceso |
EnumMap / EnumSet | new EnumMap<>(Estado.class) | Rapidísimos, orden de declaración |
| Inmutable / vista / fija | List.copyOf / unmodifiableList / Arrays.asList | Copia, vista que refleja cambios, tamaño fijo |
| Secuenciadas (Java 21) | getFirst(), getLast(), reversed(), putFirst() | API uniforme para colecciones con orden |
Comparator | comparing(...).thenComparing(...).reversed() | nullsLast, Collator para texto en español |
| Complejidad | ArrayList O(1) get, HashSet O(1) contains, TreeSet O(log n) | Elige según la operación más frecuente |