Algorithms from zero
Grouping with a hash map
You have an array of words [“eat”, “tea”, “ate”, “tan”, “nat”] and you want to group together words that are anagrams of each other. One approach: for each word, check all others to see if they are anagrams. That is O(n²). But here is the key: for each word, compute a group key (the word’s letters sorted), then append the word to the list stored under that key in a hash map. When you reach the end, your hash map contains all groups. One pass, O(n) time (plus the cost of sorting each word). This is the grouping pattern, and it partitions data by a computed property.
After this lesson you can group items by a computed key using a hash map, understand why the grouping pattern organizes items that share a property into lists (not counts), implement the group-anagrams example with a sorted-letters key, design a group key for any partition problem (by remainder, by first letter, by row, by age bracket), compare grouping with frequency counting, and recognize the shape: “map[key] is a list; append items that belong together.”
The problem we are solving:
You are given an array of words [“eat”, “tea”, “ate”, “bat”, “tab”] and asked: group all anagrams together.
One approach: nested loops and anagram checks.
function groupAnagramsNaive(words) {
const groups = [];
const used = new Array(words.length).fill(false);
for (let i = 0; i < words.length; i++) {
if (used[i]) continue;
const group = [words[i]];
used[i] = true;
// For each word, check all remaining words
for (let j = i + 1; j < words.length; j++) {
if (!used[j] && areAnagrams(words[i], words[j])) {
group.push(words[j]);
used[j] = true;
}
}
groups.push(group);
}
return groups;
}
function areAnagrams(s, t) {
if (s.length !== t.length) return false;
const sorted1 = s.split('').sort().join('');
const sorted2 = t.split('').sort().join('');
return sorted1 === sorted2;
}This is O(n² · m log m) where n is the number of words and m is the average word length.
The grouping pattern idea:
Instead, use a hash map where the value is a list, not a count. For each word, compute its group key (the letters sorted), then append it to the list under that key.
function groupAnagrams(words) {
const map = new Map(); // key → [list of words]
for (let word of words) {
// Compute the group key: sorted letters
const key = word.split('').sort().join('');
// Append this word to the list under that key
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(word);
}
// Return all groups
return Array.from(map.values());
}
// Example: ["eat", "tea", "ate", "tan", "nat"]
// "eat" → key "aet" → map["aet"] = ["eat"]
// "tea" → key "aet" → map["aet"] = ["eat", "tea"]
// "ate" → key "aet" → map["aet"] = ["eat", "tea", "ate"]
// "tan" → key "ant" → map["ant"] = ["tan"]
// "nat" → key "ant" → map["ant"] = ["tan", "nat"]
// Result: [["eat", "tea", "ate"], ["tan", "nat"]]One loop, O(n · m log m) time (n words, each taking m log m to sort and hash). One hash map, O(n) space.
Why it works:
- Same key, same group: all anagrams of a word have the same sorted letters, so they compute the same key and land in the same bucket.
- Order does not matter: if “aet” comes before “ate”, they both map to “aet” and merge into one list.
- One pass: as you iterate, items that belong together collide on purpose under the same key.
The shape of grouping:
The pattern is: for each item: key = f(item); map[key].push(item).
Key design is the heart of grouping. The right key makes items that belong together collide:
- Anagrams: key = sorted letters. “eat”, “tea”, “ate” all map to “aet”.
- Remainder groups: key = n % k. Numbers 5, 11, 17 with k=6 all map to 5 (remainder).
- First letter: key = word[0]. “apple”, “apricot” both map to “a”.
- Age bracket: key = Math.floor(age / 10). Ages 23, 24, 25 all map to 2 (20s).
- Row in a grid: key = point.row. Points (3, 1), (3, 5), (3, 9) all map to row 3.
Grouping vs. frequency counting:
- Frequency:
map[key]is a count (a number). How many times does each key appear? - Grouping:
map[key]is a list (an array). Which items map to this key?
Frequency answers “how many”. Grouping answers “which ones”.
Example:
// Frequency: count characters in "mississippi"
const freq = new Map();
for (let char of "mississippi") {
freq.set(char, (freq.get(char) ?? 0) + 1);
}
// freq: m→1, i→4, s→4, p→2
// Grouping: group characters by value in "mississippi"
const groups = new Map();
for (let char of "mississippi") {
if (!groups.has(char)) groups.set(char, []);
groups.get(char).push(char);
}
// groups: m→['m'], i→['i','i','i','i'], s→['s','s','s','s'], p→['p','p']1. Group anagrams by sorted letters:
function groupAnagrams(words) {
const map = new Map();
for (let word of words) {
// Key: the word's letters, sorted
const key = word.split('').sort().join('');
// Initialize the list if this is the first word with this key
if (!map.has(key)) {
map.set(key, []);
}
// Append the word to the group
map.get(key).push(word);
}
// Return all groups as an array of arrays
return Array.from(map.values());
}
const result = groupAnagrams(["eat", "tea", "ate", "tan", "nat"]);
console.log(result);
// Output: [["eat", "tea", "ate"], ["tan", "nat"]]2. Group numbers by remainder (modulo):
function groupByRemainder(nums, k) {
const map = new Map();
for (let num of nums) {
const key = num % k; // Group key: remainder
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(num);
}
return Array.from(map.values());
}
const result = groupByRemainder([5, 11, 2, 8, 17, 3], 6);
// 5 % 6 = 5, 11 % 6 = 5, 2 % 6 = 2, 8 % 6 = 2, 17 % 6 = 5, 3 % 6 = 3
// Grouping: {5: [5, 11, 17], 2: [2, 8], 3: [3]}
console.log(result);
// Output: [[5, 11, 17], [2, 8], [3]]3. Group words by first letter:
function groupByFirstLetter(words) {
const map = new Map();
for (let word of words) {
const key = word[0]; // Group key: first letter
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(word);
}
return Array.from(map.values());
}
const result = groupByFirstLetter(["apple", "apricot", "banana", "blueberry", "cherry"]);
// 'a': ["apple", "apricot"], 'b': ["banana", "blueberry"], 'c': ["cherry"]
console.log(result);
// Output: [["apple", "apricot"], ["banana", "blueberry"], ["cherry"]]Try the group-anagrams example:
Let us trace the group-anagrams pattern on [“eat”, “tea”, “ate”, “tan”, “nat”]:
1
function groupAnagrams(words) {
2
const map = new Map();
3
for (let word of words) {
4
const key = word.split('').sort().join('');
5
if (!map.has(key)) {
6
map.set(key, []);
7
}
8
map.get(key).push(word);
9
}
10
return Array.from(map.values());
11
}
Notice: all anagrams land in the same bucket because they compute the same key. The sorted letters act as a “fingerprint” that identical-letter sets share.
The grouping pattern:
| Operation | Time | Space |
|---|---|---|
| Group anagrams (sort key) | O(n · m log m) | O(n · m) |
| Group by remainder | O(n) | O(n) |
| Group by first letter | O(n) | O(n) |
Where n is the number of items and m is the average length (for sorting).
Comparison: naive nested loops vs. grouping pattern:
- Nested loops: for each item, check all others. n × n = O(n²) (plus anagram checks).
- Grouping pattern: one loop, compute key for each, append. O(n · m log m) where m is the cost of the key function (e.g., sorting).
For anagrams with n = 100 words of length m = 10:
- Naive: 100 × 100 × (10 log 10) = 100,000+ operations.
- Grouping: 100 × (10 log 10) = 1,000 operations.
The grouping pattern is orders of magnitude faster when the key function is cheaper than comparisons.
Why bucketing works:
The hash map divides items into buckets by key. Items that compute the same key automatically group together. This is O(1) bucket insertion and O(n) total time to fill all buckets.
In the group-anagrams pattern, why do we use map[key].push(item) instead of map[key]++?
For group anagrams, what is the group key for "eat", "tea", and "ate"?
If you group numbers [3, 9, 5, 14, 7, 12] by remainder mod 4, which numbers are in the same group?
What is the time complexity of grouping n words by anagrams if each word is sorted (m log m)?
If you group 50 items and each key function takes 5 units of work, approximately how much total work (in units) do you perform?
lesson.inset.misconception
“Grouping is just frequency counting with a list instead of a number.” Not quite. The conceptual difference is important: frequency counting answers “how many times?” Grouping answers “which items?” The two patterns solve different problems. Frequency works when you care about counts. Grouping works when you need to partition items by a shared property.
Edge cases
Empty input: An empty array produces an empty map. Return an empty array of groups.
No matches: If every item has a unique key, each group has one item. Return an array of n single-item arrays.
Null or undefined items: If the list contains null or undefined, the key function must handle them. For anagrams, null and undefined cannot be sorted, so filter them first or test the input.
Very long keys: If keys are very long strings or objects, use a hash function to map them to integers, or serialize them to a canonical string.
Why is grouping O(n · m log m) for anagrams while a naive approach is O(n² · m log m)?
Grouping with a hash map is the practice of partitioning items by a computed property: assign each item to a key, then append it to the list stored under that key.
Key facts:
- The grouping shape:
for each item: key = f(item); map[key].push(item); - Key design is everything: the right key makes items that belong together collide in the same bucket.
- Common keys:
- Sorted letters (anagrams)
- Remainder mod k (number groups)
- First character (word groups)
- Age bracket (population groups)
- Row or column (spatial groups)
- Grouping vs. frequency:
- Frequency:
map[key]is a count. - Grouping:
map[key]is a list.
- Frequency:
- Time complexity:
- Anagrams: O(n · m log m) (sorting each word).
- Remainder/first-letter: O(n) (no expensive key function).
- Space complexity: O(n · m) for the map and lists.
Grouping transforms O(n²) nested-loop problems into O(n · cost-of-key) problems. Next, you will explore how hash collisions affect performance, and how to design better keys and hash functions.