Group anagrams: canonical keysLESSON 2.04 · 4 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 30 of 252
LESSON 2.04 · 4 OF 43 IN CHAPTERTry it, then open the solution

Group anagrams: canonical keys

Constructed practice problem; no company attribution. Prerequisites: valid anagram.

Candidate brief

THE PROBLEM

The word-game service now receives a batch of words and must place words with identical character inventories together. Preserve duplicate entries and make output deterministic. How can you avoid checking every word against every previous word?

Write this:

def group_anagrams(words):
    ...
Contract Decision
Input List/tuple of Python strings, exact Unicode code-point semantics
Output List of groups; group order follows first occurrence; words retain input order
Boundaries Empty batch gives []; duplicate words and empty strings remain entries
Invalid input Invalid container or non-string element raises ValueError
Excluded Text normalization, approximate similarity, and sorting output alphabetically
Optional refresher · the underlying tool

An anagram group needs a key that ignores order but preserves repeats. Python strings cannot be rearranged in place; a tuple of sorted characters is hashable and can key a dictionary:

key = tuple(sorted("eat"))
groups = {key: ["eat"]}
groups.setdefault(tuple(sorted("tea")), []).append("tea")
print(groups[key])  # ['eat', 'tea']

State the output order contract before building groups. Two equal keys must share one group, but distinct original words keep their input order.

A design choice worth saying aloud

groups uses an immutable tuple of sorted characters to collect the original words that share a signature. Append rather than sort the words in each group: sorting their contents would discard the promised input order. The signature is an internal key, never a substitute for the word shown to the user.

What the interviewer expects

The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.

Done means: List of groups; group order follows first occurrence; words retain input order.

Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.

Test-case scenarios to settle before coding

01 · Representative

Input / starting state
["eat","tea","tan","ate"]
Expected result
[["eat","tea","ate"],["tan"]]

What it is testing: Canonical keys form groups.

02 · Empty batch

Input / starting state
[]
Expected result
[]

What it is testing: No synthetic empty group is created.

03 · Empty words

Input / starting state
["",""]
Expected result
[["",""]]

What it is testing: Empty strings are real entries.

04 · Duplicates

Input / starting state
["ab","ab","ba"]
Expected result
one group retaining all three entries

What it is testing: Do not deduplicate input.

05 · Stable order

Input / starting state
["tan","eat","nat","tea"]
Expected result
groups and members follow first appearance

What it is testing: Sorting the final answer changes the contract.

06 · Invalid/atomic

Input / starting state
["ok", 7]
Expected result
ValueError; input unchanged

What it is testing: Validate the whole batch.

For each case, show which branch or state change produces that result.

group_anagrams(["eat", "tea", "tan", "eat", "ate"]) returns [["eat", "tea", "eat", "ate"], ["tan"]]. group_anagrams(["", ""]) == [["", ""]]. group_anagrams(["ok", 7]) raises ValueError.

Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.

Worked lesson, changed requirements, and reference

Replace pairwise questions with a shared identity

A baseline compares each word with a representative of every existing group using the previous anagram checker. In the worst case, each word starts a new group, giving O(w² L) work for w words of maximum length L. The useful question is no longer “Are these two equal?” but “Can all equivalent words compute the same key independently?” A canonical key represents an entire equivalence class: equality of keys must hold exactly when the words are anagrams, not merely often.

Word, in order Sorted code-point key Existing group? Action
eat aet no create group 0
tea aet yes, 0 append to group 0
tan ant no create group 1
eat aet yes, 0 append duplicate to group 0

Sorting the characters supplies such a key: equal inventories sort identically, and identical sorted strings contain identical counts. After processing a prefix, the map has one group for each key seen, with members in original order. Python dictionaries preserve insertion order, so the map also preserves first-group order. Append to an existing group or create one at first sight; both transitions preserve the invariant. Avoid a set of words, which would destroy multiplicity.

Let T be total characters and w the number of words. The reference costs O(w + Σ length(word) log(max(2, length(word)))) expected time. Key storage costs O(T) auxiliary space in the worst case; group bookkeeping adds O(w), including references in the output. Excluding returned groups, retained keys and the map still cost O(T + w) worst case. No word contents are copied into the output.

Follow-up 1: alphabet is exactly lowercase English

Predict a cheaper key when the contract restricts all characters to a–z. Count into 26 slots and use a tuple, not a concatenated decimal string whose field boundaries might be ambiguous.

Word a b c Remaining 23 counts Key construction
cab 1 1 1 all zero O(length + 26)
abb 1 2 0 all zero O(length + 26)

The new bound is O(T + 26w) time and O(26g) key space for g groups. Validate the alphabet; applying this array to arbitrary Unicode breaks the original API.

Follow-up 2: workers group separate partitions

Independent local grouping is insufficient if equivalent words land on different workers. Redraw the data flow before selecting a transport:

Diagram: Follow-up 2: workers group separate partitions

Global ordering needs original positions; worker arrival order does not preserve the contract. A senior candidate defends collision-free key semantics and counts output storage. A lead candidate defines one key-encoding version across workers, handles a single enormous group, and separates partitioning hashes from exact key equality. Hash collisions may co-locate keys but must never merge groups.

Run and check

From the repository root:

cd curriculum/01-code/02-data-structures-algorithms/problems/03-group-anagrams
python -m unittest -v test_solution.py

Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.

Reference implementation · solution.py
def group_anagrams(words):
    if not isinstance(words, (list, tuple)) or any(not isinstance(word, str) for word in words):
        raise ValueError("expected a list or tuple of strings")
    groups = {}
    for word in words:
        key = "".join(sorted(word))
        groups.setdefault(key, []).append(word)
    return list(groups.values())
Contract and oracle tests · test_solution.py
import random
import unittest
from collections import Counter
from solution import group_anagrams

class GroupTests(unittest.TestCase):
    def test_contract(self):
        self.assertEqual(group_anagrams(["eat", "tea", "tan", "eat", "ate"]),
                         [["eat", "tea", "eat", "ate"], ["tan"]])
        self.assertEqual(group_anagrams([]), [])
        self.assertEqual(group_anagrams(["", ""]), [["", ""]])
        self.assertEqual(group_anagrams(["é", "e\u0301", "é"]), [["é", "é"], ["e\u0301"]])
        with self.assertRaises(ValueError):
            group_anagrams(["ok", 7])

    def test_pairwise_inventory_oracle(self):
        rng = random.Random(103)
        for _ in range(200):
            words = ["".join(rng.choices("abc", k=rng.randrange(6)))
                     for _ in range(rng.randrange(20))]
            expected = []
            for word in words:
                for group in expected:
                    if Counter(word) == Counter(group[0]):
                        group.append(word)
                        break
                else:
                    expected.append([word])
            original = words[:]
            self.assertEqual(group_anagrams(words), expected)
            self.assertEqual(words, original)

if __name__ == "__main__":
    unittest.main()