DSA sheet · Strings · Two Pointers pattern
Valid Palindrome II
This is the next two-pointer string question after Valid Palindrome. The teacher warns that it looks easy but isn't: one extra line, "you may delete at most one character", changes the problem. The video shows the O(n²) brute force (try deleting every character), explains why the constraints reject it, and then builds an O(n) solution: walk two pointers inwards, and at the first mismatch try exactly two options, skip the left character or skip the right one. She also clears up a common confusion: this is not dynamic programming. It's asked in interviews, so the reasoning matters more than the few lines of code.
Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the conditions from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · Two pointers from scratch (what you must know first)
- Part A · Brute force: try deleting every character
- Part B · Optimal: two pointers + two tries at the first mismatch
- Part C · Why trying both skips is enough (the proof)
- Part D · Revision page
Part 0 · Before starting: two pointers from scratch
Pointers are just indexes
A pointer here is an index into the string: "I'm looking at position i". Two pointers means we keep two indexes and move them by rules, instead of running two nested loops.
The brute force two pointers usually replaces
Trying every pair (i, j) with two loops costs O(n²). With n = 10⁵ that's about 10¹⁰ steps, way past the rough 10⁸ operations safe limit → TLE. Two pointers helps when the input lets us skip most pairs. In a sorted array, a sum that's too big tells us to move the right pointer left. In a palindrome, index i only ever needs its mirror index n − 1 − i, so the other pairs never need checking.
The two-pointer family (you'll meet all of these in the notebook)
| kind | movement | example |
|---|---|---|
| Opposite ends | left from index 0, right from the last index, walking towards each other until they meet/cross | Valid Palindrome I & II, Container With Most Water |
| Same direction (slow/fast, read/write) | fast reads every element, slow marks where the next kept element goes | Move Zeroes |
| Three pointers | low / mid / high split the array into 0s, 1s, unknown, 2s | Sort Colors |
| Fix one + two pointers | a loop fixes i, opposite-end pointers search the rest; skip equal neighbours to avoid duplicates | 3Sum |
| Left-max / right-max | opposite ends, each side remembers its tallest bar so far | Trapping Rain Water |
| Palindromes | (a) compare from both ends inwards; (b) expand around a center, odd (one character) or even (between two characters) | this page uses (a); Longest Palindromic Substring uses (b) |
Why two pointers and not sliding window (the teacher's rule)
Strings have two main patterns. Sliding window is for when we need everything in a continuous block between two ends. Two pointers is for when we compare exactly two points, then move them and compare the next two. A palindrome check compares the front character with the back one, nothing in between, so it's two pointers.
The basic palindrome check (from Valid Palindrome)
left = 0, right = n − 1. While left < right: if s[left] != s[right] → not a palindrome. Otherwise move both inwards. If the loop ends, it's a palindrome. We stop at left < right because when both point at the same middle character, it trivially equals itself. We'll reuse this check, but on a part of the string given by two indexes.
Words
- Palindrome: reads the same forwards and backwards (
"aba","aca"). - Delete at most one: you may delete zero characters or one character, never two.
- Window [l, r]: the part of the string from index l to index r, both included.
Part A · Brute force: try deleting every character
LeetCode 680
1The question in simple words
Given a string s (only lowercase letters), return True if you can make it a palindrome by deleting at most one character. Otherwise return False.
| s | what happens | answer |
|---|---|---|
"aba" | already a palindrome, delete nothing (zero deletions is allowed) | True |
"abca" | not a palindrome; delete c → "aba" (or delete b → "aca") | True |
"abcda" | delete c → "abda" ✗, delete d → "abca" ✗, any other single deletion also fails | False |
2What the constraints tell us
1 ≤ s.length ≤ 10⁵. The teacher's usual rule: past about 10⁸ operations it's risky, past 10⁹ it's certain TLE.- An O(n²) idea → (10⁵)² = 10¹⁰ → TLE. So we already know the brute force won't be accepted and we must find something around O(n).
- Only lowercase English letters → no case or symbol handling (unlike Valid Palindrome I).
3Intuition
"At most one" means two things to check, in order:
- Zero deletions: is
salready a palindrome? If yes → True. - One deletion: delete each character one at a time, and test whether what's left is a palindrome. If any works → True.
For the palindrome test, the brute force uses the plain definition: reverse the string into a copy s1 and compare position by position.
4Building the logic from the example
Take s = "abca".
- No deletion: s =
abca, s1 = reverse =acba. Position 1:bvsc→ differ → not a palindrome. Go to deletions. - Delete index 0 (
a): s =bca, s1 =acb.bvsa→ differ ✗. - Delete index 1 (
b): s =aca, s1 =aca. a = a, c = c, a = a → palindrome → return True.
The test itself is a small function: given s and its reverse s1 (same length n), loop i from 0 to n − 1. If s[i] != s1[i] → return False. After the loop → return True.
→ No, and this is the teacher's key point. One check is O(n) to copy/reverse plus O(n) to compare. But we may run that check once for every deleted position, which is n times. Worst case: a long string like
abcdefg…zabc… that never becomes a palindrome. Every deletion is tried and every one fails. So it's n checks × O(n) each = O(n²).5Approach steps
- If
sequals its reverse → return True (zero deletions). - For each index i from 0 to n − 1: build
t= s without index i. - If
tequals its reverse (checked char by char) → return True. - If no deletion worked → return False.
6Code (Python)
class Solution:
def validPalindrome(self, s: str) -> bool:
def same(a, b): # a and b have equal length
for i in range(len(a)):
if a[i] != b[i]:
return False
return True
if same(s, s[::-1]): # zero deletions
return True
for i in range(len(s)): # try deleting each index
t = s[:i] + s[i + 1:]
if same(t, t[::-1]):
return True
return False7Code line by line
| line | what it means |
|---|---|
| def same(a, b): | The teacher's helper: compare a string with its reversed copy, position by position. |
| if same(s, s[::-1]): return True | "At most one" includes zero. Check the original string first. |
| t = s[:i] + s[i + 1:] | Build a new string with index i removed. This alone is O(n) work and O(n) memory. |
| if same(t, t[::-1]): return True | Reverse the shorter string and compare. One success is enough. |
| return False | No single deletion helped. |
8Dry run (hand table)
s = "abcda" (the False example):
| deleted index | t | reverse(t) | first difference | result |
|---|---|---|---|---|
| none | abcda | adcba | position 1: b vs d | ✗ |
| 0 (a) | bcda | adcb | position 0: b vs a | ✗ |
| 1 (b) | acda | adca | position 1: c vs d | ✗ |
| 2 (c) | abda | adba | position 1: b vs d | ✗ |
| 3 (d) | abca | acba | position 1: b vs c | ✗ |
| 4 (a) | abcd | dcba | position 0: a vs d | ✗ |
All failed → False. We did 6 full checks on a 5-letter string. That's the n × n growth.
9Complexity & remember
- Time O(n²): up to n deletions, each followed by an O(n) copy + reverse + compare. n = 10⁵ → 10¹⁰ → TLE.
- Space O(n): the copies
tand its reverse.
Part B · Optimal: two pointers + two tries at the first mismatch
1The question again, with the new goal
Same question. Goal: O(n) time, no copies of the string (O(1) extra space).
2What the constraints push us towards
n up to 10⁵ means O(n) or O(n log n). The brute force repeats almost the same palindrome check n times. The idea is to do the check once, and only "branch" at the one place where things go wrong.
3Intuition: walk until something breaks
Run the normal two-pointer palindrome check from both ends. As long as the characters match, nothing needs deleting, so keep walking inwards. At the first mismatch, s[left] != s[right], we know one of these two characters is the troublemaker. We're allowed to throw away just one of them, so try both:
- Skip the left one: is the window
[left + 1, right]a palindrome? - Skip the right one: is the window
[left, right − 1]a palindrome?
If either is a palindrome → True. If neither → False. And if we never hit a mismatch, the string was already a palindrome (zero deletions) → True.
4Building the conditions from examples
Example 1: "abca"
Skip b → the window is just "c", a single character, so it's a palindrome → True (the whole string becomes "aca"). Skip c → window "b" would also be True. Either way the answer is True.
Example 2 (the teacher's second example): "afgfea"
- Skip f (left): window
"gfe": g vs e ✗ → False. - Skip e (right): window
"fgf": f = f, then both pointers on g → True.
One of them is True, so the answer is True. Combine the two tries with or, because we need at least one to work, not both.
Condition: inside a try, no more deletions
When we test the window after skipping one character, we've already used our one deletion. So inside that check, the first mismatch means False immediately. Don't try to skip again; that would be a second deletion.
Condition: don't copy the string, pass indexes
The brute force built new strings. Here the helper takes the original s plus two indexes l and r, and checks only that window in place. Passing the whole s is fine, since the helper only ever looks between l and r. No s1, no extra space.
Condition: while left < right (in both loops)
Same as Valid Palindrome: when left = right both are on one character, which matches itself, so there's nothing to check.
Condition: on a mismatch, return the result of the tries
In the main loop, a mismatch does not mean False right away, because we still have one deletion. Instead we return whatever the two tries give. The main loop never continues after that point.
s[left + 1] == s[right], skip left, otherwise skip right", and try only one side?→ No. Both sides can look fine for one step and only one works all the way. Take
"abbab": the first pair is a vs b, a mismatch. s[left+1] = b equals s[right] = b, and s[left] = a equals s[right−1] = a. Skipping left gives "bbab" ✗. Skipping right gives "abba" ✓. A one-step peek that picks "skip left" would wrongly say False. So we fully check both. It costs only O(n) more.→ The teacher says clearly: no. DP is for problems with many overlapping subproblems that we store and reuse. Here we branch once (only one deletion is allowed), each branch is a simple straight check, and nothing is repeated or stored. Call it two pointers with a small greedy step: keep matching pairs without deleting, and branch only at the first conflict.
5Approach steps
- Write a helper
check(s, l, r): whilel < r, ifs[l] != s[r]→ False, else move both inwards. After the loop → True. - Main:
left = 0,right = len(s) − 1. - While
left < right: - if
s[left] != s[right]→ returncheck(s, left + 1, right) or check(s, left, right − 1); - else →
left += 1,right -= 1. - After the loop (no mismatch at all) → return True.
6Code (Python)
class Solution:
def validPalindrome(self, s: str) -> bool:
left, right = 0, len(s) - 1
while left < right:
if s[left] != s[right]:
# one deletion allowed: skip the left char OR skip the right char
return (self.isPalindromeRange(s, left + 1, right) or
self.isPalindromeRange(s, left, right - 1))
left += 1
right -= 1
return True # no mismatch: zero deletions needed
def isPalindromeRange(self, s, l, r):
while l < r:
if s[l] != s[r]:
return False # deletion already used, so fail
l += 1
r -= 1
return True7Code line by line
| line | what it means |
|---|---|
| left, right = 0, len(s) - 1 | Fingers on the first and last characters. |
| while left < right: | Compare mirror pairs until the fingers meet. |
| if s[left] != s[right]: | First conflict. One of these two must be deleted (Part C proves why). |
| self.isPalindromeRange(s, left + 1, right) | Try 1: pretend s[left] is deleted. Is everything between left + 1 and right a palindrome? |
| or self.isPalindromeRange(s, left, right - 1) | Try 2: pretend s[right] is deleted. Python's or runs this only if try 1 was False. |
| return (…) | The answer is decided here. Pairs outside the window already matched, so only the window matters. |
| left += 1 right -= 1 | The pair matched, no deletion needed. Move both inwards. |
| return True | The loop finished without a conflict → already a palindrome. |
| def isPalindromeRange(s, l, r) | The plain Valid Palindrome check, limited to the window [l, r], with no skipping allowed. |
8Dry run
s = "afgfea" (indexes 0–5)
| step | where | left | right | s[left] | s[right] | decision | result |
|---|---|---|---|---|---|---|---|
| 1 | main | 0 | 5 | a | a | same → move both | L=1, R=4 |
| 2 | main | 1 | 4 | f | e | mismatch → try both skips | – |
| 3 | try 1: [2, 4] | 2 | 4 | g | e | differ → no more deletions | False |
| 4 | try 2: [1, 3] | 1 | 3 | f | f | same → move both | l=2, r=2 |
| 5 | try 2 | 2 | 2 | g | g | l < r false → loop ends | True |
| 6 | main | False or True | return True | ||||
s = "abcda" (the False case)
| step | where | left | right | s[left] | s[right] | decision | result |
|---|---|---|---|---|---|---|---|
| 1 | main | 0 | 4 | a | a | same → move both | L=1, R=3 |
| 2 | main | 1 | 3 | b | d | mismatch → try both | – |
| 3 | try 1: [2, 3] | 2 | 3 | c | d | differ | False |
| 4 | try 2: [1, 2] | 1 | 2 | b | c | differ | False |
| 5 | main | False or False | return False | ||||
Compare with Part A: there we needed 6 full checks for this string. Here we made 2 main comparisons and 2 short tries.
s = "aba" (zero deletions)
a = a → move both → left = right = 1 → loop ends → True. The helper is never called.
9Complexity & remember
- Time O(n). The teacher counts it precisely: the main loop does about n/2 comparisons. At a mismatch, each helper call checks what's left, at most about n/2 each, and there are two calls. Total about n/2 + n/2 + n/2 = 3n/2, which is linear. Her interview tip: say "O(n)", but showing you can count it as 3n/2 shows clear thinking.
- Space O(1): only indexes, no copies. (If you wrote the tries with slicing like
s[l+1:r+1] == s[l+1:r+1][::-1], time would still be O(n) but space would become O(n).) - Much better than the O(n²) brute force. It passes easily for n = 10⁵.
Part C · Why trying both skips is enough
The code tries only 2 deletions instead of n. Why can't the right deletion be somewhere else? Here's the reasoning in three small steps.
1Where we are at the first mismatch
s = [ x x x | s[l] . . . . . s[r] | x x x ]
^^^^^ ^^^^^
outer left part == outer right part (mirrored), already matched
window [l, r], and s[l] != s[r]
Everything outside the window matched in mirror pairs. Inside, the two ends differ.
2We must delete exactly one character
s itself isn't a palindrome (s[l] ≠ s[r] are a mirror pair), so zero deletions won't work. Exactly one deletion is needed.
3Deleting something strictly inside the window can't work
Say we delete a character between l and r (not l, not r). The outer parts are untouched and still match, so in the new string the mirror pairs line up the same way, and s[l] and s[r] are still a mirror pair. They're still different, so it's not a palindrome. So the deleted character must be s[l] or s[r] itself. Those are exactly our two tries.
4What about deleting from the outer matched part?
Could it help to delete a character left of l (or right of r)? Then everything shifts by one, so it's less obvious. But here's the trick: if deleting an outer character at k < l gives a palindrome, the mirror matches force s[k] = s[k+1] = … = s[l] (each shifted character must equal its neighbour). In other words, k sits in a run of equal letters that reaches all the way to l. And deleting any one letter from a run of equal letters gives the same string. So deleting k gives exactly the same result as deleting l, which is our try 1. The right side works the same way and matches try 2.
→
s = "aaba". The first pair a = a matches. Then left = 1 (a), right = 2 (b): a mismatch. Deleting index 0 (an outer character) gives "aba" ✓. But index 0 and index 1 are both "a", a run, so deleting index 1 gives the same "aba". Try 1 (skip left, window [2, 2] = "b") finds it ✓.5Why the greedy "match → move on" is safe
Steps 3 and 4 also show that while pairs keep matching, we never need to delete one of them. Any useful deletion out there is equivalent to deleting at the first mismatch. So walking inwards without branching on matches loses nothing. That's the "slightly greedy" part the teacher mentions.
6Why we fully check both, not just one
Both ends are candidates, and a one-step peek can't tell which is right (the "abbab" example in Part B). Checking both fully costs only about 2 × n/2, so it's still O(n).
The test file also checks this by comparing against the brute force on every string of a, b, c up to length 8.
Part D · Revision page
| Brute force (Part A) | Two pointers (Part B) | |
|---|---|---|
| idea | check s; then delete every index and check again | walk inwards; branch once at the first mismatch |
| palindrome test | reverse into a copy, compare | indexes l, r on the same string |
| deletions tried | all n | only 2 (skip left, skip right) |
| time | O(n²) → 10¹⁰ for n = 10⁵ → TLE | O(n) (about 3n/2) |
| extra space | O(n) | O(1) |
| Valid Palindrome I | Valid Palindrome II | |
|---|---|---|
| characters | mixed: skip non-alnum, compare lowercase | only lowercase letters |
| on mismatch | return False | return try(l+1, r) or try(l, r−1) |
| loop / stop | while left < right; match → move both; True after the loop | |
2. Two pointers from both ends; on a match, move both.
3. First mismatch: one of s[l], s[r] must go, so test [l+1, r] and [l, r−1].
4. Combine with
or; inside a try, no more deletions.5. O(n) time (≈ 3n/2), O(1) space; it's greedy two pointers, not DP.
✗ trying only one side, or picking a side by peeking one character (
"abbab")✗ allowing a second skip inside the helper
✗ using
and instead of or for the two tries✗ slicing new strings for every try (extra O(n) space)
✗ forgetting the zero-deletion case in the brute force
s = Solution()
print(s.validPalindrome("aba")) # True (zero deletions)
print(s.validPalindrome("abca")) # True (delete b or c)
print(s.validPalindrome("abc")) # False
print(s.validPalindrome("abcda")) # False
print(s.validPalindrome("afgfea")) # True (delete e)
print(s.validPalindrome("abbab")) # True (delete the last b)
print(s.validPalindrome("a")) # True
print(s.validPalindrome("ab")) # True (delete either)Based on this video: Valid Palindrome II | Two Pointers