Valid Palindrome After Normalisation (and With One Deletion)
Pattern: Two Pointers (Opposite Ends) · Difficulty: Easy · Asked at: Meta, Microsoft, Amazon
Classic version: LeetCode 125 · LeetCode 680
Problem
is_palindrome(s): returnTrueifsreads the same forwards and backwards after lower-casing and ignoring every non-alphanumeric character.almost_palindrome(s): returnTrueifs(compared exactly, no normalisation) is a palindrome after deleting at most one character.
Do both in O(n) time and O(1) extra space (no building a cleaned copy).
Examples
is_palindrome("A man, a plan, a canal: Panama") → True
is_palindrome("race a car") → False
almost_palindrome("abca") → True # delete 'b' or 'c'
almost_palindrome("abc") → False
Starter code
def is_palindrome(s: str) -> bool:
pass
def almost_palindrome(s: str) -> bool:
pass
Hints
Hint 1
Move lo right past non-alphanumerics and hi left past them, then compare lower-cased characters.
Hint 2
For the deletion variant: at the first mismatch you only have two options, skip s[lo] or skip s[hi]. Check whether either remaining slice is a palindrome.
Where this shows up in data engineering
Normalise-then-compare is the core of entity matching and data cleaning (keys that differ only in case and punctuation). The deletion variant is a gentle introduction to edit-distance thinking used in fuzzy matching.
Solution
def is_palindrome(s: str) -> bool:
lo, hi = 0, len(s) - 1
while lo < hi:
if not s[lo].isalnum():
lo += 1
elif not s[hi].isalnum():
hi -= 1
else:
if s[lo].lower() != s[hi].lower():
return False
lo += 1
hi -= 1
return True
def almost_palindrome(s: str) -> bool:
def is_pal(i: int, j: int) -> bool:
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return True
lo, hi = 0, len(s) - 1
while lo < hi:
if s[lo] != s[hi]:
# one deletion allowed: drop the left char or the right char
return is_pal(lo + 1, hi) or is_pal(lo, hi - 1)
lo += 1
hi -= 1
return True
Tests
Your solution should pass these:
assert is_palindrome("A man, a plan, a canal: Panama") is True
assert is_palindrome("race a car") is False
assert is_palindrome(" ") is True
assert is_palindrome("0P") is False
assert almost_palindrome("abca") is True
assert almost_palindrome("aba") is True
assert almost_palindrome("abc") is False
assert almost_palindrome("deeee") is True
assert almost_palindrome("cbbcc") is True
Explanation
Part 1: two pointers skip junk independently and compare case-insensitively. O(n) time, O(1) space. Building ''.join(c.lower() for c in s if c.isalnum()) and comparing with its reverse is O(n) space: acceptable but mention the trade-off.
Part 2: everything before the first mismatch already matches, so the only decision is which side to delete. Each check is O(n) and it runs at most twice, so O(n) total. The greedy “delete whichever looks right” is wrong: "cbbcc" needs the right-hand deletion even though the left looks plausible, so check both.
Follow-up questions
Allow up to k deletions.
The two-branch trick becomes exponential. Use DP: the minimum deletions to make s a palindrome is len(s) - LPS(s) (longest palindromic subsequence), O(n²). Return ≤ k.