TL;DR
Converging two pointers that skip non-alphanumerics β O(n) time, O(1) extra space.
Approach 1 β Brute force: clean, then compare with the reverse
Code the definition literally: build the filtered lowercase string and check it equals its reversal.
class Solution:
def isPalindrome(self, s: str) -> bool:
cleaned = [c.lower() for c in s if c.isalnum()]
return cleaned == cleaned[::-1]
- Time:
O(n) β one filtering pass plus one reversal/comparison pass.
- Space:
O(n) for the cleaned copy (and its reverse).
The constraints donβt kill it β 2 * 10^5 characters at linear time is trivial. What it fails is the follow-up: O(1) extra space means no cleaned copy, which is exactly what the two-pointer version delivers (and what interviewers are fishing for).
Approach 2 β Converging two pointers, skip as you go
The insight: you never need the cleaned string β you only need its first-vs-last character comparisons, and those can be made directly on s by having each pointer skip the characters that cleaning would delete. Filtering and comparing fuse into one pass with no allocation.
class Solution:
def isPalindrome(self, s: str) -> bool:
left, right = 0, len(s) - 1
while left < right:
if not s[left].isalnum():
left += 1
elif not s[right].isalnum():
right -= 1
else:
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
Walkthrough on s = "A man, a plan, a canal: Panama" (first few steps):
| left char | right char | action |
|---|
A (0) | a (29) | a == a β move both inward |
(1) | m (28) | space: left += 1 |
m (2) | m (28) | m == m β move both |
a (3) | a (27) | a == a β move both |
n (4) | n (26) | n == n β move both |
, (5) | a (25) | comma: left += 1 |
(6) | a (25) | space: left += 1 |
a (7) | a (25) | a == a β move both |
β¦and so on until left >= right with no mismatch β True.
On s = "0P": both alphanumeric, "0" != "p" β False immediately.
- Time:
O(n) β every iteration moves at least one pointer, so at most n iterations.
- Space:
O(1) β two indices, no copies.
Structurally this is the same converging-pointer skeleton as in-place array reversal; the βskip invalid, act on validβ refinement is shared with Reverse Vowels of a String.
Approach 3 β Regex clean + slicing one-liner
The insight: the same clean-and-compare of Approach 1, expressed with a regular expression β worth knowing as the idiomatic quick form when O(1) space isnβt demanded. re.sub deletes everything outside a character class in one call.
import re
class Solution:
def isPalindrome(self, s: str) -> bool:
cleaned = re.sub(r"[^a-z0-9]", "", s.lower())
return cleaned == cleaned[::-1]
Walkthrough on s = "race a car": lowercase is "race a car", the regex strips spaces β "raceacar"; its reverse is "racaecar", unequal at index 3 (e vs a) β False.
Common pitfalls
- Forgetting that digits are kept β
"0P" is the classic trap test; alphanumeric means letters and digits, and .lower() on a digit is a harmless no-op.
- Comparing without normalizing case (or lowercasing only one side).
- Skipping non-alphanumerics with unguarded inner
while loops β on a string like "!!" the pointers can run past each other or off the ends; re-checking left < right each iteration (as above) avoids it.
- Treating the empty cleaned string as
False β by definition itβs a palindrome, and the loop above naturally returns True for it.
Pattern takeaway
Symmetric string/array predicates (βreads the same from both endsβ) are the home turf of converging two pointers: compare the outermost meaningful pair, move inward, and fail fast on the first mismatch. When some positions donβt count, skip them inside the same loop rather than pre-filtering β fusing the filter into the scan is what turns O(n) extra space into O(1), and the identical skeleton extends to Valid Palindrome II (one deletion allowed) by branching once on the first mismatch.