Skip to content

Commit 694f38a

Browse files
Add 'Palindromic Substrings'
1 parent 0483dcd commit 694f38a

File tree

3 files changed

+302
-0
lines changed

3 files changed

+302
-0
lines changed

README.md

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

7171
[Minimum Window Substring](./src/minimum_window_substring.py)
7272

73+
[Palindromic Substrings](./src/palindromic_substrings.py)
74+
7375
[Permutation in String](./src/permutation_in_string.py)
7476

7577
[Product of Array Except Self](./src/product_of_array_except_self.py)

src/palindromic_substrings.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""
2+
647. Palindromic Substrings
3+
4+
https://leetcode.com/problems/palindromic-substrings
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 counts all valid palindromic substrings in the given string
20+
by checking all 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+
Expansion around centers
69+
------------------------
70+
An improvement to the brute force approach involves expansion around each
71+
potential palindrome's center, which brings the time complexity down to O(n^2)
72+
(2*n-1 centers * n/2 comparisons).
73+
74+
Manacher's Algorithm builds from this trivial algorithm that is based on the
75+
fact that a palindrome can be expanded if s[0] and s[n-1] are equal. The
76+
expansion around centers algorithm is slow, however, requiring O(n^2) time
77+
complexity.
78+
79+
Manacher's Algorithm
80+
--------------------
81+
We observe that palindromes with a common center form a contiguous chain, that
82+
is, if we have a palindrome of length n centered at i, we also have palindromes
83+
of length n-2, n-4, and so on also centered at i. Storing this information
84+
allows us to make assertions about the possible length of other palindromic
85+
substrings in the string. For example, given the string "abacaba", we observe
86+
that the longest palindrome is centered at "c". Since, by definition, all
87+
characters after "c" must be equal to all characters before "c", characters
88+
within the radius of the palindrome centered at "c" must have at least the same
89+
palindromic radius (i.e., the same longest palindrome) as the characters before
90+
"c". Characters before "c" are commonly referred to as "mirrored" centers.
91+
"""
92+
93+
94+
class Solution:
95+
def countSubstrings(self, s: str) -> int:
96+
"""
97+
Uses dynamic programming (2D) with O(n^2) time and space complexity.
98+
"""
99+
if not s:
100+
return 0
101+
102+
count = 0
103+
104+
# Create an n x n matrix initialized to 0s.
105+
#
106+
# a b a j →
107+
# a [0, 0, 0] i where, dp[0][0] = "a"
108+
# b [-, 0, 0] ↓ dp[1][1] = "b"
109+
# a [-, -, 0] dp[2][2] = "a"
110+
#
111+
# NOTE: i and j are the beginning and end of the substring, therefore j
112+
# must be greater than or equal to i. For example, dp[2][0] does not
113+
# make sense.
114+
n = len(s)
115+
dp: list[list[int]] = [[0 for j in range(n)] for i in range(n)]
116+
117+
# Set dp[i,i] to 1, accounting for the base case assertion that each
118+
# character is itself a palindrome.
119+
for i in range(n):
120+
dp[i][i] = 1
121+
count += 1
122+
123+
# Set dp[i,i+1] to 1, if s[i] is equal to s[i+1], accounting for the
124+
# base case assertion that each pair of characters is a palindrome if
125+
# they are equal.
126+
for i in range(n - 1):
127+
if s[i] == s[i + 1]:
128+
dp[i][i + 1] = 1
129+
count += 1
130+
131+
# Set dp[i,j] to 1, if s[i+1...j-1] is a palindrome (dp[i+1][j-1] == 1)
132+
# and s[i] is equal to s[j]. This is our recurrence relation, which
133+
# leverages the solutions to previous calculations.
134+
for length in range(3, n + 1):
135+
for i in range(n - length + 1):
136+
j = i + length - 1
137+
if dp[i + 1][j - 1] == 1 and s[i] == s[j]:
138+
dp[i][j] = 1
139+
count += 1
140+
141+
return count
142+
143+
144+
class ExpansionAroundCentersSolution:
145+
def countSubstrings(self, s: str) -> int:
146+
"""
147+
A slow, O(n^2) algorithm that finds all palindromic substrings by
148+
attempting to build a palindrome from each possible center. Odd length
149+
and even length palindromes are handled separately.
150+
"""
151+
if not s:
152+
return 0
153+
154+
count = 0
155+
156+
# Check for odd length palindromes (centered at each character).
157+
for i in range(len(s)):
158+
left, right = i, i
159+
while left >= 0 and right < len(s) and s[left] == s[right]:
160+
left -= 1
161+
right += 1
162+
count += 1
163+
164+
# Check for even length palindromes (centered at each character pair).
165+
for i in range(len(s) - 1):
166+
left, right = i, i + 1
167+
while left >= 0 and right < len(s) and s[left] == s[right]:
168+
left -= 1
169+
right += 1
170+
count += 1
171+
172+
return count
173+
174+
175+
class ManachersAlgorithmSolution:
176+
def countSubstrings(self, s: str) -> int:
177+
"""
178+
Manacher's Algorithm. Finds all palindromic substrings of a string in
179+
linear time.
180+
"""
181+
if not s:
182+
return 0
183+
184+
# Create S' by inserting a separator character '|' between each
185+
# character. This allows for the consolidation of the odd and even
186+
# palindromic arrays into a single array. Odd palindromes are centered
187+
# around non-separator characters, even palindromes are centered around
188+
# separator characters.
189+
s_prime = "|" + "|".join(s) + "|"
190+
191+
# A palindromic radii array is used to store the radius of the longest
192+
# palindrome centered on each character in S'. It is important to note
193+
# that `palindrome_radii` contains the radius of the palindrome, not
194+
# the length of the palindrome itself.
195+
palindrome_radii = [0] * len(s_prime)
196+
197+
# NOTE: In some implementations `right` may be used instead of `radius`
198+
# to denote the boundary of palindrome centered at `center`.
199+
center, radius = 0, 0
200+
201+
while center < len(s_prime):
202+
# Determine the longest palindrome centered at `center` starting
203+
# from s_prime[center - radius] and ending at
204+
# s_prime[center + radius]. This technique expands around a given
205+
# center, using the assertion that a palindrome can be expanded if
206+
# the start and end characters of the new substring are equal.
207+
while (
208+
center - (radius + 1) >= 0
209+
and center + (radius + 1) < len(s_prime)
210+
and s_prime[center - (radius + 1)] == s_prime[center + (radius + 1)]
211+
):
212+
radius += 1
213+
214+
# Store the radius of the longest palindrome in the array.
215+
palindrome_radii[center] = radius
216+
217+
# The following is the algorithm's core optimization.
218+
219+
# Save the center and radius of the original palindrome.
220+
original_center, original_radius = center, radius
221+
center, radius = center + 1, 0
222+
223+
while center <= original_center + original_radius:
224+
# Calculate the "mirrored" center for the current center.
225+
mirrored_center = original_center - (center - original_center)
226+
# Calculate the maximum possible radius of the palindrome centered
227+
# at `center`.
228+
max_radius = original_center + original_radius - center
229+
230+
# Case 1: Palindrome of mirrored center lies entirely within the
231+
# original palindrome.
232+
if palindrome_radii[mirrored_center] < max_radius:
233+
palindrome_radii[center] = palindrome_radii[mirrored_center]
234+
center += 1
235+
# Case 2: Palindrome of mirrored center extends beyond the boundary
236+
# of the original palindrome.
237+
elif palindrome_radii[mirrored_center] > max_radius:
238+
palindrome_radii[center] = max_radius
239+
center += 1
240+
# Case 3: Palindrome of mirrored center extends exactly up to the
241+
# boundary of the original palindrome.
242+
else:
243+
radius = max_radius
244+
break
245+
246+
# Instead of using the palindromic radii array to find the longest
247+
# palindromic substring, we tally up the total number of palindromes.
248+
count = 0
249+
for i in range(len(s_prime)):
250+
# Odd length palindromes
251+
if i % 2 == 1:
252+
count += (palindrome_radii[i] + 1) // 2
253+
# Even length palindromes
254+
else:
255+
count += (palindrome_radii[i]) // 2
256+
257+
return count

tests/test_palindromic_substrings.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
647. Palindromic Substrings
3+
4+
https://leetcode.com/problems/palindromic-substrings
5+
"""
6+
7+
from unittest import TestCase
8+
9+
from src.palindromic_substrings import (
10+
ExpansionAroundCentersSolution,
11+
ManachersAlgorithmSolution,
12+
Solution,
13+
)
14+
15+
16+
class TestSolution(TestCase):
17+
def test_1(self):
18+
exp = 3
19+
assert Solution().countSubstrings("abc") == exp
20+
21+
def test_2(self):
22+
exp = 6
23+
assert Solution().countSubstrings("aaa") == exp
24+
25+
26+
class TestExpansionAroundCentersSolution(TestCase):
27+
def test_1(self):
28+
exp = 3
29+
assert ExpansionAroundCentersSolution().countSubstrings("abc") == exp
30+
31+
def test_2(self):
32+
exp = 6
33+
assert ExpansionAroundCentersSolution().countSubstrings("aaa") == exp
34+
35+
36+
class TestManachersAlgorithmSolution(TestCase):
37+
def test_1(self):
38+
exp = 3
39+
assert ManachersAlgorithmSolution().countSubstrings("abc") == exp
40+
41+
def test_2(self):
42+
exp = 6
43+
assert ManachersAlgorithmSolution().countSubstrings("aaa") == exp

0 commit comments

Comments
 (0)