HashMap resolvendo problemas de contagem em Java
1. Visão geral
Problemas de contagem (counting problem) aparecem por toda parte: contar frequências de caracteres, encontrar o elemento que mais aparece, contar pares que satisfazem uma condição, agrupar elementos iguais. HashMap é a ferramenta quase “padrão” para esse tipo de problema porque consulta e atualização são O(1) em média.
Após ler este artigo, você saberá como usar HashMap (e Map.merge, getOrDefault) para resolver 4 tipos comuns de problemas de contagem, entender a complexidade de tempo/espaço e evitar alguns erros frequentes ao usar HashMap em problemas de contagem.
2. Por que HashMap é adequado para problemas de contagem
A ideia geral de um problema de contagem: para cada elemento, você precisa saber “quantas vezes já apareceu” ou “se já apareceu”. HashMap<K, V> oferece:
- Busca da chave em O(1) em média — sem necessidade de ordenação.
- Atualização do valor (incrementar contador) em O(1) em média.
- Armazenar o estado “já visto” usando
containsKeyou valor de contagem > 0.
Comparado com um array de contagem (counting array) que só funciona quando o intervalo de valores é pequeno e conhecido de antemão (por exemplo, caracteres a-z), HashMap é mais genérico: a chave pode ser String, Integer, qualquer objeto.
3. Contar frequência de elementos
3.1. Usando getOrDefault
Esta é a forma clássica de contar a frequência de cada elemento em uma 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) retorna o valor atual se word já existe, ou 0 se não existe — evitando ter que escrever if (map.containsKey(...)). Com a lista ["cam", "xoai", "cam", "cam", "xoai"], o resultado é:
{cam=3, xoai=2}
3.2. Usando Map.merge (mais conciso)
A partir do Java 8, Map.merge combina as etapas de verificação e atualização em uma única chamada.
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: se word ainda não existe, defina o valor 1; se já existe, pegue o valor antigo e some com 1 através da função Integer::sum. Essa abordagem é um pouco mais rápida e mais legível quando a lógica de acumulação é simples.
3.3. Testando com 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);
}
}
Observação:
getOrDefaultnão modifica o map se a chave não existe — ele apenas lê. Não confunda computIfAbsent, que é usado para atribuir um valor padrão quando a chave não existe.
4. Encontrar o elemento mais frequente
Depois de ter a tabela de frequências, encontrar o elemento que mais aparece é apenas uma passada 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 as entradas pelo valor (número de ocorrências), .max(...) pega a entrada com o maior valor. Se há múltiplos elementos empatados no primeiro lugar, max retorna apenas um deles — a ordem não é garantida porque HashMap não mantém a ordem de inserção.
Entrada: {cam=3, xoai=2}
Saída: Optional[cam]
Observação: Se você precisa manter a ordem de inserção para um resultado estável quando há múltiplos elementos empatados, use
LinkedHashMapno lugar deHashMap.
5. Contar pares que satisfazem uma condição (Two Sum de contagem)
Uma variante comum: contar o número de pares (i, j) de modo que arr[i] + arr[j] == target. Em vez de usar força bruta O(n²), use HashMap para reduzir 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;
}
}
Explicação linha a linha:
complement = target - number: o valor que precisamos encontrar para somar comnumbere obtertarget.count += seen.getOrDefault(complement, 0): adicione o número de vezes quecomplementjá apareceu antes — cada vez assim cria um par válido.seen.merge(number, 1, Integer::sum): registre o número atual na tabela de contagem, para que elementos posteriores possam formar pares com ele.
Com numbers = [1, 2, 3, 2, 4] e target = 5: os pares são (1,4), (2,3), (3,2) → mas como contamos pela ordem de aparição anterior, verifique com o teste.
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);
}
}
Os três pares são: (2,3) ao encontrar o número 3, (2,3) ao encontrar o segundo 2 (emparelhando com o 3 já visto), e (1,4) ao encontrar o 4.
6. Agrupar elementos iguais (Anagrama)
Um problema de contagem mais avançado: agrupar palavras que são anagramas umas das outras (mesmo conjunto de caracteres, ordem diferente), depois contar o número de grupos ou o número de elementos em 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;
}
}
O ponto-chave está em signature: ordenar os caracteres de cada palavra para criar uma “assinatura” comum para todos os anagramas. computeIfAbsent(signature, key -> new ArrayList<>()) cria uma nova lista se a assinatura ainda não existe como chave, depois adiciona a palavra atual a ela.
Com a entrada ["eat", "tea", "tan", "ate", "nat", "bat"], o resultado:
{
"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 é uma solução mais limpa que containsKey + put manual, especialmente quando o valor é uma coleção que precisa de inicialização preguiçosa (lazy init).
7. Complexidade de tempo e espaço
| Problema | Tempo | Espaço |
|---|---|---|
| Contar frequências | O(n) | O(k) — k é o número de elementos distintos |
| Encontrar elemento mais frequente | O(n) | O(k) |
| Contar pares com soma alvo | O(n) | O(n) |
| Agrupar anagramas | O(n · m log m) — m é o comprimento da palavra mais longa | O(n) |
Comparado com a solução que ordena primeiro e depois itera (O(n log n)), HashMap geralmente é mais rápido em complexidade de tempo, mas em troca usa mais espaço O(n) ou O(k). Se a memória é uma restrição rígida e o intervalo de valores é pequeno, um array de contagem (counting array) ainda é a escolha mais econômica.
8. Erros comuns
- Usar um objeto mutável como chave sem sobrescrever
equals/hashCode: se você conta por uma classe customizada (por exemplo,Point), esquecer de sobrescrever esses dois métodos faz com queHashMapnão reconheça dois objetos “iguais” como a mesma chave.
public record Point(int x, int y) {}
Usar record ajuda a gerar automaticamente equals/hashCode corretos baseados em todos os fields — evitando esse erro completamente.
-
Esperar que a ordem de iteração seja estável:
HashMapnão garante ordem. Se você precisa de ordem de inserção, useLinkedHashMap; se precisa de ordem crescente pelas chaves, useTreeMap. -
Modificar o map enquanto itera sobre
entrySet(): causaConcurrentModificationException. Se você precisa remover entradas durante a iteração, useIterator.remove()ouremoveIf. -
Usar
Integerboxing descuidadamente em loops grandes: cada vez quegetOrDefault++ 1cria um novo objetoIntegerdevido ao auto-boxing. Com dados muito grandes (milhões de elementos), considereMap<String, int[]>ou uma biblioteca de map primitivo (por exemplo, Eclipse Collections) para reduzir overhead.
Observação: Se o domínio de chaves for apenas 26 caracteres a-z ou um intervalo pequeno e fixo de inteiros, um array (
int[26]) é sempre mais rápido e economiza mais memória queHashMap. UseHashMapapenas quando o domínio de chaves for grande, não for conhecido de antemão ou for descontínuo.
9. Conclusão
HashMap transforma problemas de contagem — contar frequências, encontrar elementos destacados, contar pares, agrupar elementos iguais — em simples loops O(n) graças à consulta O(1). Os métodos getOrDefault, merge, computeIfAbsent fazem o código ser mais curto e legível comparado com verificação manual de containsKey. Lembre-se: HashMap não mantém ordem, precisa de equals/hashCode corretos se a chave for um objeto, e com um domínio de valores pequeno, um array de contagem ainda é a escolha mais compacta.