How to Check Anagrams Using Counter in Python
Checking if two strings are anagrams, containing the same characters in different orders, is a classic programming problem. While sorting works, using a hash map is faster with O(n) complexity compared to sorting's O(n log n).
Python's collections.Counter provides this hash map implementation out of the box.
Basic Anagram Check with Counter
Counter objects support direct equality comparison, making anagram detection a one-liner:
from collections import Counter
def is_anagram(s1: str, s2: str) -> bool:
"""Check if two strings are anagrams."""
return Counter(s1) == Counter(s2)
print(is_anagram("listen", "silent")) # True
print(is_anagram("triangle", "integral")) # True
print(is_anagram("hello", "world")) # False
How It Works
Counter creates a frequency dictionary for each string:
from collections import Counter
print(Counter("listen"))
# Counter({'l': 1, 'i': 1, 's': 1, 't': 1, 'e': 1, 'n': 1})
print(Counter("silent"))
# Counter({'s': 1, 'i': 1, 'l': 1, 'e': 1, 'n': 1, 't': 1})
# Same frequencies = anagrams (order in dict doesn't matter)
Output:
Counter({'l': 1, 'i': 1, 's': 1, 't': 1, 'e': 1, 'n': 1})
Counter({'s': 1, 'i': 1, 'l': 1, 'e': 1, 'n': 1, 't': 1})
Handling Real-World Input
Real-world strings often have different casing, spaces, or punctuation. A robust solution must normalize the input:
from collections import Counter
def is_anagram_robust(s1: str, s2: str) -> bool:
"""Check anagrams ignoring case, spaces, and punctuation."""
# Keep only alphanumeric characters, convert to lowercase
clean_s1 = ''.join(c.lower() for c in s1 if c.isalnum())
clean_s2 = ''.join(c.lower() for c in s2 if c.isalnum())
return Counter(clean_s1) == Counter(clean_s2)
# Famous anagrams
print(is_anagram_robust("Clint Eastwood", "Old West Action")) # True
print(is_anagram_robust("Dormitory", "Dirty Room")) # True
print(is_anagram_robust("The Morse Code", "Here Come Dots")) # True
print(is_anagram_robust("Astronomer", "Moon Starer")) # True
Choose normalization based on your use case:
.lower(): Ignore case.replace(" ", ""): Ignore spaces only.isalnum(): Keep only letters and digits.isalpha(): Keep only letters
Finding Anagram Groups
Group words that are anagrams of each other:
from collections import Counter, defaultdict
def group_anagrams(words: list) -> list:
"""Group words that are anagrams of each other."""
groups = defaultdict(list)
for word in words:
# Use sorted characters as key (frozenset of Counter works too)
key = tuple(sorted(word.lower()))
groups[key].append(word)
return [group for group in groups.values() if len(group) > 1]
words = ["listen", "silent", "enlist", "hello", "world", "inlets", "tinsel"]
anagram_groups = group_anagrams(words)
for group in anagram_groups:
print(group)
Output:
['listen', 'silent', 'enlist', 'inlets', 'tinsel']
Using Counter as Key
from collections import Counter
def group_anagrams_counter(words: list) -> list:
"""Group anagrams using Counter as a hashable key."""
groups = {}
for word in words:
# Convert Counter to frozen (hashable) form
key = frozenset(Counter(word.lower()).items())
if key not in groups:
groups[key] = []
groups[key].append(word)
return [g for g in groups.values() if len(g) > 1]
Finding All Anagrams of a Word
Check which words from a list are anagrams of a target:
from collections import Counter
def find_anagrams(target: str, word_list: list) -> list:
"""Find all anagrams of target in word_list."""
target_counter = Counter(target.lower())
return [
word for word in word_list
if Counter(word.lower()) == target_counter and word.lower() != target.lower()
]
dictionary = ["listen", "silent", "enlist", "google", "tinsel", "inlets"]
anagrams = find_anagrams("listen", dictionary)
print(f"Anagrams of 'listen': {anagrams}")
Output:
Anagrams of 'listen': ['silent', 'enlist', 'tinsel', 'inlets']
Counter vs Sorting Comparison
Both approaches work, but Counter is faster:
from collections import Counter
def is_anagram_counter(s1: str, s2: str) -> bool:
"""O(n) - Single pass through each string."""
return Counter(s1) == Counter(s2)
def is_anagram_sorted(s1: str, s2: str) -> bool:
"""O(n log n) - Requires sorting."""
return sorted(s1) == sorted(s2)
Performance Comparison
import time
from collections import Counter
# Generate test strings
s1 = "a" * 100_000 + "b" * 100_000
s2 = "b" * 100_000 + "a" * 100_000
# Counter approach
start = time.perf_counter()
result1 = Counter(s1) == Counter(s2)
counter_time = time.perf_counter() - start
# Sorting approach
start = time.perf_counter()
result2 = sorted(s1) == sorted(s2)
sorted_time = time.perf_counter() - start
print(f"Counter: {counter_time:.4f}s")
print(f"Sorted: {sorted_time:.4f}s")
print(f"Speedup: {sorted_time / counter_time:.1f}x")
Typical Output:
Counter: 0.0082s
Sorted: 0.0054s
Speedup: 0.7x
Early Exit Optimization
For non-anagrams, check length first to avoid unnecessary computation:
from collections import Counter
def is_anagram_optimized(s1: str, s2: str) -> bool:
"""Optimized anagram check with early exit."""
# Quick length check
if len(s1) != len(s2):
return False
return Counter(s1) == Counter(s2)
Complexity Comparison
| Method | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
Counter | O(n) | O(k) | Fastest, single pass |
sorted() | O(n log n) | O(n) | Simpler but slower |
| Manual dict | O(n) | O(k) | Same as Counter, more verbose |
n = string length, k = unique characters
Summary
- Use
Counter(s1) == Counter(s2)for the fastest, most Pythonic solution. - Normalize input (lowercase, remove spaces/punctuation) for real-world text.
- Check length first as an early exit optimization.
- Counter is O(n) compared to sorting's O(n log n), making it significantly faster for long strings.