HashMap para resolver problemas de conteo en Java
1. Tổng quan
Los problemas de conteo (counting problem) aparecen en todas partes: contar frecuencias de caracteres, encontrar el elemento que aparece más, contar pares que cumplen una condición, agrupar elementos similares. HashMap es la herramienta casi «predeterminada» para este grupo de problemas porque tanto la búsqueda como la actualización son O(1) en promedio.
Después de leer este artículo, sabrás cómo usar HashMap (y Map.merge, getOrDefault) para resolver 4 tipos comunes de problemas de conteo, entenderás la complejidad temporal y espacial, y evitarás algunos errores comunes al usar HashMap en problemas de conteo.
2. ¿Por qué HashMap es adecuado para problemas de conteo?
La idea general detrás de los problemas de conteo es: para cada elemento, necesitas saber «cuántas veces lo has visto» o «si lo has visto». HashMap<K, V> te proporciona:
- Búsqueda de clave en O(1) en promedio — sin necesidad de ordenar.
- Actualización de valor (incrementar contador) en O(1) en promedio.
- Almacenar el estado «ya visto» usando
containsKeyo un valor de conteo > 0.
En comparación con una matriz de conteo (counting array), que solo funciona cuando el rango de valores es pequeño y conocido de antemano (por ejemplo, caracteres a-z), HashMap es más general: la clave puede ser String, Integer, o cualquier objeto.
3. Contar frecuencias de elementos
3.1. Usar getOrDefault
Esta es la forma clásica de contar la frecuencia de cada elemento en una lista.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class FrequencyCounter {
public static Map<String, Integer> countFrequency(List<String> words) {
Map<String, Integer> freq = new HashMap<>();
for (String word : words) {
freq.put(word, freq.getOrDefault(word, 0) + 1);
}
return freq;
}
}
freq.getOrDefault(word, 0) devuelve el valor actual si la palabra ya existe, o 0 si no — evita tener que escribir if (map.containsKey(...)). Con la lista ["cam", "xoai", "cam", "cam", "xoai"], el resultado es:
{cam=3, xoai=2}
3.2. Usar Map.merge (más breve)
A partir de Java 8, Map.merge combina el paso de verificación y actualización en una sola llamada.
public static Map<String, Integer> countFrequencyMerge(List<String> words) {
Map<String, Integer> freq = new HashMap<>();
words.forEach(word -> freq.merge(word, 1, Integer::sum));
return freq;
}
freq.merge(word, 1, Integer::sum) significa: si la palabra no existe, establece el valor en 1; si ya existe, suma el valor anterior más 1 usando la función Integer::sum. Este enfoque es un poco más rápido y se lee de forma más natural cuando la lógica de acumulación es simple.
3.3. Pruebas con JUnit 5 + AssertJ
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class FrequencyCounterTest {
@Test
void shouldCountFrequencyCorrectly() {
Map<String, Integer> result = FrequencyCounter.countFrequency(
List.of("cam", "xoai", "cam", "cam", "xoai"));
assertThat(result)
.containsEntry("cam", 3)
.containsEntry("xoai", 2);
}
}
Nota:
getOrDefaultno modifica el mapa si la clave no existe — solo lee. No lo confundas conputIfAbsent, que se usa para asignar un valor predeterminado cuando la clave no existe.
4. Encontrar el elemento más frecuente
Una vez que tienes la tabla de frecuencias, encontrar el elemento que aparece más veces es solo un recorrido por entrySet().
import java.util.Map;
import java.util.Optional;
public class MostFrequentFinder {
public static Optional<String> findMostFrequent(Map<String, Integer> freq) {
return freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
}
}
Map.Entry.comparingByValue() compara las entradas por su valor (número de apariciones), .max(...) obtiene la entrada con el valor más alto. Si hay varios elementos empatados en el primer lugar, max solo devuelve uno de ellos — el orden no está garantizado porque HashMap no mantiene el orden de inserción.
Input: {cam=3, xoai=2}
Output: Optional[cam]
Nota: Si necesitas mantener el orden de inserción para resultados estables cuando hay varios elementos empatados, usa
LinkedHashMapen lugar deHashMap.
5. Contar pares de elementos que cumplen una condición (Two Sum como conteo)
Una variante común: contar el número de pares (i, j) tales que arr[i] + arr[j] == target. En lugar de usar fuerza bruta O(n²), usa HashMap para reducirlo a O(n).
import java.util.HashMap;
import java.util.Map;
public class PairCounter {
public static int countPairsWithSum(int[] numbers, int target) {
Map<Integer, Integer> seen = new HashMap<>();
int count = 0;
for (int number : numbers) {
int complement = target - number;
count += seen.getOrDefault(complement, 0);
seen.merge(number, 1, Integer::sum);
}
return count;
}
}
Explicación línea por línea:
complement = target - number: el valor que necesitas encontrar para que al sumarlo connumberobtengastarget.count += seen.getOrDefault(complement, 0): suma el número de veces quecomplementha aparecido antes — cada vez crea un par válido.seen.merge(number, 1, Integer::sum): registra elnumberactual en la tabla de conteo, para que elementos posteriores puedan formar pares con él.
Con numbers = [1, 2, 3, 2, 4] y target = 5: los pares son (1,4), (2,3), (3,2) → pero como solo contamos en el orden de aparición anterior, verifica con la prueba.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PairCounterTest {
@Test
void shouldCountPairsWithGivenSum() {
int[] numbers = {1, 2, 3, 2, 4};
int result = PairCounter.countPairsWithSum(numbers, 5);
assertThat(result).isEqualTo(3);
}
}
Los tres pares son: (2,3) cuando encuentras el 3, (2,3) cuando encuentras el segundo 2 (emparejado con el 3 ya visto), y (1,4) cuando encuentras el 4.
6. Agrupar elementos iguales (Grupos de Anagramas)
Un problema de conteo más avanzado: agrupar palabras que son anagramas entre sí (mismo conjunto de caracteres, diferente orden), y luego contar el número de grupos o el número de elementos en cada grupo.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class AnagramGrouper {
public static Map<String, List<String>> groupAnagrams(List<String> words) {
Map<String, List<String>> groups = new HashMap<>();
for (String word : words) {
char[] chars = word.toCharArray();
Arrays.sort(chars);
String signature = new String(chars);
groups.computeIfAbsent(signature, key -> new ArrayList<>())
.add(word);
}
return groups;
}
}
El punto clave está en signature: ordenar los caracteres de cada palabra para crear una «firma» común para todos los anagramas. computeIfAbsent(signature, key -> new ArrayList<>()) crea una nueva lista si la firma aún no tiene una clave, y luego añade la palabra actual.
Con la entrada ["eat", "tea", "tan", "ate", "nat", "bat"], el resultado es:
{
"aet"=[eat, tea, ate],
"ant"=[tan, nat],
"abt"=[bat]
}
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AnagramGrouperTest {
@Test
void shouldGroupAnagramsTogether() {
Map<String, List<String>> groups = AnagramGrouper.groupAnagrams(
List.of("eat", "tea", "tan", "ate", "nat", "bat"));
assertThat(groups.values())
.anySatisfy(group -> assertThat(group).containsExactlyInAnyOrder("eat", "tea", "ate"));
}
}
computeIfAbsent es una solución más limpia que containsKey + put manual, especialmente cuando el valor es una colección que necesita inicialización perezosa (lazy init).
7. Complejidad temporal y espacial
| Problema | Tiempo | Espacio |
|---|---|---|
| Contar frecuencias | O(n) | O(k) — k es el número de elementos distintos |
| Encontrar elemento más frecuente | O(n) | O(k) |
| Contar pares con suma objetivo | O(n) | O(n) |
| Agrupar anagramas | O(n · m log m) — m es la longitud de la palabra más larga | O(n) |
En comparación con la solución de ordenar primero y luego recorrer (O(n log n)), HashMap generalmente es más rápido en complejidad temporal, pero requiere más espacio O(n) u O(k). Si la memoria es muy ajustada y el rango de valores es pequeño, una matriz de conteo (counting array) sigue siendo la opción más eficiente.
8. Errores comunes
- Usar objetos mutables como clave sin sobrescribir
equals/hashCode: si cuentas según una clase personalizada (por ejemplo,Point), olvidar sobrescribir estos dos métodos hace queHashMapno reconozca dos objetos «iguales» como la misma clave.
public record Point(int x, int y) {}
Usar record te ayuda a tener automáticamente equals/hashCode correctos basados en todos los campos — evitando completamente este error.
-
Esperar un orden de iteración estable:
HashMapno garantiza el orden. Si necesitas orden de inserción, usaLinkedHashMap; si necesitas orden ascendente por clave, usaTreeMap. -
Modificar el mapa mientras lo recorres con
entrySet(): causaConcurrentModificationException. Si necesitas eliminar entradas mientras recorres, usaIterator.remove()oremoveIf. -
Usar boxing de
Integersin cuidado en bucles grandes: cada vez que usasgetOrDefault++ 1, se crea un nuevo objetoIntegerpor auto-boxing. Con datos muy grandes (millones de elementos), consideraMap<String, int[]>o una librería de mapa primitivo (por ejemplo, Eclipse Collections) para reducir el overhead.
Nota: Si el dominio de claves es solo 26 caracteres a-z o un pequeño rango fijo de números enteros, un array (
int[26]) siempre es más rápido y consume menos memoria queHashMap. Solo usaHashMapcuando el dominio de claves es grande, desconocido, o no es contiguo.
9. Conclusión
HashMap convierte los problemas de conteo — contar frecuencias, encontrar elementos destacados, contar pares, agrupar elementos similares — en simples bucles O(n) gracias a la búsqueda O(1). Los métodos getOrDefault, merge y computeIfAbsent ayudan a que el código sea más breve y claro que la verificación manual con containsKey. Recuerda: HashMap no mantiene el orden, necesitas equals/hashCode correctos cuando la clave es un objeto, y con un rango de valores pequeño, una matriz de conteo sigue siendo la opción más ligera.