力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 21字符串算法

字符串是算法题里永不退场的主角。前面你已经用过「双指针 / 滑动窗口」(专题 01、03)和「哈希计数」(专题 02)去解决不少字符串题, 比如无重复字符的最长子串、字母异位词。这一章我们把武器库补齐最后两块——中心扩展与回文、字符串模拟(拼接 / 反转 / 进位), 顺带给好奇的同学一个「选学」彩蛋:KMP 和 Manacher 这两个竞赛向的提速神器。 学完本章,你手里处理字符串的「四种武器」就齐了,绝大多数面试 / 中等题都能对号入座。

学习目标
掌握中心扩展、大数模拟、原地反转
前置要求
专题 01 双指针、专题 02 哈希、专题 03 滑窗
本章产出
4 道经典题 Java 代码 + KMP 完整模板
建议学时
7–9 小时

21.1字符串题的四种武器

在动手写代码前,先把「这类题我该掏哪把刀」想清楚,能省下大量试错时间。字符串题翻来覆去就这四类套路,前两种你早就会了,后两种是本章新补的:

武器适用信号典型题
双指针 / 滑窗
(专题 01 / 03)
连续子串、找「满足某条件的子段」无重复子串、最小覆盖子串
哈希计数
(专题 02)
频次统计、异位词、字符种类字母异位词、字符串中的第一个唯一字符
中心扩展与回文
(本章新补)
「回文」「对称」「最长对称子串」最长回文子串、分割回文串
字符串模拟
(本章新补)
「大数相加 / 乘」「反转」「进位」字符串相加、反转字符串里的单词

为什么回文和模拟要单列出来

回文问题有个特点:它天然「对称」,但对称的中心可能是字符,也可能是两个字符中间的空隙。这种「奇偶两种中心」的细节,用滑窗很难干净地表达,所以单独给一套「中心扩展」的打法。

模拟类题(比如两个字符串形式的大数相加)则反过来——它没有任何巧妙算法,考的就是你能不能把小学竖式一丝不苟地翻译成代码:末位对齐、逐位加、处理进位、处理长度不等。这类题「暴力就是最优解」,拼的是细心。

本章定位:中心扩展(s2)、字符串相加(s3)、反转单词(s4)是主线,面试高频,必须会写。 KMP(s5)和 Manacher(s6)标了「选学」——它们把某些问题的复杂度从 O(n²) 压到 O(n),是竞赛利器,但第一次学只要看懂思想即可,不必死磕代码。

21.2最长回文子串:中心扩展(中等,力扣 5)

中等 核心:枚举 2n-1 个中心 · 奇偶统一

题意:给你一个字符串 s,找出其中的最长回文子串(回文 = 正读反读一样的串,如 "aba"、"bb"),返回它。 例:s = "babad" → 最长回文是 "bab"(或 "aba",返回哪个都算对);s = "cbbd" → "bb"。

思路推导

第一步,暴力。枚举所有子串(O(n²) 个),每个判断是不是回文(O(n)),总 O(n³)。太慢,但给了我们一个正确性的底线。

第二步,换个角度:从「中心」往外扩。任何一段回文,都围绕某个中心左右对称。比如 "aba" 以中间的 'b' 为中心,"bb" 以两个 'b' 中间的「空隙」为中心。 如果我们枚举每一个可能的中心,从它开始向两边比对,一旦左右字符不相等就停,那么从当前中心能扩出的最长回文就找到了。把每个中心的结果取最大,就是答案。

第三步,奇偶中心怎么统一。这是最容易漏的点:

如果只枚举「字符」当中心,会漏掉所有偶数长度回文。怎么办?把中心数从 n 个扩成 2n-1 个:把每个「字符」和每对「相邻字符的空隙」都当作中心。 代码上最干净的做法是写一个辅助函数 expand(left, right),传两个起点:

中心 i(奇数长度):expand(i, i)       // 左右从同一个字符出发
中心 i 与 i+1 之间(偶数):expand(i, i+1)   // 左右从相邻两个字符出发

第四步,为什么不用 DP?最长回文子串也能用区间 DP:dp[i][j] 表示 s[i..j] 是否回文,根据 s[i]==s[j] 和 dp[i+1][j-1] 转移,时间 O(n²)、空间 O(n²)。 中心扩展同样是 O(n²) 时间,但空间只要 O(1)(只用了几个指针)。在「只求一个最长」的场景下,中心扩展又省空间又好写,所以优先用它;DP 版本的价值在于——它正是下一章 23.2 节「最长回文子序列」的铺垫,那里子序列不连续,中心扩展就不灵了。

参考代码

class Solution {
    public String longestPalindrome(String s) {
        if (s == null || s.length() < 2) return s;   // 空或单字符,本身即回文
        int start = 0, maxLen = 1;                    // 记录最长回文的起点和长度
        for (int i = 0; i < s.length(); i++) {
            int len1 = expand(s, i, i);               // 以 i 为中心(奇数长度)
            int len2 = expand(s, i, i + 1);           // 以 i 与 i+1 间为中心(偶数长度)
            int len = Math.max(len1, len2);
            if (len > maxLen) {                        // 发现更长的,更新记录
                maxLen = len;
                start = i - (len - 1) / 2;            // 反推回文起点
            }
        }
        return s.substring(start, start + maxLen);    // 右开区间,含 start+maxLen 前一个
    }

    // 从 (left, right) 向两边扩,返回以之为中心的最长回文长度
    private int expand(String s, int left, int right) {
        while (left >= 0 && right < s.length()
               && s.charAt(left) == s.charAt(right)) {
            left--;                                   // 左右相等,继续往外扩
            right++;
        }
        // 退出时 s[left]!=s[right] 或越界,回文长度为 (right-1)-(left+1)+1 = right-left-1
        return right - left - 1;
    }
}

复杂度

O(n²) 时间(2n-1 个中心,每个最多扩 n 步),O(1) 空间(只用了常数个指针)。这是面试里关于「最长回文子串」的标准写法。

边界自查:① 单字符 "a" → 直接返回,正确。② 全部相同 "aaaa" → 偶数中心 expand(1,2) 扩到长度 4,正确。 ③ 起点反推公式 start = i - (len-1)/2:当 len 为偶数,如 i=1、len=4,(4-1)/2=1,start=0,覆盖 s[0..3],正确。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 回文子串(力扣 647,中等)——与本题共用「中心扩展」技巧;差异在统计所有回文子串的个数而不是只找最长的(提示:每个中心向外扩,每遇到一次 left==right 就计数 +1,最后把奇数、偶数中心的计数都累加)。
  • 最长回文串(力扣 409,简单)——同为回文但不再「子串」,而是用字符频次拼一个最长回文(提示:偶数频次全取、奇数频次取偶数部分,最多留一个奇数频次字符放正中心)。
  • 最短回文串(力扣 214,困难)——和本题共享「回文 + KMP 思想」;差异在用 next 数组找最长回文前缀,把前面非回文部分反转补到末尾(提示:构造 s + "#" + reverse(s),求其 next 数组末值即得最长回文前缀长度)。

21.3字符串相加:大数模拟(简单,力扣 415)

简单 核心:末位对齐 · 逐位加 · 进位

题意:给定两个非负整数的字符串 num1、num2(长度可能极大,大到 int 装不下),返回它们和的字符串(也不能用库函数直接转大数)。 例:num1 = "11", num2 = "123" → "134";num1 = "0", num2 = "0" → "0"。

思路推导

第一步,明确为什么不能直接转。这是本题最关键的提醒:Integer.parseInt("12345678901234567890") 会直接溢出抛异常,因为 64 位 long 也才约 9×10¹⁸。题目给的「字符串」就是用来绕开数值类型上限的,所以必须按位模拟竖式加法。

第二步,回忆小学竖式。我们从两个数字的最右(个位)开始,一位一位往左加,每位产生一个「和」与一个「进位」:

    1 2 3
  +   1 1
  -------
    1 3 4     个位 3+1=4 无进位;十位 2+1=3;百位 1 落下

第三步,用双指针 + carry 翻译。设 i 指向 num1 末尾、j 指向 num2 末尾,carry 初始为 0。每一步:

第四步,注意结果顺序。我们从个位往高位算,得到的是「低位在前」的片段,最后要反转一下才是正常数字顺序。用 StringBuilder 拼接后 reverse() 即可。

参考代码

class Solution {
    public String addStrings(String num1, String num2) {
        int i = num1.length() - 1;          // 指向 num1 个位
        int j = num2.length() - 1;          // 指向 num2 个位
        int carry = 0;                       // 进位
        StringBuilder sb = new StringBuilder();
        while (i >= 0 || j >= 0 || carry != 0) {
            int d1 = i >= 0 ? num1.charAt(i) - '0' : 0;  // 用完补 0
            int d2 = j >= 0 ? num2.charAt(j) - '0' : 0;
            int sum = d1 + d2 + carry;
            sb.append(sum % 10);             // 当前位(低位先放)
            carry = sum / 10;                // 进位留给下一轮
            i--; j--;
        }
        return sb.reverse().toString();      // 反转成正常高位在前的顺序
    }
}

复杂度

O(max(n, m)) 时间(两个串各走一遍),O(max(n, m)) 空间(结果串长度)。这是「大数加法」的骨架,乘法、减法同一套路。

字符转数字的小技巧:num1.charAt(i) - '0' 利用 ASCII 码差值把字符 '7' 变成整数 7。千万别写 Integer.parseInt(Character.toString(ch)),既慢又啰嗦。详见 21.7 节坑一。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 字符串相乘(力扣 43,中等)——同是大数模拟,但升级成「乘」;差异在逐位相乘再加进位、错位累加(提示:用 int[] 存结果,num1[i]*num2[j] 累加到 res[i+j] 与 res[i+j+1],最后统一处理进位)。
  • 加一(力扣 66,简单)——末尾加 1 的进位版;差异在从个位 +1,遇 9 变 0 继续向前进(提示:从末位往前,若为 9 置 0 继续,否则 +1 返回;全 9 则新建长度 +1 的数组,首位填 1)。
  • 二进制求和(力扣 67,简单)——与本题几乎一模一样,只是基数是 2(提示:双指针从末位,carry = sum/2、当前位 = sum%2,逻辑完全一致)。

21.4反转字符串里的单词(中等,力扣 151)

中等 核心:trim + 从后往前拼 · 两次反转

题意:给你一个字符串 s,把其中单词的顺序反转,并去掉多余空格(单词间只留一个空格,首尾无空格)。 例:s = " the sky is blue " → "blue is sky the"。

思路推导

第一步,暴力直观法:分割再逆序。用 split(" +") 按空格切开,反转数组,再拼回去。能过,但面试官通常想看你会不会不依赖库函数、且 O(1) 额外空间地做。

第二步,从后往前直接拼(推荐)。既然要反转单词顺序,那就逆着读:用指针 i 从末尾往前扫,遇到单词就收集,遇到空格就跳过。收集一个单词后补一个空格,最后再 trim 掉末尾多余空格。这样一遍扫描就搞定,且只用 O(1) 额外变量(除了结果串)。

第三步,「原地 O(1) 空间」的两次反转技巧(提一嘴)。如果题目严格限制不能开新大数组,可以用「原地反转」三步法:

  1. 先去掉多余空格(双指针把有效字符前移,压实);
  2. 整个字符串整体反转一遍(单词顺序被反过来,但每个单词内部也被反了);
  3. 再逐个单词内部反转回来。

例:"the sky" → 压实 → 整体反转 "yks eht" → 单词内反转 "sky the"。两次反转互为抵消,巧妙地只用了几个指针变量就完成。本题我们用更直观的「从后往前拼」即可,但要知道这个技巧存在。

参考代码(从后往前拼,易读版)

class Solution {
    public String reverseWords(String s) {
        int i = s.length() - 1;             // 从末尾开始
        StringBuilder sb = new StringBuilder();
        while (i >= 0) {
            // ① 跳过尾部空格
            while (i >= 0 && s.charAt(i) == ' ') i--;
            if (i < 0) break;               // 全是空格,结束
            // ② 收集一个单词(从右往左读)
            int j = i;
            while (j >= 0 && s.charAt(j) != ' ') j--;
            // 此时 s[j+1 .. i] 是一个单词,按顺序加入结果
            sb.append(s.substring(j + 1, i + 1));
            sb.append(' ');                 // 单词间补一个空格
            i = j;                          // 继续往前
        }
        // 去掉最后一个多余空格(若存在)并返回
        return sb.length() == 0 ? "" : sb.substring(0, sb.length() - 1);
    }
}

复杂度

O(n) 时间(每个字符最多被访问一两次),O(n) 空间(StringBuilder 存结果)。如果要严格 O(1) 额外空间,则用上面的「两次反转」原地做法。

substring(a, b) 是右开区间:它取的是 [a, b),即包含 a、不包含 b。所以单词 s[j+1..i] 要写成 substring(j+1, i+1)。这个右开特性几乎每个字符串题都会踩,详见 21.7 节坑二。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 反转字符串(力扣 344,简单)——本题的基石;差异在只整体反转、不管单词(提示:首尾双指针,交换后往中间收,直到相遇)。
  • 反转字符串中的单词 III(力扣 557,简单)——单词内部反转、单词顺序不变;差异在定位每个单词边界后逆序其字符(提示:遇空格就处理前一个单词,反转它再接上)。
  • 反转字符串里的单词 II(力扣 186,中等)——就是本题的原地版;差异在不许开新串、用两次反转(提示:先整体反转,再逐单词内部反转回来)。

21.5KMP:next 数组怎么来的(中等,力扣 28,选学)

中等 核心:主串指针不回退 选学

题意:在字符串 haystack 中找出 needle 第一次出现的位置(返回下标),找不到返回 -1。 例:haystack = "aabaabaaf", needle = "aabaaf" → 出现在下标 3。

思路推导

第一步,朴素做法为什么慢。最直白:i 扫主串、j 扫模式串,一旦 haystack[i]!=needle[j] 就令 i 退回「本次起点 +1」、j 归零重来。最坏 O(n·m)。慢在哪?主串指针 i 反复回退。

第二步,核心思想:主串指针永不回退。既然匹配失败时已经比过了前面一段字符,那前面这段里藏着的信息能不能让我们不从头比?可以。关键观察:模式串自己前缀和后缀的重合,决定了「失败后能跳多远」。这就是 KMP 的全部动机。

第三步,next 数组 = 最长相等前后缀长度。对模式串的每个位置 i,定义 next[i] 为「needle[0..i] 这个前缀里,最长的、相等的前缀和后缀的长度」。 以前缀 ababc(假设模式 ababc... 的前 5 个字符)为例,逐位算:

模式:  a  b  a  b  c
下标:  0  1  2  3  4
next:  0  0  1  2  0

解释:
 i=0 'a'      : 单字符,前后缀无真重叠 → 0
 i=1 'ab'     : 前缀{a} 后缀{b} 不等 → 0
 i=2 'aba'    : 前缀{a} 后缀{a} 相等,长 1 → 1
 i=3 'abab'   : 前缀{ab} 后缀{ab} 相等,长 2 → 2
 i=4 'ababc'  : 前缀里没有以 c 结尾的等于后缀 c 的部分 → 0

第四步,怎么用 next 跳。匹配时 j 是模式串指针:若 haystack[i]==needle[j] 则两指针同进;一旦失配,不让 j 归零,而是 j = next[j-1](利用已匹配部分的最长重合前缀),i 不动。这样主串 i 只增不减,整体 O(n+m)。

第五步,next 数组本身怎么求(自匹配)。用「模式串匹配自己」来填:len 表示当前已匹配的前后缀长度,i 从 1 走: 若 needle[i]==needle[len] 则 len++、next[i]=len;否则 len = next[len-1] 回退(len==0 时填 0)。

参考代码(完整版,第一次学只要求看懂思想)

class Solution {
    public int strStr(String haystack, String needle) {
        if (needle.length() == 0) return 0;
        int n = haystack.length(), m = needle.length();
        int[] next = buildNext(needle);     // 先算 next
        int j = 0;                           // 模式串指针
        for (int i = 0; i < n; i++) {
            // 失配就利用 next 回退 j(主串 i 不回退)
            while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
                j = next[j - 1];
            }
            if (haystack.charAt(i) == needle.charAt(j)) {
                j++;                         // 匹配上,j 前进
            }
            if (j == m) return i - m + 1;    // 整段匹配完,返回起点
        }
        return -1;
    }

    // 求 next 数组:next[i] = needle[0..i] 最长相等前后缀长度
    private int[] buildNext(String p) {
        int m = p.length();
        int[] next = new int[m];
        int len = 0;                         // 当前最长前后缀长度
        for (int i = 1; i < m; i++) {
            while (len > 0 && p.charAt(i) != p.charAt(len)) {
                len = next[len - 1];         // 回退到更短的可行前后缀
            }
            if (p.charAt(i) == p.charAt(len)) {
                len++;                       // 延长
            }
            next[i] = len;
        }
        return next;
    }
}

复杂度

建 next 数组 O(m),匹配 O(n),总 O(n+m) 时间,O(m) 空间。比朴素 O(n·m) 在长串上快非常多。

选学定位:第一次学只要理解「主串指针不回退、靠 next 跳」这个思想即可,不必能默写。面试中「实现 strStr」用朴素或上面的 KMP 都行;但 next 数组的下标偏移(是 next[j-1] 还是 next[j])是经典写错点,见 21.7 节坑三。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 重复的子字符串(力扣 459,简单)——直接吃 next 数组的性质;差异在判断能否由子串重复构成(提示:若 n % (n - next[n-1]) == 0 且 next[n-1] > 0,则可由前面那段重复拼成)。
  • 旋转字符串(力扣 796,简单)——巧用子串包含;差异在于把原串拼自己判断是否含目标(提示:若 s+s 包含 goal 且等长,则 goal 是 s 的旋转)。
  • 最短回文串(力扣 214,困难)——回文前缀 + KMP;差异在构造 s+"#"+reverse(s) 求 next 找最长回文前缀(提示:next 数组末值即最长回文前缀长度,把非回文后缀反转补到前面)。

21.6Manacher 速览(概念,选学)

选学 核心:线性求最长回文

它解决什么:和 21.2 节同一个问题——求最长回文子串,但中心扩展是 O(n²),Manacher 把它压到 O(n)。当你面对超长字符串(10⁵ 级)时,O(n²) 直接超时,才需要它。

两个关键直觉

① 用 '#' 统一奇偶。前面说过奇偶中心麻烦。Manacher 的预处理是:在原串每个字符之间和首尾都插入一个不会出现的字符(如 '#')。 "aba" 变成 "#a#b#a#",无论原来奇偶,处理后的回文一定以 '#' 为中心、长度必为奇数,中心数从 2n-1 简化成 n+1 个统一处理。

② 利用已算回文的「镜像」避免重复扩。这是精髓。维护一个已经到达的最右边界 R 及其对应的中心 C。当处理新中心 i 时:

因为每次扩都让 R 单调右移、且借用镜像省掉了内部重复扩展,总步数线性。

面试够用版本:知道 Manacher 干三件事——插入 '#' 统一奇偶、用 R/C 和镜像半径跳过已确定的部分、每个位置记录回文半径数组 P[]。不要求写完整代码,能说出「它靠镜像复用把 O(n²) 降到 O(n)」就已经是加分项。多数面试用中心扩展(O(n²),n≤2000 足够)就能过。

21.7本专题常见坑

  • 坑一:s.charAt(i) - '0' 的数字转换。把字符变数字必须用 - '0',不是减 0 也不是 Integer.parseInt。同样,数字变字符是 (char)(digit + '0')。忘写 '0' 会得到完全错的 ASCII 码值。
  • 坑二:substring(a, b) 是右开区间。取 [a, b),包含 a、不含 b。反转单词里单词 s[j+1..i] 必须写 substring(j+1, i+1);写 substring(j+1, i) 会丢掉最后一个字符。笔试里这个 bug 极隐蔽。
  • 坑三:KMP next 数组下标偏移。失配回退写 j = next[j-1] 还是 next[j],取决于 next 的定义(本书记为「前 j 个字符的最长相等前后缀长」,故用 j-1)。混用会越界或死循环。配上时一定用「ababc」手算一遍交叉验证,见 21.5 节。
  • 坑四:大数模拟忘处理「两个串都读完但还有进位」。如 "99"+"1",循环条件必须是 i>=0 || j>=0 || carry!=0,漏掉 carry!=0 会得到 "90" 而非 "100"。
  • 坑五:中心扩展只枚举字符中心。会漏掉所有偶数长度回文(如 "bb")。必须同时枚举 expand(i, i) 与 expand(i, i+1) 两种。

21.8自测题

Q1.【动手题】只用中心扩展,写出求「字符串里所有回文子串个数」的函数签名与核心循环(不要求编译,但要写出 expand 被调用的次数与计数位置)。

共枚举 2n-1 个中心:对每个 i 调一次 expand(i,i) 和一次 expand(i,i+1)。计数放在 expand 内部——每当 s[left]==s[right] 成立就 count++ 并继续外扩,遇不等或越界停止。把所有中心的计数累加即答案(即力扣 647)。

Q2. 为什么最长回文子串「中心扩展」比「区间 DP」更受面试青睐?请给出两者的时空复杂度对比。

中心扩展时间 O(n²)、空间 O(1);区间 DP 时间 O(n²)、空间 O(n²)。两者时间相同,但中心扩展几乎不占额外空间且代码更短,故优先。DP 的价值在下一章「最长回文子序列」(子序列不连续,中心扩展失效)。

Q3.【选学】一句话说明 KMP 为什么比朴素匹配快。

因为它利用模式串自身的「最长相等前后缀」(next 数组),在失配时让主串指针不回退、只把模式串指针跳到合适位置,从而把 O(n·m) 降到 O(n+m)。
学完这章,你应该能回答:① 给你一个字符串,能否立刻判断该用「中心扩展」还是「滑窗 + 哈希」?② 两个超大整数相加为什么不能直接转 int,并手写逐位加 + 进位?③ 反转单词时 substring 的右开区间如何影响边界?④(选学)说出 KMP 与 Manacher 各自把什么问题从 O(n²) 降到了 O(n)。