Demo: word frequency with Map
On This Lesson 11 topics
Lesson Overview
This is a follow-along demo. You will build a word counter step by step: split a sentence into words, count each word with a Map, print the counts, switch to sorted output, and finally list the top three words. Along the way we plant two mistakes on purpose, the most common ones in counting code, so you see them fail once, safely, and know the fix.
The finished program is the heart of the module lab, Word frequency, where you will do the same for a whole paragraph and produce the top 10.
Type the code yourself. Reading it is not the same as running it. You can use any editor or the terminal from BEG-007; menu names differ between IDEs, so use whatever "run" action yours provides. This lesson builds on Lessons 3, 4, and 6, and on String.split and loops from BEG-013 and BEG-015.
Learning Objectives
By the end of this lesson you will be able to:
- Turn a text into a clean array of lowercase words.
- Count occurrences with a
Map<String, Integer>usinggetOrDefault. - Reproduce and fix the
NullPointerExceptionfrom usingget(word) + 1on a new word. - Spot and handle the empty first word that
splitcan produce. - Switch from
HashMaptoTreeMapto get alphabetical output, and say why that works. - Find the most frequent words without sorting tools, by repeatedly picking the maximum.
Written Explanation
The plan
The task: given a sentence, report how many times each word appears. In the language of Lesson 6:
- We look up and update by word, so the family is
Map, from word to count. - We do not care about order while counting, so the implementation is
HashMap. - For a sorted report we can copy into a
TreeMapat the end.
Before coding, hand-trace a tiny case, as in BEG-016. For a b a: start empty; see a (count 1); see b (count 1); see a again (count 2). The result is {a=2, b=1}. If the code reproduces this, we trust it on bigger input.
Step 1: split into words
String text = "The cat saw the dog. The dog saw the bird, and the bird saw the cat!";
String[] words = text.toLowerCase().split("[^a-z]+");toLowerCase() makes The and the the same word. split cuts the string wherever the pattern matches. The pattern [^a-z]+ is a small regular expression that means "one or more characters that are not a lowercase letter", so spaces, commas, periods, and exclamation marks all act as separators. Treat the pattern as something to copy for now; regular expressions are a topic of their own. The pattern keeps only ASCII letters a to z, so it would also split a word containing digits or accented letters, which is acceptable for this demo and worth remembering for real text.
Step 2: count, with the planned mistake first
The natural-looking first attempt is:
Map<String, Integer> counts = new HashMap<>();
for (String w : words) {
counts.put(w, counts.get(w) + 1); // planned mistake
}Run it on a b a. It crashes on the first word with Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null. The first time we see a, get("a") is null, and adding 1 forces Java to unbox null. This is exactly the missing-key trap from Lesson 4.
Step 3: the fix
Give missing words a starting count of 0:
counts.put(w, counts.getOrDefault(w, 0) + 1);Equivalent and shorter: counts.merge(w, 1, Integer::sum);. The documentation says merge stores the given value when the key is absent and otherwise combines old and new values with the function you pass. Integer::sum is a method reference, a preview of BEG-035: read it as "add the two numbers". Both forms give the same counts; we use getOrDefault here because every piece of it is already familiar.
Step 4: the second planned mistake, the empty word
If the text starts with a separator, split produces an empty string as the first element. The demo text begins with two spaces, so words.length is 17, not 16, and words[0] is "". The empty string would be counted as a "word". Guard against it:
if (w.isEmpty()) continue;continue skips to the next loop iteration, as in BEG-013-L04.
Step 5: sorted output
A HashMap prints in no promised order. To see the counts alphabetically, copy them into a TreeMap:
Map<String, Integer> sorted = new TreeMap<>(counts);This works because a TreeMap keeps its keys in sorted order (Lesson 4) and has a constructor that copies from another map. Alternatively, count into a TreeMap from the start, which costs a little more per update.
Step 6: the top three, without sorting tools
Sorting entries by value needs comparators and lambdas, which come in BEG-032 and BEG-035. For now we can do it with what we know, borrowing the selection idea from BEG-017-L05: repeatedly find the entry with the highest count, report it, and remove it from a copy of the map. We copy so the original counts survive.
Map<String, Integer> copy = new HashMap<>(counts);
for (int rank = 1; rank <= 3 && !copy.isEmpty(); rank++) {
String best = null;
for (Map.Entry<String, Integer> e : copy.entrySet()) {
if (best == null || e.getValue() > copy.get(best)
|| (e.getValue().equals(copy.get(best)) && e.getKey().compareTo(best) < 0)) {
best = e.getKey();
}
}
System.out.println(rank + ". " + best + " " + copy.get(best));
copy.remove(best);
}The third condition breaks ties alphabetically, so the output is the same on every run even though HashMap order is not stable. Two details: we compare the Integer objects with equals, not ==, because == on objects tests identity (BEG-026); and we remove from copy after the inner loop finishes, never inside it, so there is no ConcurrentModificationException (Lesson 7).
Examples
Example 1: the crash, step by step (simple)
Map<String, Integer> counts = new HashMap<>();
for (String w : "a b a".split(" ")) {
counts.put(w, counts.get(w) + 1);
}Output:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
at Bad.main(Bad.java:6)Read the message from the right: Map.get(Object) returned null, and intValue() was invoked on it. That sentence is the whole bug.
Example 2: the fixed counter and the sorted report (practical)
String text = " The cat saw the dog. The dog saw the bird, and the bird saw the cat!";
String[] words = text.toLowerCase().split("[^a-z]+");
System.out.println(words.length + " words, first=" + words[0]);
Map<String, Integer> counts = new HashMap<>();
for (String w : words) {
if (w.isEmpty()) continue;
counts.put(w, counts.getOrDefault(w, 0) + 1);
}
System.out.println(counts);
System.out.println(new TreeMap<>(counts));Output:
17 words, first=
{the=6, and=1, cat=2, bird=2, saw=3, dog=2}
{and=1, bird=2, cat=2, dog=2, saw=3, the=6}The first line shows the empty-string element: 17 entries, and first= followed by nothing. The HashMap line is one real run on JDK 21; yours may differ in order, but the counts are the same. The TreeMap line is alphabetical. The sum of the counts is 16, which matches the number of real words, a quick sanity check.
Example 3: the top three (realistic)
Using the copy loop from Step 6 on the same counts:
1. the 6
2. saw 3
3. bird 2the appears six times and saw three times. Several words appear twice (cat, bird, dog); the tie-break picked bird first because it is alphabetically first. If you ask for the top 10 of a text with fewer than 10 distinct words, the !copy.isEmpty() check in the loop condition stops it from calling copy.get(null).
Backend Insight
Counting by key is one of the most common backend operations. Requests per endpoint, errors per type, orders per customer, and clicks per page are all Map<Key, Integer> in miniature, and real systems often compute them with a database GROUP BY or a metrics library. The Java version teaches the shape: key in, count out, with a sensible default for the first time a key appears.
The cleaning step matters as much as the counting. Lowercasing and splitting decided that The and the are one word. In a real service the same decision appears as "are Ana@Mail.com and ana@mail.com the same user?" Define "same key" deliberately, and write the normalization in one place.
Common Mistakes
Mistake 1: get(word) + 1 on an unseen word
Always supply a default: getOrDefault(word, 0) or merge.
Mistake 2: Forgetting to lowercase (and to trim punctuation)
The, the, and the, become three keys. Normalize first, then count.
Mistake 3: Counting the empty string
A leading separator produces "" as the first element. Skip empty words, or trim the text first.
Mistake 4: Modifying the map inside the loop that iterates over it
Removing from copy inside the for over copy.entrySet() would trigger a ConcurrentModificationException. Pick the winner first, then remove after the inner loop.
Mistake 5: Comparing Integer values with ==
For values above a small cached range, == on two Integer objects can be false even when the numbers are equal. Use equals, or unbox with intValue().
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. What does a TreeMap count map print for "a b a c b a" split on spaces?
Show solution A1
A1. {a=3, b=2, c=1}.
A2. Why does counts.put(w, counts.get(w) + 1) fail for the first occurrence of a word?
Show solution A2
A2. For a new word, get returns null, and null + 1 requires unboxing null, which throws NullPointerException.
A3. Why do we call toLowerCase() before splitting?
Show solution A3
A3. So The and the count as the same word; otherwise they would be separate keys.
Level B: Application
B1. Count the letters in "banana" into a Map<Character, Integer> and print it sorted by letter. (Use toCharArray() to get the characters.)
Show solution B1
B1.
Map<Character, Integer> letters = new TreeMap<>();
for (char ch : "banana".toCharArray()) {
letters.put(ch, letters.getOrDefault(ch, 0) + 1);
}
System.out.println(letters);It prints {a=3, b=1, n=2}. The key type is Character, the reference type for char, as Integer is for int.
B2. Starting from the counts in A1, build a list of the words that occur exactly once.
Show solution B2
B2.
List<String> once = new ArrayList<>();
for (Map.Entry<String, Integer> e : c.entrySet()) {
if (e.getValue() == 1) once.add(e.getKey());
}With c from A1 the result is [c]. Comparing with == 1 works because Java unboxes the Integer when the other side is an int.
B3. After counting, print the total number of words counted and the number of distinct words, using the map.
Show solution B3
B3.
int total = 0;
for (int v : c.values()) total += v;
System.out.println(total + " " + c.size());It prints 6 3.
Level C: Challenge
C1. Build Map<Integer, List<String>> byCount that maps each count to the words with that count, with counts in ascending order, for the counts in A1.
Show solution C1
C1.
Map<Integer, List<String>> byCount = new TreeMap<>();
for (Map.Entry<String, Integer> e : c.entrySet()) {
if (!byCount.containsKey(e.getValue())) byCount.put(e.getValue(), new ArrayList<>());
byCount.get(e.getValue()).add(e.getKey());
}The result is {1=[c], 2=[b], 3=[a]}.
C2. Explain the collection choices for the top-three program in one line each: the counting map, the sorted report, and the copy.
Show solution C2
C2. The counting map is a HashMap because we look up and update by word and order is not needed. The sorted report is a TreeMap copy because the output must be in alphabetical order. The copy is a HashMap copy so we can remove winners without destroying the real counts.
Mini Challenge
Extend the demo into a mini report:
- Use your own text of at least three sentences.
- Print the number of words and the number of distinct words.
- Print the alphabetical list of distinct words with their counts.
- Print the top five by count (ties alphabetical) using the repeated-maximum loop.
- Add a one-line comment above each collection saying why you chose it.
Check your work: the counts add up to the word total, the empty string is not counted, and no NullPointerException or ConcurrentModificationException appears.
Summary & Next Lesson
Key takeaways
- Counting is a
Mapfrom item to count;getOrDefault(key, 0)(ormerge) handles the first sighting. get(key) + 1on a missing key throwsNullPointerException: a planned mistake worth seeing once.- Normalize first (lowercase, strip punctuation), and skip the empty string
splitmay produce. - Copy a
HashMapinto aTreeMapfor sorted output. - A top-N can be found without sorting tools by repeatedly picking the maximum from a copy.
Next lesson
The next lesson, "Demo: the wrong collection, measured", times a List against a Set for repeated lookups so you can see why the choice matters.
Self-check
- What starting value do you give a word the first time you count it?
- What sort of exception comes from
counts.get(w) + 1for a new word, and why? - How do you get alphabetical output from a
HashMapof counts? - Why copy the map before finding the top words by removal?
- Why skip empty strings after
split?
Show answers
- 0, through
getOrDefault(w, 0), so the first count becomes 1. NullPointerException, becausegetreturnsnulland adding 1 unboxes it.- Copy it into a
TreeMap. - Removal would otherwise destroy the real counts; the copy lets us keep them.
- A leading separator makes
splitreturn an empty first element, which would be counted as a word.
References
- Oracle, Java SE 21 API,
Map. Supportsgetreturningnullfor no mapping andgetOrDefaultreturning the default. - Oracle, Java SE 21 API,
HashMap. Supports no iteration order guarantee and the behavior ofmerge. - Oracle, Java SE 21 API,
TreeMap. Supports sorted key order. - Output and the
NullPointerExceptionmessage were produced by compiling and running the programs on JDK 21, including the 17-element result of splitting text that starts with spaces. I did not fetch a page onString.splitor regular expressions, so the[^a-z]+explanation is taught as a pattern to copy, and theIntegercache remark in Mistake 5 is general Java knowledge that I did not verify against a page.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.