目录

最长回文子串怎么解?四种 Go 写法从暴力到 Manacher

给你一个字符串 s,要找出里面最长的那段回文子串。

这就是 LeetCode 第 5 题,面试里出现频率很高。

先说清楚两个容易混的概念。

子串是连续的,子序列可以不连续,这题要的是连续子串。

回文就是正着读反着读一样,比如 abaabba

下面按从慢到快的顺序,给四种 Go 解法。

暴力枚举:能过但慢

最直接的思路:枚举所有子串,逐个判断是不是回文,记录最长的那个。

func longestPalindrome(s string) string {
	r := []rune(s)
	if len(r) < 2 {
		return s
	}
	maxStr := string(r[0])
	for i := 0; i < len(r); i++ {
		for j := i + 1; j <= len(r); j++ {
			if j-i > len(maxStr) && isPalindrome(r[i:j]) {
				maxStr = string(r[i:j])
			}
		}
	}
	return maxStr
}

func isPalindrome(r []rune) bool {
	for l, h := 0, len(r)-1; l < h; l, h = l+1, h-1 {
		if r[l] != r[h] {
			return false
		}
	}
	return true
}

这里有个细节值得提一下。

判断回文不必把整个字符串反转再比较,那样多一次内存分配。

双指针从两头往中间走,一对不相等就直接返回 false,省一半比较。

时间复杂度 O(n³),枚举子串 O(n²),每次判断 O(n)。

字符串一长就超时,n 到几千就吃力了。

当练手可以,正式提交不推荐。

中心扩散:一行思路省一个量级

回文有个对称特点:从中心往两边扩展,左右字符相等就继续,不等就停。

所以可以枚举每一个中心,向外扩散找最长回文。

问题是回文长度有奇有偶。

aba 中心是单个字符 babba 中心在两个 b 之间。

处理办法是每个位置都试两次。

func longestPalindrome(s string) string {
	r := []rune(s)
	if len(r) < 2 {
		return s
	}
	start, maxLen := 0, 1
	expand := func(l, h int) {
		for l >= 0 && h < len(r) && r[l] == r[h] {
			l--
			h++
		}
		// 循环结束时 l、h 各多走了一步,真实长度是 h-l-1
		if h-l-1 > maxLen {
			start = l + 1
			maxLen = h - l - 1
		}
	}
	for i := 0; i < len(r); i++ {
		expand(i, i)   // 奇数长度,中心是单字符
		expand(i, i+1) // 偶数长度,中心在两字符之间
	}
	return string(r[start : start+maxLen])
}

时间复杂度 O(n²),空间 O(1)。

这是面试里最推荐的写法,思路好讲、代码短、不额外占空间。

我自己刷题基本都用这个。

expand 退出后那个 h-l-1 我第一次写的时候算错过,写成了 h-l,结果长度多 2。

调试时打印了几组才反应过来:循环退出前 lh 已经各自越界一格,得把这两格减掉。

动态规划:把判断结果缓存起来

暴力法慢在重复判断。abcba 是回文,那判断 bcb 时其实可以复用结论。

动态规划就是把这种重叠子问题的结果存下来。

定义 dp[i][j] 表示子串 s[i..j] 是不是回文。状态转移:

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

意思是:两端字符相等,且去掉两端后的内部也是回文(长度小于 2 时内部为空,直接算回文)。

func longestPalindrome(s string) string {
	r := []rune(s)
	n := len(r)
	if n < 2 {
		return s
	}
	dp := make([][]bool, n)
	for i := range dp {
		dp[i] = make([]bool, n)
	}
	start, maxLen := 0, 1
	// 注意遍历顺序:i 从大到小,j 从小到大
	// 保证算 dp[i][j] 时 dp[i+1][j-1] 已经算好
	for i := n - 1; i >= 0; i-- {
		for j := i; j < n; j++ {
			if r[i] == r[j] && (j-i < 2 || dp[i+1][j-1]) {
				dp[i][j] = true
				if j-i+1 > maxLen {
					start = i
					maxLen = j - i + 1
				}
			}
		}
	}
	return string(r[start : start+maxLen])
}

时间复杂度 O(n²),空间也是 O(n²)。和中心扩散同一量级,但多用了内存。

这里最坑的是遍历顺序

dp[i][j] 依赖 dp[i+1][j-1],也就是依赖"更靠下、更靠左"的格子。

所以 i 必须从下往上、j 从左往右。

Manacher:唯一的 O(n) 解法

如果面试官追问能不能做到线性,那就是 Manacher 算法。

它的核心是利用已经算过的回文信息,避免重复扩散。

Manacher 先在每个字符间插入分隔符(比如 #),把奇偶长度统一成奇数处理。

然后维护一个数组记录每个位置的回文半径,借助当前最右回文边界做镜像加速。

func longestPalindrome(s string) string {
	if len(s) < 2 {
		return s
	}
	// 预处理:a#b#c,首尾再加哨兵避免越界
	t := []rune("^#")
	for _, c := range s {
		t = append(t, c, '#')
	}
	t = append(t, '$')

	n := len(t)
	p := make([]int, n) // p[i] 是以 i 为中心的回文半径
	center, right := 0, 0
	for i := 1; i < n-1; i++ {
		if i < right {
			mirror := 2*center - i
			p[i] = min(right-i, p[mirror])
		}
		for t[i+p[i]+1] == t[i-p[i]-1] {
			p[i]++
		}
		if i+p[i] > right {
			center, right = i, i+p[i]
		}
	}
	maxLen, centerIdx := 0, 0
	for i := 1; i < n-1; i++ {
		if p[i] > maxLen {
			maxLen = p[i]
			centerIdx = i
		}
	}
	start := (centerIdx - maxLen) / 2
	return s[start : start+maxLen]
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

时间复杂度 O(n),空间 O(n)。

代码不算长,但理解成本高,镜像那段我也是看了好几遍才彻底搞明白。

说实话,除非面试明确要求线性,否则用中心扩散就够了,没必要为了炫技上 Manacher。

注意这版 Manacher 用了 s[start:start+maxLen] 按字节切片,对纯 ASCII 没问题。如果输入含中文等多字节字符,要换成 rune 索引,否则会切坏。

四种解法对比

解法 时间复杂度 空间复杂度 适用场景
暴力枚举 O(n³) O(1) 练手,理解题意
中心扩散 O(n²) O(1) 面试首选,最实用
动态规划 O(n²) O(n²) 考察 DP 思维
Manacher O(n) O(n) 追求最优、数据量大

实际工作和面试中,中心扩散性价比最高。动态规划适合用来锻炼状态转移的思路。Manacher 属于加分项,不是必备。

常见问题

最长回文子串和最长回文子序列有什么区别?

子串必须连续,子序列可以跳着取。

比如 bbbab,最长回文子串是 bbb,最长回文子序列是 bbbb

两者解法完全不同,子序列那题要用区间 DP。

中心扩散为什么要扩散两次?

因为回文长度有奇有偶。

奇数长度(如 aba)中心是单个字符,偶数长度(如 abba)中心落在两个字符中间。

每个位置分别按奇、偶各试一次,才不会漏掉答案。

动态规划的遍历顺序为什么不能从上往下?

dp[i][j] 依赖 dp[i+1][j-1],即依赖行号更大、列号更小的格子。

只有 i 从大到小、j 从小到大遍历,才能保证算当前格子时所依赖的值已经计算完成。

顺序写反结果必错。

处理中文字符串要注意什么?

Go 的 string 按字节存储,中文一个字占 3 字节。

直接用下标切片会切坏字符。建议先转成 []rune 再处理,前面三种解法都这么做了。

Manacher 那版若要支持中文,也得改成 rune 索引。

小结

四种解法各有侧重,记不住没关系,先把中心扩散吃透,应付绝大多数场景足够了。

动态规划帮你建立"缓存子问题"的直觉,Manacher 当作进阶了解。

如果对某种解法的细节还有疑问,或者发现哪里写得不对,欢迎在评论区交流~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/longest-palindromic-substring-go/

备用原文链接: https://blog.fiveyoboy.com/articles/longest-palindromic-substring-go/