Skip to main content

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
Normalization Options

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

MethodTime ComplexitySpace ComplexityNotes
CounterO(n)O(k)Fastest, single pass
sorted()O(n log n)O(n)Simpler but slower
Manual dictO(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.