Introduction to the Longest Palindromic Substring Problem
The Longest Palindromic Substring problem is one of the most classic algorithmic challenges you will encounter in coding interviews and competitive programming. Given a string s, the task is to find the longest contiguous substring that reads the same forwards and backwards. For example, in the string "babad", both "bab" and "aba" are valid answers, each with a length of three.
While the problem statement is deceptively simple, the challenge lies in solving it efficiently. A naive approach can take cubic time, but with the right technique, you can reduce this to linear time. In this tutorial, we will explore multiple approaches to solving this problem in Go, starting from the brute force method and progressing to the optimal Manacher's algorithm.
Why This Problem Matters
Beyond being a frequent interview question at companies like Amazon, Google, and Microsoft, the Longest Palindromic Substring problem teaches several fundamental concepts that every developer should master:
- String manipulation: Working with substrings, indices, and character comparisons is a foundational skill.
- Algorithmic optimization: The problem demonstrates how to move from O(n³) to O(n) by recognizing structural properties of palindromes.
- Dynamic programming: It is a textbook example of how overlapping subproblems can be cached for efficiency.
- Two-pointer techniques: The expand-around-center approach is a pattern that appears in many other problems.
- Bioinformatics applications: Palindrome detection is used in DNA sequence analysis, where complementary base pairs form palindromic structures.
Understanding this problem deeply will make you a stronger problem solver and give you tools that transfer to many other algorithmic challenges.
Understanding Palindromes
Before diving into code, let us clarify what a palindrome is. A palindrome is a sequence of characters that reads identically from left to right and right to left. Palindromes come in two flavors that matter for this problem:
- Odd-length palindromes: These have a single center character, such as
"racecar"where the center is'e'. - Even-length palindromes: These have two center characters, such as
"abba"where the center is between the two'b'characters.
This distinction is crucial because any algorithm that expands around a center must handle both cases. Failing to account for even-length palindromes is one of the most common bugs developers introduce when first attempting this problem.
Approach 1: Brute Force
The most straightforward approach is to generate every possible substring and check whether each one is a palindrome. While this is easy to understand and implement, it is computationally expensive.
How It Works
For a string of length n, there are approximately n² substrings. For each substring, checking whether it is a palindrome takes O(n) time in the worst case. This gives us an overall time complexity of O(n³), which is impractical for strings longer than a few hundred characters.
Implementation
package main
import "fmt"
func isPalindrome(s string, left, right int) bool {
for left < right {
if s[left] != s[right] {
return false
}
left++
right--
}
return true
}
func longestPalindromeBruteForce(s string) string {
if len(s) < 2 {
return s
}
longest := ""
for i := 0; i < len(s); i++ {
for j := i; j < len(s); j++ {
if isPalindrome(s, i, j) {
if j-i+1 > len(longest) {
longest = s[i : j+1]
}
}
}
}
return longest
}
func main() {
testCases := []string{"babad", "cbbd", "a", "ac", "racecar"}
for _, tc := range testCases {
fmt.Printf("Input: %s -> Longest Palindrome: %s\n", tc, longestPalindromeBruteForce(tc))
}
}
When you run this code, you will see output like:
Input: babad -> Longest Palindrome: bab
Input: cbbd -> Longest Palindrome: bb
Input: a -> Longest Palindrome: a
Input: ac -> Longest Palindrome: a
Input: racecar -> Longest Palindrome: racecar
The brute force approach works correctly but will time out on large inputs. Let us improve it.
Approach 2: Expand Around Center
The key insight for this approach is that a palindrome mirrors around its center. Therefore, we can iterate through each possible center and expand outward as long as the characters on both sides match. Since there are 2n - 1 possible centers (each character for odd-length palindromes, and each gap between characters for even-length palindromes), and each expansion takes at most O(n) time, the overall complexity is O(n²).
How It Works
For each index i in the string, we perform two expansions:
- Expand around
ias a single center (odd-length palindrome). - Expand around the gap between
iandi+1as a double center (even-length palindrome).
We track the start and end indices of the longest palindrome found so far, updating them whenever we discover a longer one.
Implementation
package main
import "fmt"
func expandAroundCenter(s string, left, right int) (int, int) {
for left >= 0 && right < len(s) && s[left] == s[right] {
left--
right++
}
// When the loop exits, left and right are one step beyond the palindrome.
return left + 1, right - 1
}
func longestPalindromeExpand(s string) string {
if len(s) < 2 {
return s
}
start, end := 0, 0
for i := 0; i < len(s); i++ {
// Odd-length palindrome centered at i
left1, right1 := expandAroundCenter(s, i, i)
// Even-length palindrome centered between i and i+1
left2, right2 := expandAroundCenter(s, i, i+1)
if right1-left1 > end-start {
start, end = left1, right1
}
if right2-left2 > end-start {
start, end = left2, right2
}
}
return s[start : end+1]
}
func main() {
testCases := []string{"babad", "cbbd", "a", "ac", "racecar", "abacdfgdcaba"}
for _, tc := range testCases {
fmt.Printf("Input: %s -> Longest Palindrome: %s\n", tc, longestPalindromeExpand(tc))
}
}
This approach is the sweet spot for most interviews and real-world scenarios. It runs in O(n²) time and uses only O(1) extra space, making it both efficient and easy to explain.
Approach 3: Dynamic Programming
Dynamic programming offers another O(n²) solution, but it uses O(n²) space to store a table of boolean values indicating whether a substring s[i:j] is a palindrome. While this is less space-efficient than the expand-around-center method, it is worth understanding because the DP pattern appears in many string problems.
How It Works
We define a 2D table dp[i][j] that is true if the substring from index i to index j is a palindrome. The recurrence relation is:
dp[i][j] = (s[i] == s[j]) AND (j - i < 2 OR dp[i+1][j-1])
This means a substring is a palindrome if its outer characters match and the inner substring is also a palindrome (or the substring has length 1 or 2). We fill the table by increasing substring length, which ensures that when we need dp[i+1][j-1], it has already been computed.
Implementation
package main
import "fmt"
func longestPalindromeDP(s string) string {
n := len(s)
if n < 2 {
return s
}
// dp[i][j] is true if s[i:j+1] is a palindrome
dp := make([][]bool, n)
for i := range dp {
dp[i] = make([]bool, n)
}
start, maxLen := 0, 1
// Every single character is a palindrome
for i := 0; i < n; i++ {
dp[i][i] = true
}
// Check substrings of length 2 and greater
for length := 2; length <= n; length++ {
for i := 0; i <= n-length; i++ {
j := i + length - 1
if s[i] == s[j] {
if length == 2 {
dp[i][j] = true
} else {
dp[i][j] = dp[i+1][j-1]
}
if dp[i][j] && length > maxLen {
start = i
maxLen = length
}
}
}
}
return s[start : start+maxLen]
}
func main() {
testCases := []string{"babad", "cbbd", "a", "ac", "racecar"}
for _, tc := range testCases {
fmt.Printf("Input: %s -> Longest Palindrome: %s\n", tc, longestPalindromeDP(tc))
}
}
The DP approach is a great teaching tool, but in practice, the expand-around-center method is usually preferred because it achieves the same time complexity with constant space.
Approach 4: Manacher's Algorithm
For those who want the absolute best performance, Manacher's algorithm solves the problem in O(n) time. It achieves this by exploiting the symmetry of palindromes to avoid redundant comparisons. The algorithm is more complex to implement but is the gold standard for this problem.
How It Works
Manacher's algorithm works in three main steps:
- Transform the string: Insert a special separator character (such as
'#') between every character and at both ends. This unifies odd and even-length palindromes into a single case. For example,"abba"becomes"#a#b#b#a#". - Maintain a radius array: An array
Pstores the radius of the palindrome centered at each position in the transformed string. - Use mirror symmetry: When expanding around a center, if the current position falls within the right boundary of a previously found palindrome, we can initialize its radius using the mirror value of the corresponding position on the left, then expand only as needed.
Implementation
package main
import "fmt"
func longestPalindromeManacher(s string) string {
if len(s) < 2 {
return s
}
// Transform s into T with separators
// Example: "aba" -> "^#a#b#a#$"
// The '^' and '$' sentinels prevent bounds checking
var T []byte
T = append(T, '^')
for i := 0; i < len(s); i++ {
T = append(T, '#', s[i])
}
T = append(T, '#', '$')
n := len(T)
P := make([]int, n)
C, R := 0, 0
for i := 1; i < n-1; i++ {
mirror := 2*C - i
if i < R {
if P[mirror] < R-i {
P[i] = P[mirror]
} else {
P[i] = R - i
}
}
// Expand around center i
for T[i+1+P[i]] == T[i-1-P[i]] {
P[i]++
}
// Update center and right boundary
if i+P[i] > R {
C = i
R = i + P[i]
}
}
// Find the maximum element in P
maxLen, centerIndex := 0, 0
for i := 1; i < n-1; i++ {
if P[i] > maxLen {
maxLen = P[i]
centerIndex = i
}
}
// Map back to original string
start := (centerIndex - maxLen) / 2
return s[start : start+maxLen]
}
func main() {
testCases := []string{"babad", "cbbd", "a", "ac", "racecar", "abacdfgdcaba"}
for _, tc := range testCases {
fmt.Printf("Input: %s -> Longest Palindrome: %s\n", tc, longestPalindromeManacher(tc))
}
}
Manacher's algorithm is the most efficient solution, but its complexity makes it harder to implement correctly under interview pressure. Practice it thoroughly before attempting it in a live setting.
Comparing the Approaches
Here is a summary of the four approaches we have covered:
- Brute Force: O(n³) time, O(1) space. Simple but impractical for large inputs.
- Expand Around Center: O(n²) time, O(1) space. The best balance of simplicity and efficiency for most use cases.
- Dynamic Programming: O(n²) time, O(n²) space. Good for learning DP patterns but wasteful in space.
- Manacher's Algorithm: O(n) time, O(n) space. Optimal time complexity but complex to implement.
For most practical purposes and interviews, the expand-around-center approach is the recommended choice. It is efficient, easy to explain, and uses minimal memory.
Best Practices
When implementing a solution to this problem, keep the following best practices in mind:
- Handle edge cases early: Always check for empty strings and single-character strings at the beginning of your function. This prevents unnecessary computation and avoids index-out-of-bounds errors.
- Use sentinels in Manacher's: Adding
'^'and'$'at the start and end of the transformed string eliminates the need for explicit bounds checking during expansion, making the code cleaner and slightly faster. - Avoid string concatenation in loops: In Go, strings are immutable. Building a result through repeated concatenation creates many intermediate allocations. Instead, track indices and slice the original string once at the end.
- Write test cases: Test with strings that have odd-length palindromes, even-length palindromes, no palindromes longer than one character, all identical characters, and very long strings to verify performance.
- Profile before optimizing: If your input sizes are small, the expand-around-center approach is more than sufficient. Only reach for Manacher's algorithm when you have confirmed through profiling that the O(n²) solution is a bottleneck.
- Understand the trade-offs: In an interview, a clean O(n²) solution that you can explain confidently is often more valuable than a buggy O(n) solution you cannot fully justify.
Testing Your Solution
Robust testing is essential for algorithmic problems. Here is a comprehensive test suite using Go's built-in testing framework that covers edge cases and typical scenarios:
package main
import "testing"
func TestLongestPalindromeExpand(t *testing.T) {
tests := []struct {
input string
expected int // expected length, since multiple valid answers may exist
}{
{"babad", 3},
{"cbbd", 2},
{"a", 1},
{"ac", 1},
{"racecar", 7},
{"", 0},
{"abacdfgdcaba", 3},
{"aaaaaaaaaa", 10},
{"abcdef", 1},
{"bananas", 5},
}
for _, tt := range tests {
result := longestPalindromeExpand(tt.input)
if len(result) != tt.expected {
t.Errorf("longestPalindromeExpand(%q) = %q (len %d), expected length %d",
tt.input, result, len(result), tt.expected)
}
// Verify the result is actually a palindrome
if len(result) > 0 && !isPalindrome(result, 0, len(result)-1) {
t.Errorf("longestPalindromeExpand(%q) returned %q which is not a palindrome",
tt.input, result)
}
}
}
func BenchmarkLongestPalindromeExpand(b *testing.B) {
// Create a long string for benchmarking
longString := ""
for i := 0; i < 1000; i++ {
longString += "a"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
longestPalindromeExpand(longString)
}
}
func BenchmarkLongestPalindromeManacher(b *testing.B) {
longString := ""
for i := 0; i < 1000; i++ {
longString += "a"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
longestPalindromeManacher(longString)
}
}
Run the tests with go test -v and the benchmarks with go test -bench=.. You will likely see that Manacher's algorithm outperforms the expand-around-center method on very long strings, while for shorter inputs the difference is negligible.
Common Pitfalls
As you work through this problem, watch out for these common mistakes:
- Forgetting even-length palindromes: If your solution only checks odd-length palindromes, it will fail on inputs like
"cbbd"where the answer is"bb". - Off-by-one errors in slicing: Go's slice syntax
s[start:end]is exclusive on the right. If your palindrome spans indicesstarttoendinclusive, you must slice withs[start : end+1]. - Incorrect DP table fill order: When using dynamic programming, you must fill the table by increasing substring length, not by iterating
iandjindependently. Otherwise,dp[i+1][j-1]may not be computed when you need it. - Not handling empty input: Always guard against empty strings to avoid panics when accessing
s[0]or slicing. - Returning the wrong substring: When multiple palindromes of the same maximum length exist, any of them is a valid answer. Make sure your tests check the length rather than a specific substring to avoid false failures.
Conclusion
The Longest Palindromic Substring problem is a rich exercise that rewards careful thinking about string structure and algorithmic optimization. We explored four approaches in Go, ranging from the intuitive but slow brute force method to the optimal but intricate Manacher's algorithm. For most real-world applications and coding interviews, the expand-around-center technique offers the best combination of clarity, efficiency, and minimal memory usage. By understanding all four approaches, practicing the implementations, and writing thorough tests, you will be well-equipped to tackle this problem and many related string challenges with confidence. Remember that the journey from O(n³) to O(n) is itself a lesson in how recognizing structural properties of a problem can unlock dramatic performance improvements, a skill that will serve you throughout your career as a developer.