Skip to content

Commit 3d105c8

Browse files
Add 'Longest Palindromic Substring'
1 parent 694f38a commit 3d105c8

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ A collection of LeetCode solutions
4040

4141
[Longest Increasing Subsequence](./src/longest_increasing_subsequence.py)
4242

43+
[Longest Palindromic Substring](./src/longest_palindromic_substring.py)
44+
4345
[Longest Repeating Character Replacement](./src/longest_repeating_character_replacement.py)
4446

4547
[Longest Substring with At Most K Distinct Characters](./src/longest_substring_with_at_most_k_distinct_characters.py)

src/longest_palindromic_substring.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""
2+
5. Longest Palindromic Substring
3+
4+
https://leetcode.com/problems/longest-palindromic-substring
5+
6+
NOTES
7+
* Use dynamic programming (2D), expansion around centers, or Manacher's
8+
Algorithm.
9+
10+
Brute force
11+
-----------
12+
The total number of operations to traverse all the substrings in a string of
13+
length n is:
14+
15+
n(n + 1)(n + 2)
16+
--------------- ≃ n^3
17+
6
18+
19+
So, a solution that finds the longest palindromic substring by checking all
20+
possible substrings would have O(n^3) time complexity.
21+
22+
Dynamic programming
23+
-------------------
24+
It turns out, checking whether a string is a palindrome or not is a good
25+
candidate for dynamic programming, as it displays the two necessary
26+
characteristics of a dynamic programming problem:
27+
28+
1. Optimal substructure: A single character or pair of characters is a
29+
palindrome. Additionally, larger palindromes are made of smaller
30+
palindromes. So, if we know a string is a palindrome, we only need to
31+
check the characters to the left and right of the string to determine if
32+
the next substring is a palindrome. This is the optimal substructure for
33+
the problem because it only requires one check, which can be done in
34+
constant time.
35+
36+
2. Overlapping subproblems: To check if a substring is a palindrome, we must
37+
also check if the smaller substrings that make up the string are also
38+
palindromes. Since this inevitably will result in checking the same
39+
substring more than once, we can store the result of the check and reuse
40+
it for larger substrings.
41+
42+
First, we define our state, dp[i,j], where the palindromicity of the substring
43+
s[i,j] is stored: 0 for false, 1 for true.
44+
45+
NOTE: A 2D array is used to represent both odd and even length palindromes,
46+
where i and j represent the beginning and end of each substring.
47+
48+
Next, we define our base cases. There are essentially two base cases:
49+
50+
1. Single-letter substrings are palindromes.
51+
2. Double-letter substrings of the same character are palindromes.
52+
53+
Programmatically, this results in the following:
54+
55+
dp[i][i] = 1
56+
dp[i][i+1] = 1, if s[i] == s[i+1], otherwise 0.
57+
58+
Finally, we define the optimal substructure:
59+
60+
>A palindrome can be expanded if s[i-1] and s[j+1] are equal. Therefore, a
61+
substring is a palindrome if the beginning and end characters are equal
62+
and the inner substring is also a palindrome.
63+
64+
This can be represented by the following recurrence relation:
65+
66+
dp[i][j] = 1, if dp[i+1][j-1] == 1 ∧ s[i] == s[j], otherwise 0.
67+
68+
Since we start with the shortest substrings and iterate toward the longest
69+
substrings, every time we find a new palindrome, it must be the longest one we
70+
have seen so far. This removes an additional check against the current longest
71+
palindromic substring.
72+
73+
Expansion around centers
74+
------------------------
75+
An improvement to the brute force approach involves expansion around each
76+
potential palindrome's center, which brings the time complexity down to O(n^2)
77+
(2*n-1 centers * n/2 comparisons).
78+
79+
Manacher's Algorithm builds from this trivial algorithm that is based on the
80+
fact that a palindrome can be expanded if s[0] and s[n-1] are equal. The
81+
expansion around centers algorithm is slow, however, requiring O(n^2) time
82+
complexity.
83+
84+
Manacher's Algorithm
85+
--------------------
86+
We observe that palindromes with a common center form a contiguous chain, that
87+
is, if we have a palindrome of length n centered at i, we also have palindromes
88+
of length n-2, n-4, and so on also centered at i. Storing this information
89+
allows us to make assertions about the possible length of other palindromic
90+
substrings in the string. For example, given the string "abacaba", we observe
91+
that the longest palindrome is centered at "c". Since, by definition, all
92+
characters after "c" must be equal to all characters before "c", characters
93+
within the radius of the palindrome centered at "c" must have at least the same
94+
palindromic radius (i.e., the same longest palindrome) as the characters before
95+
"c". Characters before "c" are commonly referred to as "mirrored" centers.
96+
"""
97+
98+
99+
class Solution:
100+
def longestPalindrome(self, s: str) -> str:
101+
"""
102+
Uses dynamic programming (2D) with O(n^2) time and space complexity.
103+
"""
104+
if not s:
105+
return ""
106+
107+
start, end = 0, 0
108+
109+
# Create an n x n matrix initialized to 0s.
110+
#
111+
# a b a j →
112+
# a [0, 0, 0] i where, dp[0][0] = "a"
113+
# b [-, 0, 0] ↓ dp[1][1] = "b"
114+
# a [-, -, 0] dp[2][2] = "a"
115+
#
116+
# NOTE: i and j are the beginning and end of the substring, therefore j
117+
# must be greater than or equal to i. For example, dp[2][0] does not
118+
# make sense.
119+
n = len(s)
120+
dp: list[list[int]] = [[0 for j in range(n)] for i in range(n)]
121+
122+
# Set dp[i,i] to 1, accounting for the base case assertion that each
123+
# character is itself a palindrome.
124+
for i in range(n):
125+
dp[i][i] = 1
126+
127+
# Set dp[i,i+1] to 1, if s[i] is equal to s[i+1], accounting for the
128+
# base case assertion that each pair of characters is a palindrome if
129+
# they are equal.
130+
for i in range(n - 1):
131+
if s[i] == s[i + 1]:
132+
dp[i][i + 1] = 1
133+
start, end = i, i + 1
134+
135+
# Set dp[i,j] to 1, if s[i+1...j-1] is a palindrome (dp[i+1][j-1] == 1)
136+
# and s[i] is equal to s[j]. This is our recurrence relation, which
137+
# leverages the solutions to previous calculations.
138+
for length in range(3, n + 1):
139+
for i in range(n - length + 1):
140+
j = i + length - 1
141+
if dp[i + 1][j - 1] == 1 and s[i] == s[j]:
142+
dp[i][j] = 1
143+
start, end = i, j
144+
145+
return s[start : end + 1]
146+
147+
148+
class ExpansionAroundCentersSolution:
149+
def longestPalindrome(self, s: str) -> str:
150+
"""
151+
A slow, O(n^2) algorithm that finds the longest palindromic substring
152+
by attempting to build a palindrome from each possible center. Odd
153+
length and even length palindromes are handled separately.
154+
"""
155+
if not s:
156+
return ""
157+
158+
start, end, maximum_length = 0, 0, 0
159+
160+
# Check for odd length palindromes (centered at each character).
161+
for i in range(len(s)):
162+
left, right = i, i
163+
while left >= 0 and right < len(s) and s[left] == s[right]:
164+
current_length = right - left + 1
165+
if current_length > maximum_length:
166+
maximum_length = current_length
167+
start, end = left, right
168+
left -= 1
169+
right += 1
170+
171+
# Check for even length palindromes (centered at each character pair).
172+
for i in range(len(s) - 1):
173+
left, right = i, i + 1
174+
while left >= 0 and right < len(s) and s[left] == s[right]:
175+
current_length = right - left + 1
176+
if current_length > maximum_length:
177+
maximum_length = current_length
178+
start, end = left, right
179+
left -= 1
180+
right += 1
181+
182+
return s[start : end + 1]
183+
184+
185+
class ManachersAlgorithmSolution:
186+
def longestPalindrome(self, s: str) -> str:
187+
"""
188+
Manacher's Algorithm. Finds the longest palindromic substring of a
189+
string in linear time.
190+
"""
191+
if not s:
192+
return ""
193+
194+
# Create S' by inserting a separator character '|' between each
195+
# character. This allows for the consolidation of the odd and even
196+
# palindromic arrays into a single array. Odd palindromes are centered
197+
# around non-separator characters, even palindromes are centered around
198+
# separator characters.
199+
s_prime = "|" + "|".join(s) + "|"
200+
201+
# A palindromic radii array is used to store the radius of the longest
202+
# palindrome centered on each character in S'. It is important to note
203+
# that `palindrome_radii` contains the radius of the palindrome, not
204+
# the length of the palindrome itself.
205+
palindrome_radii = [0] * len(s_prime)
206+
207+
# NOTE: In some implementations `right` may be used instead of `radius`
208+
# to denote the boundary of palindrome centered at `center`.
209+
center, radius = 0, 0
210+
211+
while center < len(s_prime):
212+
# Determine the longest palindrome centered at `center` starting
213+
# from s_prime[center - radius] and ending at
214+
# s_prime[center + radius]. This technique expands around a given
215+
# center, using the assertion that a palindrome can be expanded if
216+
# the start and end characters of the new substring are equal.
217+
while (
218+
center - (radius + 1) >= 0
219+
and center + (radius + 1) < len(s_prime)
220+
and s_prime[center - (radius + 1)] == s_prime[center + (radius + 1)]
221+
):
222+
radius += 1
223+
224+
# Store the radius of the longest palindrome in the array.
225+
palindrome_radii[center] = radius
226+
227+
# The following is the algorithm's core optimization.
228+
229+
# Save the center and radius of the original palindrome.
230+
original_center, original_radius = center, radius
231+
center, radius = center + 1, 0
232+
233+
while center <= original_center + original_radius:
234+
# Calculate the "mirrored" center for the current center.
235+
mirrored_center = original_center - (center - original_center)
236+
# Calculate the maximum possible radius of the palindrome
237+
# centered at `center`.
238+
max_radius = original_center + original_radius - center
239+
240+
# Case 1: Palindrome of mirrored center lies entirely within
241+
# the original palindrome.
242+
if palindrome_radii[mirrored_center] < max_radius:
243+
palindrome_radii[center] = palindrome_radii[mirrored_center]
244+
center += 1
245+
# Case 2: Palindrome of mirrored center extends beyond the
246+
# boundary of the original palindrome.
247+
elif palindrome_radii[mirrored_center] > max_radius:
248+
palindrome_radii[center] = max_radius
249+
center += 1
250+
# Case 3: Palindrome of mirrored center extends exactly up to
251+
# the boundary of the original palindrome.
252+
else:
253+
radius = max_radius
254+
break
255+
256+
# The longest palindrome in S is formed from the center with the largest
257+
# radius.
258+
max_radius, max_center = 0, 0
259+
for i in range(len(palindrome_radii)):
260+
if palindrome_radii[i] > max_radius:
261+
max_radius, max_center = palindrome_radii[i], i
262+
263+
# Convert back to indices in the original string.
264+
start, end = (max_center - max_radius) // 2, (max_center + max_radius - 1) // 2
265+
return s[start : end + 1]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
5. Longest Palindromic Substring
3+
4+
https://leetcode.com/problems/longest-palindromic-substring
5+
"""
6+
7+
from unittest import TestCase
8+
9+
from src.longest_palindromic_substring import (
10+
ExpansionAroundCentersSolution,
11+
ManachersAlgorithmSolution,
12+
Solution,
13+
)
14+
15+
16+
class TestSolution(TestCase):
17+
def test_1(self):
18+
exp = ["bab", "aba"]
19+
assert Solution().longestPalindrome("babad") in exp
20+
21+
def test_2(self):
22+
exp = "bb"
23+
assert Solution().longestPalindrome("cbbd") == exp
24+
25+
26+
class TestExpansionAroundCentersSolution(TestCase):
27+
def test_1(self):
28+
exp = ["bab", "aba"]
29+
assert Solution().longestPalindrome("babad") in exp
30+
31+
def test_2(self):
32+
exp = "bb"
33+
assert ExpansionAroundCentersSolution().longestPalindrome("cbbd") == exp
34+
35+
36+
class TestManachersAlgorithmSolution(TestCase):
37+
def test_1(self):
38+
exp = ["bab", "aba"]
39+
assert Solution().longestPalindrome("babad") in exp
40+
41+
def test_2(self):
42+
exp = "bb"
43+
assert ManachersAlgorithmSolution().longestPalindrome("cbbd") == exp

0 commit comments

Comments
 (0)