Java

Tutorial Java: Mapas

Java map HashMap: uso y ejemplos. Aprende a usar el objeto HashMap en Java para mapear datos con ejemplos prácticos.

Aprende Java y certifícate

HashMap: funcionamiento interno y rendimiento

El HashMap es una de las implementaciones más utilizadas de la interfaz Map en Java y proporciona una estructura de datos que almacena pares clave-valor. Su funcionamiento se basa en el concepto de tabla hash, una estructura que permite operaciones de búsqueda, inserción y eliminación en tiempo constante O(1) en el caso promedio.

Estructura interna

Internamente, un HashMap se compone de un array de "buckets" o cubetas, donde cada una puede contener múltiples entradas. Cuando se añade un par clave-valor, se calcula el código hash de la clave para determinar en qué posición del array debe almacenarse.

Map<String, Integer> puntuaciones = new HashMap<>();
puntuaciones.put("Ana", 95);  // Se calcula hash de "Ana" para determinar posición

El proceso interno que sigue un HashMap para almacenar y recuperar valores consta de varios pasos:

1. Se calcula el código hash de la clave mediante el método hashCode() 2. Se aplica una función de dispersión para convertir ese hash en un índice dentro del array 3. Se almacena el par clave-valor en la posición correspondiente

Resolución de colisiones

Las colisiones ocurren cuando dos claves diferentes generan el mismo índice en la tabla hash. Java resuelve este problema mediante el uso de listas enlazadas (hasta Java 8) o árboles binarios (desde Java 8 cuando hay muchas colisiones) para almacenar múltiples entradas en la misma posición.

// Estas dos claves podrían colisionar (aunque es poco probable)
puntuaciones.put("Carlos", 87);
puntuaciones.put("Diana", 92);

A partir de Java 8, cuando una cubeta contiene más de 8 entradas (umbral TREEIFY_THRESHOLD), la lista enlazada se convierte automáticamente en un árbol rojo-negro para mejorar el rendimiento de búsqueda de O(n) a O(log n) en caso de muchas colisiones.

Factor de carga y redimensionamiento

El factor de carga es un parámetro que determina cuándo debe crecer la tabla hash. Por defecto, es 0.75, lo que significa que cuando el 75% de las cubetas están ocupadas, el HashMap aumentará su tamaño (normalmente duplicándolo) y redistribuirá todas las entradas.

// Creación con capacidad inicial y factor de carga personalizados
Map<String, Double> precios = new HashMap<>(100, 0.8f);

Este redimensionamiento es una operación costosa, por lo que si se conoce aproximadamente el número de elementos que se almacenarán, se recomienda especificar una capacidad inicial adecuada para evitar redimensionamientos frecuentes.

Rendimiento de operaciones básicas

Las operaciones fundamentales en un HashMap tienen los siguientes rendimientos:

  • put(K, V): O(1) en promedio, O(n) en el peor caso (muchas colisiones)
  • get(K): O(1) en promedio, O(log n) con árboles en caso de colisiones
  • remove(K): O(1) en promedio, similar a get()
  • containsKey(K): O(1) en promedio, similar a get()
  • containsValue(V): O(n) ya que requiere recorrer todas las entradas
// Operaciones básicas
Map<Integer, String> empleados = new HashMap<>();

// Inserción - O(1) promedio
empleados.put(1001, "Juan Pérez");

// Búsqueda - O(1) promedio
String nombre = empleados.get(1001);

// Comprobación de existencia - O(1) promedio
boolean existe = empleados.containsKey(1001);

// Eliminación - O(1) promedio
empleados.remove(1001);

Iteración sobre un HashMap

Se puede iterar sobre un HashMap de varias formas, cada una con diferentes características de rendimiento:

Map<String, Integer> población = new HashMap<>();
población.put("Madrid", 3223000);
población.put("Barcelona", 1620000);
población.put("Valencia", 791000);

// Iteración sobre entradas (más eficiente)
for (Map.Entry<String, Integer> entrada : población.entrySet()) {
    System.out.println(entrada.getKey() + ": " + entrada.getValue());
}

// Iteración sobre claves (requiere búsqueda adicional para valores)
for (String ciudad : población.keySet()) {
    System.out.println(ciudad + ": " + población.get(ciudad));
}

// Iteración con forEach y expresión lambda
población.forEach((ciudad, habitantes) -> 
    System.out.println(ciudad + " tiene " + habitantes + " habitantes"));

La iteración con entrySet() es más eficiente que usar keySet() y luego get(), ya que evita búsquedas adicionales.

Consideraciones de rendimiento

Para obtener el mejor rendimiento de un HashMap, se deben tener en cuenta varios factores:

  • Implementación correcta de hashCode() y equals(): Si se utilizan objetos personalizados como claves, es crucial implementar estos métodos correctamente.
public class Producto {
    private String código;
    private String nombre;
    
    // Constructor y otros métodos...
    
    @Override
    public int hashCode() {
        return Objects.hash(código);
    }
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Producto producto = (Producto) obj;
        return Objects.equals(código, producto.código);
    }
}
  • Capacidad inicial adecuada: Si se conoce aproximadamente el tamaño final, se puede mejorar el rendimiento estableciendo una capacidad inicial apropiada.
  • Claves inmutables: Se recomienda usar claves inmutables para evitar comportamientos inesperados, ya que si una clave cambia después de ser insertada, puede no encontrarse posteriormente.

Comparación con otros mapas

El HashMap ofrece el mejor rendimiento general para operaciones de búsqueda, inserción y eliminación cuando no se requiere un orden específico. Sin embargo, tiene algunas limitaciones:

  • No mantiene ningún orden de los elementos
  • No es sincronizado (no es seguro para múltiples hilos)
  • Permite una única clave nula y múltiples valores nulos
// HashMap no sincronizado (no seguro para múltiples hilos)
Map<String, Integer> mapa = new HashMap<>();

// Versión sincronizada (thread-safe pero menos eficiente)
Map<String, Integer> mapaSincronizado = Collections.synchronizedMap(new HashMap<>());

Casos de uso óptimos

El HashMap es ideal para:

  • Cachés de datos: Almacenamiento temporal de información con acceso rápido por clave
  • Conteo de frecuencias: Registro de ocurrencias de elementos
  • Indexación de datos: Acceso rápido a información mediante identificadores únicos
  • Eliminación de duplicados: Almacenamiento de elementos únicos con verificación rápida
// Ejemplo: Conteo de frecuencia de palabras
String texto = "Java es un lenguaje de programación Java es versátil";
Map<String, Integer> frecuencias = new HashMap<>();

for (String palabra : texto.split(" ")) {
    frecuencias.put(palabra, frecuencias.getOrDefault(palabra, 0) + 1);
}

// Resultado: {Java=2, es=2, un=1, lenguaje=1, de=1, programación=1, versátil=1}

TreeMap: mapas ordenados por clave

El TreeMap es una implementación de la interfaz Map en Java que mantiene sus elementos ordenados según el orden natural de las claves o mediante un comparador personalizado. A diferencia del HashMap, que prioriza el rendimiento en operaciones básicas, el TreeMap sacrifica algo de velocidad para garantizar un orden predecible de sus elementos.

Estructura interna y funcionamiento

Internamente, el TreeMap se implementa utilizando un árbol rojo-negro, una estructura de datos de árbol binario balanceado que garantiza operaciones de búsqueda, inserción y eliminación en tiempo logarítmico O(log n). Esta estructura permite mantener las claves ordenadas automáticamente.

// Creación de un TreeMap con orden natural
Map<String, Integer> calificaciones = new TreeMap<>();
calificaciones.put("Carlos", 85);
calificaciones.put("Ana", 92);
calificaciones.put("Beatriz", 78);
calificaciones.put("David", 90);

// Al iterar, las claves aparecerán en orden alfabético: Ana, Beatriz, Carlos, David
calificaciones.forEach((nombre, nota) -> 
    System.out.println(nombre + ": " + nota));

Cuando se añade un nuevo par clave-valor, el TreeMap utiliza la comparación entre claves para determinar la posición exacta donde debe insertarse el elemento, manteniendo así el árbol balanceado y ordenado en todo momento.

Ordenación de claves

El TreeMap ofrece dos mecanismos para ordenar las claves:

  • Orden natural: Las claves deben implementar la interfaz Comparable
  • Comparador personalizado: Se proporciona un objeto Comparator al crear el TreeMap
// Usando el orden natural (String implementa Comparable)
TreeMap<String, Double> divisas = new TreeMap<>();
divisas.put("USD", 1.0);
divisas.put("EUR", 1.08);
divisas.put("GBP", 1.27);

// Usando un comparador personalizado (orden inverso)
TreeMap<String, Double> divisasInvertidas = new TreeMap<>(Comparator.reverseOrder());
divisasInvertidas.put("USD", 1.0);
divisasInvertidas.put("EUR", 1.08);
divisasInvertidas.put("GBP", 1.27);

Para utilizar clases personalizadas como claves en un TreeMap, estas deben implementar la interfaz Comparable o se debe proporcionar un Comparator específico:

public class Producto implements Comparable<Producto> {
    private String nombre;
    private double precio;
    
    // Constructor y getters
    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }
    
    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }
    
    @Override
    public int compareTo(Producto otro) {
        // Ordenar por nombre
        return this.nombre.compareTo(otro.nombre);
    }
    
    @Override
    public String toString() {
        return nombre + " (" + precio + "€)";
    }
}

// Usando la implementación Comparable
TreeMap<Producto, Integer> inventario = new TreeMap<>();
inventario.put(new Producto("Laptop", 899.99), 15);
inventario.put(new Producto("Teclado", 49.99), 30);

// Usando un comparador personalizado (ordenar por precio)
TreeMap<Producto, Integer> porPrecio = new TreeMap<>((p1, p2) -> 
    Double.compare(p1.getPrecio(), p2.getPrecio()));
porPrecio.put(new Producto("Laptop", 899.99), 15);
porPrecio.put(new Producto("Teclado", 49.99), 30);

Métodos específicos de navegación

Una de las principales ventajas del TreeMap es que ofrece métodos de navegación que no están disponibles en otras implementaciones de Map:

TreeMap<Integer, String> empleados = new TreeMap<>();
empleados.put(1001, "Ana García");
empleados.put(1005, "Carlos López");
empleados.put(1010, "Elena Martín");
empleados.put(1015, "David Sánchez");
empleados.put(1020, "Beatriz Ruiz");

// Obtener la entrada con la clave más baja
Map.Entry<Integer, String> primera = empleados.firstEntry();  // 1001=Ana García

// Obtener la entrada con la clave más alta
Map.Entry<Integer, String> última = empleados.lastEntry();  // 1020=Beatriz Ruiz

// Obtener la entrada con la clave inmediatamente inferior a la dada
Map.Entry<Integer, String> anterior = empleados.lowerEntry(1010);  // 1005=Carlos López

// Obtener la entrada con la clave inmediatamente superior a la dada
Map.Entry<Integer, String> siguiente = empleados.higherEntry(1010);  // 1015=David Sánchez

// Obtener todas las entradas con claves menores que la especificada
SortedMap<Integer, String> subMapa = empleados.headMap(1010);  // {1001=Ana García, 1005=Carlos López}

// Obtener todas las entradas con claves mayores o iguales a la especificada
SortedMap<Integer, String> subMapaDesde = empleados.tailMap(1010);  // {1010=Elena Martín, 1015=David Sánchez, 1020=Beatriz Ruiz}

// Obtener un rango de entradas (desde inclusivo, hasta exclusivo)
SortedMap<Integer, String> rango = empleados.subMap(1005, 1015);  // {1005=Carlos López, 1010=Elena Martín}

Estos métodos de navegación hacen que el TreeMap sea útil para aplicaciones que requieren acceso ordenado a los datos o necesitan encontrar elementos cercanos a un valor dado.

Rendimiento comparado con HashMap

El TreeMap ofrece operaciones con un rendimiento predecible pero menos eficiente que HashMap:

  • put(K, V): O(log n) vs O(1) en HashMap
  • get(K): O(log n) vs O(1) en HashMap
  • remove(K): O(log n) vs O(1) en HashMap
  • containsKey(K): O(log n) vs O(1) en HashMap
// Medición simple de rendimiento
Map<Integer, String> hashMap = new HashMap<>();
Map<Integer, String> treeMap = new TreeMap<>();

long inicio, fin;

// Inserción en HashMap
inicio = System.nanoTime();
for (int i = 0; i < 100000; i++) {
    hashMap.put(i, "Valor " + i);
}
fin = System.nanoTime();
System.out.println("Tiempo de inserción en HashMap: " + (fin - inicio) / 1000000 + " ms");

// Inserción en TreeMap
inicio = System.nanoTime();
for (int i = 0; i < 100000; i++) {
    treeMap.put(i, "Valor " + i);
}
fin = System.nanoTime();
System.out.println("Tiempo de inserción en TreeMap: " + (fin - inicio) / 1000000 + " ms");

La diferencia de rendimiento se hace más notable a medida que aumenta el tamaño del mapa, pero para colecciones pequeñas o medianas, el impacto puede ser aceptable si se necesita la funcionalidad de ordenación.

Casos de uso óptimos

El TreeMap es la elección ideal cuando se necesita:

  • Datos ordenados: Mantener elementos ordenados por clave
  • Búsqueda por rango: Encontrar todos los elementos dentro de un rango de claves
  • Acceso secuencial: Procesar elementos en orden
  • Búsqueda del "vecino más cercano": Encontrar elementos con claves cercanas a un valor dado
// Ejemplo: Sistema de reservas por fecha
TreeMap<LocalDate, List<String>> reservas = new TreeMap<>();

// Añadir algunas reservas
reservas.put(LocalDate.of(2023, 11, 15), List.of("Juan Pérez", "Ana García"));
reservas.put(LocalDate.of(2023, 11, 20), List.of("Carlos López"));
reservas.put(LocalDate.of(2023, 12, 5), List.of("Elena Martín", "David Sánchez"));
reservas.put(LocalDate.of(2023, 12, 10), List.of("Beatriz Ruiz"));

// Obtener todas las reservas de noviembre
LocalDate inicioNoviembre = LocalDate.of(2023, 11, 1);
LocalDate finNoviembre = LocalDate.of(2023, 12, 1);
SortedMap<LocalDate, List<String>> reservasNoviembre = reservas.subMap(inicioNoviembre, finNoviembre);

// Encontrar la próxima fecha disponible después de hoy
LocalDate hoy = LocalDate.now();
Map.Entry<LocalDate, List<String>> proximaReserva = reservas.ceilingEntry(hoy);

Consideraciones importantes

Al trabajar con TreeMap, se deben tener en cuenta algunas consideraciones específicas:

  • Consistencia del comparador: El orden definido por el comparador debe ser consistente con equals. Si dos objetos son iguales según equals(), su comparación debe devolver 0.
  • Claves inmutables: Al igual que con HashMap, se recomienda usar claves inmutables para evitar comportamientos inesperados.
  • Null no permitido en claves: A diferencia de HashMap, TreeMap no permite claves nulas (a menos que se use un comparador que las maneje explícitamente).
// Esto lanzará NullPointerException
TreeMap<String, Integer> mapa = new TreeMap<>();
mapa.put(null, 100);  // Error: no se permiten claves nulas

// Esto funciona bien
HashMap<String, Integer> hashMapa = new HashMap<>();
hashMapa.put(null, 100);  // Permitido en HashMap

LinkedHashMap: mapas con orden de inserción

El LinkedHashMap representa una implementación híbrida dentro del framework Collections de Java que combina las características de un HashMap con una lista doblemente enlazada. Esta estructura mantiene un registro del orden de inserción de los elementos, ofreciendo un equilibrio entre la eficiencia de acceso del HashMap y la capacidad de preservar el orden de los elementos.

Map<String, Integer> puntuaciones = new LinkedHashMap<>();
puntuaciones.put("Ana", 95);
puntuaciones.put("Carlos", 87);
puntuaciones.put("Beatriz", 92);

// Al iterar, los elementos aparecerán en el orden de inserción: Ana, Carlos, Beatriz
puntuaciones.forEach((nombre, puntuación) -> 
    System.out.println(nombre + ": " + puntuación));

Estructura interna

Internamente, el LinkedHashMap mantiene dos estructuras:

  • Una tabla hash similar a la de HashMap para acceso rápido por clave
  • Una lista doblemente enlazada que conecta todas las entradas en su orden de inserción

Cuando se añade un nuevo elemento, además de colocarlo en la tabla hash, se añade al final de la lista enlazada. Esta estructura dual permite que las operaciones de búsqueda mantengan la eficiencia O(1) en promedio, mientras que la iteración refleja el orden en que se insertaron los elementos.

Modos de ordenación

El LinkedHashMap ofrece dos modos de ordenación que se pueden configurar al momento de su creación:

// Modo predeterminado: orden de inserción
Map<String, Double> preciosInserción = new LinkedHashMap<>();

// Modo de acceso: orden de acceso más reciente (LRU)
Map<String, Double> preciosAcceso = new LinkedHashMap<>(16, 0.75f, true);

El tercer parámetro del constructor (accessOrder) determina el comportamiento:

  • false (predeterminado): mantiene el orden de inserción
  • true: mantiene el orden de acceso, reordenando los elementos cuando se accede a ellos

Orden de inserción vs orden de acceso

El modo de orden de inserción es útil cuando se necesita mantener la secuencia exacta en que se añadieron los elementos:

Map<String, String> capitales = new LinkedHashMap<>();
capitales.put("España", "Madrid");
capitales.put("Francia", "París");
capitales.put("Italia", "Roma");

// Iteración en orden de inserción: España, Francia, Italia
for (Map.Entry<String, String> entrada : capitales.entrySet()) {
    System.out.println(entrada.getKey() + ": " + entrada.getValue());
}

El modo de orden de acceso (también conocido como LRU - Least Recently Used) reordena los elementos cada vez que se accede a ellos mediante get() o put(), moviendo el elemento accedido al final de la lista:

// Creación con orden de acceso (LRU)
Map<String, Integer> cacheLRU = new LinkedHashMap<>(16, 0.75f, true);
cacheLRU.put("A", 1);
cacheLRU.put("B", 2);
cacheLRU.put("C", 3);

// Acceso a un elemento
cacheLRU.get("A");  // Esto mueve "A" al final

// Ahora el orden es: B, C, A
cacheLRU.forEach((k, v) -> System.out.println(k + ": " + v));

Implementación de caché LRU

Una de las aplicaciones más comunes del LinkedHashMap en modo de acceso es la implementación de un caché con política LRU (Least Recently Used), que automáticamente descarta los elementos menos utilizados cuando se alcanza un tamaño máximo:

public class CacheLRU<K, V> extends LinkedHashMap<K, V> {
    private final int capacidadMaxima;
    
    public CacheLRU(int capacidadMaxima) {
        super(16, 0.75f, true);  // Orden de acceso activado
        this.capacidadMaxima = capacidadMaxima;
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // Elimina la entrada más antigua cuando se supera la capacidad
        return size() > capacidadMaxima;
    }
}

// Uso del caché LRU
CacheLRU<String, byte[]> imagenCache = new CacheLRU<>(100);
imagenCache.put("imagen1.jpg", new byte[1024]);
// Cuando se supere la capacidad, se eliminarán automáticamente 
// las imágenes menos accedidas recientemente

El método removeEldestEntry() se invoca automáticamente después de cada operación put() o putAll(), permitiendo decidir si se debe eliminar la entrada más antigua (la que está al principio de la lista enlazada).

Rendimiento y características

El LinkedHashMap mantiene un rendimiento similar al HashMap para las operaciones básicas:

  • put(K, V): O(1) en promedio
  • get(K): O(1) en promedio
  • remove(K): O(1) en promedio
  • containsKey(K): O(1) en promedio

Sin embargo, consume algo más de memoria debido a la lista enlazada adicional que mantiene. Cada entrada en un LinkedHashMap requiere referencias adicionales para los nodos anterior y siguiente en la lista.

// Comparación de rendimiento en iteración
Map<Integer, String> hashMap = new HashMap<>();
Map<Integer, String> linkedHashMap = new LinkedHashMap<>();

// Llenamos ambos mapas con los mismos datos
for (int i = 0; i < 1000; i++) {
    int clave = (int)(Math.random() * 10000);
    String valor = "Valor " + clave;
    hashMap.put(clave, valor);
    linkedHashMap.put(clave, valor);
}

// Iteración sobre HashMap (orden impredecible)
long inicio = System.nanoTime();
for (Map.Entry<Integer, String> entrada : hashMap.entrySet()) {
    // Procesamiento
}
long finHashMap = System.nanoTime();

// Iteración sobre LinkedHashMap (orden predecible)
for (Map.Entry<Integer, String> entrada : linkedHashMap.entrySet()) {
    // Procesamiento
}
long finLinkedHashMap = System.nanoTime();

// La iteración en LinkedHashMap puede ser ligeramente más rápida
// debido a que utiliza la lista enlazada en lugar de recorrer la tabla hash

Casos de uso prácticos

El LinkedHashMap es útil en los siguientes escenarios:

  • Mantenimiento del orden de inserción: Cuando se necesita preservar el orden en que se añadieron los elementos.
// Registro de eventos en orden cronológico
Map<LocalDateTime, String> registroEventos = new LinkedHashMap<>();
registroEventos.put(LocalDateTime.now(), "Inicio de sesión");
registroEventos.put(LocalDateTime.now().plusMinutes(5), "Consulta de datos");
registroEventos.put(LocalDateTime.now().plusMinutes(10), "Cierre de sesión");

// Los eventos se mostrarán en orden cronológico
  • Cachés con política LRU: Para implementar cachés que descartan automáticamente los elementos menos utilizados.
// Caché de resultados de consultas a base de datos
Map<String, List<Object>> resultadosCache = new LinkedHashMap<>(100, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, List<Object>> eldest) {
        return size() > 1000;  // Limitar a 1000 consultas en caché
    }
};
  • Menús y configuraciones: Para mantener el orden visual de elementos en interfaces de usuario.
// Menú de opciones que mantiene el orden definido
Map<String, Runnable> opcionesMenu = new LinkedHashMap<>();
opcionesMenu.put("Nuevo", () -> System.out.println("Creando nuevo documento..."));
opcionesMenu.put("Abrir", () -> System.out.println("Abriendo documento..."));
opcionesMenu.put("Guardar", () -> System.out.println("Guardando documento..."));
opcionesMenu.put("Imprimir", () -> System.out.println("Imprimiendo documento..."));
  • Historial de navegación: Para mantener un registro ordenado de páginas visitadas.
// Historial de navegación web
LinkedHashMap<String, LocalDateTime> historial = new LinkedHashMap<>();
historial.put("https://www.ejemplo.com/inicio", LocalDateTime.now());
historial.put("https://www.ejemplo.com/productos", LocalDateTime.now().plusMinutes(2));
historial.put("https://www.ejemplo.com/contacto", LocalDateTime.now().plusMinutes(5));

Comparación con otras implementaciones de Map

El LinkedHashMap ocupa una posición intermedia entre HashMap y TreeMap:

  • HashMap: Máxima eficiencia, sin garantía de orden
  • LinkedHashMap: Buena eficiencia, orden de inserción o acceso
  • TreeMap: Menor eficiencia, orden natural o personalizado de las claves
// Ejemplo comparativo de comportamiento
Map<String, Integer> hashMap = new HashMap<>();
Map<String, Integer> linkedHashMap = new LinkedHashMap<>();
Map<String, Integer> treeMap = new TreeMap<>();

// Añadimos elementos en el mismo orden a los tres mapas
String[] claves = {"C", "A", "B"};
for (String clave : claves) {
    hashMap.put(clave, clave.hashCode());
    linkedHashMap.put(clave, clave.hashCode());
    treeMap.put(clave, clave.hashCode());
}

// HashMap: orden impredecible basado en hash
// LinkedHashMap: C, A, B (orden de inserción)
// TreeMap: A, B, C (orden alfabético)

Consideraciones de uso

Al utilizar LinkedHashMap, se deben tener en cuenta algunas consideraciones:

  • Mayor consumo de memoria: Requiere más memoria que HashMap debido a la lista enlazada adicional.
  • Sincronización: Al igual que HashMap, no es sincronizado por defecto. Para uso concurrente, se debe envolver con Collections.synchronizedMap().
Map<String, Object> mapaSeguro = Collections.synchronizedMap(new LinkedHashMap<>());
  • Claves nulas: Permite una única clave nula, al igual que HashMap.
  • Valores nulos: Permite múltiples valores nulos.

Patrones comunes de uso y operaciones avanzadas

Los mapas en Java ofrecen una versatilidad que va más allá de las operaciones básicas de inserción y recuperación.

Combinación de mapas

La combinación de mapas es una operación frecuente cuando se trabaja con múltiples fuentes de datos. Se puede realizar de varias formas según los requisitos específicos:

Map<String, Integer> mapaBase = new HashMap<>();
mapaBase.put("A", 1);
mapaBase.put("B", 2);

Map<String, Integer> mapaAdicional = new HashMap<>();
mapaAdicional.put("B", 3);
mapaAdicional.put("C", 4);

// Método 1: putAll (sobrescribe valores existentes)
Map<String, Integer> resultado1 = new HashMap<>(mapaBase);
resultado1.putAll(mapaAdicional);
// resultado1: {A=1, B=3, C=4}

// Método 2: Combinación selectiva con lógica personalizada
Map<String, Integer> resultado2 = new HashMap<>(mapaBase);
mapaAdicional.forEach((clave, valor) -> 
    resultado2.merge(clave, valor, (valorExistente, nuevoValor) -> 
        Math.max(valorExistente, nuevoValor)));
// resultado2: {A=1, B=3, C=4}

El método merge() es particularmente útil para combinar mapas con lógica personalizada, permitiendo decidir cómo manejar las colisiones de claves.

Mapas bidireccionales

En ocasiones se necesita buscar tanto por clave como por valor. Aunque Java no proporciona una implementación estándar de mapa bidireccional, se puede crear fácilmente:

public class MapaBidireccional<K, V> {
    private final Map<K, V> adelante = new HashMap<>();
    private final Map<V, K> inverso = new HashMap<>();
    
    public void put(K clave, V valor) {
        // Eliminar mapeos antiguos si existen
        if (adelante.containsKey(clave)) {
            V valorAntiguo = adelante.get(clave);
            inverso.remove(valorAntiguo);
        }
        if (inverso.containsKey(valor)) {
            K claveAntigua = inverso.get(valor);
            adelante.remove(claveAntigua);
        }
        
        adelante.put(clave, valor);
        inverso.put(valor, clave);
    }
    
    public V getByKey(K clave) {
        return adelante.get(clave);
    }
    
    public K getByValue(V valor) {
        return inverso.get(valor);
    }
    
    public void remove(K clave) {
        if (adelante.containsKey(clave)) {
            V valor = adelante.get(clave);
            adelante.remove(clave);
            inverso.remove(valor);
        }
    }
    
    public void removeByValue(V valor) {
        if (inverso.containsKey(valor)) {
            K clave = inverso.get(valor);
            inverso.remove(valor);
            adelante.remove(clave);
        }
    }
}

Este patrón es útil en casos como traducciones, mapeos de IDs a nombres, o cualquier situación donde se necesite buscar en ambas direcciones.

Mapas multivalor

A veces se necesita asociar múltiples valores a una misma clave. Este patrón se implementa típicamente usando un mapa cuyo valor es una colección:

// Mapa de estudiantes por curso
Map<String, List<String>> estudiantesPorCurso = new HashMap<>();

// Añadir estudiantes a cursos
public void inscribirEstudiante(String curso, String estudiante) {
    estudiantesPorCurso.computeIfAbsent(curso, k -> new ArrayList<>())
                      .add(estudiante);
}

// Uso
inscribirEstudiante("Programación", "Ana García");
inscribirEstudiante("Programación", "Carlos López");
inscribirEstudiante("Matemáticas", "Ana García");

// Obtener todos los estudiantes de un curso
List<String> estudiantesProgramacion = estudiantesPorCurso.getOrDefault("Programación", Collections.emptyList());

El método computeIfAbsent() es fundamental en este patrón, ya que crea la lista automáticamente si la clave no existe, evitando comprobaciones manuales de nulidad.

Contadores y acumuladores

Los mapas son ideales para implementar contadores y acumuladores, especialmente con los métodos funcionales introducidos en versiones recientes de Java:

// Contar frecuencia de palabras en un texto
String texto = "el perro persigue al gato y el gato corre";
Map<String, Integer> frecuencias = new HashMap<>();

for (String palabra : texto.split(" ")) {
    frecuencias.merge(palabra, 1, Integer::sum);
}
// frecuencias: {perro=1, el=2, persigue=1, al=1, gato=2, y=1, corre=1}

// Acumular valores por categoría
List<Venta> ventas = List.of(
    new Venta("Electrónica", 1200.0),
    new Venta("Ropa", 450.0),
    new Venta("Electrónica", 800.0),
    new Venta("Alimentación", 120.0)
);

Map<String, Double> ventasPorCategoria = new HashMap<>();
for (Venta venta : ventas) {
    ventasPorCategoria.merge(venta.getCategoria(), venta.getMonto(), Double::sum);
}
// ventasPorCategoria: {Electrónica=2000.0, Ropa=450.0, Alimentación=120.0}

El método merge() simplifica enormemente este patrón, eliminando la necesidad de verificaciones condicionales explícitas.

Mapas anidados

Para representar estructuras de datos jerárquicas, los mapas anidados son una solución:

// Mapa de provincias -> ciudades -> códigos postales
Map<String, Map<String, List<String>>> geografía = new HashMap<>();

// Añadir un código postal
public void añadirCódigoPostal(String provincia, String ciudad, String códigoPostal) {
    geografía.computeIfAbsent(provincia, k -> new HashMap<>())
            .computeIfAbsent(ciudad, k -> new ArrayList<>())
            .add(códigoPostal);
}

// Uso
añadirCódigoPostal("Madrid", "Madrid", "28001");
añadirCódigoPostal("Madrid", "Madrid", "28002");
añadirCódigoPostal("Madrid", "Alcobendas", "28100");
añadirCódigoPostal("Barcelona", "Barcelona", "08001");

// Obtener todos los códigos postales de una ciudad
List<String> códigosMadrid = geografía.getOrDefault("Madrid", Collections.emptyMap())
                                    .getOrDefault("Madrid", Collections.emptyList());

Este patrón es muy útil para representar relaciones jerárquicas, pero requiere cuidado para evitar excepciones NullPointerException al navegar por la estructura.

Operaciones atómicas con compute

Los métodos compute(), computeIfAbsent() y computeIfPresent() permiten realizar operaciones atómicas sobre los valores de un mapa:

Map<String, Integer> inventario = new HashMap<>();
inventario.put("Laptop", 5);
inventario.put("Teclado", 10);

// Incrementar stock (seguro incluso si el producto no existe)
String producto = "Laptop";
int cantidadRecibida = 3;
inventario.compute(producto, (k, v) -> (v == null) ? cantidadRecibida : v + cantidadRecibida);
// inventario: {Laptop=8, Teclado=10}

// Decrementar stock con validación
boolean realizarVenta(String producto, int cantidad) {
    return inventario.computeIfPresent(producto, (k, stockActual) -> {
        if (stockActual >= cantidad) {
            return stockActual - cantidad;
        }
        // Si no hay suficiente stock, devolver el valor actual sin cambios
        return stockActual;
    }) != null;
}

// Inicializar valores por defecto
inventario.computeIfAbsent("Ratón", k -> 0);
// inventario: {Laptop=8, Teclado=10, Ratón=0}

Estos métodos son útiles en entornos concurrentes, ya que garantizan que la lectura y actualización del valor se realicen como una operación atómica.

Filtrado y transformación de mapas

Aunque los mapas no implementan directamente la interfaz Stream, se pueden filtrar y transformar utilizando sus vistas:

Map<String, Double> precios = new HashMap<>();
precios.put("Laptop", 899.99);
precios.put("Teclado", 49.99);
precios.put("Ratón", 29.99);
precios.put("Monitor", 299.99);

// Filtrar productos caros (>100€)
Map<String, Double> productosCostosos = precios.entrySet().stream()
    .filter(entry -> entry.getValue() > 100.0)
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue
    ));
// productosCostosos: {Laptop=899.99, Monitor=299.99}

// Aplicar descuento del 10% a todos los productos
Map<String, Double> preciosConDescuento = precios.entrySet().stream()
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        entry -> entry.getValue() * 0.9
    ));
// preciosConDescuento: {Laptop=809.99, Teclado=44.99, Ratón=26.99, Monitor=269.99}

Este patrón permite aplicar toda la potencia de la API Stream a los mapas, facilitando operaciones complejas de filtrado y transformación.

Mapas inmutables y vistas no modificables

Para garantizar la integridad de los datos, a menudo se necesitan mapas inmutables o vistas no modificables:

// Crear mapa inmutable
Map<String, Integer> configuración = Map.of(
    "maxConexiones", 100,
    "tiempoEspera", 30,
    "intentosReconexión", 3
);

// Convertir mapa existente en inmutable
Map<String, Double> tasasCambio = new HashMap<>();
tasasCambio.put("USD", 1.0);
tasasCambio.put("EUR", 0.92);
tasasCambio.put("GBP", 0.78);

Map<String, Double> tasasInmutables = Collections.unmodifiableMap(tasasCambio);

// Crear vista no modificable (cambios en el original se reflejan en la vista)
Map<String, Integer> estadísticas = new HashMap<>();
estadísticas.put("visitas", 1000);
estadísticas.put("descargas", 250);

Map<String, Integer> estadísticasVista = Collections.unmodifiableMap(estadísticas);
estadísticas.put("compras", 50);  // Se refleja en estadísticasVista

La diferencia clave es que un mapa inmutable no puede cambiar nunca, mientras que una vista no modificable refleja los cambios realizados en el mapa original pero no permite modificaciones directas.

Sincronización y concurrencia

Para escenarios concurrentes, Java ofrece varias opciones:

// Mapa sincronizado (thread-safe pero con bloqueo completo)
Map<String, Object> mapaSincronizado = Collections.synchronizedMap(new HashMap<>());

// ConcurrentHashMap (mejor rendimiento en concurrencia)
Map<String, Object> mapaConcurrente = new ConcurrentHashMap<>();

// Operaciones atómicas en ConcurrentHashMap
ConcurrentHashMap<String, AtomicInteger> contadores = new ConcurrentHashMap<>();
contadores.put("visitas", new AtomicInteger(0));

// Incremento atómico
contadores.get("visitas").incrementAndGet();

// Operación compuesta atómica
contadores.compute("visitas", (k, v) -> 
    v == null ? new AtomicInteger(1) : new AtomicInteger(v.get() + 1));

ConcurrentHashMap ofrece mejor rendimiento en escenarios de alta concurrencia al utilizar bloqueos a nivel de segmento en lugar de bloquear todo el mapa.

Expiración y caché avanzado

Para implementar cachés con expiración, se puede extender el patrón LRU de LinkedHashMap:

public class CacheConExpiración<K, V> {
    private final Map<K, V> cache;
    private final Map<K, Long> tiemposExpiración;
    private final long tiempoVidaMs;
    
    public CacheConExpiración(int capacidad, long tiempoVidaMs) {
        this.cache = new LinkedHashMap<K, V>(capacidad, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacidad;
            }
        };
        this.tiemposExpiración = new HashMap<>();
        this.tiempoVidaMs = tiempoVidaMs;
    }
    
    public synchronized V get(K clave) {
        limpiarExpirados();
        
        if (!cache.containsKey(clave)) {
            return null;
        }
        
        Long tiempoExpiracion = tiemposExpiración.get(clave);
        if (tiempoExpiracion != null && System.currentTimeMillis() > tiempoExpiracion) {
            cache.remove(clave);
            tiemposExpiración.remove(clave);
            return null;
        }
        
        return cache.get(clave);
    }
    
    public synchronized void put(K clave, V valor) {
        limpiarExpirados();
        
        cache.put(clave, valor);
        tiemposExpiración.put(clave, System.currentTimeMillis() + tiempoVidaMs);
    }
    
    private void limpiarExpirados() {
        long ahora = System.currentTimeMillis();
        Iterator<Map.Entry<K, Long>> it = tiemposExpiración.entrySet().iterator();
        
        while (it.hasNext()) {
            Map.Entry<K, Long> entrada = it.next();
            if (entrada.getValue() < ahora) {
                cache.remove(entrada.getKey());
                it.remove();
            }
        }
    }
}

Este patrón es esencial para aplicaciones que necesitan gestionar recursos con tiempo de vida limitado, como sesiones de usuario o resultados de consultas costosas.

Mapas como índices invertidos

Los mapas son ideales para implementar índices invertidos, útiles en búsquedas de texto y sistemas de recuperación de información:

// Índice invertido: palabra -> documentos que la contienen
Map<String, Set<Integer>> índice = new HashMap<>();

// Indexar documentos
void indexarDocumento(int idDocumento, String contenido) {
    String[] palabras = contenido.toLowerCase().split("\\W+");
    for (String palabra : palabras) {
        if (!palabra.isEmpty()) {
            índice.computeIfAbsent(palabra, k -> new HashSet<>())
                 .add(idDocumento);
        }
    }
}

// Buscar documentos que contienen todas las palabras
Set<Integer> buscar(String consulta) {
    String[] términos = consulta.toLowerCase().split("\\W+");
    if (términos.length == 0 || términos[0].isEmpty()) {
        return Collections.emptySet();
    }
    
    // Comenzar con los documentos del primer término
    Set<Integer> resultado = new HashSet<>(
        índice.getOrDefault(términos[0], Collections.emptySet())
    );
    
    // Intersectar con los documentos de los demás términos
    for (int i = 1; i < términos.length; i++) {
        resultado.retainAll(índice.getOrDefault(términos[i], Collections.emptySet()));
        if (resultado.isEmpty()) {
            break;  // Optimización: si no hay resultados, terminar
        }
    }
    
    return resultado;
}

Este patrón es la base de los motores de búsqueda y sistemas de recuperación de información, permitiendo búsquedas eficientes en grandes volúmenes de texto.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

Plan mensual

19.00 € /mes

Precio normal mensual: 19 €
47 % DE DESCUENTO

Plan anual

10.00 € /mes

Ahorras 108 € al año
Precio normal anual: 120 €
Aprende Java online

Ejercicios de esta lección Mapas

Evalúa tus conocimientos de esta lección Mapas con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Clases abstractas

Test

Listas

Código

Métodos de la clase String

Código

Streams: reduce()

Test

Polimorfismo

Código

Pattern Matching

Código

Streams: flatMap()

Test

Llamada y sobrecarga de funciones

Puzzle

Métodos referenciados

Test

Métodos de la clase String

Código

Representación de Fecha

Puzzle

Operadores lógicos

Test

Inferencia de tipos con var

Código

Tipos de datos

Código

Estructuras de iteración

Puzzle

Streams: forEach()

Test

Objetos

Puzzle

Funciones lambda

Test

Uso de Scanner

Puzzle

CRUD en Java de modelo Customer sobre un ArrayList

Proyecto

Tipos de variables

Puzzle

Streams: collect()

Puzzle

Operadores aritméticos

Puzzle

Arrays y matrices

Código

Clases y objetos

Código

Interfaz funcional Consumer

Test

Interfaces

Código

Enumeraciones Enums

Código

API java.nio 2

Puzzle

API Optional

Test

Interfaz funcional Function

Test

Encapsulación

Test

Interfaces

Código

Uso de API Optional

Puzzle

Representación de Hora

Test

Herencia básica

Test

Clases y objetos

Código

Interfaz funcional Supplier

Puzzle

HashMap

Puzzle

Sobrecarga de métodos

Test

Polimorfismo de tiempo de ejecución

Puzzle

OOP en Java

Proyecto

Sobrecarga de métodos

Código

Clases sealed

Código

Creación de Streams

Test

Records

Código

Encapsulación

Código

Streams: min max

Puzzle

Métodos avanzados de la clase String

Puzzle

Funciones

Código

Polimorfismo de tiempo de compilación

Test

Reto sintaxis Java

Proyecto

Conjuntos

Código

Estructuras de control

Código

Recursión

Código

Excepciones

Puzzle

Herencia avanzada

Puzzle

Estructuras de selección

Test

Uso de interfaces

Test

Operadores

Código

Variables

Código

HashSet

Test

Objeto Scanner

Test

Streams: filter()

Puzzle

Operaciones de Streams

Puzzle

Interfaz funcional Predicate

Puzzle

Streams: sorted()

Test

Configuración de entorno

Test

CRUD en Java de modelo Customer sobre un HashMap

Proyecto

Uso de variables

Test

Clases

Test

Streams: distinct()

Puzzle

Streams: count()

Test

ArrayList

Test

Datos de referencia

Test

Interfaces funcionales

Puzzle

Métodos básicos de la clase String

Test

Tipos de datos

Código

Clases abstractas

Código

Instalación

Test

Funciones

Código

Excepciones

Código

Estructuras de control

Código

Herencia de clases

Código

La clase Scanner

Código

Generics

Código

Streams: map()

Puzzle

Funciones y encapsulamiento

Test

Streams: match

Test

Gestión de errores y excepciones

Código

Datos primitivos

Puzzle

Todas las lecciones de Java

Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Instalación De Java

Introducción Y Entorno

Configuración De Entorno Java

Introducción Y Entorno

Tipos De Datos

Sintaxis

Variables

Sintaxis

Operadores

Sintaxis

Estructuras De Control

Sintaxis

Funciones

Sintaxis

Recursión

Sintaxis

Excepciones

Programación Orientada A Objetos

Clases Y Objetos

Programación Orientada A Objetos

Encapsulación

Programación Orientada A Objetos

Herencia

Programación Orientada A Objetos

Clases Abstractas

Programación Orientada A Objetos

Interfaces

Programación Orientada A Objetos

Sobrecarga De Métodos

Programación Orientada A Objetos

Polimorfismo

Programación Orientada A Objetos

La Clase Scanner

Programación Orientada A Objetos

Métodos De La Clase String

Programación Orientada A Objetos

Records

Programación Orientada A Objetos

Pattern Matching

Programación Orientada A Objetos

Inferencia De Tipos Con Var

Programación Orientada A Objetos

Enumeraciones Enums

Programación Orientada A Objetos

Generics

Programación Orientada A Objetos

Clases Sealed

Programación Orientada A Objetos

Listas

Framework Collections

Conjuntos

Framework Collections

Mapas

Framework Collections

Funciones Lambda

Programación Funcional

Interfaz Funcional Consumer

Programación Funcional

Interfaz Funcional Predicate

Programación Funcional

Interfaz Funcional Supplier

Programación Funcional

Interfaz Funcional Function

Programación Funcional

Métodos Referenciados

Programación Funcional

Creación De Streams

Programación Funcional

Operaciones Intermedias Con Streams: Map()

Programación Funcional

Operaciones Intermedias Con Streams: Filter()

Programación Funcional

Operaciones Intermedias Con Streams: Distinct()

Programación Funcional

Operaciones Finales Con Streams: Collect()

Programación Funcional

Operaciones Finales Con Streams: Min Max

Programación Funcional

Operaciones Intermedias Con Streams: Flatmap()

Programación Funcional

Operaciones Intermedias Con Streams: Sorted()

Programación Funcional

Operaciones Finales Con Streams: Reduce()

Programación Funcional

Operaciones Finales Con Streams: Foreach()

Programación Funcional

Operaciones Finales Con Streams: Count()

Programación Funcional

Operaciones Finales Con Streams: Match

Programación Funcional

Api Optional

Programación Funcional

Api Java.nio 2

Entrada Y Salida (Io)

Api Java.time

Api Java.time

Ecosistema Jakarta Ee De Java

Frameworks Para Java

Accede GRATIS a Java y certifícate

Certificados de superación de Java

Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender el funcionamiento interno de HashMap
  • Conocer cómo funciona el cálculo de hash y su impacto en el rendimiento
  • Identificar formas de evitar y manejar colisiones
  • Implementar HashMaps con un factor de carga personalizado
  • Comparar HashMap con TreeMap y LinkedHashMap
  • Aprender a optimizar el uso de HashMap en aplicaciones Java