Skip to content

49. Group Anagrams (Medium)

Problem

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

Example

  • strs = ["eat","tea","tan","ate","nat","bat"][["bat"], ["nat","tan"], ["ate","eat","tea"]]
  • strs = [""][[""]]
  • strs = ["a"][["a"]]

LeetCode 49 · Link · Medium

Try it yourself

idle

Starter code: this editor begins with intentional TODOs. Fill the function, run the embedded tests, then compare your solution with the worked approaches below.

Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Approach 1: Brute force, pairwise anagram check

For each string, compare its character count against representatives of existing groups.

from collections import Counter
def group_anagrams(strs: list[str]) -> list[list[str]]:
groups = [] # L1: O(1)
for s in strs: # L2: outer loop, n iterations
cs = Counter(s) # L3: O(k) per string
placed = False # L4: O(1)
for g in groups: # L5: inner loop, up to n groups
if Counter(g[0]) == cs: # L6: O(k) counter comparison
g.append(s) # L7: O(1) amortized
placed = True
break
if not placed:
groups.append([s]) # L8: O(1) amortized
return groups

Where the time goes, line by line

Variables: n = len(strs), k = average length of each string.

LinePer-call costTimes executedContribution
L2 (outer loop)O(1)O(1)nO(n)O(n)
L3 (Counter build)O(k)O(k)nO(nk)O(n·k)
L5, L6 (inner loop + compare)O(k)O(k)up to n per outerO(n2k)O(n²·k) ← dominates
L7, L8 (append)O(1)O(1) amortizednO(n)O(n)

For each of n strings, the inner loop scans up to n existing groups and does an O(k)O(k) counter comparison each time.

Complexity

  • Time: O(n2k)O(n²·k), driven by L5/L6 (inner loop with counter comparison).
  • Space: O(nk)O(n·k) for the output plus counters.
final class Solution {
private func normalized(_ groups: [[String]]) -> [[String]] {
groups.map { $0.sorted() }.sorted {
$0.joined(separator: "\u{1F}") < $1.joined(separator: "\u{1F}")
}
}
func groupAnagrams(_ strs: [String]) -> [[String]] {
var groups: [[String]] = []
for word in strs {
if let index = groups.firstIndex(where: { $0.first!.sorted() == word.sorted() }) { groups[index].append(word) } else { groups.append([word]) }
}
return normalized(groups)
}
}

Approach 2: Sorted-string as hash key

Anagrams share the same multiset of characters; their sorted form is identical. Use the sorted string as a hash map key.

from collections import defaultdict
def group_anagrams(strs: list[str]) -> list[list[str]]:
groups = defaultdict(list) # L1: O(1)
for s in strs: # L2: n iterations
key = "".join(sorted(s)) # L3: O(k log k) sort + O(k) join
groups[key].append(s) # L4: O(1) avg hash insert + append
return list(groups.values()) # L5: O(n) to collect

Where the time goes, line by line

Variables: n = len(strs), k = average length of each string.

LinePer-call costTimes executedContribution
L2 (loop)O(1)O(1)nO(n)O(n)
L3 (sort + join)O(klogk)O(k log k)nO(nklogk)O(n·k log k) ← dominates
L4 (hash + append)O(k)O(k) avg (key hashing)nO(nk)O(n·k)
L5 (collect values)O(n)O(n)1O(n)O(n)

The sort step on each string drives the total. Key hashing is O(k)O(k) but that’s dominated by the sort.

Complexity

  • Time: O(nklogk)O(n·k log k), driven by L3 (sorting each string).
  • Space: O(nk)O(n·k) for keys + output.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).
final class Solution {
private func normalized(_ groups: [[String]]) -> [[String]] {
groups.map { $0.sorted() }.sorted {
$0.joined(separator: "\u{1F}") < $1.joined(separator: "\u{1F}")
}
}
func groupAnagrams(_ strs: [String]) -> [[String]] {
var groups: [String: [String]] = [:]
for word in strs { groups[String(word.sorted()), default: []].append(word) }
return normalized(Array(groups.values))
}
}

Approach 3: Char-count tuple as key (optimal for bounded alphabet)

Skip sorting entirely; a 26-slot frequency tuple is a cheaper, immutable key.

from collections import defaultdict
def group_anagrams(strs: list[str]) -> list[list[str]]:
groups = defaultdict(list) # L1: O(1)
for s in strs: # L2: n iterations
count = [0] * 26 # L3: O(1), fixed 26-slot array
for ch in s: # L4: k iterations per string
count[ord(ch) - ord('a')] += 1 # L5: O(1) array index
groups[tuple(count)].append(s) # L6: O(26)=O(1) tuple + hash + append
return list(groups.values()) # L7: O(n) to collect

Where the time goes, line by line

Variables: n = len(strs), k = average length of each string.

LinePer-call costTimes executedContribution
L2 (outer loop)O(1)O(1)nO(n)O(n)
L3 (init count)O(1)O(1)nO(n)O(n)
L4, L5 (char frequency)O(1)O(1)n·k totalO(nk)O(n·k) ← dominates
L6 (tuple + hash)O(1)O(1) (26-slot fixed)nO(n)O(n)
L7 (collect)O(n)O(n)1O(n)O(n)

The inner loop counts each character in O(1)O(1); no sort is needed. The tuple key is fixed at 26 elements regardless of string length.

Complexity

  • Time: O(nk)O(n·k), driven by L4/L5 (character counting). Linear per string, strictly better than the sort-key approach.
  • Space: O(nk)O(n·k) for the output; O(n)O(n) hash map slots of fixed-size (26) keys.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).
final class Solution {
private func normalized(_ groups: [[String]]) -> [[String]] {
groups.map { $0.sorted() }.sorted {
$0.joined(separator: "\u{1F}") < $1.joined(separator: "\u{1F}")
}
}
func groupAnagrams(_ strs: [String]) -> [[String]] {
var groups: [[Int]: [String]] = [:]
for word in strs {
var counts = Array(repeating: 0, count: 26)
for byte in word.utf8 { counts[Int(byte - 97)] += 1 }
groups[counts, default: []].append(word)
}
return normalized(Array(groups.values))
}
}

Summary

ApproachTimeSpace
Pairwise Counter compareO(n2k)O(n²·k)O(nk)O(n·k)
Sorted-string keyO(nklogk)O(n·k log k)O(nk)O(n·k)
Char-count tuple keyO(nk)O(n·k)O(nk)O(n·k)

For bounded alphabets, the count-tuple approach is the tightest. For huge or unbounded alphabets, the sort-key version is simpler and often fast enough.

Test cases

# Quick smoke tests, paste into a REPL or save as test_group_anagrams.py and run.
# Uses the canonical implementation (Approach 3: char-count tuple key).
from collections import defaultdict
def group_anagrams(strs: list[str]) -> list[list[str]]:
groups = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
groups[tuple(count)].append(s)
return list(groups.values())
def _run_tests():
# Sort inner lists for deterministic comparison
def normalize(result):
return sorted(sorted(g) for g in result)
r1 = group_anagrams(["eat","tea","tan","ate","nat","bat"])
assert normalize(r1) == [["ate","eat","tea"], ["bat"], ["nat","tan"]]
r2 = group_anagrams([""])
assert normalize(r2) == [[""]]
r3 = group_anagrams(["a"])
assert normalize(r3) == [["a"]]
# All same anagram group
r4 = group_anagrams(["abc","bca","cab"])
assert normalize(r4) == [["abc","bca","cab"]]
# All distinct
r5 = group_anagrams(["a","b","c"])
assert normalize(r5) == [["a"],["b"],["c"]]
print("all tests pass")
if __name__ == "__main__":
_run_tests()
  • Strings, input type; character-frequency canonicalization
  • Hash Tables, grouping by a canonical key is the core idea
  • Hash Map Counting, the lookup table pattern for complements, frequencies, and seen items.
  • Sorting as Preprocessing, the order first tactic that exposes adjacency, sweep boundaries, and duplicate control.