243 lines
9.7 KiB
YAML
243 lines
9.7 KiB
YAML
title: Palindromic Substrings
|
||
slug: palindromic-substrings
|
||
difficulty: medium
|
||
leetcode_id: 647
|
||
leetcode_url: https://leetcode.com/problems/palindromic-substrings/
|
||
categories:
|
||
- strings
|
||
- dynamic-programming
|
||
patterns:
|
||
- slug: two-pointers
|
||
is_optimal: true
|
||
- slug: dynamic-programming
|
||
is_optimal: false
|
||
|
||
function_signature: "def count_substrings(s: str) -> int:"
|
||
|
||
test_cases:
|
||
visible:
|
||
- input: { s: "abc" }
|
||
expected: 3
|
||
- input: { s: "aaa" }
|
||
expected: 6
|
||
hidden:
|
||
- input: { s: "a" }
|
||
expected: 1
|
||
- input: { s: "aa" }
|
||
expected: 3
|
||
- input: { s: "ab" }
|
||
expected: 2
|
||
- input: { s: "aba" }
|
||
expected: 4
|
||
- input: { s: "abba" }
|
||
expected: 6
|
||
|
||
description: |
|
||
Given a string `s`, return *the number of **palindromic substrings** in it*.
|
||
|
||
A string is a **palindrome** when it reads the same backward as forward.
|
||
|
||
A **substring** is a contiguous sequence of characters within the string.
|
||
|
||
constraints: |
|
||
- `1 <= s.length <= 1000`
|
||
- `s` consists of lowercase English letters.
|
||
|
||
examples:
|
||
- input: 's = "abc"'
|
||
output: "3"
|
||
explanation: "Three palindromic strings: \"a\", \"b\", \"c\"."
|
||
- input: 's = "aaa"'
|
||
output: "6"
|
||
explanation: 'Six palindromic strings: "a", "a", "a", "aa", "aa", "aaa".'
|
||
|
||
explanation:
|
||
intuition: |
|
||
Imagine each character in the string as a potential **centre** of a palindrome. A palindrome is symmetric — it mirrors around its centre.
|
||
|
||
The key insight is that palindromes can have either:
|
||
- **Odd length**: A single character at the centre (e.g., "aba" centres on "b")
|
||
- **Even length**: Two identical characters at the centre (e.g., "abba" centres between the two "b"s)
|
||
|
||
Think of it like dropping a pebble into still water and watching ripples expand outward. From each centre point, we **expand outward** in both directions as long as the characters on both sides match. Each successful expansion represents another valid palindrome we've found.
|
||
|
||
By systematically considering every possible centre (both single characters and pairs of adjacent characters), we can count all palindromic substrings without missing any or counting duplicates.
|
||
|
||
approach: |
|
||
We solve this using the **Expand Around Centre** approach:
|
||
|
||
**Step 1: Initialise a counter**
|
||
|
||
- `count`: Set to `0` to track the total number of palindromic substrings
|
||
|
||
|
||
|
||
**Step 2: Iterate through each potential centre**
|
||
|
||
- For each index `i` from `0` to `n-1`, treat it as a potential centre
|
||
- Each position can be the centre of both odd-length and even-length palindromes
|
||
|
||
|
||
|
||
**Step 3: Expand around odd-length centres**
|
||
|
||
- Call `expand(s, i, i)` — starting with a single character centre
|
||
- Expand outward while `s[left] == s[right]`
|
||
- Each successful comparison means we found another palindrome
|
||
- Continue until characters don't match or we hit boundaries
|
||
|
||
|
||
|
||
**Step 4: Expand around even-length centres**
|
||
|
||
- Call `expand(s, i, i + 1)` — starting with two adjacent characters as the centre
|
||
- Same expansion logic as odd-length centres
|
||
- This catches palindromes like "aa", "abba", etc.
|
||
|
||
|
||
|
||
**Step 5: Return the total count**
|
||
|
||
- Return `count` after processing all centres
|
||
|
||
|
||
|
||
This approach is efficient because each expansion takes O(n) time in the worst case, and we have O(n) centres, giving us O(n²) total — much better than checking all O(n²) substrings explicitly.
|
||
|
||
common_pitfalls:
|
||
- title: Checking All Substrings Naively
|
||
description: |
|
||
A common first approach is to generate all substrings and check each one for being a palindrome:
|
||
- Outer loop for start index: O(n)
|
||
- Inner loop for end index: O(n)
|
||
- Palindrome check for each substring: O(n)
|
||
|
||
This results in **O(n³) time complexity**. While it passes for `n <= 1000`, it's significantly slower than necessary. For larger inputs (in similar problems), this approach would cause TLE.
|
||
wrong_approach: "Generate all substrings, check each for palindrome"
|
||
correct_approach: "Expand around centres in O(n²)"
|
||
|
||
- title: Forgetting Even-Length Palindromes
|
||
description: |
|
||
When expanding from centres, it's easy to only consider single characters as centres (odd-length palindromes).
|
||
|
||
For example, in "abba", if you only expand from single characters, you'll find "a", "b", "b", "a" but miss "bb" and "abba".
|
||
|
||
You must expand from both:
|
||
- Single characters: `expand(i, i)` for odd-length
|
||
- Adjacent pairs: `expand(i, i+1)` for even-length
|
||
wrong_approach: "Only expand from single character centres"
|
||
correct_approach: "Expand from both single characters and adjacent pairs"
|
||
|
||
- title: Off-by-One Errors in Expansion
|
||
description: |
|
||
When expanding outward, boundary checks are crucial. The expansion should stop when:
|
||
- `left < 0` (hit the start of string)
|
||
- `right >= n` (hit the end of string)
|
||
- `s[left] != s[right]` (characters don't match)
|
||
|
||
A common mistake is checking boundaries after accessing the characters, causing index out of bounds errors.
|
||
wrong_approach: "Check boundaries after character comparison"
|
||
correct_approach: "Check boundaries before accessing characters"
|
||
|
||
key_takeaways:
|
||
- "**Expand around centre pattern**: For palindrome problems, thinking in terms of centres rather than endpoints often leads to cleaner solutions"
|
||
- "**Odd vs even length**: Always consider both cases when dealing with palindromes — single character centres and two-character centres"
|
||
- "**Foundation for Longest Palindromic Substring**: The same expand-around-centre technique solves LeetCode 5, just track the longest instead of counting"
|
||
- "**Alternative: Dynamic Programming**: This problem can also be solved with DP where `dp[i][j]` indicates if `s[i:j+1]` is a palindrome, useful for problems requiring the actual substrings"
|
||
|
||
time_complexity: "O(n²). For each of the `n` positions, we potentially expand up to `n` times in each direction."
|
||
space_complexity: "O(1). We only use a constant number of variables for counting and expansion indices."
|
||
|
||
solutions:
|
||
- approach_name: Expand Around Centre
|
||
is_optimal: true
|
||
code: |
|
||
def count_substrings(s: str) -> int:
|
||
def expand(left: int, right: int) -> int:
|
||
"""Count palindromes by expanding from centre."""
|
||
count = 0
|
||
# Expand while within bounds and characters match
|
||
while left >= 0 and right < len(s) and s[left] == s[right]:
|
||
count += 1 # Found a palindrome
|
||
left -= 1 # Expand left
|
||
right += 1 # Expand right
|
||
return count
|
||
|
||
total = 0
|
||
for i in range(len(s)):
|
||
# Odd-length palindromes (single character centre)
|
||
total += expand(i, i)
|
||
# Even-length palindromes (two character centre)
|
||
total += expand(i, i + 1)
|
||
|
||
return total
|
||
explanation: |
|
||
**Time Complexity:** O(n²) — For each of n centres, expansion can take up to O(n) time.
|
||
|
||
**Space Complexity:** O(1) — Only using a few integer variables.
|
||
|
||
We iterate through each position as a potential centre and expand outward for both odd and even length palindromes. Each expansion continues while characters match, counting each valid palindrome found.
|
||
|
||
- approach_name: Dynamic Programming
|
||
is_optimal: false
|
||
code: |
|
||
def count_substrings(s: str) -> int:
|
||
n = len(s)
|
||
count = 0
|
||
|
||
# dp[i][j] = True if s[i:j+1] is a palindrome
|
||
dp = [[False] * n for _ in range(n)]
|
||
|
||
# Single characters are palindromes
|
||
for i in range(n):
|
||
dp[i][i] = True
|
||
count += 1
|
||
|
||
# Check substrings of length 2
|
||
for i in range(n - 1):
|
||
if s[i] == s[i + 1]:
|
||
dp[i][i + 1] = True
|
||
count += 1
|
||
|
||
# Check substrings of length 3 and above
|
||
for length in range(3, n + 1):
|
||
for i in range(n - length + 1):
|
||
j = i + length - 1
|
||
# Palindrome if ends match and middle is palindrome
|
||
if s[i] == s[j] and dp[i + 1][j - 1]:
|
||
dp[i][j] = True
|
||
count += 1
|
||
|
||
return count
|
||
explanation: |
|
||
**Time Complexity:** O(n²) — We fill an n×n DP table.
|
||
|
||
**Space Complexity:** O(n²) — We store the DP table.
|
||
|
||
This approach builds up from smaller substrings: single characters are palindromes, length-2 substrings are palindromes if both characters match, and longer substrings are palindromes if the ends match and the middle (already computed) is a palindrome. While correct and useful for understanding, the expand-around-centre approach is preferred due to O(1) space.
|
||
|
||
- approach_name: Brute Force
|
||
is_optimal: false
|
||
code: |
|
||
def count_substrings(s: str) -> int:
|
||
def is_palindrome(substring: str) -> bool:
|
||
"""Check if a string is a palindrome."""
|
||
return substring == substring[::-1]
|
||
|
||
count = 0
|
||
n = len(s)
|
||
|
||
# Check all possible substrings
|
||
for i in range(n):
|
||
for j in range(i, n):
|
||
if is_palindrome(s[i:j + 1]):
|
||
count += 1
|
||
|
||
return count
|
||
explanation: |
|
||
**Time Complexity:** O(n³) — O(n²) substrings, each taking O(n) to check.
|
||
|
||
**Space Complexity:** O(n) — Creating substring copies for comparison.
|
||
|
||
This approach explicitly generates every substring and checks if it's a palindrome by comparing it to its reverse. While straightforward and correct, it's significantly slower than the optimal approaches. Included to illustrate the progression from naive to optimal thinking.
|