๐ŸŽ“ A1Learner
๐Ÿ”

DSA Valid Anagram

Two words are anagrams when every letter appears the same number of times in both. Counting one word up and the other down in a single table answers the question in two passes, without sorting.

The problem

An anagram is a word formed by rearranging the letters of another word. Two words are anagrams when they use exactly the same letters, each the same number of times. The task is to answer True or False for a pair of words.

Order does not matter. Counts do. The word aab is not an anagram of abb, even though both words use only the letters a and b.

The cases used in this article

cases = [
    ("listen", "silent", True),
    ("anagram", "nagaram", True),
    ("hello", "world", False),
    ("cat", "cats", False),
    ("aab", "abb", False),
]
for a, b, expected in cases:
    print(repr(a), "and", repr(b), "anagrams:", expected)

The fourth row fails on length alone. Words of different lengths can never hold the same letters with the same counts. The fifth row has equal lengths and the same letters present, and still fails, because the counts differ.

Intuition

The first idea most people try is sorting. Sort the letters of each word. Anagrams sort to the same string, and non-anagrams sort to different strings.

Sorting both words

for a, b in [("listen", "silent"), ("aab", "abb"), ("hello", "world")]:
    print(a, "sorted:", "".join(sorted(a)), "|", b, "sorted:", "".join(sorted(b)), "| equal:", sorted(a) == sorted(b))

Sorting works. It is also more machinery than the question needs. Sorting rearranges a full copy of each word, and its cost grows faster than a single counting pass.

A quicker-looking idea fails outright. Comparing only which letters appear ignores counts completely. The presence test below calls aab and abb anagrams, which they are not.

The presence shortcut ignores counts

a = "aab"
b = "abb"
print("same letters present:", all(ch in b for ch in a) and all(ch in a for ch in b))
print("sorted letters equal:", sorted(a) == sorted(b))

The lesson is that counts are the real content of the problem. Any correct method has to compare how many times each letter appears. Counting does that directly.

Brute force: match and cross out

The straightforward method works like crossing letters off a page. Take a copy of the second word as a list. For each letter of the first word, search the list for that letter. A found letter is removed, so it cannot be matched twice.

A letter that is not found settles the answer at once. If every letter is found and crossed out, the words are anagrams. The length check runs first, so the list is empty at the end exactly when every letter matched.

The cross-out method, with a search counter

def is_anagram_brute(a, b):
    if len(a) != len(b):
        return False, 0
    pool = list(b)
    checks = 0
    for ch in a:
        found = False
        for i in range(len(pool)):
            checks += 1
            if pool[i] == ch:
                pool.pop(i)
                found = True
                break
        if not found:
            return False, checks
    return True, checks

for a, b in [("listen", "silent"), ("aab", "abb")]:
    result, checks = is_anagram_brute(a, b)
    print(a, "vs", b, "->", result, "search comparisons:", checks)

The method is correct, and its cost is visible. Each letter triggers a fresh search through what is left of the pool. Long words mean long searches, repeated for every letter.

The optimal solution: count, then cancel

The table is a Python dict. A dict holds one value for each key. In this table the keys are letters, and the value under a letter is its count so far. The line counts[ch] = counts.get(ch, 0) + 1 reads the count stored under ch, or 0 when the letter is not in the dict yet, and stores the count raised by one.

A dict counts the letters of one word

counts = {}
for ch in "aab":
    counts[ch] = counts.get(ch, 0) + 1
print(counts)

One table counts the first word up and the second word down, and the words are anagrams exactly when every count in the table ends at zero. That is the whole solution.

The length check still runs first, because different lengths settle the answer with no counting at all. The first loop adds one per letter of the first word. The second loop subtracts one per letter of the second word. A letter missing from the table subtracts from zero and ends negative, which the final zero check catches.

The counting solution

def is_anagram(a, b):
    if len(a) != len(b):
        return False
    counts = {}
    for ch in a:
        counts[ch] = counts.get(ch, 0) + 1
    for ch in b:
        counts[ch] = counts.get(ch, 0) - 1
    for value in counts.values():
        if value != 0:
            return False
    return True

cases = [
    ("listen", "silent", True),
    ("anagram", "nagaram", True),
    ("hello", "world", False),
    ("cat", "cats", False),
    ("aab", "abb", False),
]
for a, b, expected in cases:
    print(a, "vs", b, "->", is_anagram(a, b), "expected", expected)

All five cases from the start of the article pass. Building two tables and comparing them also works. The single-table version simply cancels the counts in place instead of comparing two separate tables at the end.

Walkthrough

Watch the table for the pair aab and aba. These words are anagrams. Every count should return to zero.

Counting up, then cancelling down

counts = {}
for ch in "aab":
    counts[ch] = counts.get(ch, 0) + 1
    print("counted", repr(ch), "->", list(counts.items()))
for ch in "aba":
    counts[ch] = counts.get(ch, 0) - 1
    print("cancelled", repr(ch), "->", list(counts.items()))
print("all zero:", all(v == 0 for v in counts.values()))

The table prints in insertion order, so a appears before b. Counting aab leaves a at 2 and b at 1. Cancelling aba brings a down to 1 first, then b down to 0, then a down to 0.

Read the answer off the final table. Every value is 0. No letter is left over on either side, so the words are anagrams.

Complexity

The counting solution takes O(n) time, where n is the length of the words: it makes two passes over the letters and one short pass over the table. It takes O(k) space, where k is the number of distinct letters, because the table holds one entry per distinct letter.

Counting the work as words grow

def brute_checks(n):
    a = "".join(chr(ord("a") + i % 26) for i in range(n))
    b = a[::-1]
    pool = list(b)
    checks = 0
    for ch in a:
        for i in range(len(pool)):
            checks += 1
            if pool[i] == ch:
                pool.pop(i)
                break
    return checks

def counting_steps(n):
    a = "".join(chr(ord("a") + i % 26) for i in range(n))
    b = a[::-1]
    steps = 0
    counts = {}
    for ch in a:
        counts[ch] = counts.get(ch, 0) + 1
        steps += 1
    for ch in b:
        counts[ch] = counts.get(ch, 0) - 1
        steps += 1
    return steps

for n in [4, 8, 16]:
    print("length", n, "brute search comparisons:", brute_checks(n), "counting steps:", counting_steps(n))

The brute-force words here are reversals of each other, which forces long searches. Its comparison count grows roughly with the square of the length. The counting steps are exactly twice the length, one step per letter per pass.

Edge cases and pitfalls

Two empty words are anagrams. Both loops run zero times, the table is empty, and an empty table passes the zero check. One empty word and one non-empty word fail on length before any counting.

Case matters unless the problem says otherwise. The letter A and the letter a are different characters, so they get different table entries. Spaces and punctuation are characters too, and they are counted like letters.

The most common pitfall is stopping at which letters appear. The same letters present with unequal counts, like aab and abb, are the standard counterexample. A second pitfall is forgetting the early length check. Without it, the counting still answers correctly, but it does full work on pairs that one length comparison settles.

Edge cases, run

def is_anagram(a, b):
    if len(a) != len(b):
        return False
    counts = {}
    for ch in a:
        counts[ch] = counts.get(ch, 0) + 1
    for ch in b:
        counts[ch] = counts.get(ch, 0) - 1
    return all(v == 0 for v in counts.values())

pairs = [("", ""), ("a", ""), ("aab", "aba"), ("Listen", "listen"), ("a b", "ba ")]
for a, b in pairs:
    print(repr(a), "vs", repr(b), "->", is_anagram(a, b))

The last pair is True because the space is a character like any other. Both words hold one space, one a and one b. Ignoring spaces would need an explicit rule in the problem statement.

Exercise

Word A has counts {c: 1, a: 1, t: 1} and word B has counts {a: 1, t: 1, c: 1}. Are they anagrams?