Group Anagrams is a clean lesson in canonical keys: turn each item into a fingerprint, then bucket items by that fingerprint with a hash map.
Problem. Given an array of strings words, group the anagrams together. Two strings are
anagrams if one is a rearrangement of the other's letters. Return a list of groups, in any order.
Example: words = ["eat", "tea", "tan", "ate", "nat", "bat"] → [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]].
The slow way first
The obvious idea: compare every pair of words and ask "are these two anagrams?" Checking one pair takes O(k) work (sort or count the letters), and there are O(n²) pairs, so the whole thing is O(n² k). For a long list that is far too slow, and stitching the matches into groups is fiddly.
The question to ask: what do all anagrams have in common that I can compute once per word? Their sorted letters. "eat", "tea", and "ate" all become "aet". That shared string is a perfect map key.
The idea: a canonical key
Walk the list once. For each word w, build its canonical key by sorting its letters: "".join(sorted(w)). Use that key to look up a bucket in a hash map and append w to it. Anagrams collapse onto the same key automatically, so they land in the same bucket.
The key insight: anagrams are defined by having the same multiset of letters, and sorting turns that multiset into one canonical string. Equal multisets → equal key → same bucket.
Walk through it
Step through the animation. The pointer w scans the words left to right. For each word we show its sorted key, then watch the matching bucket fill. "eat", "tea", "ate" all route to "aet"; "tan" and "nat" route to "ant"; "bat" lands alone in "abt". At the end we return the buckets.
Pseudocode
make an empty map "groups" whose default value is an empty list
for each word w in words:
key = the letters of w, sorted, joined into a string
append w to groups[key]
return all the lists stored in groupsThe Python solution
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
key = "".join(sorted(w))
groups[key].append(w)
return list(groups.values())defaultdict(list)means an unseen key auto-creates an empty list, so we never check "does this bucket exist yet?"sorted(w)returns the word's characters in order;"".join(...)glues them back into a string like"aet".groups[key].append(w)is the whole algorithm — one O(1) lookup, then add the word.list(groups.values())hands back the groups; the order is unspecified, which the problem allows.
Complexity
| Case | Time | Notes |
|---|---|---|
| Brute force (compare every pair) | O(n² k) (moderate) | O(n²) pairs, O(k) per check |
| Canonical key (this solution) | O(n k log k) (moderate) | n words, sort each length-k word |
O(n k) (moderate)Sorting each word costs O(k log k), done n times. We trade O(n k) extra space (the map of buckets) for a big speed win. Using a 26-letter count tuple as the key instead of a sorted string drops the per-word cost to O(k), giving O(n k) overall.
When this pattern shows up
Whenever a problem says "group / bucket items that are equivalent under some rule," build a canonical key for each item and use a hash map. Group Anagrams, "group shifted strings," and "isomorphic grouping" are all the same move: collapse each item to a fingerprint, then bucket by it.
Do not use the raw word as the key, and do not forget to join the sorted list. sorted("eat") returns
a list ['a', 'e', 't'] — lists cannot be dictionary keys, so you must join it into a string (or use a
tuple).
Practice
Scanning ['eat', 'tea', 'tan', 'ate', 'nat', 'bat'], which words end up under the key 'ant'?
1. Why do all anagrams of a word produce the same map key?
2. What does defaultdict(list) save us from writing?
3. Why is the brute-force approach O(n squared k)?
4. Why can we not use sorted(w) directly as the dictionary key?