Skip to content
Ameen Abozaid
PublishedBEG-030-L08

Demo: word frequency with Map

  • Lesson 8 of 9
  • 10 min read
  • Beginner
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> using getOrDefault.
  • Reproduce and fix the NullPointerException from using get(word) + 1 on a new word.
  • Spot and handle the empty first word that split can produce.
  • Switch from HashMap to TreeMap to 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:

  1. We look up and update by word, so the family is Map, from word to count.
  2. We do not care about order while counting, so the implementation is HashMap.
  3. For a sorted report we can copy into a TreeMap at 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

java
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:

java
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:

java
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:

java
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:

java
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.

java
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)

java
Map<String, Integer> counts = new HashMap<>();
for (String w : "a b a".split(" ")) {
    counts.put(w, counts.get(w) + 1);
}

Output:

text
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)

java
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:

text
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:

text
1. the 6
2. saw 3
3. bird 2

the 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.

java
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.

java
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.

java
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.

java
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:

  1. Use your own text of at least three sentences.
  2. Print the number of words and the number of distinct words.
  3. Print the alphabetical list of distinct words with their counts.
  4. Print the top five by count (ties alphabetical) using the repeated-maximum loop.
  5. 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 Map from item to count; getOrDefault(key, 0) (or merge) handles the first sighting.
  • get(key) + 1 on a missing key throws NullPointerException: a planned mistake worth seeing once.
  • Normalize first (lowercase, strip punctuation), and skip the empty string split may produce.
  • Copy a HashMap into a TreeMap for 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

  1. What starting value do you give a word the first time you count it?
  2. What sort of exception comes from counts.get(w) + 1 for a new word, and why?
  3. How do you get alphabetical output from a HashMap of counts?
  4. Why copy the map before finding the top words by removal?
  5. Why skip empty strings after split?
Show answers
  1. 0, through getOrDefault(w, 0), so the first count becomes 1.
  2. NullPointerException, because get returns null and adding 1 unboxes it.
  3. Copy it into a TreeMap.
  4. Removal would otherwise destroy the real counts; the copy lets us keep them.
  5. A leading separator makes split return an empty first element, which would be counted as a word.

References

  • Oracle, Java SE 21 API, Map. Supports get returning null for no mapping and getOrDefault returning the default.
  • Oracle, Java SE 21 API, HashMap. Supports no iteration order guarantee and the behavior of merge.
  • Oracle, Java SE 21 API, TreeMap. Supports sorted key order.
  • Output and the NullPointerException message 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 on String.split or regular expressions, so the [^a-z]+ explanation is taught as a pattern to copy, and the Integer cache 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.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.