LEETCODE 647Medium

回文子串

每个回文都有一个中心,枚举 2n-1 个中心向两侧扩展,扩一步成功就多一个回文子串。

问题拆解

统计字符串中回文子串的个数,位置不同就算不同的子串,哪怕内容一样。朴素做法是枚举全部 O(n²) 个子串、逐一花 O(n) 判断回文,总共 O(n³),对 n = 1000 的上限太吃力。

浪费在哪里?判断 "abcba" 时其实已经顺路看过 "bcb""c"——回文之间有嵌套结构:一个回文剥掉两端还是回文。反过来利用这一点:从中心出发向两侧扩展,只要两端字符相等就继续,每扩一步就发现一个新的回文。这样同一个中心的所有回文一趟扫出来,判断的功夫一点不浪费。

每个回文子串都有唯一的中心:长度为奇数时中心是一个字符,长度为偶数时中心是相邻两个字符的缝隙。所以中心一共 n + (n-1) = 2n - 1 个,逐个扩展就能不重不漏地数完所有回文。

最容易漏的就是偶数长度:只枚举 n 个单字符中心,"aa""abba" 这类回文会被整体漏掉。

中心扩展计数

把“从给定的左右端点向外扩”抽成一个辅助过程,奇数中心传 (i, i),偶数中心传 (i, i+1),两类中心统一处理。

public int countSubstrings(String s) {
    int n = s.length(), count = 0;
    for (int i = 0; i < n; i++) {
        count += expand(s, i, i);     // 奇数长度,中心是单字符
        count += expand(s, i, i + 1); // 偶数长度,中心是缝隙
    }
    return count;
}

private int expand(String s, int l, int r) {
    int count = 0;
    while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
        count++; // 每扩一步就是一个新回文
        l--;
        r++;
    }
    return count;
}
def countSubstrings(s: str) -> int:
    def expand(l: int, r: int) -> int:
        count = 0
        while l >= 0 and r < len(s) and s[l] == s[r]:
            count += 1  # 每扩一步就是一个新回文
            l -= 1
            r += 1
        return count

    return sum(expand(i, i) + expand(i, i + 1) for i in range(len(s)))
func countSubstrings(s string) int {
    expand := func(l, r int) int {
        count := 0
        for l >= 0 && r < len(s) && s[l] == s[r] {
            count++ // 每扩一步就是一个新回文
            l--
            r++
        }
        return count
    }
    total := 0
    for i := range s {
        total += expand(i, i)   // 奇数长度,中心是单字符
        total += expand(i, i+1) // 偶数长度,中心是缝隙
    }
    return total
}
pub fn count_substrings(s: String) -> i32 {
    let b = s.as_bytes();
    let n = b.len();
    let expand = |mut l: i32, mut r: i32| -> i32 {
        let mut count = 0;
        while l >= 0 && (r as usize) < n && b[l as usize] == b[r as usize] {
            count += 1; // 每扩一步就是一个新回文
            l -= 1;
            r += 1;
        }
        count
    };
    (0..n as i32).map(|i| expand(i, i) + expand(i, i + 1)).sum()
}

值得体会的是计数的位置:count 在循环内每次比较成功就加一,因为“扩一步成功”本身就意味着一个新的回文子串诞生——(i, i) 出发扩两次,说明 s[i]s[i-1..i+1]s[i-2..i+2] 三个回文各计一次。偶数中心第一次比较就是在检查 s[i] == s[i+1],两字符不等则立刻返回 0,天然不会多算。

这个框架和第 5 题“最长回文子串”完全同源:那道题在扩展过程中记录最长的区间,这道题改成累加成功的步数。中心扩展是一套框架、两种统计。

复杂度

指标 复杂度 原因
时间 O(n²) 2n-1 个中心,每个最多向外扩 n/2 步
空间 O(1) 只用常数个指针和计数器

可以迁移的模式

  • 回文问题优先想“中心扩展”,记住中心有 2n-1 个,偶数缝隙别漏;
  • 把“从某状态向外探”的动作抽成辅助函数,奇偶两类中心就能共用一套代码;
  • 同一个枚举框架换掉统计动作(计数、记最长、存区间),就是一族不同的题。

如果字符串长到 O(n²) 也扛不住,再去了解 Manacher 算法——它把中心扩展的重复功夫也省掉了,但面试场景下中心扩展几乎总是够用的。