Counting and grouping
Use it when you must put together the items that belong together, or count how many items share a property. Signal words: group the items that share, the same letters in any order, one group for each hour, how many of each.
the whole pattern cardfold it
Start an empty map from key to a count, or from key to a group, plus the list of groups in the order they start.
Step compute the key of the current item. Look it up. If it is new, start the entry. Then add one to the count, or append the position to the group.
Stop when the items run out.
It does not find items that are close but not equal. One key answers "are these two items equal under the key?".
Cost: O(n) map steps on average, plus the cost of computing and hashing one key for each item. For n words of L letters with a sorted key, that is O(n × L log L) in all. The comparison of every pair costs O(n^2) comparisons.
In this lesson you learn to count how often things occur, and to put things that belong together into one group, in a single pass. You compute a key for each item, and a hash table collects everything that shares a key.
1. Scenario: a word puzzle#
The rewards tab of a payments app has a daily word puzzle. Each puzzle shows a clue word. Players earn a reward for every dictionary word that uses exactly the same letters, for example listen, silent and tinsel.
Your team lead says: Before the game starts, group the whole dictionary by letters, so that each puzzle knows its full answer set.
The dictionary holds 100,000 words, and the groups are rebuilt every time the word list changes.
Here is a short test: ["listen", "tinsel", "stone", "notes", "silent", "onset", "apple"]. The answer is the positions of the words in each group: positions 0, 1 and 4, then positions 2, 3 and 5, then position 6 on its own.
So the task is this: put the words that use the same letters into one group, without comparing every pair of words.
Think about how a phone sorts photos into albums by day. For each photo, the phone reads its time stamp and works out the day. It then drops the photo into the album of that day.
The phone never compares one photo with another photo. The day is not printed on a photo. The phone computes it, and every photo taken on one day gets the same answer.
The albums are the hash table of this lesson, and the day is the key. Counting how many photos each day has is the same job, with a number in the album and not the photos.
Like a phone sorting photos into day albums: the day is computed from the time stamp, and every photo with that day goes in one album.
2. Struggle: solve it by hand first#
Before you write code, solve the test by hand. It shows how much work one rebuild takes.
Solve it by hand: The word puzzle's dictionary, grouped by sorted letters. The hand-solve sheet loads in your browser.
You write the words in a row. You take each word that is not in a group yet, and compare it with every later word that is not in a group yet. Two words belong together when their letters, in sorted order, are equal.
Words: ["listen", "tinsel", "stone", "notes", "silent", "onset", "apple"].
| First word | Later words compared | Comparisons | Result |
|---|---|---|---|
| listen, position 0 | tinsel, stone, notes, silent, onset, apple | 6 | tinsel and silent join it |
| tinsel, position 1 | none: already in a group | 0 | |
| stone, position 2 | notes, onset, apple | 3 | notes and onset join it |
| notes, silent, onset | none: already in groups | 0 | |
| apple, position 6 | none: no later word | 0 | stays alone |
The search made 6 + 3 = 9 comparisons. If no two words had matched, it would have made all 21 comparisons.
Look at what grows. Seven words need up to 21 comparisons. A list of n words needs up to n × (n − 1) / 2.
The first attempt#
You turn the hand method into code.
Start with a correct version. For each word that is not in a group yet, compare it with every later word, and collect the words that match.
def group_words(words):
groups = []
placed = [False] * len(words)
for i in range(len(words)):
if placed[i]:
continue
group = [i]
for j in range(i + 1, len(words)): # every later word
if not placed[j] and sorted(words[i]) == sorted(words[j]):
group.append(j)
placed[j] = True
groups.append(group)
return groupsThis is correct. It makes up to n × (n − 1) / 2 comparisons: O(n^2). Each comparison also sorts two words. The team keeps it as the reference.
The scale check#
Now check the real dictionary.
A real dictionary holds 100,000 words, and the groups are rebuilt every time the word list changes.
Here is the count:
- Comparisons: 100,000 × 99,999 / 2, which is about 5 × 10^9.
- Time: at about 10^8 simple steps a second, that is about 50 seconds if each comparison cost one step.
- Each comparison sorts two words, so it costs many steps and the real time is longer still.
So a rebuild takes about a minute or more, and the puzzle cannot refresh its groups quickly.
Predict the cost
Q A dictionary holds 100,000 words, and no two words use the same letters. The first attempt compares every pair. About how many comparisons does it make?
Answer & explanation
Answer: C
The number of pairs is n × (n − 1) / 2, which is 4,999,950,000 for 100,000 words. At 10^8 steps a second, that is about 50 seconds before the cost of sorting is counted.
The first attempt is correct, but it looks at pairs, and there are far too many pairs. Now the team looks for a way to avoid them.
3. See: compute a key, then collect#
Can we do better?#
Start with a simpler question. How many times does each value appear in a list?
Keep a hash table from value to count. For each value, add one to its count, starting from 0. This is the whole job in one line:
counts[value] = counts.get(value, 0) + 1One lookup in a hash table takes one step on average, so counting n values takes n steps. A value that is not in the table has the count 0. This line is the heart of the lesson.
Now the puzzle. Listen, silent and tinsel are different words, so a table of word counts does not put them together. Their letters in sorted order are equal, though: all three become eilnst, and stone, notes and onset all become enost.
Words that use the same letters in a different order are called anagrams. The sorted string is the key. It is a computed key: you work it out from the word, and you do not store it in the word. It is the same for every anagram, and it differs for any word with other letters, so it is exactly the rule that decides who belongs together.
Now the table does the grouping. For each word:
- Compute its key, the letters in sorted order.
- If the key is new, start a new group for it.
- Add the position of the word to the group of its key.
| Position | Word | Key | Key in the map? | Groups after |
|---|---|---|---|---|
| 0 | listen | eilnst | no: start group 0 | group 0: 0 |
| 1 | tinsel | eilnst | yes, group 0 | group 0: 0, 1 |
| 2 | stone | enost | no: start group 1 | group 1: 2 |
| 3 | notes | enost | yes, group 1 | group 1: 2, 3 |
| 4 | silent | eilnst | yes, group 0 | group 0: 0, 1, 4 |
| 5 | onset | enost | yes, group 1 | group 1: 2, 3, 5 |
| 6 | apple | aelpp | no: start group 2 | group 2: 6 |
Seven words cost 7 keys and 7 lookups. The pair scan made 9 comparisons here, and up to 21 in the worst case.
Counting and grouping are the same move. When you count, the table stores a number, and you add one. When you group, the table stores a list, and you append the position. In both, the key decides which entry to change.
Race: Compare every pair, against one key and one lookup per word. The operation counter loads in your browser.
The race counts the worst case, where no two words share letters, so the pair scan compares every pair. At 10,000 words the pair scan makes 49,995,000 comparisons. The map makes 20,000 operations, one key and one lookup for each word. That is about 2,500 times less, and the gap grows with n.
The key of a word is its letters in sorted order, so listen, silent and tinsel all share one key.
Counting keeps a number for each key and grouping keeps a list. In both, the key decides which entry to change.
The words are ["abc", "bad", "cab", "dab", "cba"]. Three of them use the letters a, b and c, and two use a, b and d. The player stops at five steps and asks you to predict.
Dry run: Predict each step: three words share one key. The step-by-step player loads in your browser.
Reading without the player? Cover the last two columns and predict them row by row.
| Position | Word | Key | Key in the map? | Groups after |
|---|---|---|---|---|
| 0 | abc | abc | no: start group 0 | group 0: 0 |
| 1 | bad | abd | no: start group 1 | group 1: 1 |
| 2 | cab | abc | yes, group 0 | group 0: 0, 2 |
| 3 | dab | abd | yes, group 1 | group 1: 1, 3 |
| 4 | cba | abc | yes, group 0 | group 0: 0, 2, 4 |
Look at position 1. The word bad uses the letters a, b and d, so its key abd is new, and it starts its own group. At position 2, the word cab has the key abc, which is already in the map, so it joins group 0.
4. Shape: the pattern#
Here is the rule that keeps both templates correct. Every line exists to protect it.
After i items are read, each group holds the positions of the items that share its key, in increasing order, and no two groups share a key.
At the start no item is read and there are no groups, so the rule holds. Say it holds after i items. Look at the next item and its key.
If the key is in the map, the item joins that group. Its position is the largest so far, so the positions stay in increasing order. If not, it starts a new group with a key that no other group has.
So two items end up in one group exactly when their keys are equal. The groups come out in the order they started, because the list of groups only grows at the end.
Use it when you must put together the items that belong together, or count how many items share a property. Signal words: group the items that share, the same letters in any order, one group for each hour, how many of each.
Start: an empty map from key to a count, or from key to a group, plus the list of groups in the order they start.
Step: compute the key of the current item. Look it up. If it is new, start the entry. Then add one to the count, or append the position to the group.
Stop: when the items run out.
It does not find items that are close but not equal. One key answers "are these two items equal under the key?".
Cost: O(n) map steps on average, plus the cost of computing and hashing one key for each item. For n words of L letters with a sorted key, that is O(n × L log L) in all. The comparison of every pair costs O(n^2) comparisons.
Template 1: count by key#
def biggest_group(words):
# [1] A map from each key to how many words have it.
counts = {}
best = 0
# [2] Read each word once.
for word in words:
# [3] The key: the letters of the word in sorted order. Words with the same letters share it.
key = "".join(sorted(word))
# [4] Add one to the count of that key, and keep the largest count seen.
counts[key] = counts.get(key, 0) + 1
best = max(best, counts[key])
# [5] The size of the biggest group, or 0 for an empty list.
return bestpublic int biggestGroup(String[] words) {
// [1] A map from each key to how many words have it.
java.util.HashMap<String, Integer> counts = new java.util.HashMap<>();
int best = 0;
// [2] Read each word once.
for (String word : words) {
// [3] The key: the letters of the word in sorted order. Words with the same letters share it.
char[] letters = word.toCharArray();
java.util.Arrays.sort(letters);
String key = new String(letters);
// [4] Add one to the count of that key, and keep the largest count seen.
int count = counts.getOrDefault(key, 0) + 1;
counts.put(key, count);
best = Math.max(best, count);
}
// [5] The size of the biggest group, or 0 for an empty list.
return best;
}int biggestGroup(vector<string>& words) {
// [1] A map from each key to how many words have it.
unordered_map<string, int> counts;
int best = 0;
// [2] Read each word once.
for (const string& word : words) {
// [3] The key: the letters of the word in sorted order. Words with the same letters share it.
string key = word;
sort(key.begin(), key.end());
// [4] Add one to the count of that key, and keep the largest count seen.
int count = ++counts[key];
best = max(best, count);
}
// [5] The size of the biggest group, or 0 for an empty list.
return best;
}function biggestGroup(words) {
// [1] A map from each key to how many words have it.
const counts = new Map();
let best = 0;
// [2] Read each word once.
for (const word of words) {
// [3] The key: the letters of the word in sorted order. Words with the same letters share it.
const key = word.split("").sort().join("");
// [4] Add one to the count of that key, and keep the largest count seen.
const count = (counts.get(key) ?? 0) + 1;
counts.set(key, count);
best = Math.max(best, count);
}
// [5] The size of the biggest group, or 0 for an empty list.
return best;
}Template 2: group by key#
def group_words(words):
# [1] A map from each key to its group number, and the groups in the order they started.
index_of = {}
groups = []
for i in range(len(words)):
# [2] The key: the letters of the word in sorted order. Words with the same letters share it.
key = "".join(sorted(words[i]))
# [3] The first word with a new key starts a new group.
if key not in index_of:
index_of[key] = len(groups)
groups.append([])
# [4] Add this position to the group of its key.
groups[index_of[key]].append(i)
# [5] The groups, in the order they started.
return groupspublic int[][] groupWords(String[] words) {
// [1] A map from each key to its group number, and the groups in the order they started.
java.util.HashMap<String, Integer> indexOf = new java.util.HashMap<>();
java.util.List<java.util.List<Integer>> groups = new java.util.ArrayList<>();
for (int i = 0; i < words.length; i++) {
// [2] The key: the letters of the word in sorted order. Words with the same letters share it.
char[] letters = words[i].toCharArray();
java.util.Arrays.sort(letters);
String key = new String(letters);
// [3] The first word with a new key starts a new group.
if (!indexOf.containsKey(key)) {
indexOf.put(key, groups.size());
groups.add(new java.util.ArrayList<>());
}
// [4] Add this position to the group of its key.
groups.get(indexOf.get(key)).add(i);
}
// [5] The groups, in the order they started.
int[][] result = new int[groups.size()][];
for (int g = 0; g < groups.size(); g++) {
result[g] = groups.get(g).stream().mapToInt(Integer::intValue).toArray();
}
return result;
}vector<vector<int>> groupWords(vector<string>& words) {
// [1] A map from each key to its group number, and the groups in the order they started.
unordered_map<string, int> indexOf;
vector<vector<int>> groups;
for (int i = 0; i < (int)words.size(); i++) {
// [2] The key: the letters of the word in sorted order. Words with the same letters share it.
string key = words[i];
sort(key.begin(), key.end());
// [3] The first word with a new key starts a new group.
if (!indexOf.count(key)) {
indexOf[key] = groups.size();
groups.push_back({});
}
// [4] Add this position to the group of its key.
groups[indexOf[key]].push_back(i);
}
// [5] The groups, in the order they started.
return groups;
}function groupWords(words) {
// [1] A map from each key to its group number, and the groups in the order they started.
const indexOf = new Map();
const groups = [];
for (let i = 0; i < words.length; i++) {
// [2] The key: the letters of the word in sorted order. Words with the same letters share it.
const key = words[i].split("").sort().join("");
// [3] The first word with a new key starts a new group.
if (!indexOf.has(key)) {
indexOf.set(key, groups.length);
groups.push([]);
}
// [4] Add this position to the group of its key.
groups[indexOf.get(key)].push(i);
}
// [5] The groups, in the order they started.
return groups;
}Rebuild the template: put its lines back in order. Loads in your browser.
The template in order: compute the key, start a group when the key is new, then add the position to the group of its key.
A ride-hailing app batches pickup requests into slots of 15 minutes. The request times, in minutes after 08:00, are [3, 47, 12, 50, 16, 9]. Which requests share a slot?
This is Template 2 with a new key. Only line [2] changes: the key is the time divided by 15, rounded down.
| Position | Time | Key | Key in the map? | Groups after |
|---|---|---|---|---|
| 0 | 3 | 0 | no: start group 0 | group 0: 0 |
| 1 | 47 | 3 | no: start group 1 | group 1: 1 |
| 2 | 12 | 0 | yes, group 0 | group 0: 0, 2 |
| 3 | 50 | 3 | yes, group 1 | group 1: 1, 3 |
| 4 | 16 | 1 | no: start group 2 | group 2: 4 |
| 5 | 9 | 0 | yes, group 0 | group 0: 0, 2, 5 |
The result is [[0, 2, 5], [1, 3], [4]]. The requests at minutes 3, 12 and 9 share the first slot. The key was a bucket: a time divided into slots.
Complexity in plain words#
- Time. The pass reads each item once, builds one key and makes one lookup and at most one insert. A key is not free: sorting the L letters of a word costs about L log L steps. Hashing the key and checking it against its bucket costs about L more, so the whole pass is O(n × L log L).
- At scale. For 100,000 words of 8 letters, the sorting is about 100,000 × 24, or 2.4 million, steps. The pair scan needs about 5 × 10^9 comparisons, and each of them sorts two words.
- Worst case. If many keys land in one bucket, each lookup walks a long chain, as the first lesson showed. A good hash and a resizing table keep this rare.
- Space. The map holds one entry for each different key, and the groups hold every position once. That is O(n) entries, plus the keys themselves, which take up to n × L letters.
- Trade. The pair scan stores almost nothing extra. The map stores one key for each group. That memory buys the drop from billions of comparisons to millions of steps.
5. Stretch: edges, mistakes and variations#
Edge cases#
Real production data brings edge cases.
Before any word is loaded, words = []. The loop never runs, and the template returns []: no groups. A dictionary with one word, ["a"], returns [[0]]: one group with one position.
For ["ab", "ba", "ab"], the answer is [[0, 1, 2]]. The first and the third word are equal, and the second has the same letters, so all three share the key ab. A repeated word is not a special case.
For ["aab", "abb", "baa"], the keys are aab, abb and aab. The answer is [[0, 2], [1]]. Words that use the same letters different numbers of times are not in one group, because their sorted letters differ.
For ["Dog", "god"], the keys are Dgo and dgo, and the template returns [[0], [1]]. A capital letter sorts before every lowercase letter, so the keys differ. If your team lead wants them together, change the key: lowercase the word first.
The mistake museum#
This version adds up the position of each letter in the alphabet, with a = 1, b = 2 and so on, and uses the sum as the key. It is quick to compute, but different words can have the same sum.
Counterexample: words = ["abd", "acc"]. The sums are 1 + 2 + 4 = 7 and 1 + 3 + 3 = 7, so the bug puts both words in one group. They do not use the same letters, so the right answer is [[0], [1]].
A key must be equal for every item that belongs together and different for every item that does not.
A sum of letter positions is a bad key: abd and acc both add up to seven, so two different words land in one group.
This version uses the set of distinct letters as the key. Two words with the same letters in different numbers share a set.
Counterexample: words = ["aab", "abb"]. Both have the set {a, b}, so the bug returns [[0, 1]]. The first word has two a's and one b, the second has one a and two b's, so the right answer is [[0], [1]].
Keep the repeats: sort the letters, do not collapse them.
In Java, the key is the sorted char[] itself, with no conversion to a String. An array compares by identity, so two arrays with equal letters are two different keys. In JavaScript, a Map also compares an array key by identity. In Python, a list key raises an error, because a list cannot be hashed.
Counterexample: words = ["act", "cat"]. In Java, the bug returns [[0], [1]] and the right answer is [[0, 1]].
Turn the sorted letters back into a String before you use them as a key.
In Java, an array used as a key compares by identity, so act and cat get two keys and land in two groups.
This version uses the faster key from Variation 3, the count of each of the 26 letters, and writes the counts one after another with nothing between them.
Counterexample: words = ["abbbbbbbbbbb", "aaaaaaaaaaab"]. The first word has one a and eleven b's, so its counts read 1, 11 and then 24 zeros: 111000…. The second has eleven a's and one b, which reads 11, 1 and 24 zeros: the same string. The bug returns [[0, 1]], and the right answer is [[0], [1]].
Put a separator between the counts, as in 1#11#0#…, or use a tuple of counts as the key. A count that can reach 10 needs one.
When the pattern does not apply#
Your team lead asks for the words that are one letter away from each other, such as cat and cot. A key says whether two words are equal under a rule. Cat and cot have different sorted letters, so no single key puts them together.
For near matches you need a different tool. One trick makes several keys for each word, by replacing each letter in turn with a star. Another is to compare candidates directly. Grouping by one key is for an exact relation.
Product variations#
Your team lead brings four product changes. Each one asks what the key is.
The puzzle team wants the size of the biggest group, to rate the hardest puzzle. This is Template 1: count by key, and keep the largest count. The groups themselves are never built.
A text app wants to group strings that differ by the same shift, such as abc and bcd. The key is the list of differences between neighbouring letters, taken modulo 26. Both give 1, 1. Keep the separator in the key here too: the differences 1 and 11 must not read the same as 11 and 1 (LeetCode 249: Group Shifted Strings).
For long words, sorting costs L log L steps. The key can instead be the count of each of the 26 letters, which takes L steps plus 26. Write the counts with a separator between them, such as 1#0#2#…, or use a tuple in Python. Exhibit 4 shows what goes wrong without one.
The map and the rest of the template stay the same (LeetCode 49: Group Anagrams).
A monitoring system wants the number of log lines in each hour. The key is the second divided by 3,600, and the table stores a count. This is counting with a computed key, and it is the checkpoint problem "Bucket the events by hour".
Why this matters in real systems#
6. Say: lock it in#
Explain it back#
Explain it back: write the idea in your own words and get feedback.
In two or three sentences, explain to a teammate how you group items that belong together in one pass. Then explain why the key must be equal for every item in a group and different for every other item. The AI tutor checks your answer against the invariant and the key points of this lesson.
Here is how you could explain it in an interview, now that you have built it.
For each item, I compute a key that is the same for every item that belongs with it. For words with the same letters, the key is the letters in sorted order.
I look the key up in a map. If it is new, I start a group, and then I add the item to the group of its key.
Counting is the same move with a number in place of a list. One pass, O(n) map steps, and a record of the order the groups start.
The 60-second interview answer#
The straightforward answer compares every pair of items, which costs O(n^2) comparisons. I can group them in one pass with a hash map.
For each item, I compute a key that is the same for every item that belongs together. For words that use the same letters, the key is the letters in sorted order. For events in the same hour, the key is the time divided by 3,600.
I look the key up in a map. If the key is new, I start a group. Then I add the item to the group of its key. Counting is the same move, with a number instead of a list.
Each lookup is O(1) on average, so the pass is O(n) map steps plus the cost of one key for each item. A map keeps no order, so I record the order in which groups start. If the question asks for near matches, one key is not enough.
Checkpoint#
Checkpoint · Counting
Q1/3 A program counts how often each id appears in a list. What does it do to the map for each id it reads?
Answer & explanation
Answer: C
Counting is one line: counts[id] = counts.get(id, 0) + 1. An id that is not in the map has the count 0, so the first read makes it 1.
Q2/3 The list is
[4, 7, 4, 4, 7]. What are the counts?Answer & explanation
Answer: B
The id 4 is read at positions 0, 2 and 3, so its count reaches 3. The id 7 is read at positions 1 and 4, so its count reaches 2.
Q3/3 How is grouping different from counting?
Answer & explanation
Answer: C
Both look up a key in a map. Counting adds one to a number. Grouping appends the position to a list.
Checkpoint · The key
Q1/4 Which key puts words that use the same letters into one group?
Answer & explanation
Answer: C
Listen, silent and tinsel all give eilnst when the letters are sorted. The length and the first letter are shared by words that use different letters, and the reversed word is different for each of these three.
Q2/4 What key does the word
stoneget when the letters are sorted, and which word below shares it?Answer & explanation
Answer: B
The letters of stone in sorted order are e, n, o, s, t, so the key is enost. Notes has the same letters, so it has the same key. Stoke has a k instead of an o.
Q3/4 Which key is a bad choice for grouping words with the same letters?
Answer & explanation
Answer: B
The sum can be equal for words that do not use the same letters. With a = 1, b = 2, c = 3 and d = 4, abd and acc both add up to 7.
Q4/4 For a very long word, which key can be built faster than sorting its letters?
Answer & explanation
Answer: B
A single pass over the word fills 26 counters, which costs about L steps for L letters, where sorting costs about L log L. The set of distinct letters is quicker but loses the repeats, so aab and abb would be grouped.
Checkpoint · Cost and limits
Q1/3 A dictionary holds 100,000 words and no two words use the same letters. About how many comparisons does the first attempt make?
Answer & explanation
Answer: D
The number of pairs is n × (n − 1) / 2, which is 4,999,950,000. At 10^8 steps a second, that is about 50 seconds before the cost of sorting is counted.
Q2/3 Why does the group template keep a list of groups next to the map?
Answer & explanation
Answer: C
The list of groups grows by one each time a new key appears, so it records the order. The map only sends each key to the number of its group.
Q3/3 Which question is a job for a single computed key?
Answer & explanation
Answer: C
A key answers whether two items are equal under a rule. Near matches and rankings need other tools, such as comparing candidates or sorting.
Ten questions on this lesson. Pass with 80% or more to be sure the idea has landed before the next lesson. The checkpoint of this stretch, with its two mixed problems, comes after the next lesson.
Practice ladder#
| Step | Problem | Product line | Level | What it practises |
|---|---|---|---|---|
| 1 | Count orders per customer | Shop | Easy | A table of counts that answers many lookups |
| 2 | Do all poll options have different tallies? | Feed | Easy | Counting with a map, then counting the counts |
| 3 | Ad banners with the same shape | Ads | Medium | A computed key: a ratio reduced to lowest terms |
| 4 | Chats filled by requested size | Chat | Medium | Groups that fill up and start again |
Review cards#
Review cards from this lesson.
Five cards from this lesson join your spaced-review deck. The first one comes back tomorrow.