Files
interview/16-LeetCode Hot 100/最长回文子串.md
yasinshaw a5736a4db7 docs: improve solution explanations for 最长回文子串 and 括号生成
- 添加思路推导部分,从暴力解法分析优化过程
- 增加详细的算法流程和Q&A形式的解释
- 添加执行过程演示和常见错误分析
- 完善边界条件和复杂度分析
- 保持原有的代码实现和进阶问题
2026-03-08 21:32:02 +08:00

20 KiB
Raw Blame History

最长回文子串 (Longest Palindromic Substring)

题目描述

给你一个字符串 s,找到 s 中最长的回文子串。

示例

示例 1

输入s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2

输入s = "cbbd"
输出:"bb"

约束条件

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

思路推导

暴力解法分析

第一步:最直观的思路 - 枚举所有子串

def longestPalindrome_brute(s):
    n = len(s)
    max_len = 1
    start = 0

    # 枚举所有可能的子串
    for i in range(n):
        for j in range(i+1, n):
            # 检查 s[i:j+1] 是否是回文
            if isPalindrome(s, i, j) and (j - i + 1) > max_len:
                start = i
                max_len = j - i + 1

    return s[start:start+max_len]

def isPalindrome(s, left, right):
    while left < right:
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

时间复杂度分析:

  • 枚举所有子串O(n²)(有 n(n+1)/2 个子串)
  • 判断每个子串是否回文O(n)
  • 总时间复杂度O(n³)

问题:

  • 当 n = 1000 时n³ = 10⁹ 次操作,会超时
  • 存在大量重复判断,效率极低

优化思考 - 如何降低复杂度?

核心观察:

  1. 暴力法的瓶颈:每次判断回文都要重新遍历子串
  2. 优化方向:能否利用已计算的信息,避免重复判断?

思路一:动态规划(空间换时间)

关键问题:小问题的解能否帮助解决大问题?

如果 s[i:j] 是回文,那么:
- s[i+1:j-1] 也必须是回文
- s[i] == s[j]

这就是最优子结构

思路二:中心扩展法(逆向思维)

与其判断每个子串是否回文,不如:

  1. 枚举每个可能的"中心"
  2. 从中心向两边扩展
  3. 扩展到不能扩展为止

为什么这样可行?

  • 回文串是关于中心对称的
  • 回文串的中心可能是:
    • 一个字符(奇数长度):如 "aba",中心是 'b'
    • 两个字符之间(偶数长度):如 "abba",中心在两个 'b' 之间

思路三Manacher 算法(极致优化)

中心扩展法的问题:同一个位置可能被访问多次

Manacher 的核心思想:

  • 利用回文串的对称性
  • 如果已经知道一个长回文串,可以快速计算出它内部对称位置的回文半径
  • 时间复杂度降到 O(n)

为什么这样思考?

1. 降维思想

暴力法:枚举起点 + 枚举终点 + 判断回文
      ↓
优化:枚举中心 + 向两边扩展

从二维枚举(起点,终点) 降到 一维枚举(中心)

2. 对称性利用

回文串的定义:正读和反读相同

这意味着:
- 如果 s[left:right+1] 是回文
- 那么 s[left] == s[right]
- 且 s[left+1:right] 也是回文

3. 中心扩展的优势

暴力法:每个子串都要单独判断
中心扩展:一次扩展可以同时判断多个子串

例如 "babad"
以 'b'(index=0) 为中心扩展:
  - "b" ✓
  - "aba" ✓
  - "bab" 不可能(越界)

解题思路

方法一:中心扩展法(推荐)

**核心思想:**回文串关于中心对称。从每个字符(或两个字符之间)向两边扩展,寻找最长的回文串。

详细算法流程

步骤1遍历所有可能的中心点

for i in range(len(s)):
    # 奇数长度:以 s[i] 为中心
    len1 = expand(s, i, i)

    # 偶数长度:以 s[i] 和 s[i+1] 之间为中心
    len2 = expand(s, i, i+1)

    # 取较长的
    max_len = max(max_len, len1, len2)

Q: 为什么要考虑奇数和偶数两种情况?

A: 因为回文串长度的奇偶性不同,中心位置也不同:

  • 奇数长度 "aba":中心是单个字符 'b'
  • 偶数长度 "abba":中心在两个字符之间

步骤2从中心向两边扩展

def expand(s, left, right):
    # 当不越界且字符相等时,继续扩展
    while left >= 0 and right < len(s) and s[left] == s[right]:
        left -= 1
        right += 1

    # 返回回文串长度
    # 注意循环结束时left 和 right 已经不满足条件
    # 所以回文串是 s[left+1:right],长度为 right - left - 1
    return right - left - 1

Q: 为什么返回 right - left - 1

A: 因为循环结束时:

  • left 和 right 指向的位置要么越界,要么字符不等
  • 实际的回文串是 s[left+1:right]
  • 长度 = right - (left+1) = right - left - 1

举例:

s = "babad", 中心 i=1('a')
初始: left=1, right=1
第1次: left=0('b'), right=2('b'), 相等 ✓
第2次: left=-1(越界), right=3('a')
终止
返回: right - left - 1 = 3 - (-1) - 1 = 3
回文串: s[0:3] = "bab" ✓

步骤3更新最大长度和起始位置

if current_len > max_len:
    max_len = current_len
    # 计算起始位置
    start = i - (current_len - 1) // 2

Q: 起始位置为什么是 i - (current_len - 1) // 2

A: 需要根据奇偶性分别计算:

  • 奇数长度:中心在字符上,起始 = i - len//2
  • 偶数长度:中心在两个字符间,起始 = i - len//2 + 1

统一公式:start = i - (len-1)//2

举例:

奇数i=1, len=3 ("bab"的中心)
      start = 1 - (3-1)//2 = 1 - 1 = 0 ✓

偶数i=1, len=2 ("bb"的中心在两个字符间)
      start = 1 - (2-1)//2 = 1 - 0 = 1 ✓

关键细节说明

细节1为什么需要两次扩展奇数和偶数

# 错误做法:只考虑奇数长度
for i in range(len(s)):
    max_len = max(max_len, expand(s, i, i))

# 对于 "abba",会漏掉 "bb" 和 "abba"
# 正确做法:奇偶都要考虑
for i in range(len(s)):
    len1 = expand(s, i, i)      # 奇数长度
    len2 = expand(s, i, i+1)    # 偶数长度
    max_len = max(max_len, len1, len2)

细节2为什么边界条件是 left >= 0 而不是 left > 0

# 错误写法
while left > 0 and right < len(s)-1 and s[left] == s[right]:
    left -= 1
    right += 1

# 问题:会漏掉边界上的回文串
# 例如 "aba" 的中心在 index=1
# 如果 left > 0就无法扩展到 index=0
# 正确写法
while left >= 0 and right < len(s) and s[left] == s[right]:
    left -= 1
    right += 1

细节3如何处理单个字符的情况

def longestPalindrome(s):
    if len(s) < 2:
        return s  # 直接返回,长度为 0 或 1 都是回文
    # ... 后续逻辑

边界条件分析

边界1空字符串或单字符

输入: s = ""
输出: ""
原因: 空字符串也是回文串

输入: s = "a"
输出: "a"
原因: 单字符本身就是回文串

边界2全部相同字符

输入: s = "aaaa"
输出: "aaaa"
处理: 每个位置扩展都能得到长度 4

边界3无回文串除单字符外

输入: s = "abc"
输出: "a" 或 "b" 或 "c"
原因: 任意多字符子串都不是回文,返回任意单字符即可

边界4多个最长回文串

输入: s = "babad"
输出: "bab" 或 "aba"
原因: 题目允许返回任意一个最长回文串

复杂度分析(详细版)

时间复杂度:

- 外层循环:遍历 n 个字符 - O(n)
- 内层扩展:每个中心最多扩展 O(n) 次
- 总计O(n) × O(n) = O(n²)

为什么不是 O(n³)
- 暴力法:枚举起点 O(n) × 枚举终点 O(n) × 判断回文 O(n) = O(n³)
- 中心扩展:枚举中心 O(n) × 扩展 O(n) = O(n²)
- 关键:扩展是"一次判断,逐层扩展",而不是对每个子串重新判断

空间复杂度:

- 只使用了常数级别的变量O(1)
- 不需要额外的数组或哈希表
- 递归调用栈O(1)(虽然看起来像递归,但深度最多 O(n)

方法二:动态规划

**核心思想:**使用二维数组 dp[i][j] 表示 s[i:j+1] 是否为回文串。

状态转移方程:

dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i+1][j-1])

解释:
1. s[i] == s[j]:首尾字符必须相等
2. j - i < 2子串长度小于 3如 "a", "aa")必然是回文
3. dp[i+1][j-1]:去掉首尾后的子串也必须是回文

Q: 为什么按长度递增的顺序遍历?

A: 因为计算 dp[i][j] 需要 dp[i+1][j-1],而 dp[i+1][j-1] 对应更短的子串。

# 按长度从 2 到 n 遍历
for length in range(2, n + 1):
    for i in range(n - length + 1):
        j = i + length - 1
        # 计算 dp[i][j]

代码实现

Go 实现(中心扩展法)

package main

import "fmt"

func longestPalindrome(s string) string {
	if len(s) < 2 {
		return s
	}

	start, maxLen := 0, 1

	for i := 0; i < len(s); i++ {
		// 奇数长度:以当前字符为中心
		len1 := expandAroundCenter(s, i, i)
		// 偶数长度:以当前字符和下一个字符之间为中心
		len2 := expandAroundCenter(s, i, i+1)

		currentLen := max(len1, len2)
		if currentLen > maxLen {
			maxLen = currentLen
			start = i - (currentLen-1)/2
		}
	}

	return s[start : start+maxLen]
}

func expandAroundCenter(s string, left, right int) int {
	for left >= 0 && right < len(s) && s[left] == s[right] {
		left--
		right++
	}
	return right - left - 1
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

// 测试用例
func main() {
	// 测试用例1
	s1 := "babad"
	fmt.Printf("输入: %s\n", s1)
	fmt.Printf("输出: %s\n", longestPalindrome(s1))

	// 测试用例2
	s2 := "cbbd"
	fmt.Printf("\n输入: %s\n", s2)
	fmt.Printf("输出: %s\n", longestPalindrome(s2))

	// 测试用例3: 单个字符
	s3 := "a"
	fmt.Printf("\n输入: %s\n", s3)
	fmt.Printf("输出: %s\n", longestPalindrome(s3))

	// 测试用例4: 全部相同
	s4 := "aaaa"
	fmt.Printf("\n输入: %s\n", s4)
	fmt.Printf("输出: %s\n", longestPalindrome(s4))

	// 测试用例5: 无回文
	s5 := "abc"
	fmt.Printf("\n输入: %s\n", s5)
	fmt.Printf("输出: %s\n", longestPalindrome(s5))
}

Go 实现(动态规划)

func longestPalindromeDP(s string) string {
	if len(s) < 2 {
		return s
	}

	n := len(s)
	dp := make([][]bool, n)
	for i := range dp {
		dp[i] = make([]bool, n)
	}

	start, maxLen := 0, 1

	// 初始化:所有单个字符都是回文串
	for i := 0; i < n; i++ {
		dp[i][i] = true
	}

	// 按长度递增的顺序遍历
	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+1][j-1] {
					dp[i][j] = true
					if length > maxLen {
						maxLen = length
						start = i
					}
				}
			}
		}
	}

	return s[start : start+maxLen]
}

执行过程演示

s = "babad" 为例:

初始状态: start=0, maxLen=1

i=0, 字符='b':
  奇数扩展: expand(s, 0, 0)
    left=0, right=0: 'b'=='b' ✓
    left=-1, right=1: 越界,停止
    返回: 1 - (-1) - 1 = 1

  偶数扩展: expand(s, 0, 1)
    left=0, right=1: 'b'!='a' ✗
    返回: 1 - 0 - 1 = 0

  maxLen = max(1, 1, 0) = 1

i=1, 字符='a':
  奇数扩展: expand(s, 1, 1)
    left=1, right=1: 'a'=='a' ✓
    left=0, right=2: 'b'=='b' ✓
    left=-1, right=3: 越界,停止
    返回: 3 - (-1) - 1 = 3

  偶数扩展: expand(s, 1, 2)
    left=1, right=2: 'a'!='b' ✗
    返回: 2 - 1 - 1 = 0

  更新: maxLen=3, start=1-(3-1)/2=0
  当前最长: s[0:3]="bab"

i=2, 字符='b':
  奇数扩展: expand(s, 2, 2)
    left=2, right=2: 'b'=='b' ✓
    left=1, right=3: 'a'=='a' ✓
    left=0, right=4: 'b'!='d' ✗
    返回: 4 - 0 - 1 = 3

  偶数扩展: expand(s, 2, 3)
    left=2, right=3: 'b'!='a' ✗
    返回: 3 - 2 - 1 = 0

  maxLen = max(3, 3, 0) = 3 (保持不变)

i=3, 字符='a':
  奇数扩展: expand(s, 3, 3)
    left=3, right=3: 'a'=='a' ✓
    left=2, right=4: 'b'!='d' ✗
    返回: 4 - 2 - 1 = 1

  偶数扩展: expand(s, 3, 4)
    left=3, right=4: 'a'!='d' ✗
    返回: 4 - 3 - 1 = 0

  maxLen = max(3, 1, 0) = 3 (保持不变)

i=4, 字符='d':
  奇数扩展: expand(s, 4, 4)
    left=4, right=4: 'd'=='d' ✓
    left=3, right=5: 越界,停止
    返回: 5 - 3 - 1 = 1

  偶数扩展: expand(s, 4, 5)
    right=5 越界,直接返回 0

  maxLen = max(3, 1, 0) = 3 (保持不变)

最终结果: s[0:3] = "bab"

常见错误

错误1只考虑奇数长度回文

错误写法:

func longestPalindromeWrong(s string) string {
    for i := 0; i < len(s); i++ {
        len := expand(s, i, i)  // 只考虑奇数
        // ...
    }
}

正确写法:

func longestPalindrome(s string) string {
    for i := 0; i < len(s); i++ {
        len1 := expand(s, i, i)      // 奇数
        len2 := expand(s, i, i+1)    // 偶数
        maxLen = max(maxLen, len1, len2)
    }
}

**原因:**会漏掉偶数长度的回文串,如 "abba"。

错误2边界条件错误

错误写法:

func expand(s string, left, right int) int {
    for left > 0 && right < len(s)-1 && s[left] == s[right] {
        left--
        right++
    }
    return right - left - 1
}

正确写法:

func expand(s string, left, right int) int {
    for left >= 0 && right < len(s) && s[left] == s[right] {
        left--
        right++
    }
    return right - left - 1
}

**原因:**边界字符也是回文串的一部分,需要判断。

错误3起始位置计算错误

错误写法:

if currentLen > maxLen {
    maxLen = currentLen
    start = i - currentLen/2  // 错误!
}

正确写法:

if currentLen > maxLen {
    maxLen = currentLen
    start = i - (currentLen-1)/2  // 正确
}

**原因:**需要考虑奇偶性,统一公式是 i - (len-1)/2

进阶问题

Q1: 如何找到所有回文子串?

A: 修改中心扩展法,找到每个回文子串都记录下来。

func findAllPalindromes(s string) []string {
    result := []string{}

    for i := 0; i < len(s); i++ {
        // 奇数长度
        l, r := i, i
        for l >= 0 && r < len(s) && s[l] == s[r] {
            result = append(result, s[l:r+1])
            l--
            r++
        }

        // 偶数长度
        l, r = i, i+1
        for l >= 0 && r < len(s) && s[l] == s[r] {
            result = append(result, s[l:r+1])
            l--
            r++
        }
    }

    return result
}

Q2: 如何判断一个字符串是否可以通过重新排列成为回文串?

A: 统计每个字符出现的次数,最多只能有一个字符出现奇数次。

func canPermutePalindrome(s string) bool {
    count := make(map[rune]int)
    for _, c := range s {
        count[c]++
    }

    oddCount := 0
    for _, c := range count {
        if c%2 == 1 {
            oddCount++
        }
    }

    return oddCount <= 1
}

Q3: 最长回文子序列(非连续)如何求解?

A: 使用动态规划,dp[i][j] 表示 s[i:j+1] 的最长回文子序列长度。

func longestPalindromeSubseq(s string) int {
    n := len(s)
    dp := make([][]int, n)
    for i := range dp {
        dp[i] = make([]int, n)
        dp[i][i] = 1
    }

    for length := 2; length <= n; length++ {
        for i := 0; i <= n-length; i++ {
            j := i + length - 1
            if s[i] == s[j] {
                dp[i][j] = dp[i+1][j-1] + 2
            } else {
                dp[i][j] = max(dp[i+1][j], dp[i][j-1])
            }
        }
    }

    return dp[0][n-1]
}

P7 加分项

1. 深度理解:为什么中心扩展法最优?

对比分析:

  • 暴力法: O(n³) - 枚举所有子串 O(n²),判断是否回文 O(n)
  • 动态规划: O(n²) 时间O(n²) 空间
  • 中心扩展法: O(n²) 时间O(1) 空间
  • Manacher 算法: O(n) 时间O(n) 空间

选择建议:

  • 面试: 中心扩展法 - 代码简洁,易于实现
  • 实际应用: Manacher 算法 - 性能最优
  • 学习: 都要掌握,理解不同思路

2. 实战扩展:回文串相关算法

短回文串构造

LeetCode 214: 给定一个字符串 s你可以通过在字符串前面添加字符将其转换为回文串。找到并返回可以用这种方式转换的最短回文串。

func shortestPalindrome(s string) string {
    if len(s) < 2 {
        return s
    }

    // 找到最长的回文前缀
    n := len(s)
    rev := reverse(s)
    combined := s + "#" + rev

    // KMP 算法计算最长公共前后缀
    pi := make([]int, len(combined))
    for i := 1; i < len(combined); i++ {
        j := pi[i-1]
        for j > 0 && combined[i] != combined[j] {
            j = pi[j-1]
        }
        if combined[i] == combined[j] {
            j++
        }
        pi[i] = j
    }

    // 添加剩余字符的逆序
    add := rev[:n-pi[len(combined)-1]]
    return add + s
}

func reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

3. 变形题目

变形1回文子串个数

LeetCode 647: 给定一个字符串,计算这个字符串中有多少个回文子串。

func countSubstrings(s string) int {
    count := 0

    for i := 0; i < len(s); i++ {
        // 奇数长度
        count += expandAroundCenterCount(s, i, i)
        // 偶数长度
        count += expandAroundCenterCount(s, i, i+1)
    }

    return count
}

func expandAroundCenterCount(s string, left, right int) int {
    count := 0
    for left >= 0 && right < len(s) && s[left] == s[right] {
        count++
        left--
        right++
    }
    return count
}

变形2分割回文串

LeetCode 131: 给定一个字符串 s将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。

func partition(s string) [][]string {
    result := [][]string{}
    current := []string{}

    var backtrack func(start int)
    backtrack = func(start int) {
        if start == len(s) {
            temp := make([]string, len(current))
            copy(temp, current)
            result = append(result, temp)
            return
        }

        for end := start + 1; end <= len(s); end++ {
            if isPalindrome(s[start:end]) {
                current = append(current, s[start:end])
                backtrack(end)
                current = current[:len(current)-1]
            }
        }
    }

    backtrack(0)
    return result
}

func isPalindrome(s string) bool {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        if s[i] != s[j] {
            return false
        }
    }
    return true
}

4. 优化技巧

优化1提前终止

如果找到的回文串已经接近最大可能长度,可以提前终止。

func longestPalindromeOptimized(s string) string {
    if len(s) < 2 {
        return s
    }

    start, maxLen := 0, 1

    for i := 0; i < len(s); i++ {
        // 提前终止:不可能找到更长的回文串了
        if maxLen >= 2*(len(s)-i) {
            break
        }

        len1 := expandAroundCenter(s, i, i)
        len2 := expandAroundCenter(s, i, i+1)
        currentLen := max(len1, len2)

        if currentLen > maxLen {
            maxLen = currentLen
            start = i - (currentLen-1)/2
        }
    }

    return s[start : start+maxLen]
}

5. 实际应用场景

  • DNA 序列分析: 寻找重复序列
  • 文本编辑: 检查拼写和语法
  • 数据压缩: 利用重复模式
  • 模式匹配: 查找对称模式

6. 面试技巧

面试官可能会问:

  1. "为什么中心扩展法比动态规划更好?"
  2. "Manacher 算法的核心思想是什么?"
  3. "如何处理 unicode 字符?"

回答要点:

  1. 中心扩展法空间复杂度 O(1),动态规划需要 O(n²)
  2. Manacher 算法利用回文串的对称性,避免重复计算
  3. 使用 rune 类型处理 unicode 字符

7. 相关题目推荐

  • LeetCode 5: 最长回文子串(本题)
  • LeetCode 125: 验证回文串
  • LeetCode 131: 分割回文串
  • LeetCode 214: 最短回文串
  • LeetCode 516: 最长回文子序列
  • LeetCode 647: 回文子串个数