๐ŸŽ“ A1Learner
๐Ÿ”

DSA Valid Palindrome

A palindrome reads the same forwards and backwards, after cleaning keeps only letters and digits and lowercases them. Two pointers compare the phrase from both ends and stop at the first mismatch.

The problem

A palindrome reads the same forwards and backwards. The Valid Palindrome problem asks whether a phrase is a palindrome after cleaning. Cleaning keeps only letters and digits and lowercases every letter.

Spaces and punctuation do not count. Case does not count. Every kept character counts, including digits.

The cases used in this article

cases = [
    ('A man, a plan, a canal: Panama', True),
    ('race a car', False),
    ('No lemon, no melon', True),
    ('0P', False),
    ('', True),
]
for phrase, expected in cases:
    print(repr(phrase), '->', expected)

Intuition

The first idea compares the raw phrase with its reverse. That test fails on real phrases. Spaces, commas and capital letters differ even when the letters match.

The raw reverse test fails a real palindrome

phrase = 'No lemon, no melon'
print('phrase:', phrase)
print('reversed:', phrase[::-1])
print('raw test:', phrase == phrase[::-1])
print('cleaned phrase:', 'nolemonnomelon')

Cleaning is therefore part of the problem, not a detail. The cleaned text decides the answer. The raw text only supplies the characters.

Brute force

The straightforward method cleans the phrase into a new string. It then builds the reverse of that string. Equal strings mean a palindrome.

Clean, reverse and compare

def is_palindrome_brute(text):
    clean = ''
    for ch in text:
        if ch.isalnum():
            clean += ch.lower()
    reversed_clean = ''
    for ch in clean:
        reversed_clean = ch + reversed_clean
    return clean == reversed_clean, clean

for phrase in ['A man, a plan, a canal: Panama', 'race a car', 'No lemon, no melon']:
    verdict, clean = is_palindrome_brute(phrase)
    print(repr(phrase), 'clean:', clean, '->', verdict)

The method builds two extra strings. Each prepend in the reverse also copies the string built so far. A comparison from both ends needs no copies at all.

The optimal solution: pointers that skip and compare

The key idea is two pointers on the original phrase. Each pointer skips characters that cleaning would drop, then the kept pair is compared in lowercase.

The skipping-pointers solution

def is_palindrome(text):
    left = 0
    right = len(text) - 1
    while left < right:
        while left < right and not text[left].isalnum():
            left += 1
        while left < right and not text[right].isalnum():
            right -= 1
        if text[left].lower() != text[right].lower():
            return False
        left += 1
        right -= 1
    return True

cases = ['A man, a plan, a canal: Panama', 'race a car', 'No lemon, no melon', '0P', '']
for phrase in cases:
    print(repr(phrase), '->', is_palindrome(phrase))

Walkthrough

Take the phrase 'race a car'. Its cleaned form is 'raceacar'. The pointers below work on the cleaned form so each pair is visible.

Pairs checked until the mismatch

s = 'raceacar'
left = 0
right = len(s) - 1
while left < right:
    print('compare', s[left], 'with', s[right], ':', s[left] == s[right])
    if s[left] != s[right]:
        print('mismatch, the answer is False')
        break
    left += 1
    right -= 1

Three pairs match. The fourth pair compares e with a. That one mismatch settles the answer, and the middle is never reached.

Complexity

Time is O(n). Each pointer moves inward only, so each character is visited a small fixed number of times.

Space is O(1) for the pointer version. No cleaned copy and no reversed copy are built.

Counting characters copied against characters visited

def brute_copies(text):
    clean = ''
    copies = 0
    for ch in text:
        if ch.isalnum():
            clean += ch.lower()
            copies += len(clean)
    return copies

def pointer_visits(text):
    left = 0
    right = len(text) - 1
    visits = 0
    while left < right:
        visits += 1
        if text[left].lower() != text[right].lower():
            break
        left += 1
        right -= 1
    return visits

for text in ['abba', 'racecar', 'nolemonnomelon']:
    print(text, 'brute copies:', brute_copies(text), 'pointer pair visits:', pointer_visits(text))

Edge cases and pitfalls

An empty phrase is a palindrome. A phrase with no letters or digits cleans to empty, so it also answers True. The pointers meet at once in both cases.

Digits are kept characters. The phrase '0P' cleans to '0p', and 0 differs from p. Dropping digits during cleaning would flip that answer, which is the pitfall the run below exposes.

Edge cases, run

def is_palindrome(text):
    left = 0
    right = len(text) - 1
    while left < right:
        while left < right and not text[left].isalnum():
            left += 1
        while left < right and not text[right].isalnum():
            right -= 1
        if text[left].lower() != text[right].lower():
            return False
        left += 1
        right -= 1
    return True

print('empty:', is_palindrome(''))
print('single letter:', is_palindrome('a'))
print('only punctuation:', is_palindrome('.,!'))
print('digits count:', is_palindrome('0P'))
print('mixed case:', is_palindrome('RaceCar'))

Exercise

After cleaning, a phrase becomes "abca". The pointers compare a with a, then b with c. What is the verdict?