Introduction
This is a class of very popular interview problems.
A palindrome is defined as a string which is the same reversed.
ie. s == s[::-1].
Palindromes have a very nice recursive structure.
So: 'a', 'aba', 'eee', 'abba' are palindromes. 'Ace' is not a palindrome. (empty string is trivially a palindrome)
A crucial property to know is that palindromes have at most one character of odd frequency count. This is very useful for certain problems.
Iterative Check
def isPalindrome(self, s: str) -> bool:
n = len(s)
i, j = 0, n-1
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return TrueRecursive Check
def isPalindrome(self, s: str) -> bool:
if not s: return True
return s[0] == s[-1] and self.isPalindrome(s[1:-1])Valid Palindrome Problems
Problems that check if a string is a valid palindrome with various constraints.
01Valid PalindromeEasy
A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers.
Given a string s, return true if it is a palindrome, or false otherwise.
Solution 1: Continue on non-alphanumeric
def isPalindrome(self, s: str) -> bool:
n = len(s)
i, j = 0, n-1
while i < j:
if not s[i].isalnum():
i += 1
continue
if not s[j].isalnum():
j -= 1
continue
if s[i].lower() != s[j].lower():
return False
i += 1
j -= 1
return TrueSolution 2: Inner while loops
def isPalindrome(self, s: str) -> bool:
n = len(s)
i, j = 0, n-1
while i < j:
while i < j and not s[i].isalnum():
i += 1
while i < j and not s[j].isalnum():
j -= 1
if s[i].lower() != s[j].lower():
return False
i += 1
j -= 1
return TrueO(n)O(1)We basically use two pointers. We initialize from both ends of the string. We loop while the string is more than 1 character long. We iterate until the left and right pointers are alphanumeric. We then check if the lowercase equivalents are equal, if not this string is not a palindrome. Otherwise these 2 characters match and we continue.
02Valid Palindrome IIEasy
Given a string s, return true if the s can be palindrome after deleting at most one character from it.
def validPalindrome(self, s: str) -> bool:
def isPalindrome(i, j):
while i < j:
if s[i] != s[j]: return False
i += 1
j -= 1
return True
# return s[i:j+1] == s[i:j+1][::-1]
n = len(s)
i = 0
j = n-1
while i < j:
if s[i] != s[j]:
return isPalindrome(i+1, j) or isPalindrome(i, j-1)
i += 1
j -= 1
return TrueO(n)O(1)This is a slight modification to the existing algorithm. The idea is we are allowed a single mismatching pair. So we basically compare pairs until we see the first mismatch. (notice that at most 1 means we can also have no mismatches. So if the original string is just a palindrome, the algorithm will default to the same behaviour as the definition)
We have two options, either delete s[i] or s[j]. If we delete s[i] we no longer have any deletions remaining, so we just check if s[i+1:j] (inclusive) is a palindrome. Similarly for if we delete s[j]. We take the or of both cases, ie. if any of them is a palindrome, we can just delete the one that leads to a palindrome.
03Valid Palindrome II - GeneralizedMedium
Generalized version: what about if we want to check if a palindrome can be created with at most k deletions?
def validPalindrome(self, s: str) -> bool:
# this is actually O(k) space, since ONCE we see a mismatch, we will go downwards...
# the cache is actually pointless, we will never re-encounter the same state!!
# @cache
def dp(i, j, k): # true iff s[i:j+1] is a palindrome after deleting at most k characters from it
if k == 0: return s[i:j+1] == s[i:j+1][::-1]
if i == j: return True
while i < j:
if s[i] != s[j]:
return dp(i+1, j, k-1) or dp(i, j-1, k-1)
i += 1
j -= 1
return True
return dp(0, len(s)-1, 1)O(n^2 * k)O(k)We can generalize this idea using recursion. Time complexity is O(n^2 * k), with O(k) space (depth of recursive stack).
Note: See LCS section in DP for more palindrome problems.
04Valid Palindrome IIIHard
Given a string s and an integer k, return true if s is a k-palindrome.
A string is k-palindrome if it can be transformed into a palindrome by removing at most k characters from it.
def isValidPalindrome(self, s: str, k: int) -> bool:
# standard dp(i,j,k) dp will tle O(n^2k)
# transform into get longest palindromic subsequence
# reverse problem
# return if length >= n-k
# dp: dp(i,j): max palindromic subsequence in s[i:j+1]
# if i > j: return 0. if i == j: return 1: if s[i] == s[j]: 2 + dp(i+1,j-1)
# else: return max(dp(i+1,j), dp(i,j-1))
@cache
def dp(i, j):
if i >= j: return i == j
return 2 + dp(i+1, j-1) if s[i] == s[j] else max(dp(i+1, j), dp(i, j-1))
return dp(0, len(s)-1) >= len(s)-kO(n^2)O(n^2)Note there is actually a linear time solution to this k removals problem using longest palindromic subsequence.
Idea: Think in reverse. If we remove up to k characters, that means the remaining characters must form a palindrome. We can remove characters anywhere in the string, so the remaining characters form a palindromic subsequence. So we just check if the longest palindromic subsequence has length >= n-k, if so we can just remove all indices not in that palindromic subsequence.
05Valid Palindrome IVMedium
You are given a 0-indexed string s consisting of only lowercase English letters. In one operation, you can change any character of s to any other character.
Return true if you can make s a palindrome after performing exactly one or two operations, or return false otherwise.
Two Pointer Solution
# we can count the number of "mistakes", if there are 1 or 2 mistakes we can fix.
# if there are 3 mistakes we are screwed. if there are 0 mistakes, we can change 2 chars to the same char, so we are fine.
# so just check if < 3 mistakes..
def makePalindrome(self, s: str) -> bool:
left, right, mistakes = 0, len(s)-1, 0
while left <= right:
if s[left] != s[right]:
mistakes += 1
if mistakes >= 3: break # optimization
left += 1
right -= 1
return mistakes < 3One-liner Solution
def makePalindrome(self, s: str) -> bool:
return sum(s[i] != s[len(s)-i-1] for i in range(len(s) // 2)) < 3O(n)O(1)Note that this simple 2 pointers works because this is 'changes', and not removals. Removals will require DP because we have 2 options to remove, i or j. In this case of changes, we can choose either one to become the other, it doesn't matter which one we choose and it doesn't change our answer as each pair of indices is independent after matching.
Longest Palindromic Substring
Problems involving finding the longest palindromic substring.
01Longest Palindromic SubstringMedium
Given a string s, return the longest palindromic substring in s.
DP Solution (O(n²) time, O(n²) space)
We can brute force this problem in O(n³) time and O(1) space. There are O(n²) substrings, and we can verify if it is palindromic in O(n) time.
However, this is clearly wasteful, because of the substructure of palindromes. We are repeatedly checking if the same substrings are palindromic, when we already know whether they are or not. So we can simply cache this using a dp.
def longestPalindrome(self, s: str) -> str:
n = len(s)
@cache
def dp(i, j):
if i > j: return True
return s[i] == s[j] and dp(i+1, j-1)
res_length = 0
res = ""
for i in range(n):
for j in range(i, n):
if dp(i, j):
if j-i+1 > res_length:
res_length = j-i+1
res = s[i:j+1]
return resExpand from Center (O(n²) time, O(1) space)
We can reverse the problem, by thinking about the centres of palindromes. Palindromes can be either even length or odd lengthed. If they are odd lengthed, they have a unique centre. However if they are even length, they have 2 centres. (ie. 'aba', b is the centre. And 'abba', 'bb' are the 2 centres)
The idea is, any palindrome has a centre, so if we try all centres we will visit all palindromes. We start at the centre and expand outwards, once there is a mismatch we know this centre is finished, as any larger string with this centre cannot be a palindrome any longer. (this is greedy thinking)
This is efficient because 1. We don't have to store any extra space and run the risk of MLE. and 2. We do not need any precomputation because we start from the centre instead of the ends, and don't have to search all substrings.
# time: O(n^2). space: O(1). n = len(s)
def longestPalindrome(self, s: str) -> str:
res = ""
n = len(s)
def expand(i, j):
nonlocal res
while 0 <= i and j < n and s[i] == s[j]:
if j-i+1 > len(res):
res = s[i:j+1]
i -= 1
j += 1
for i in range(n):
expand(i, i) # expand odd length centres
expand(i, i+1) # expand even length centres
return resO(n²)O(1)The worst case is when substrings are palindromes: ie. s = 'aaaaaa'...
Note: there is actually an O(n) solution called Manacher's algorithm, but you aren't expected to know it.
02Palindromic SubstringsMedium
Given a string s, return the number of palindromic substrings in it.
Expand from Center Solution
def countSubstrings(self, s: str) -> int:
n = len(s)
res = 0
def expand(i, j):
nonlocal res
while i >= 0 and j < n and s[i] == s[j]:
res += 1
i -= 1
j += 1
for i in range(n):
expand(i, i)
expand(i, i+1)
return resDP Solution
def countSubstrings(self, s: str) -> int:
n = len(s)
@cache
def dp(i, j):
if i > j: return True
return s[i] == s[j] and dp(i+1, j-1)
return sum(dp(i, j) for i in range(n) for j in range(i, n))O(n²)O(1) for expand, O(n²) for DPThis is the same idea as Longest Palindromic Substring. We can either expand from centers or use DP to count all palindromic substrings.
Palindrome Queries
Problems involving multiple queries about palindrome properties.
01Can Make Palindrome from SubstringMedium
You are given a string s and array queries where queries[i] = [lefti, righti, ki]. We may rearrange the substring s[lefti...righti] for each query and then choose up to ki of them to replace with any lowercase English letter.
If the substring is possible to be a palindrome string after the operations above, the result of the query is true. Otherwise, the result is false.
Return a boolean array answer where answer[i] is the result of the ith query queries[i].
Note that each letter is counted individually for replacement, so if, for example s[lefti...righti] = "aaa", and ki = 2, we can only replace two of the letters. Also, note that no query modifies the initial string s.
Prefix Frequency Solution
def canMakePaliQueries(self, s: str, queries: List[List[int]]) -> List[bool]:
cumulative_freq = [Counter()]
# you can add and subtract counters
n = len(s)
for i in range(n):
cumulative_freq.append(cumulative_freq[-1] + Counter([s[i]]))
res = []
for l, r, k in queries:
freq = cumulative_freq[r+1] - cumulative_freq[l] # 1 indexed
min_num_moves_required = sum(f % 2 == 1 for _, f in freq.items()) // 2
res.append(min_num_moves_required <= k)
return resOne-liner Solution
def canMakePaliQueries(self, s: str, queries: List[List[int]]) -> List[bool]:
cumulative_freq = [Counter()]
for i in range(len(s)):
cumulative_freq.append(cumulative_freq[-1] + Counter([s[i]]))
return [sum(f % 2 == 1 for _, f in (cumulative_freq[r+1] - cumulative_freq[l]).items()) // 2 <= k for l, r, k in queries]O(n + q * 26)O(n * 26)Few things:
1. Rearrangement means order doesn't matter. So this means only the frequency count matters.
2. You can add and subtract counters... this is really useful.
3. Use the key property mentioned at the beginning, where palindromes can have at most 1 odd frequency character. So when we have more than 1 odd frequency character, we are forced to use some of our k operations to replace them.
Example: say there are 2 odd frequencies (a->3, b->9). Easy, just change 1. (change one a to b, OR change 1 b to an a)
Say there are 3 odd frequencies (a->3, b->9, c->5). This still takes just 1. (ex. change one a to b. KEEP THE c as is, just use it as the middle)
So it's basically just num_odd_freq // 2 is the number of moves required!
Summary
Key Palindrome Properties:
1. s == s[::-1]
2. At most one character can have odd frequency count
3. Recursive structure: s is palindrome if s[0] == s[-1] and s[1:-1] is palindrome
Key Techniques:
1. Two Pointers - Compare from both ends
2. Expand from Center - O(n²) time, O(1) space for finding all palindromes
3. DP - Cache isPalindrome(i,j) results
4. Prefix Frequency Arrays - For query problems with rearrangement
Problem Categories:
- Validation: Check if string is/can become palindrome
- Finding: Find longest palindromic substring/subsequence
- Counting: Count number of palindromic substrings
- Queries: Answer multiple palindrome-related queries
Changes vs Removals:
- Changes: Simple two pointers, each pair is independent
- Removals: Need DP because removing s[i] vs s[j] gives different results