Grouping by Key
When elements that look different are secretly equivalent, compute a canonical signature for each one and group them in a hash map. The signature is the key — not the element. This one idea solves Group Anagrams, Group Shifted Strings, and any equivalence-class problem.
Table of contents
- Before we start
- Picture this first (no code yet)
- The actual problem
- First, the slow way (so you feel the pain)
- The turning point
- The one idea to remember
- Watch it happen, frame by frame
- Now, the code — line by line
- Why does the canonical form always work?
- When should I reach for this? (the trigger list)
- The same trick in four disguises
- Disguise 1 — Group Shifted Strings (LC #249)
- Disguise 2 — Find Duplicate File in System (LC #609)
- Disguise 3 — Isomorphic Strings / Isomorphic Groups
- Disguise 4 — Group by Row in Spiral / BFS level
- Traps that catch beginners
- Say it like a pro (interview one-liner)
- Check yourself
- Practice problems
Before we start
By the end of this page you will be able to:
- See that grouping problems are really about finding a "name" (signature) that all equivalent elements share.
- Explain out loud why you hash the signature, not the element itself.
- Recognise this pattern in anagram grouping, shifted-string grouping, and any "group by hidden property" problem.
Stop at every Pause & Think box.
Picture this first (no code yet)
A real-life story
A post office receives thousands of letters every day. Each envelope has a different destination written on it: "22 Baker Street", "22 Baker St", "22 Baker Str.", "22 baker street". These all look different, but they all go to the same house.
A smart sorter doesn't read the full address. She has a rule: strip punctuation, lowercase everything, convert "Street" → "St". The result — let's call it the sorting code — is the same for all four envelopes: "22 baker st". She drops each letter into the tray labelled with its sorting code.
At the end, each tray holds a group of letters that all belong to the same destination. She never compared any two envelopes directly. She just computed the code and filed.
The sorting code is the canonical form (or signature). The tray label is the hash map key. The filing action is map[signature].append(element). That's the entire Grouping by Key pattern.
The actual problem
Given a list of strings, group together all strings that are anagrams of each other. Return each group as a list.
input : ["eat","tea","tan","ate","nat","bat"]
output : [["eat","tea","ate"], ["tan","nat"], ["bat"]]
"eat", "tea", "ate" are all anagrams — they have the same letters in different order. They belong in the same group.
First, the slow way (so you feel the pain)
Compare every pair of strings. Two strings are anagrams if, after sorting their characters, they become identical.
# O(n²) × O(k log k) — checking every pair
groups = []
used = [False] * len(strs)
for i in range(len(strs)):
if used[i]: continue
group = [strs[i]]
for j in range(i+1, len(strs)):
if not used[j] and sorted(strs[i]) == sorted(strs[j]):
group.append(strs[j])
used[j] = True
groups.append(group)
For n=10,000 strings of average length k=10: ~50 million pair comparisons, each costing O(k log k). Painful.
The grouping approach is O(n × k log k) — one sort per string, one hash map insert. No pairwise comparisons at all.
The turning point
The key question: what do all anagrams of "eat" have in common?
Pause & think
"eat", "tea", "ate" — what single operation produces the same result for all three, no matter what order the letters are in?
Sort the characters: sorted("eat") = ['a','e','t'], sorted("tea") = ['a','e','t'], sorted("ate") = ['a','e','t']. All produce "aet". That's the canonical form — the sorting code.
Any two strings are anagrams if and only if their sorted-character form is identical. So: sort each string's characters → use the result as the hash map key → append the original string to that key's group.
The one idea to remember
The entire pattern in one sentence
Compute a canonical form (signature) for each element — a transformation that produces the same value for all equivalent elements — then use that signature as the hash map key to collect groups.
The entire pattern is in three steps:
- Choose the right signature for this problem.
- Map each element to its signature.
- Group by collecting all elements with the same signature.
Watch it happen, frame by frame
Input: ["eat","tea","tan","ate","nat","bat"]
Signature = sorted characters joined as string.
"eat" → sorted = "aet" → groups={"aet": ["eat"]}
"tea" → sorted = "aet" → groups={"aet": ["eat","tea"]}
"tan" → sorted = "ant" → groups={"aet": [...], "ant": ["tan"]}
"ate" → sorted = "aet" → groups={"aet": ["eat","tea","ate"]}
"nat" → sorted = "ant" → groups={"aet": [...], "ant": ["tan","nat"]}
"bat" → sorted = "abt" → groups={"aet": [...], "ant": [...], "abt": ["bat"]}
Result: [["eat","tea","ate"], ["tan","nat"], ["bat"]] ✅
Pause & think
Cover the trace below. Group these: ["ab","ba","abc","bca","cab","a"]. What is the canonical form of each? How many groups result?
Check your trace
"ab" → "ab" → group "ab": ["ab"]
"ba" → "ab" → group "ab": ["ab","ba"]
"abc" → "abc" → group "abc": ["abc"]
"bca" → "abc" → group "abc": ["abc","bca"]
"cab" → "abc" → group "abc": ["abc","bca","cab"]
"a" → "a" → group "a": ["a"]
3 groups: [["ab","ba"], ["abc","bca","cab"], ["a"]] ✅
Now, the code — line by line
from collections import defaultdict
def groupAnagrams(strs):
groups = defaultdict(list) # signature → [list of strings]
for s in strs:
key = "".join(sorted(s)) # compute canonical form: sorted characters
groups[key].append(s) # file the original string under its signature
return list(groups.values()) # return all groups
Mapping every line to the post office:
groups = defaultdict(list)— a set of empty trays, one created automatically when needed.key = "".join(sorted(s))— compute the sorting code: sort the envelope address.groups[key].append(s)— drop the original letter into the tray for this code.return list(groups.values())— hand over all the sorted trays.
Time: O(n × k log k) — n strings, each sorted in O(k log k). Space: O(n × k) — storing all strings in the groups.
Can we do better than O(k log k) per string? Yes — use a character frequency tuple as the key:
def groupAnagrams_linear(strs):
groups = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
key = tuple(count) # (1,0,0,...,1,0,...,1,...) — O(k) to compute
groups[key].append(s)
return list(groups.values())
Now each key is computed in O(k) — better for long strings with large k.
Why does the canonical form always work?
The canonical form must have one property: two elements produce the same canonical form if and only if they are equivalent. For anagrams, sorted characters satisfies this:
- Sorted characters of two anagrams are always identical (same letters, now in the same order). ✓
- If two strings have identical sorted characters, they must be anagrams. ✓
So the canonical form is a perfect equivalence detector — it neither over-groups (non-anagrams never collide) nor under-groups (all anagrams always match).
When should I reach for this? (the trigger list)
Reach for grouping-by-signature when:
- You need to collect elements into equivalence classes — groups where all members share a hidden property.
- Two elements look different on the surface but are "the same" by some transformation (anagram, shifted, rotated, palindrome-normalised, etc.).
- The brute-force answer compares every pair to decide if they belong together — O(n²).
- You can describe the equivalence as: "elements A and B are equivalent if
f(A) == f(B)" for some computable functionf.
The choice of f (the signature function) is the entire design decision. Everything else is the same hash-map grouping code.
The same trick in four disguises
The skeleton is always:
groups = defaultdict(list)
for element in data:
key = signature(element) # ← only this line changes per problem
groups[key].append(element)
return list(groups.values())
Disguise 1 — Group Shifted Strings (LC #249)
Two strings are "shifted" equivalents if one can become the other by shifting all characters by the same amount (circular). Canonical form: normalise to start from 'a' by subtracting the first character's value.
def groupStrings(strings):
groups = defaultdict(list)
for s in strings:
# Shift so first char becomes 'a'
shift = ord(s[0]) - ord('a')
key = tuple((ord(ch) - ord('a') - shift) % 26 for ch in s)
groups[key].append(s)
return list(groups.values())
"abc" → (0,1,2). "bcd" → (0,1,2). Same key → same group. ✓
Disguise 2 — Find Duplicate File in System (LC #609)
Two files are duplicates if their content is identical. Canonical form = the file content string itself.
def findDuplicate(paths):
content_map = defaultdict(list)
for path in paths:
parts = path.split()
directory = parts[0]
for file_info in parts[1:]:
name, content = file_info.split('(')
content = content[:-1] # remove trailing ')'
content_map[content].append(directory + '/' + name)
return [group for group in content_map.values() if len(group) > 1]
Disguise 3 — Isomorphic Strings / Isomorphic Groups
Two strings are isomorphic if there's a consistent character mapping from one to the other. Canonical form: replace each character with its first-occurrence index.
def canonicalForm(s):
mapping = {}
result = []
counter = 0
for ch in s:
if ch not in mapping:
mapping[ch] = counter
counter += 1
result.append(mapping[ch])
return tuple(result)
# "egg" → (0,1,1), "add" → (0,1,1) → same group
# "foo" → (0,1,1), "bar" → (0,1,2) → different groups
Disguise 4 — Group by Row in Spiral / BFS level
In tree/graph problems, group nodes by their level (BFS) or by a computed coordinate. Same skeleton: compute the "address" (level, coordinate), group by it.
Level up — Choosing the right signature (the real skill)
The hardest part of Grouping by Key is choosing the canonical form. A checklist:
| Equivalence condition | Canonical form |
|---|---|
| Same characters (anagram) | sorted(s) or frequency tuple |
| Same relative differences (shifted) | Normalise first char to 0; take differences mod 26 |
| Same structure (isomorphic) | Replace chars with first-occurrence indices |
| Same content (file duplicate) | Content string itself |
| Same numeric sequence | Tuple of values |
When in doubt: ask "what transformation makes all equivalent elements identical?" That transformation is your f.
Traps that catch beginners
Watch out for these
- Using a mutable type as the key. Lists can't be dict keys (
TypeError: unhashable type: 'list'). Convert totupleor"".join(sorted(...))— both are hashable and immutable. - Choosing a signature that over-groups. If your canonical form is too coarse, non-equivalent elements collide. Test with a small example: do two non-equivalent elements produce the same key? If yes, refine the signature.
- Choosing a signature that under-groups. If your canonical form distinguishes equivalent elements, they land in separate groups. Test: do two equivalent elements produce the same key?
- Forgetting
defaultdict(list). Using a plaindictand doinggroups[key].append(s)will raiseKeyErrorwhen a new key is first seen. Usedefaultdict(list)orgroups.setdefault(key, []).append(s).
| Bug | Fix |
|---|---|
TypeError: unhashable type: 'list' | Use tuple(count) or "".join(sorted(s)) as key |
| Non-equivalent elements in same group | Signature is too coarse — add more discriminating information |
| Equivalent elements in different groups | Signature is too fine — apply a normalisation step |
KeyError on new key | Use defaultdict(list) |
Say it like a pro (interview one-liner)
"This is a grouping-by-canonical-form problem. I'll compute a signature for each element — one that's identical for equivalent elements and different otherwise — then use the signature as a hash map key and collect all elements with the same key into a list. One pass, O(n) groups built. The only design choice is the right signature function."
Remember this forever
Grouping by Key (Canonical Form)
groups = defaultdict(list)
for element in data:
key = canonical_form(element) # same for equivalent elements, different otherwise
groups[key].append(element)
return list(groups.values())
Signature examples: sorted characters · frequency tuple · first-occurrence index tuple · normalised differences
Trigger: group equivalent-but-different-looking elements · O(n²) pairwise comparison → O(n) grouping
Key must be hashable: use tuple or str, never list
Cost: O(n × cost_of_signature) time, O(n) space
Check yourself
Why can't you use a list as a dictionary key in Python?
Dictionary keys must be hashable — their hash value must be stable and immutable. Lists are mutable: you can append to them, changing their content. A mutable object has no stable hash. Python enforces this by making lists unhashable. Use tuple (immutable) or str instead.
What is the canonical form for the anagram problem, and why does sorting work?
The canonical form is the sorted character sequence, e.g. "".join(sorted(s)). Sorting works because anagrams contain exactly the same characters — just in different order. Sorting puts all characters in the same order regardless of their original arrangement. Two strings are anagrams ⟺ their sorted forms are identical — making sorted form a perfect equivalence key.
How would you group strings that are "rotations" of each other (e.g., "abc", "bca", "cab")?
A canonical form for rotation equivalence: concatenate the string with itself (s + s), then find the lexicographically smallest rotation. Or simpler: the minimum rotation.
key = min(s[i:] + s[:i] for i in range(len(s)))
All rotations of "abc" produce the same minimum rotation "abc". All rotations of "bca" also produce "abc". Same key → same group. ✓
Practice problems
| Problem | Difficulty | What to notice | Link |
|---|---|---|---|
| Group Anagrams | Medium | Signature = sorted characters or frequency tuple | LC #49 |
| Group Shifted Strings | Medium | Normalise first char to 0; take differences mod 26 | LC #249 |
| Find Duplicate File in System | Medium | Signature = file content string | LC #609 |
| Isomorphic Strings | Easy | Check if two strings have the same first-occurrence-index pattern | LC #205 |
| Word Pattern | Easy | Same isomorphic structure check between pattern and words | LC #290 |
That's the core of the Hashing chapter's grouping pattern. Next up: Hashing for Subarray Problems — where the hash map stores prefix sums to count subarrays satisfying a condition in a single pass.