Two Pointers on Strings
The same opposite-direction and same-direction two-pointer techniques from arrays apply directly to strings. This chapter covers palindrome checking, subsequence testing, and comparing two strings character by character — with the exact same skeleton as Chapter 1.1 and 1.2, just on characters instead of numbers.
Table of contents
- A quick connection
- Pattern A — Opposite direction: palindrome check
- The skeleton
- Valid Palindrome (LC #125) — ignore non-alphanumeric, ignore case
- Pattern B — Same direction: is subsequence
- The skeleton
- Pattern C — Two-string comparison: merge/align
- Backspace String Compare (LC #844)
- The one idea to remember
- Where to spot this pattern
- Common traps
- Complexity
- Say it like a pro (interview one-liner)
- Check yourself
- Practice problems
A quick connection
In Chapter 1.1 you used two pointers walking toward each other to find pairs in a sorted array. In Chapter 1.2 you used two pointers walking in the same direction to merge or copy. Those skeletons work unchanged on strings — a string is just an array of characters.
This chapter is deliberately short. The patterns are the same; the only newness is recognising string-flavoured problem statements as two-pointer problems, and handling two small string-specific gotchas (case, non-alpha characters).
Pattern A — Opposite direction: palindrome check
A real-life story
Two readers start at opposite ends of a word and walk toward each other, comparing the letters they land on. If they always find matching letters and meet in the middle without a mismatch, the word reads the same both ways. That is palindrome checking — two pointers, opposite direction.
The skeleton
left, right = 0, len(s) - 1
while left < right:
if s[left] != s[right]:
return False # mismatch found
left += 1
right -= 1
return True
Valid Palindrome (LC #125) — ignore non-alphanumeric, ignore case
def isPalindrome(s: str) -> bool:
left, right = 0, len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1 # skip non-alphanumeric from left
while left < right and not s[right].isalnum():
right -= 1 # skip non-alphanumeric from right
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
Frame-by-frame: "A man, a plan, a canal: Panama"
After stripping non-alpha in-place (conceptually):
"amanaplanacanalpanama"
left=0 'a', right=19 'a' → match, advance
left=1 'm', right=18 'm' → match, advance
left=2 'a', right=17 'a' → match, advance
...
All characters match → True ✅
Pause & think
What's the difference between s[left].isalnum() and s[left].isalpha()? When does that matter?
Answer
isalpha() returns True only for letters (a-z, A-Z). isalnum() returns True for letters and digits (0-9). For palindrome problems, digits count as valid characters — "race1car" with digits should compare '1' with '1'. Using isalpha() would incorrectly skip digits. Always use isalnum() for these problems unless the problem explicitly says "letters only."
Pattern B — Same direction: is subsequence
A subsequence of string t is a string s whose characters appear in t in the same order but not necessarily consecutively. "ace" is a subsequence of "abcde"; "aec" is not.
A real-life story
A librarian scans a shelf of books (string t) looking for a reading list (string s). She has one finger on her list and one finger on the shelf. When the shelf book matches the list entry, she advances both fingers. Otherwise she advances only the shelf finger. If she finishes the list, all entries were found in order.
The skeleton
i, j = 0, 0 # i → s, j → t
while i < len(s) and j < len(t):
if s[i] == t[j]:
i += 1 # matched one more character of s
j += 1 # always advance through t
return i == len(s) # did we consume all of s?
Frame-by-frame: s = "ace", t = "abcde"
i=0 j=0: s[0]='a' == t[0]='a' → match, i=1, j=1
i=1 j=1: s[1]='c' != t[1]='b' → no match, j=2
i=1 j=2: s[1]='c' == t[2]='c' → match, i=2, j=3
i=2 j=3: s[2]='e' != t[3]='d' → no match, j=4
i=2 j=4: s[2]='e' == t[4]='e' → match, i=3, j=5
i=3 → i == len(s) → True ✅
O(n) time, O(1) space — no extra array, no sorting, single pass.
Pattern C — Two-string comparison: merge/align
Some problems give you two strings and ask you to compare them character by character, skipping or inserting characters. These use two separate pointers, one per string.
Backspace String Compare (LC #844)
Given two strings where # means backspace, check if they produce the same result after processing.
def backspaceCompare(s: str, t: str) -> bool:
i, j = len(s) - 1, len(t) - 1
skip_s = skip_t = 0
while i >= 0 or j >= 0:
# find the next valid character in s
while i >= 0:
if s[i] == '#':
skip_s += 1
i -= 1
elif skip_s > 0:
skip_s -= 1
i -= 1
else:
break
# find the next valid character in t
while j >= 0:
if t[j] == '#':
skip_t += 1
j -= 1
elif skip_t > 0:
skip_t -= 1
j -= 1
else:
break
# compare the two valid characters
if i >= 0 and j >= 0:
if s[i] != t[j]:
return False
elif i >= 0 or j >= 0:
return False # one string has extra characters
i -= 1
j -= 1
return True
Why scan from the right? Because # deletes the character to its left. Scanning right-to-left lets us count pending backspaces and skip the correct number of characters without building a new string.
O(n + m) time, O(1) space — superior to the O(n) space approach of building both result strings and comparing.
The one idea to remember
The entire pattern in one sentence
String two-pointer problems are array two-pointer problems on characters: opposite-direction pointers check symmetry (palindromes), same-direction pointers test order (subsequences), and dual pointers (one per string) align or merge two streams — all in O(n) time and O(1) space.
Where to spot this pattern
Trigger words:
- "palindrome" or "reads the same forwards and backwards"
- "subsequence" — is one string hidden in another in order?
- "after processing / simulating" — backspace, delete, skip
- "compare two strings" ignoring certain characters
- "reverse the string" — setup for palindrome or reversal trick (Chapter 3.7)
5 disguises:
- Valid Palindrome (LC #125): skip non-alphanumeric; case-insensitive.
- Valid Palindrome II (LC #680): allowed to delete at most one character — try skipping left or right on first mismatch.
- Is Subsequence (LC #392): same-direction classic.
- Backspace String Compare (LC #844): dual right-to-left with skip counters.
- Long Pressed Name (LC #925): same-direction, allow repeated characters in typed string.
Common traps
Watch out for these
- Using
isalpha()instead ofisalnum()in Valid Palindrome — digits count as valid characters and should not be skipped. left < rightvsleft <= rightin the while condition. Use<(strict) — whenleft == rightyou're at the middle of an odd-length palindrome and there's nothing to compare; it's trivially valid.- In Is Subsequence: returning
Truewhenjexhausts butihasn't. The checkreturn i == len(s)handles this correctly — only return True if all characters ofswere matched. - Building a new string for Backspace Compare. Correct but O(n) space. The right-to-left pointer approach solves it in O(1) space — prefer this in interviews.
Complexity
| Problem | Time | Space |
|---|---|---|
| Valid Palindrome | O(n) | O(1) |
| Is Subsequence | O(n + m) | O(1) |
| Backspace String Compare | O(n + m) | O(1) |
Say it like a pro (interview one-liner)
"This is a two-pointer problem on a string. For palindromes, I walk inward from both ends comparing characters. For subsequences, I use one pointer per string advancing together. All variants run in O(n) time and O(1) space — no extra array needed."
Remember this forever
Two Pointers on Strings — 3 skeletons
Palindrome (opposite direction):
left, right = 0, len(s) - 1
while left < right:
skip non-alphanumeric, compare s[left].lower() vs s[right].lower()
if mismatch → False; else advance both
return True
Subsequence (same direction):
i, j = 0, 0
while i < len(s) and j < len(t):
if s[i] == t[j]: i += 1
j += 1
return i == len(s)
Dual-string (right-to-left with skip):
- Count backspaces, skip characters, compare valid chars from right.
Check yourself
For Valid Palindrome II, how do you handle the "delete one character" allowance?
Run the standard palindrome check. On the first mismatch at positions left and right, try two sub-problems: skip left (check s[left+1..right]) or skip right (check s[left..right-1]). If either half is a palindrome, the whole string is a valid palindrome with one deletion. This is still O(n) — you scan at most twice.
Why does "Is Subsequence" advance `j` regardless of match, but only advance `i` on a match?
j scans through the longer string t looking for characters of s. Every position in t must be visited (we might need any character). i only advances when we actually match the current s[i] — advancing it before a match would skip a required character of s.
In Backspace String Compare, what happens if a `#` appears at the very beginning of the string (no character to delete)?
The skip_s counter increments, but when the inner while loop tries to consume a non-# character with a pending skip, it just decrements skip_s and moves i left — skipping that character. If i reaches -1 while skip_s > 0, the while condition i >= 0 terminates the loop. The remaining skip count is simply discarded (no more characters to delete). This is the correct behaviour: leading backspaces in a typed sequence with nothing before them have no effect.
Practice problems
| Problem | Difficulty | What to notice | Link |
|---|---|---|---|
| Valid Palindrome | Easy | isalnum(), case-fold, left < right | LC #125 |
| Valid Palindrome II | Easy | On mismatch, try skipping left or right | LC #680 |
| Is Subsequence | Easy | Same-direction; return i == len(s) | LC #392 |
| Backspace String Compare | Easy | Right-to-left with skip counter; O(1) space | LC #844 |
| Long Pressed Name | Easy | Same-direction; allow repeated chars in typed | LC #925 |
Next up: Sliding Window on Strings — maintaining a character frequency map inside a moving window to find minimum windows, permutation matches, and anagram occurrences.