PSP Logo

3.7 Anexo III - HashMap hoja de referencia de los alumnos

Logo IES Doctor Balmis

Apuntes de PSP creados por Vicente Martínez bajo licencia CC BY-NC-SA 4.0

3.7 Anexo III - HashMap hoja de referencia de los alumnos

Autoría

Esto es un extracto del trabajo Reto I (Challenge I) realizado por mis alumnos como parte del módulo de PSP. He tomado partes de los diferentes trabajos entregados para complementar la información a la que podréis acceder durante los exámenes.

Gracias a todos.

Para los ejemplos vamos a trabajar con la siguiente clase

Diagrama de clases

A. Definición y creación

Una colección representa un grupo de objetos. Esto objetos son conocidos como elementos. Cuando queremos trabajar con un conjunto de elementos, necesitamos un almacén donde poder guardarlos. En Java, se emplea la interfaz genérica Collection para este propósito. Gracias a esta interfaz, podemos almacenar cualquier tipo de objeto y podemos usar una serie de métodos comunes, como pueden ser: añadir, eliminar, obtener el tamaño de la colección.

Partiendo de la interfaz genérica Collection extienden otra serie de interfaces genéricas. Estas subinterfaces aportan distintas funcionalidades sobre la interfaz anterior.

Un HashMap básicamente designa claves únicas para los valores correspondientes que se pueden recuperar en cualquier punto dado, es decir, nos permite almacenar elementos asociando a cada clave un valor.

Para cada clave tenemos un valor asociado. Podemos después buscar fácilmente un valor para una determinada clave. Las claves en el diccionario no pueden repetirse.

A.1. Constructores de HashMap

HashMap proporciona 4 constructores que definen la capacidad inicial de la colección y en qué momento debe redimensionarse. Son parámetros para mejorar el rendimiento en el uso del HashMap.

// Crea una instancia de HashMap con una capacidad inicial de 16 y un factor de carga de 0,75.
HashMap<Integer, String> hm1 = new HashMap<>(); 

// HashMap(int initialCapacity). Crea una instancia de HashMap con una capacidad inicial especificada y un factor de carga de 0,75. 
HashMap<Integer, String> hm1 = new HashMap<>(10); 

// HashMap(int initialCapacity, float loadFactor). Crea una instancia de HashMap con una capacidad inicial especificada y un factor de carga especificados. 
HashMap<Integer, String> hm1 = new HashMap<>(5, 0.75f); 

//HashMap(Mapa de mapas) . Crea una instancia de HashMap con las mismas asignaciones que el mapa especificado. 
HashMap<Integer, String> hm1 = new HashMap<>();

B. Métodos y propiedades generales

Partiendo de una serie de objetos, vamos a ver el resultado que obtendríamos con la ejecución de estos métodos

  Persona p1 = new Persona("Manuel", "García", 44, 1.74d, 80, "Hombre");
  Persona p2 = new Persona("Juan", "Martínez", 65, 1.84d, 82, "Hombre");

B.1. Creación de un HashMap

  // Crear un HashMap con claves de tipo String y valores de tipo Persona
  HashMap<String,Persona> DNIs = new HashMap<>();

B.2. Añadir y eliminar elementos

  // Element.put(k,v) - Añade un par clave-valor al mapa.
  DNIs.put("390543M",p);
  // Element.remove(Object key) - Removes the key and his value.
  DNIs.remove("298423Z");// Will remove (“390543M”, p1) from the map.

Clave ya existente

Si ya existe un elemento con la misma clave en el mapa, el método put reemplaza el valor existente por el nuevo. Podemos evitarlo comprobando previamente si ya existe esa clave o con el método DNIs.putIfAbsent(k,v).

B.3. Comprobar si una clave o un valor existen

  // Element.containsKey(Key) – Comprueba si existe la clave dada
  DNIs.containsKey("390543M");
  // Element.containsValue(Value) – Comprueba si existe el valor dado asociado a alguna clave
  DNIs.containsValue(p2);

B.4. Acceder a las partes del mapa

  // Element.keySet() - Obtiene el conjunto de claves del mapa
  Set<String> claves = DNIs.keySet();
  // Element.values() - Obtiene el conjunto de valores almacenados en el mapa
  Collection<Persona> valores = DNIs.values();
  // Element.entrySet() - Obtiene el conjunto de pares clave-valor del mapa
  Set<Entry<String,Persona>> tuplas = DNIs.entrySet();

B.5. Acceder a un elemento del mapa

  // Element.get(Key) - Obtiene el valor asociado a la clave
  Persona p = DNIs.get("390543")

B.6. Otras funciones de utilidad

  // Element.size() - Devuelve el número de elementos en el mapa
  int size = DNIs.size();
  //Element.clear() - Elimina todas las asignaciones y vacía el mapa
  DNIs.clear();

C. Añadir datos a un HashMap

Orden de los elementos en el HashMap

Cuando añadimos elementos a una HashMap, el orden de inserción no se conserva. Internamente, para cada elemento, se genera un hash separado y los elementos se indexan en función de este hash para hacerlo más eficiente. Antes de añadir un elemento, como se ha avisado antes, es recomendable comprobar si ya existe para no sustituirlo

C.1. Añadir elementos desde el constructor

A la hora de crear el Hashmap, podemos añadirle datos, usando la sintaxis del doble corchete o bien con la construcción Map.of

  // Crea un nuevo mapa y a la vez lo inicializa con valores  
  HashMap<String, Persona> map1 = new HashMap<>() {{
    put("390543M", p1);
    put("298423Z", p2);
  }};
  // En este caso indicamos los pares clave valor como si de un array se tratase
  // De esta forma podemos añadir hasta un máximo de 10 elementos
  HashMap<String, Persona> map2 = new HashMap<>(
    Map.of("390543M", p1, "298423Z", p2)
  );

C.2. Añadir elementos desde otras colecciones

Al ser una colección compuesta por una clave y un valor, la inicialización se limita a los tipos de colecciones que tienen una estructura similar.

  // Partiendo del código anterior, creamos un nuevo mapa a partir de map2
  HashMap<String, Persona> map3 = new HashMap<>(map2);
  // O bien copiando todos los pares clave-valor
  map3.putAll(map2);  

C.3. Añadir / eliminar elementos desde código

  // Añadir y si existe la clave reemplazarlo
  DNIs.put("390543M", p1);
  // Añadir sólo si la clave no existe
  DNIs.putIfAbsent("390543M", p1);
  // Eliminar un elemento. Si la clave existe devuelve el valor asociado, si no devuelve null
  Persona eliminada = map3.remove("390543M");  
  // Eliminar un par clave-valor. Si existe la tupla, la elimina y devuelve true, si no devuelve false
  boolean existe = map3.remove("390543M", p1);  

D. Recorrer la colección

Vamos a preparar un HashMap para recorrerlo y usarlo en los siguientes apartados

  // HashMap creation
  HashMap<String, Persona> grupoPersonas = new HashMap<>() {{
          put("12345678A", new Persona("Nombre1", "Apellido1", 35, 1.66d, 71, "Mujer"));
          put("23456789B", new Persona("Nombre2", "Apellido2", 40, 1.84d, 88, "Mujer"));
          put("34567890C", new Persona("Nombre3", "Apellido3", 52, 1.70d, 66, "Hombre"));
          put("45678901D", new Persona("Nombre4", "Apellido4", 23, 1.96d, 98, "Mujer"));
          put("56789012E", new Persona("Nombre5", "Apellido5", 16, 1.55d, 60, "Hombre"));
          put("67890123F", new Persona("Nombre6", "Apellido6", 20, 1.75d, 74, "Hombre"));
  }};

Tipos de valores

Vamos a mostrar ejemplos de cómo recorrer cada uno de los elementos que componen el HashMap, claves, valores y pares clave-valor.

.entrySet() - devuelve el conjunto de pares clave-valor

.keySet() - devuelve el conjunto de claves

.values() - devuelve la colección de valores

D.1. Usando un bucle for

Con el bucle for iteramos de forma natural accediendo a los elementos por índice, por lo que vamos a necesitar una forma de obtener el índice (i) de cada elemento para recorrer el HashMap.

En este caso, vamos a usar el índice de la clave, a través del método toArray.

Orden de los elementos en el HashMap

Es importante recordar y tener en cuenta que los elementos en un HashMap se ordenan automáticamente en base a una función Hash (resumen) que permite realizar una búsqueda muy rápida sobre la clave. Por lo tanto, no podemos esperar que el recorrido por índice coincida con el orden en el que los elementos se añaden al mapa.

  for (int i = 0; i < grupoPersonas.size(); i++) {
    System.out.println(grupoPersonas.get(map.keySet().toArray()[i]));
  }

D.2. Usando un bucle foreach de Java

Otra forma de recorrer el HashMap es con un bucle similar al foreach de C#, aunque con el formato de un bucle for, pero en este caso indicando for(elemento : colección)

  // Recorremos la estructura obteniendo la tupla (k,v) en cada iteración
  for (Map.Entry<String, Persona> entry : grupoPersonas.entrySet()) {
        System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
  }

o bien para ir mostrando las claves

  for (String key : grupoPersonas.keySet()) {
    System.out.println("DNI = " + key);
  }

o los valores

  for (Person value : grupoPersonas.values()) {
    System.out.println("Value = " + value);

D.3. Usando Iterator

El interface Iterator de Java permite movernos por una colección y acceder a sus elementos

java.lang.Iterator

Todas las colecciones de Java incluyen un método iterator() que devuelve una instancia de Iterator para recorrer la colección.

Iterator tiene 4 métodos:

  • hasNext() - devuelve true si hay un elemento más en la lista
  • next() - devuelve el siguiente elemento de la lista
  • remove() - elimina el último elemento de la lista que hemos obtenido con next()
  • forEachRemaining() - realiza la acción indicada con cada uno de los elementos que quedan por recorrer de la lista

Vamos a ver un ejemplo con los valores de la colección

  Iterator<Persona> iterator = grupoPersonas.values().iterator();  
  while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

D.4. Usando el método forEach con expresiones lambda

En este caso aprovechamos el método foreach de las colecciones para poder realizar una acción concreta sobre cada uno de los elementos de la misma

  // Pares (clave,valor)
  grupoPersonas.forEach((k,v) -> System.out.println("Clave: " + k + ", Values: " + v));
  // Claves
  grupoPersonas.keySet().forEach(k -> System.out.println("Clave: " + k));
  // Valores
  grupoPersonas.values().forEach(v -> System.out.println("Values: " + v));

D.5 Eliminando / Modificando elementos mientras se itera sobre la colección

Mientras se está recorriendo una colección, no con todos los tipos de bucles se puede modificar (añadir/eliminar elementos) de la colección. Vamos a ver el comportamiento de cada uno de ellos.

D.5.1 Con un bucle for

En este caso no tendríamos problemas. Al acceder por índice, podemos añadir o eliminar elementos mientras se recorre la colección.

  for (int i = 0; i < grupoPersonas.size(); i++) {
    if(grupoPersonas.keySet().toArray()[i].equals("23456789B")) {
      grupoPersonas.remove(grupoPersonas.entrySet().toArray()[i]);
    }
  }

D.5.2 Con un bucle foreach de Java

No modificable mientras se recorre

Si intentamos eliminar un elemento mientras lo recorremos con foreach, provocaremos una java.util.ConcurrentModificationException. Por lo tanto, sólo debemos usar este bucle si queremos leer sus elementos sin modificar la estructura de la colección.

  for (Map.Entry<String, Persona> p : grupoPersonas.entrySet()) {
    if (p.getKey().equals("34567890C")) {
      personas.remove(p.getKey());
    }
    System.out.println("Clave: " + p.getKey() + " Valor: " + p.getValue().toString());
}

D.5.3 Con Iterator

Si usamos el método remove para eliminar elementos de la colección mientras la recorremos, podremos hacerlo sin que se genere ninguna excepción.

  Iterator<Entry<String, Persona>> iterator = grupoPersonas.entrySet().iterator();
  while (iterator .hasNext()) {
    Map.Entry<String, Persona> p = (Map.Entry<String, Persona>) iterator.next();
    if (p.getKey().equals("12345678A")) {
      // Si borramos usando iterator.remove o el método remove(key) de HashMap, funciona
      iterator.remove();
    }
  }




 



D.5.4 Con el método forEach y expresiones lambda

No modificable mientras se recorre

Si intentamos eliminar un elemento mientras lo recorremos con foreach, provocaremos una java.util.ConcurrentModificationException. Por lo tanto, sólo debemos usar este bucle si queremos leer sus elementos sin modificar la estructura de la colección.

  // Elimina si encuentra un elemento concreto
  grupoPersonas.keySet().forEach((k) -> {if (k.equals("34567890C")) grupoPersonas.remove(k);});

Otra cosa es que intentemos hacer cambios en los valores de la colección, por ejemplo, intercambiar los apellidos

  // Intercambia los apellidos de todas las personas
  grupoPersonas.values().forEach((p) -> {
      String aux = p.getApellidos();
      p.setApellidos(p.getNombre());
      p.setNombre(aux);                
    }
  );

E. Búsqueda de elementos

Para buscar elementos en un HashMap hay distintas formas de hacerlo. Desde los propios métodos que nos ofrece la clase hasta el uso de API Stream. Vamos a describir cada uno de ellos

E.1. Búsqueda por clave o usando los métodos de la clase

La clase HashMap nos ofrece diferentes alternativas para buscar y/o saber si un elemento está presente en la colección. Así, podemos usar los métodos

  // A partir de una clave, obtener el valor
  grupoPersonas.get("34567890C");
  // O simplemente comprobar si existe esa clave o ese valor antes de buscarlo
  grupoPersonas.containsKey("34567890C");
  grupoPersonas.containsValue(person);

E.2. Búsqueda por el valor de una propiedad

A diferencia del caso anterior, si queremos buscar un objeto que contenga un valor concreto en un campo, debemos recorrer la colección hasta encontrarlo. Para eso, una de las alternativas es usar alguno de los bucles vistos anteriormente.

  // Puede haber más de un elemento que cumpla el criterio de búsqueda
  Iterator<Map.Entry<String, Persona>> it = grupoPersonas.entrySet().iterator();
  while (it.hasNext()) {
    Map.Entry<String, Persona> entry = it.next();
    if (((Persona)entry.getValue()).getNombre().equals("Jorge")) {
      // Se puede obtener el elemento o bien modificarlo
      System.out.println("Key: " + entry.getKey() + ", Value: " +
      entry.getValue());
    break;
    }
  }

E.3. Búsqueda usando expresiones lambda

Mediante expresiones lambda, podemos incluir una condicional que nos haga el filtrado de elementos que deseemos

  // Puede haber más de un elemento que cumpla el criterio de búsqueda
  grupoPersonas.forEach((k, v) -> {
    if (v.getNombre().equals("Jorge")) {
      // Se puede obtener el elemento o bien modificarlo
      System.out.println("Key: " + k + ", Value: " + v);
    }
  });

E.4. Búsqueda usando API Stream

En este tipo de acciones es donde ya podemos empezar a ver la potencia que ofrece el API Stream para el manejo y gestión de las colecciones. Podemos emplear varios métodos, como filter, findAny, findFirst, allMatch, anyMatch, count, distinct. Como veremos en el siguiente apartado, esos resultados los podemos guardar en forma de subcolección

  // Puede haber más de un elemento que cumpla el criterio de búsqueda

  // Obtener un submapa con los elementos que cumplan el criterio
  grupoPersonas.entrySet().stream()
    .filter(k -> k.getKey().equals("abc"))
    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

  // O bien recorrer la lista de entradas obtenidas
  for (Entry<String, Persona> p : grupoPersonas.entrySet().stream()
      .filter(k -> k.getKey().equals("abc"))
      .collect(Collectors.toList())) {
          System.out.println(p.getValue());   // Sacamos el valor de la tupla <clave,valor>
  }

  for (Persona p : grupoPersonas.entrySet().stream()
      .filter((k) -> k.getKey().equals("98761234D"))
      .map(Map.Entry::getValue)         // Cogemos sólo los valores del entryMap
      .collect(Collectors.toList())) {
          System.out.println(p);        // Muestra solo la persona
  }

  // Saber cuántos cumplen el criterio de búsqueda
  grupoPersonas.entrySet().stream().filter(k -> k.getKey().equals("abc")).count();

  // Obtener el primero que cumpla el criterio, si es que hay alguno
  Optional<Entry<String,Persona>> s = grupoPersonas.entrySet().stream()
    .filter(k -> k.getKey().equals("6780123F"))
    .findFirst();

F. Obtención de subcolecciones

Lo podemos considerar un tipo especial de búsqueda en el que el objetivo es conseguir una colección con los elementos que cumplan un determinado criterio.

Así, la forma de buscar es idéntica a la del apartado anterior, pero en este caso lo que obtendremos de esa búsqueda será un nuevo tipo de colección, no necesariamente otro HashMap.

F.1. Subcolecciones usando bucles

  // Personas cuyo DNI acaba en "F"
  HashMap<String,Persona> personas2 = new HashMap<String,Persona>();
  for (Entry<String, Persona> e : grupoPersonas.entrySet()) {
    if(e.getKey().endsWith("F")) {
      personas2.put(e.getKey(), e.getValue());
    }
  }

F.2. Subcolecciones usando expresiones lambda

En este ejemplo obtenemos un nuevo HashMap, pero también podríamos guardar la información en un ArrayList o en cualquier otro tipo de estructura.

  // Personas cuyo DNI acaba en "F"
  HashMap<String, Persona> grupoPersonas2 = new HashMap<String, Persona>();
  grupoPersonas.forEach((k, v) -> {
    if (k.endsWith("F")) {
      grupoPersonas2.put(k, v);
    }
  });

F.3. Subcolecciones usando API Stream

Podemos obtener diferentes tipos de subcolecciones. Con API Stream podemos filtrar, hacer subconjuntos y guardar el resultado usando diferentes formas de .collect, que darán como resultados distintos tipos de colecciones.

  // HashMap de Personas cuyo DNI acaba en "F"
  HashMap<String, Persona> grupoPersonas3 = (HashMap<String, Persona>) grupoPersonas.entrySet().stream()
      .filter(x -> x.getKey().endsWith("F"))
      .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

  // Lista de Personas cuyo DNI acaba en "F"
  List<Persona> listaPersonas3 = (List<Persona>) grupoPersonas.entrySet().stream()
      .filter(x -> x.getKey().endsWith("F"))
      .collect(Collectors.toList());

G. Ordenación de elementos

HashMap no garantiza el orden de los elementos

Como ya hemos comentado anteriormente, un HashMap es una estructura de datos en la que el orden de los elementos no está garantizado. Es por eso que si queremos mantener una copia ordenada de los elementos, deberemos recurrir a otros tipos de colecciones que sí garantizan el orden.

G.1. Ordenar por clave

Hay un tipo especial de map, TreeMap, en el que los elementos se ordenan siguiendo el orden natural de las claves. Por lo tanto es la opción ideal si queremos tener los elementos ordenados por clave.

  // Así podemos recorrer el TreeMap y mostrar los elementos ordenados por clave
  TreeMap<String, Persona> grupoPersonasOrdenado = new TreeMap<>(grupoPersonas);
 
  Iterator it=grupoPersonasOrdenado.keySet().iterator();
  while(it.hasNext())
  {
    int key=(int)itr.next();
    System.out.println("Key: "+key+" Element: "+hashMap.get(key));
  }

G.2. Ordenar usando métodos de Collection

Si lo que queremos es tener el conjunto de claves o valores ordenados por separado, la forma más fácil es obtener una lista y ordenarla usando el método sort() de Collection.

  // Para las claves
  List<String> clavesGrupoPersonas = new ArrayList<>(grupoPersonas.keySet());
  Collections.sort(clavesGrupoPersonas);

  // Para los valores
  List<Persona> valoresGrupoPersonas = new ArrayList<>(grupoPersonas.values());
  // Ordena según el método compareTo sobrescrito al implementar el interfaz Comparable
  Collections.sort(valoresGrupoPersonas);
  // Ordena por un campo cualquiera que indiquemos
  Collections.sort(valoresGrupoPersonas, Comparator.comparing(Persona::getApellidos));
  // Con Comparator tenemos disponibles varios comparadores (naturalOrder, reverseOrder, nullsFirst, ....)

Interfaz Comparable

Para que la primera forma de sort funcione, la clase Persona debe implementar el interfaz Comparable y sobrescribir su método compareTo para definir la forma de ordenar los objetos de tipo Persona.

Veremos más adelante que tenemos formas de definir el comparador usando expresiones lambda o APi Stream, permitiendo mayor flexibilidad a la hora de comparar elementos.

  // Un ejemplo, si queremos ordenar a las personas por edad
    @Override
    public int compareTo(Object o) {        
        return ((Integer)this.getEdad()).compareTo((Integer)((Persona)o).getEdad());
    }

G.3. Ordenar con expresiones lambda

Si usamos una lista, no es necesario implementar el interfaz Comparable, ya que podemos indicar la comparación que queremos hacer como parámetro del método sort

  // Para los valores
  List<Persona> valoresGrupoPersonas2 = new ArrayList<>(grupoPersonas.values());
  // Indicamos la comparación que queremos hacer. Podemos usar compareTo o definir
  // nuestro propio comparador
  Collections.sort(valoresGrupoPersonas2, (e1, e2) -> ((Integer)e1.getEdad()).compareTo(e2.getEdad()));
  System.out.println(valoresGrupoPersonas2);      

G.4. Ordenar con API Stream

Con API Stream usamos también el método sorted para indicar qué comparación se debe realizar. Tenemos varias opciones en función del tipo de dato que pasemos.

Como en el caso anterior, el más flexible es aquel en el que indicamos, mediante una expresión lambda qué comparación realizar.

  // Guardamos el resultado en un LinkedHashMap que sí garantiza el orden
  Map<String, Persona> sortedMap = grupoPersonas.entrySet().stream()
      // En este caso ordenamos por apellido, ascendente.
      .sorted((e1, e2) -> e1.getValue().getApellidos().compareTo(e2.getValue().getApellidos()))
      // Si queremos hacerlo descendente, ponemos .sorted((e2,e1) -> ......
      //.sorted((e2, e1) -> e1.getValue().getApellidos().compareTo(e2.getValue().getApellidos()))
      .collect(Collectors.toMap(Entry::getKey, Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new));

Encadenar métodos

Al final, el uso de API Stream nos permite en una misma sentencia, buscar los elementos que queramos, ordenarlos y generar una subcolección con los resultados.

Es lo más parecido que vamos a encontrar a una consulta SQL para los datos de una colección cualquiera.

Aunque su sintaxis no es muy clara, si aprendemos a usarla correctamente, podremos realizar operaciones instantáneas, sin lugar a bugs, con muy poco código.

Última actualización:
Editores: Vicente Martínez