回文字符串和最长回文子串怎样判断?
判断回文用两个指针从两头往中间走,逐个比较,O(n)。忽略大小写和标点时,跳过非字母数字字符再比较。求最长回文子串用中心扩展:每个位置和每两个相邻位置之间都可能是回文中心,从中心向两边扩,共 2n-1 个中心,时间 O(n²)、空间 O(1)。
简答
回文就是正着读和倒着读一样的字符串,比如
level、abba。最直接的判断是s === s.split('').reverse().join(''),但它要额外建数组和新字符串。更省的做法是两个指针,一个从头、一个从尾往中间走,每一步比较两个字符,有一处不同就不是回文,O(n) 时间、O(1) 空间。面试常见的变体是忽略大小写和标点,比如
A man, a plan, a canal: Panama算回文。做法是指针遇到非字母数字字符就跳过,比较时都转成小写。最长回文子串要找出字符串里最长的回文片段。思路是中心扩展:回文一定关于中心对称,所以枚举每个可能的中心,向两边扩到不相等为止。中心有两种,奇数长度的回文中心是一个字符,比如
aba的b;偶数长度的中心在两个字符之间,比如abba的两个b中间。长度为 n 的字符串共有 2n-1 个中心,每个最多扩 O(n),总共 O(n²)。要注意两点:只枚举单字符中心会漏掉所有偶数长度的回文;按下标比较的是 UTF-16 编码单元,emoji 这类字符会被拆成两半,要按用户看到的字符处理时先用
Array.from拆分。
判断回文
先看基础版本,再看忽略标点的版本里指针怎样跳过不相干的字符。
function isPalindrome(s) {
let left = 0;
let right = s.length - 1;
while (left < right) {
if (s[left] !== s[right]) return false;
left++;
right--;
}
return true;
}
const isAlphanumeric = (char) => /[a-z0-9]/i.test(char);
function isPalindromeLoose(s) {
let left = 0;
let right = s.length - 1;
while (left < right) {
if (!isAlphanumeric(s[left])) {
left++;
} else if (!isAlphanumeric(s[right])) {
right--;
} else if (s[left].toLowerCase() !== s[right].toLowerCase()) {
return false;
} else {
left++;
right--;
}
}
return true;
}
console.log(isPalindrome('level')); // true
console.log(isPalindrome('abba')); // true
console.log(isPalindrome('abc')); // false
console.log(isPalindrome('')); // true
console.log(isPalindrome('a')); // true
console.log(isPalindromeLoose('A man, a plan, a canal: Panama')); // true
console.log(isPalindromeLoose('race a car')); // false
console.log(isPalindromeLoose(',.')); // true
循环条件为什么是 left < right 而不是 <=?两个指针相遇时指向同一个字符,自己和自己一定相等,不用比。奇数长度时它们停在正中间,偶数长度时交错而过,两种情况都正好比完所有对称位置。空字符串和单个字符一次都不比较,直接返回 true。
,. 为什么是 true?两个字符都不是字母数字,全部被跳过,剩下的是空串,按约定算回文。这里用的正则 /[a-z0-9]/i 只认 ASCII 字母和数字;题目要支持中文等其他文字时,可以改成带 u 标志的 Unicode 属性类 /[\p{L}\p{N}]/u。
最长回文子串
expand 从给定的左右下标出发往外扩,返回扩到的回文长度。主循环对每个 i 调用两次,分别对应奇数中心和偶数中心。
function longestPalindrome(s) {
if (s.length < 2) return s;
let start = 0;
let maxLength = 1;
function expand(left, right) {
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
// 循环停下时两端已经多走了一步,真正的回文是 left+1 到 right-1
return right - left - 1;
}
for (let i = 0; i < s.length; i++) {
const length = Math.max(expand(i, i), expand(i, i + 1));
if (length > maxLength) {
maxLength = length;
start = i - Math.floor((length - 1) / 2);
}
}
return s.slice(start, start + maxLength);
}
console.log(longestPalindrome('babad')); // bab
console.log(longestPalindrome('cbbd')); // bb
console.log(longestPalindrome('forgeeksskeegfor')); // geeksskeeg
console.log(longestPalindrome('a')); // a
console.log(longestPalindrome('ac')); // a
console.log(longestPalindrome('aaaa')); // aaaa
console.log(JSON.stringify(longestPalindrome(''))); // ""
babad 为什么返回 bab 而不是 aba?两个都是长度 3 的回文。代码只在长度严格变大时更新,bab 以下标 1 为中心,先被找到,后来的 aba 长度相同,不会覆盖。题目一般接受任意一个。
起点 i - Math.floor((length - 1) / 2) 怎么来的?回文的中心在 i(奇数)或 i 与 i + 1 之间(偶数),左半边的长度是 (length - 1) / 2 向下取整。比如 cbbd 里 bb 的中心在下标 1 和 2 之间,length 是 2,起点是 1 - 0 = 1;aaaa 在 i = 1 时偶数中心扩出长度 4,起点是 1 - 1 = 0。
为什么要两种中心?cbbd 的答案 bb 是偶数长度,只用 expand(i, i) 的话,每个中心都只能扩出长度 1,结果会错成 c。
复杂度和更快的做法
中心扩展有 2n-1 个中心,每次扩展最多走 O(n) 步,最坏情况是 aaaa… 这种全相同的字符串,总时间 O(n²),只用几个变量,空间 O(1)。
另一种常见写法是动态规划:dp[i][j] 表示 s[i..j] 是不是回文,它成立当且仅当两端字符相等且 dp[i+1][j-1] 成立。时间同样 O(n²),但要 O(n²) 的表格,所以面试里中心扩展更常用。存在 O(n) 的 Manacher 算法,它利用已经找到的回文的对称性跳过重复扩展,实现细节多,一般知道名字和思路就够了。
常见追问
最多删除一个字符后能不能成为回文?
还是两个指针从两头比。遇到第一处不相等时,有两种删法:删左边字符,检查
left + 1到right是不是回文;或者删右边,检查left到right - 1。两种有一种成立就返回 true。整体仍是 O(n)。
为什么 split('').reverse().join('') 处理 emoji 会出错?
split('')按 UTF-16 编码单元拆分,一个 emoji 通常占两个编码单元,反转后两半的顺序颠倒,得到的就不是原来的字符了。改成Array.from(s)或展开[...s],会按码点拆分,常见 emoji 就不会被拆开;带肤色或组合的 emoji 由多个码点组成,还需要Intl.Segmenter按字素拆分,见《为什么字符串 length 不一定等于用户看到的字符数?》。