Blind 75 Visualized

#10 · Arrays & Hashing

Group Anagrams

Given an array of strings, group the anagrams together. You may return the groups in any order.

MediumLeetCode
  • Active
  • Found
  • Window
  • Visited
  • Rejected
step 1 / 1

Pointers

Variables

Hash map / table

Stack / queue

Foundations & data-structure selection

Hash map keyed by a canonical form

Map each word to a key that is identical for all anagrams of it (its sorted letters, or its letter-count signature), and collect words that share a key in one bucket. Grouping becomes a single pass of "insert into bucket".

Memory layout

A hash table of key → list of words. Keys are short strings; buckets are growable arrays. Total space is O(total characters), because every word is stored once plus a key per distinct group.

OperationCost
insert word into bucketO(k) to build the key + O(1) average hash
whole inputO(n · k) with counts, O(n · k log k) with sorting
spaceO(n · k)

Letter-count signature (fixed-size array)

A table of 26 integers, one per letter, describing a word as a multiset. Equal tables means equal letters-with-multiplicity, which is the definition of anagram.

Memory layout

A single contiguous block of 26 small integers: one cache line. It can be reused for every word with a fill instead of reallocating.

OperationCost
build from a word of length kO(k)
compare two signaturesO(26) = O(1)

Why it fits

  • Anagram is an equivalence relation, so words can be partitioned by a canonical representative. A hash map groups by that representative in one pass, with no pairwise comparisons.
  • Both keys (sorted letters, letter counts) are order-independent descriptions of the same multiset, which is exactly what anagrams share.
  • The count key replaces an O(k log k) sort with O(k) counting, which matters for long words.

Why alternatives degrade

Pairwise anagram checks:
Comparing each word with the groups costs O(n · g) comparisons, each O(k log k): quadratic in the worst case (all words distinct).
Set of characters as the key:
A set forgets multiplicity: "aab" and "abb" share {a, b} but are not anagrams.
Sum or product of character codes as the key:
Different multisets can collide (e.g. "ad" and "bc" have the same sum), so groups would be wrong. Prime products are collision-free but overflow quickly.

Four approaches, from naive to production

1 · beginner

Compare with each group's first word

time O(n · g · k log k)space O(n · k)

Keep a list of groups. For each word, test it against the first word of every group with an anagram check, and either join a group or start a new one.

vector<vector<string>> groupAnagrams(vector<string>& strs) {
    auto isAnagram = [](string a, string b) { sort(a.begin(), a.end()); sort(b.begin(), b.end()); return a == b; };
    vector<vector<string>> groups;
    for (const string& word : strs) {
        bool placed = false;
        for (auto& group : groups) {
            if (isAnagram(group[0], word)) {
                group.push_back(word);
                placed = true;
                break;
            }
        }
        if (!placed) groups.push_back({word});
    }
    return groups;
}

Flaws & bottlenecks

  • Up to n × g anagram checks, where g is the number of groups; if all words are distinct that is ~n²/2 checks.
  • Each check re-sorts both words from scratch, with no caching of the sorted form.
  • Placement depends on scanning groups linearly instead of jumping to the right one.

Interviewer cross-questions

Why is comparing against only the first word of each group enough?

Say: Anagram is transitive: if w is an anagram of the group's first word, it is an anagram of every member. So one representative per group suffices, which the later versions turn into a hash key.

How would you speed it up without hashing?

Say: At least precompute and cache each word's sorted form so each comparison is a string equality instead of two sorts. That still leaves the scan over groups, which is why a hash map is the real fix.

What is the worst case?

Say: All n words belong to different groups: every word is compared with all earlier groups, giving about n²/2 comparisons, each costing O(k log k).

2 · intermediate

Hash map keyed by sorted letters

time O(n · k log k)space O(n · k)

Sort the letters of each word; anagrams produce the same sorted string, which serves as a bucket key in a hash map.

vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> buckets;
    for (const string& word : strs) {
        string key = word; sort(key.begin(), key.end());
        if (!buckets.count(key)) buckets[key] = {};
        buckets[key].push_back(word);
    }
    vector<vector<string>> out;
    for (auto& kv : buckets) out.push_back(kv.second);
    return out;
}

Flaws & bottlenecks

  • Sorting each word costs O(k log k) and allocates temporary arrays and strings.
  • Splitting a string into characters, sorting and re-joining creates garbage for every word.
  • The sort is more work than needed: only the multiset of letters matters, not their order.

Interviewer cross-questions

Why does the sorted word work as a key?

Say: Two words are anagrams iff they have the same multiset of letters, and sorting is a canonical form of a multiset: sorted strings are equal exactly when the multisets are equal.

Can you do better than sorting?

Say: Yes, counting sort: for a bounded alphabet, count the letters into a 26-slot table in O(k) and use the counts as the key, which is the advanced approach.

What if the strings contain uppercase or Unicode?

Say: The sorted-key version works for any characters (it just sorts code units). The 26-slot count version does not, so it needs a larger table, a map of counts, or normalization such as lowercasing and Unicode NFC.

3 · advanced

Hash map keyed by letter counts

time O(n · (k + 26))space O(n · k)

Replace sorting with counting: build a 26-slot count table per word and join it into a key. Anagrams have identical count tables.

vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> buckets;
    for (const string& word : strs) {
        array<int, 26> counts{};
        for (char ch : word) counts[ch - 'a']++;
        string key; for (int c : counts) key += to_string(c) + ',';
        auto it = buckets.find(key);
        if (it != buckets.end()) it->second.push_back(word);
        else buckets[key] = {word};
    }
    vector<vector<string>> out;
    for (auto& kv : buckets) out.push_back(kv.second);
    return out;
}

Flaws & bottlenecks

  • Allocates a fresh 26-element array per word, and joining it into a long string key costs more than the word itself for short words.
  • Only handles a fixed alphabet (lowercase a-z); other characters index outside the table.
  • Keys like "1,0,0,1,..." must be separated (the comma): concatenating counts without a separator would make "11" ambiguous (1,1 or 11).

Interviewer cross-questions

Why does the key need a separator?

Say: Without one, counts [1, 11] and [11, 1] both stringify to "111", so different words would collide. A delimiter (or fixed-width fields) keeps the key unambiguous.

When is sorting actually better than counting?

Say: For short words (k below roughly 26) sorting a handful of characters is cheap, and the count table plus a long key is more work. Counting wins for long words, since O(k) beats O(k log k).

How do you handle a larger alphabet?

Say: Use a hash map of counts (sorted by character to build a canonical key), or a 128 or 256-slot table for ASCII or bytes. For Unicode, count code points and normalize first.

4 · expert

Reused count table, compact key, validation

time O(total characters)space O(n · k)

Allocate one count table and reuse it, validate each letter against a-z, and pack the 26 counts into a short fixed-length string key instead of a comma-joined list.

vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> buckets;
    uint16_t counts[26];
    for (const string& word : strs) {
        fill(begin(counts), end(counts), 0);
        for (size_t i = 0; i < word.size(); i++) {
            int c = word[i] - 'a';
            if (c < 0 || c > 25) throw out_of_range("lowercase a-z only");
            counts[c]++;
        }
        string key(reinterpret_cast<const char*>(counts), sizeof(counts));
        auto it = buckets.find(key);
        if (it != buckets.end()) it->second.push_back(word);
        else buckets[key] = {word};
    }
    vector<vector<string>> out;
    for (auto& kv : buckets) out.push_back(kv.second);
    return out;
}

Flaws & bottlenecks

  • Spreading a typed array into fromCharCode(...counts) is fine for 26 values, but would hit argument-count limits for large tables.
  • Throwing on non a-z input is a strict contract; real data may need normalization or a fallback path instead.
  • Counts above 65 535 would overflow Uint16; words are far shorter in practice, but it is an assumption to state.

Interviewer cross-questions

Why reuse the count array?

Say: It removes one allocation per word (less garbage-collector pressure and better cache behaviour). The cost is a fill(0) per word, which is O(26), and the key must be copied into a string before the table is reused.

How would you scale this to billions of words?

Say: Hash-partition the words by key across machines (a map-reduce shuffle): all anagrams share a key, so they land on the same node, and each node groups its partition locally. The key function is the only thing that has to be identical everywhere.

Could you avoid building strings as keys?

Say: Yes: hash the 26 counts into a 64-bit value and bucket by that hash, verifying with a full count comparison on a match to rule out collisions. That saves allocation but adds code and a verification step.

Synchronized code walkthrough

Showing: Compare with each group's first word — shares the player above; click any step to jump.

vector<vector<string>> groupAnagrams(vector<string>& strs) {
    auto isAnagram = [](string a, string b) { sort(a.begin(), a.end()); sort(b.begin(), b.end()); return a == b; };
    vector<vector<string>> groups;
    for (const string& word : strs) {
        bool placed = false;
        for (auto& group : groups) {
            if (isAnagram(group[0], word)) {
                group.push_back(word);
                placed = true;
                break;
            }
        }
        if (!placed) groups.push_back({word});
    }
    return groups;
}