bigywater.tech
算法与数据结构 · LeetCode 5

LeetCode 5. 最长回文子串

给定字符串 s,找出其中最长的回文子串。本文用"中心扩展":把每个位置当作回文的中心,向两边同时扩展,直到两端字符不等。时间 O(n²),空间 O(1),写法简单,不容易出错。真正容易漏掉的是回文中心有两种:奇数长度的中心是一个字符,偶数长度的中心是两个相邻字符之间。本文实测只处理奇数中心会漏掉什么,给出五种语言的实现,并把扩展过程做成分步演示。

5,000
组随机对拍,与暴力解的结果全部一致
20 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。这道题的答案不唯一,"babad" 的最长回文可以是 "bab" 也可以是 "aba",所以核对方式不是比较字符串是否相等,而是检查三件事:返回值是原串的子串、它本身是回文、长度等于暴力解(枚举所有子串逐个检查)得到的最大长度。前几组基准用例的输出取的是暴力解返回的最靠左的一个:

真实实测 · 本机执行输出
输入 ["babad"]
输出 "bab"
输入 ["cbbd"]
输出 "bb"
输入 ["a"]
输出 "a"
输入 ["ac"]
输出 "a"
输入 ["bb"]
输出 "bb"
输入 ["abcba"]
输出 "abcba"

陷阱:偶数长度的回文没有单个字符做中心

只用"每个字符当中心"的写法,扩展出来的回文长度一定是奇数。"bb"、"abba" 这样的偶数长度回文没有中心字符,必须再把"相邻两个字符之间"也当作中心。下面是只扩展奇数中心的写法和正确写法的真实结果:

真实实测 · 本机执行输出
# 只处理奇数长度中心(漏掉偶数长度)的写法
"cbbd": 只扩奇数中心 -> "c"   正确写法 -> "bb"
"bb": 只扩奇数中心 -> "b"   正确写法 -> "bb"
"abba": 只扩奇数中心 -> "a"   正确写法 -> "abba"

"cbbd" 的最长回文是 "bb",只扩展奇数中心的写法得到的是 "c"。题目的第二个官方示例恰好就是这种情况,所以这个错误很容易在提交时才被发现。

中心扩展:五种语言的实现

对每个下标 i,做两次扩展:一次从 (i, i) 开始(奇数长度),一次从 (i, i + 1) 开始(偶数长度)。每次扩展到两端字符不相等或越界为止,然后退回一格得到这个中心的最长回文,和当前最优比较长度。Python、Java、Go、JavaScript、C++ 五种语言都是真实编译运行的;实现里用一个 k 循环(0 是奇数、1 是偶数)合并两种中心。

Python · 解法代码def solve(s): def expand(l, r): # 从中心向两边扩,返回回文的 [l, r] while l >= 0 and r < len(s) and s[l] == s[r]: l -= 1 r += 1 return l + 1, r - 1 best_l, best_r = 0, 0 for i in range(len(s)): for l, r in (expand(i, i), expand(i, i + 1)): # 奇数长度、偶数长度两种中心 if r - l > best_r - best_l: best_l, best_r = l, r return s[best_l:best_r + 1]
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["babad"]  →  "bab"
["cbbd"]  →  "bb"
["a"]  →  "a"
["ac"]  →  "a"
["bb"]  →  "bb"
["abcba"]  →  "abcba"
Java · 解法代码class Solution { public String longestPalindrome(String s) { int bestL = 0, bestR = 0; for (int i = 0; i < s.length(); i++) { for (int k = 0; k < 2; k++) { // k = 0:奇数长度中心;k = 1:偶数长度中心 int l = i, r = i + k; while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) { l--; r++; } l++; r--; // 退回到最后一个成功的回文 if (r - l > bestR - bestL) { bestL = l; bestR = r; } } } return s.substring(bestL, bestR + 1); } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["babad"]  →  "bab"
["cbbd"]  →  "bb"
["a"]  →  "a"
["ac"]  →  "a"
["bb"]  →  "bb"
["abcba"]  →  "abcba"
Go · 解法代码func longestPalindrome(s string) string { bestL, bestR := 0, 0 for i := 0; i < len(s); i++ { for k := 0; k < 2; k++ { // k = 0:奇数长度中心;k = 1:偶数长度中心 l, r := i, i+k for l >= 0 && r < len(s) && s[l] == s[r] { l-- r++ } l++ r-- // 退回到最后一个成功的回文 if r-l > bestR-bestL { bestL, bestR = l, r } } } return s[bestL : bestR+1] }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["babad"]  →  "bab"
["cbbd"]  →  "bb"
["a"]  →  "a"
["ac"]  →  "a"
["bb"]  →  "bb"
["abcba"]  →  "abcba"
JavaScript · 解法代码function longestPalindrome(s) { let bestL = 0, bestR = 0; for (let i = 0; i < s.length; i++) { for (let k = 0; k < 2; k++) { // k = 0:奇数长度中心;k = 1:偶数长度中心 let l = i, r = i + k; while (l >= 0 && r < s.length && s[l] === s[r]) { l--; r++; } l++; r--; // 退回到最后一个成功的回文 if (r - l > bestR - bestL) { bestL = l; bestR = r; } } } return s.slice(bestL, bestR + 1); }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["babad"]  →  "bab"
["cbbd"]  →  "bb"
["a"]  →  "a"
["ac"]  →  "a"
["bb"]  →  "bb"
["abcba"]  →  "abcba"
C++ · 解法代码#include <string> using namespace std; class Solution { public: string longestPalindrome(string s) { int bestL = 0, bestR = 0, n = s.size(); for (int i = 0; i < n; i++) { for (int k = 0; k < 2; k++) { // k = 0:奇数长度中心;k = 1:偶数长度中心 int l = i, r = i + k; while (l >= 0 && r < n && s[l] == s[r]) { l--; r++; } l++; r--; // 退回到最后一个成功的回文 if (r - l > bestR - bestL) { bestL = l; bestR = r; } } } return s.substr(bestL, bestR - bestL + 1); } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["babad"]  →  "bab"
["cbbd"]  →  "bb"
["a"]  →  "a"
["ac"]  →  "a"
["bb"]  →  "bb"
["abcba"]  →  "abcba"
真实实测 · 本机执行输出
共 10 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 10/10
  Java        通过 10/10
  Go          通过 10/10
  JavaScript  通过 10/10
  C++         通过 10/10

复杂度实测:最坏情况下平方增长

最坏输入是全部字符相同(每个中心都能一路扩到边界)。下表里"暴力解"是枚举所有子串再逐个判断回文,每个规模取三次中最快的一次:

真实实测 · 本机执行输出
字符串长度 n | 暴力解 | 本文解法
       100 |     0.715 ms | 0.220 ms
       300 |     8.526 ms | 2.103 ms
      1000 |      (未测,太慢) | 25.396 ms
      2000 |      (未测,太慢) | 102.819 ms
      4000 |      (未测,太慢) | 413.663 ms

中心扩展从 1000 到 2000 再到 4000(每次翻倍),耗时从 26.2ms 涨到 102.1ms 再涨到 424.0ms,每次增长约 4 倍,符合 O(n²)。暴力解在这个测试里只比中心扩展慢 3 到 4 倍(0.76ms 对 0.21ms、8.3ms 对 2.0ms),远没有理论上 O(n³) 与 O(n²) 差一个数量级的样子,因为暴力解里判断回文用的是 Python 内置的切片和比较,跑在 C 层,常数很小;这个差距不能拿来说明"暴力解只慢几倍",只说明在这个实现下的常数。更大规模的暴力解耗时太长,没有再测。

随机对拍

Python 版本另外和暴力解做了随机对拍(因为答案不唯一,检查的是返回值是回文、是原串的子串、长度等于暴力解的最大长度):

真实实测 · 本机执行输出
随机对拍 5000 组(长度 1 到 25、字母表大小 1 到 3 的随机字符串(字母表小,回文多,经常有多个等长的最长回文);因为答案不唯一,检查的是:返回值确实是回文、是原串的子串、长度等于暴力解得到的最大长度),solve 与 brute 的结果全部一致。

另外四种语言只在上面 10 组固定用例上验证过,没有做大规模随机对拍。

分步演示:中心怎么向两边扩

下面的演示用 s = "cbbd",它会经过奇数中心和偶数中心两种情况。每一帧都是把上面的解法在这个字符串上真实执行记录下来的,运行器还检查了演示最终得到的答案是合法的最长回文。

最长回文子串:s = "cbbd",从每个中心向两边扩展
正在比较的一对字符已确认对称的部分目前最长的回文

    参考与说明

    • 官方题目页:LeetCode 5. 最长回文子串。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:Manacher 算法(O(n) 时间);动态规划解法(O(n²) 时间和空间,比中心扩展多占空间)。
    京ICP备2026067502号-1京公网安备11010502063487号