HashMap Solutions for Counting Problems in Java
1. Overview
Counting problems pop up everywhere: tallying character frequencies, finding the most frequent element, counting pairs that satisfy a condition, grouping identical elements. HashMap is the go-to tool for this class of problems because both lookup and update are O(1) on average.
By the end of this article, you’ll know how to use HashMap (and Map.merge, getOrDefault) to solve 4 common counting patterns, understand time and space complexity, and avoid a few pitfalls when working with HashMap in counting problems.
2. Why HashMap Fits Counting Problems
The general idea behind counting problems: for each element, you need to know “how many times have we seen it” or “have we seen it yet?”. HashMap<K, V> gives you:
- O(1) average key lookup — no sorting needed.
- O(1) average value update (incrementing the counter).
- State tracking via
containsKeyor a counter value > 0.
Compared to counting arrays (which only work when the value range is small and known in advance, like characters a-z), HashMap is more versatile: keys can be String, Integer, or any object.
3. Counting Element Frequencies
3.1. Using getOrDefault
This is the classic approach to count the frequency of each element in a list.
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) returns the current value if word already exists, or 0 if it doesn’t — sparing you the need to write if (map.containsKey(...)). With the list ["cam", "xoai", "cam", "cam", "xoai"], the result is:
{cam=3, xoai=2}
3.2. Using Map.merge (More Concise)
Starting with Java 8, Map.merge combines the check and update into a single call.
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) means: if word doesn’t exist yet, set its value to 1; if it already exists, take the old value and add 1 via the Integer::sum function. This approach is slightly faster and reads more naturally when the accumulation logic is simple.
3.3. Testing with 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);
}
}
Note:
getOrDefaultdoesn’t modify the map if the key doesn’t exist — it just reads. Don’t confuse it withputIfAbsent, which is used to assign a default value when a key is missing.
4. Finding the Most Frequent Element
Once you have the frequency table, finding the most frequent element is just one pass through 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() compares entries by their value (frequency), and .max(...) finds the entry with the highest value. If multiple elements tie for most frequent, max only returns one — the order is not guaranteed since HashMap doesn’t maintain insertion order.
Input: {cam=3, xoai=2}
Output: Optional[cam]
Note: If you need to keep insertion order so results are stable when multiple elements tie, use
LinkedHashMapinstead ofHashMap.
5. Counting Pairs Meeting a Condition (Two Sum Counting Variant)
A common variant: count the number of pairs (i, j) such that arr[i] + arr[j] == target. Instead of brute-force O(n²), use HashMap to reduce it to 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;
}
}
Explanation line by line:
complement = target - number: the value we need to find to pair withnumberand reachtarget.count += seen.getOrDefault(complement, 0): add the number of timescomplementhas already appeared — each occurrence forms a valid pair.seen.merge(number, 1, Integer::sum): recordnumberin the frequency table so future elements can pair with it.
With numbers = [1, 2, 3, 2, 4] and target = 5: the pairs are (1,4), (2,3), (3,2) — but since we only count in order of appearance, verify with a test.
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);
}
}
The three pairs are: (2,3) when we hit 3, (2,3) again when we hit the second 2 (pairing with the 3 we’ve seen), and (1,4) when we hit 4.
6. Grouping Identical Elements (Anagram Groups)
A more advanced counting problem: group words that are anagrams of each other (same character set, different order), then count the number of groups or elements per group.
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;
}
}
The key insight is the signature: sort each word’s characters to create a common “signature” for all anagrams. computeIfAbsent(signature, key -> new ArrayList<>()) creates a new list if the signature doesn’t have a key yet, then adds the current word to it.
With input ["eat", "tea", "tan", "ate", "nat", "bat"], the result is:
{
"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 is cleaner than manually checking containsKey and then put, especially when the value is a collection that needs lazy initialization.
7. Time and Space Complexity
| Problem | Time | Space |
|---|---|---|
| Count frequencies | O(n) | O(k) — k is the number of distinct elements |
| Find most frequent | O(n) | O(k) |
| Count pairs with given sum | O(n) | O(n) |
| Group anagrams | O(n · m log m) — m is the longest word length | O(n) |
Compared to sorting first then iterating (O(n log n)), HashMap is usually faster in time complexity but trades off extra space O(n) or O(k). If memory is tightly constrained and the value range is small, a counting array is still more space-efficient.
8. Common Pitfalls
- Using a mutable object as a key without overriding
equals/hashCode: if you’re counting using a custom class (e.g.,Point), forgetting to override these methods meansHashMapwon’t recognize two “identical” objects as the same key.
public record Point(int x, int y) {}
Using a record automatically provides correct equals/hashCode based on all fields — avoiding this pitfall entirely.
-
Expecting iteration order to be stable:
HashMapdoesn’t guarantee order. UseLinkedHashMapfor insertion order, orTreeMapfor ascending key order. -
Modifying the map while iterating over
entrySet(): causesConcurrentModificationException. If you need to remove entries during iteration, useIterator.remove()orremoveIf. -
Careless
Integerboxing in large loops: eachgetOrDefault++ 1creates a newIntegerobject via auto-boxing. With massive data (millions of elements), considerMap<String, int[]>or primitive map libraries (e.g., Eclipse Collections) to reduce overhead.
Note: If your key domain is just 26 letters a-z or a small fixed integer range, an array (
int[26]) is always faster and uses less memory thanHashMap. Only useHashMapwhen the key domain is large, unknown upfront, or sparse.
9. Conclusion
HashMap transforms counting problems — tallying frequencies, spotting standout elements, counting pairs, grouping identical items — into simple O(n) loops thanks to O(1) lookup. Methods like getOrDefault, merge, and computeIfAbsent make code shorter and clearer than manual containsKey checks. Remember: HashMap doesn’t maintain order, keys that are objects need correct equals/hashCode, and for small value ranges, a counting array is still more lightweight.