|
| 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 |
0 commit comments