字符串题在力扣里出现的频率,只要刷过两周的人应该都有体会:说它是半壁江山不算夸张,哪怕保守点,把数组、动态规划也算进去,字符串依然是绕不过去的那一道坎。很多人一看到字符串就觉得是“背函数”,什么split、reverse、toLowerCase,调完就AC,真遇到卡人的题就懵。我之前也这么想过,直到刷了大几十道字符串题之后才意识到,字符串题真正考的东西其实很统一,就三样:双指针、哈希表、动态规划,再加一个KMP偶尔出来吓唬人。这篇文章我就围绕这几个方向,结合我在力扣上刷过的经典题目,把字符串题从套路到实战完整梳理一遍,适合刚开始刷题的人打基础,也适合刷了一定数量但总是差临门一脚的同学整理思路。
1. 先把话说清楚:字符串题到底在考什么
1.1 字符串的本质是“带性格的字符数组”
字符串在底层就是字符数组,这是理解所有字符串题的第一性原则。在C/C++里是char[]或以\0结尾的字符序列,在Java里是char[]加不可变封装,在Python里是str不可变对象。但不管语言怎么封装,抽象层面你要时刻把它当成一个有序的、可索引的字符序列来处理。
这个“有序+可索引”意味着什么?意味着你在数组题里用过的一切技巧,双指针、二分、前缀和、差分、单调栈,理论上都能搬到字符串上来。比如力扣344题“反转字符串”,它本质上就是数组反转,双指针从两端往中间走,交换字符,一行思路都不用改。再比如力扣28题“找出字符串中第一个匹配项的下标”,本质就是在一个数组里找子数组的匹配位置,只是把数字换成了字符。
但字符串也有自己的“性格”,主要体现在三点。
第一,字符的取值空间有限。数字可以是任意大小,但字符一般就是ASCII的128个(或扩展的256个),或者Unicode的几万个。有限取值空间意味着你经常会用一个长度为128或256的数组来当哈希表用,这比用HashMap快得多。第二,字符串有“字典序”这种天然的比较规则,所以排序题在字符串里特别多,比如力扣179题“最大数”,表面是排序,实际是自定义比较器的事。第三,字符串拼接和切分的成本往往不是O(1)的,尤其在Java和Python这类不可变字符串的语言里,频繁拼接会产生大量中间对象,这也是很多字符串题优化空间的来源。
1.2 字符串题的五种常见“包装”
字符串题之所以看着杂,是因为同样一个底层考点,它可以打扮成各种样子来考你。我刷了这么多道,整理了五种最常见的“包装”。
第一种是回文类。回文串就是正着读倒着读一样的字符串,比如aba、abba。这类题的核心考点是双指针或中心扩展,经典题有力扣125“验证回文串”、力扣5“最长回文子串”、力扣647“回文子串”数量。
第二种是子串/子序列类。子串是连续的,子序列可以跳着取。连续子串的问题十有八九用滑动窗口,不连续的子序列问题基本都靠动态规划。经典题有力扣3“无重复字符的最长子串”、力扣76“最小覆盖子串”、力扣1143“最长公共子序列”。
第三种是匹配/查找类。在一个长串里找一个模式串是否出现,或者找第一个出现位置。这类最简单的是暴力匹配,进阶是KMP、Boyer-Moore、Sunday这些字符串匹配算法。力扣28题是标准入口。
第四种是变形/转换类。比如大小写转换、字符串转数字、压缩解压、反转单词等。这类题往往看起来简单,但坑很多。力扣8题“字符串转换整数(atoi)”就是典型的看似简单却能把你绕晕的题,各种空格、正负号、越界、非法字符的边界条件能把人磨疯。
第五种是组合构造类。比如电话号码的字母组合、括号生成、分割回文串等,基本是回溯/DFS的舞台,字符串只是作为结果展示的载体。
一旦你能把一道新题拆解成“包装 + 核心考点”,刷题效率会高很多,遇到没见过的题也不会慌。下面我就按核心考点来展开,这些才是真正要背下来、练熟的东西。
2. 字符串题的三板斧:双指针、滑动窗口、哈希表
2.1 双指针:从两边逼近还是从同向出发
双指针在字符串里有两个流派:相向指针和同向指针。相向指针通常用于回文串、反转类题目,一个指针在头,一个指针在尾,按条件向中间移动。同向指针就是两个指针都从左边出发,一个快一个慢,实际上这就是滑动窗口的基础形态。
力扣344“反转字符串”是相向指针最简单也最典型的题:
void reverseString(vector<char>& s) { int left = 0, right = s.size() - 1; while (left < right) { swap(s[left], s[right]); left++; right--; } }力扣125“验证回文串”同样是相向指针,但多了一个细节:只考虑字母和数字,且忽略大小写。于是你需要在指针移动的时候跳过非字母数字字符,再统一转成小写来比较。很多人在这种“跳过+比较”的组合上犯错:跳过之后忘记检查left < right导致越界。这是相向指针最容易踩的坑。
再进阶一点是力扣345“反转字符串中的元音字母”。这题的思路和反转字符串完全一样,只不过交换的条件变成了“当前指针指向的是元音字母”。注意题目里元音字母是包含大小写的,aeiouAEIOU,少写一个就出Bug。
同向双指针我放在滑动窗口里一起讲,因为它们其实是同一个东西。但这里要竖一个观点:双指针不只是用来降低复杂度的,更重要的是它能帮你把“涉及区间”的题目想清楚边界。每次移动left或right的时候,你要在心里明确当前区间[left, right]代表什么,代表什么字符集合,这样的区间是否合法。想不清楚边界,刷十道题错十道。
2.2 滑动窗口:最长子串的万能钥匙
滑动窗口解决的是一类经典问题:连续子串/子数组,求满足某条件的最长或最短长度。它之所以高效,是因为每个字符最多被left和right各访问一次,时间复杂度O(n)。
拿力扣3“无重复字符的最长子串”来说,这是滑动窗口的入门题,但它是后面一大堆难题的母题。核心思路:right指针不断向右扩展,把新字符加入窗口;如果发现窗口中已经有重复字符,就移动left指针缩小窗口,直到没有重复为止。怎么判断有没有重复?哈希表或长度为128的数组,记录字符最后一次出现的位置。
int lengthOfLongestSubstring(string s) { vector<int> last(128, -1); int left = 0, ans = 0; for (int right = 0; right < s.size(); ++right) { if (last[s[right]] >= left) { left = last[s[right]] + 1; } last[s[right]] = right; ans = max(ans, right - left + 1); } return ans; }这里有个非常关键的点:if (last[s[right]] >= left)。为什么不是if (last[s[right]] != -1)?因为last数组里存的是字符上一次出现的位置,但这个位置可能已经在窗口之外了。如果它出现在left之前,说明虽然这个字符之前出现过,但已经不属于当前窗口,不影响我们。只有上一次出现位置在窗口内(即>= left),才真正造成重复。这个细节我当初看题解时想了很久,画了好几遍窗口才彻底明白。
力扣76“最小覆盖子串”是滑动窗口的进阶题,也是我当年刷到“怀疑人生”的题。它要求找出s中包含t所有字符的最短子串。做法是:先统计t中每个字符的需求量,然后right扩展找可行解,再移动left找最优解,同时维护一个valid变量表示当前窗口中有多少个字符已经满足了需求。这个过程里,你要时刻更新valid,而且在收缩窗口的时候注意检查收缩后的窗口是否仍然满足条件,不满足就继续扩展right。这个题可以说是检验滑动窗口是否真正理解的分水岭,能把76题自己写出来,滑动窗口基本就过关了。
2.3 哈希表:异位词和频次统计的利器
哈希表在字符串里的角色是“频次统计”和“快速查找”。力扣242“有效的字母异位词”是最基本的:统计两个字符串中各字符出现的次数,完全相等就是异位词。由于只有小写字母,可以直接用长度为26的数组,根本不需要哈希表。
力扣49“字母异位词分组”稍微升级一点:给定一组字符串,把异位词分到同一组。核心在于“如何定义异位词的键”。两个常见做法:一是把每个字符串排序后作为键,因为异位词排序后结果是相同的;二是统计每个字符串中26个字母的出现次数,序列化成一个数组或字符串作为键。用排序法代码最简单:
vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (string& s : strs) { string key = s; sort(key.begin(), key.end()); mp[key].push_back(s); } vector<vector<string>> ans; for (auto& p : mp) ans.push_back(p.second); return ans; }这里我踩过一个坑:用排序后的字符串当key没问题,但如果你把key定义成“字符出现次数拼接的字符串”,要小心数字的歧义。比如"12"到底是“1个a2个b”还是“12个a”?所以正确的做法是用带分隔符的序列化,或者直接用一个引用计数为0的数组加自定义哈希函数。这类序列化细节在面试里很爱被追问,平时刷题就要注意。
哈希表还有一个高频场景是“找两个字符串的公共字符”“字符串中第一个唯一字符”(力扣387)之类。核心无非是先扫一遍记录频次,再扫一遍按条件找答案。这类题不难,但有一个共通的优化点:能用数组就不要用unordered_map,尤其在字符集有限(ASCII)的情况下,数组的访问是O(1)且常数极小,而unordered_map每次操作都有哈希计算和潜在的冲突处理成本。力扣上很多字符串题的题解区都在强调这一点。
3. 高频经典题实战拆解:从暴力到最优
3.1 “最长回文子串”的三种做法对比
力扣5“最长回文子串”是字符串题里的常青树,解法多到可以写一篇论文。先把三种主流做法对比清楚,你才能理解为什么推荐中心扩展。
第一种是暴力枚举所有子串,逐一判断是否回文。时间复杂度O(n^3),基本只适合用来验证小数据。第二种是动态规划,定义dp[i][j]表示s[i..j]是否为回文串,转移方程是dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1],注意要从短的子串向长的子串递推,时间复杂度O(n^2),空间也是O(n^2)。第三种是中心扩展法,遍历每个中心(包含单字符中心和对双字符中心两种情况),向左右扩展,统计每次扩展的最大长度,时间复杂度O(n^2),但空间O(1)。
我第一次写这题用的是动态规划,后来发现中心扩展代码更简洁直观:
string longestPalindrome(string s) { int n = s.size(); auto expand = [&](int l, int r) { while (l >= 0 && r < n && s[l] == s[r]) { l--; r++; } return r - l - 1; }; int start = 0, maxLen = 0; for (int i = 0; i < n; ++i) { int len1 = expand(i, i); int len2 = expand(i, i + 1); int len = max(len1, len2); if (len > maxLen) { maxLen = len; start = i - (len - 1) / 2; } len = max(len1, len2); } return s.substr(start, maxLen); }中心扩展的启动位置计算是个容易写错的地方。如果回文串长度为奇数,中心是s[i];长度为偶数,中心在s[i]和s[i+1]之间。算start时用的是i - (len - 1) / 2,因为len是从中心向两边扩展的总长度。这个公式我建议直接记住,现场推容易在边界上翻车。
还有一个更高阶的算法叫Manacher(马拉车),复杂度O(n)。它通过在字符之间插入特殊符号(如#),把奇数长度和偶数长度的回文串统一成奇数长度处理,同时利用已计算的回文半径数组加速。说实话,我在面试里从来没有被要求手写Manacher,但理解它的思想对加深“以中心扩展为原型、利用对称性优化”这件事很有帮助。如果你有时间,可以看一遍Manacher并手写一遍,对自信心的建立很有帮助。
3.2 字符串匹配:KMP算法的前因后果
力扣28题“找出字符串中第一个匹配项的下标”是最经典的字符串匹配入口。暴力做法是:从原串的每个位置开始尝试匹配模式串,一旦失配就回退到下一个位置重来。最坏情况时间复杂度O(mn),比如s全是aaaaaaaab,模式串是aaaab,每匹配到最后一个字符就失配,然后从头再来,非常浪费。
KMP算法的核心思想是:在失配的时候,不把匹配位置回退到模式串的开头,而是利用已经匹配的前后缀信息,跳到下一个可能匹配的位置。这个“前后缀信息”就是next数组。next[i]表示模式串的前缀子串p[0..i]中,最长相等前后缀的长度。举个例子,模式串ababc,对子串abab来说,最长相等前后缀是ab,长度2。
构建next数组的代码是KMP最绕的一部分,我直接给出常用的写法:
vector<int> buildNext(const string& p) { int m = p.size(); vector<int> next(m, 0); for (int i = 1, j = 0; i < m; ++i) { while (j > 0 && p[i] != p[j]) { j = next[j - 1]; } if (p[i] == p[j]) j++; next[i] = j; } return next; }这段代码的精髓在于while (j > 0 && p[i] != p[j]) j = next[j - 1];,它其实是在递归失败时利用已有的next信息来缩短回退距离。理解这一行,KMP就成功了一半。匹配阶段类似:遍历主串s,当字符匹配不上时,沿着next回退模式串指针j,直到重新匹配或j归零。
这里要特别提示:KMP并不是工程中最常用的字符串匹配算法,因为大多数语言的indexOf、find底层用的是优化过的双端匹配算法(如Boyer-Moore变体)。但KMP在算法面试里是“理解层次”的题,它考察的是你有没有真正理解“利用已匹配信息避免重复比较”这个思想。所以就算你只是准备面试,也应该至少能默写一遍buildNext和匹配主循环。
3.3 从公共子序列到编辑距离:二维DP的反复应用
字符串的动态规划题,十有八九是二维DP,dp[i][j]表示“第一个串的前i个字符”和“第二个串的前j个字符”作为子问题的答案。
力扣1143“最长公共子序列”就是最基础的二维DP模板。定义dp[i][j]为text1[0..i-1]和text2[0..j-1]的最长公共子序列长度。转移时,如果当前两个字符相等,dp[i][j] = dp[i-1][j-1] + 1;如果不相等,则是max(dp[i-1][j], dp[i][j-1])。这里的“删掉一个字符看剩余”的思考方式,是后续所有字符串DP的基础。
力扣72“编辑距离”是二维DP的另一座高山。这题的dp[i][j]表示把word1[0..i-1]转换成word2[0..j-1]所需的最小操作数。操作有插入、删除、替换三种。转移方程是:
如果 word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] 否则: dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])这三个选项分别对应删除、插入、替换。我学这题的时候,最大困惑在于“为什么删除和插入是对称的”。后来想明白了:dp[i-1][j]表示删掉word1当前字符,然后继续转换;dp[i][j-1]表示在word1当前位置插入一个和word2当前字符一样的字符,然后继续转换。一个是删原串,一个是补目标串,本质上都是“让当前字符对齐”。
字符串DP题有一个通用套路:先画出二维表,手动填几行数,你会发现填表逻辑非常直观。很多人在没画表之前看转移方程觉得天书,画过一遍之后就觉得“不过如此”。所以你在刷这类题的时候,强烈建议在纸上把dp表的前几行自己填一遍,比如"horse"转"ros"的编辑距离,填完你就能理解为什么初始化时第一行和第一列是递增的。
4. 容易被问爆的进阶套路:排序、分割、大小写转换
4.1 字符串排序与比较的隐藏细节
字符串排序看起来简单,sort(strs.begin(), strs.end())就完事了,但实际场景里坑很多。第一个坑是大小写是否敏感。默认的字典序比较中,大写字母的ASCII码小于小写字母(A是65,a是97),所以"Apple"会排在"banana"前面。如果需求是“忽略大小写排序”,你需要自定义比较器,把每个字符先tolower再比较。
第二个坑是自然排序。对"file10"和"file2",字典序排序会得到file10在前,因为比较到第5个字符时'1'比'2'小。但人类通常期望file2排在file10前面,这就是自然排序。力扣179题“最大数”其实也是排序比较器问题:把数字转成字符串,然后按照“a+bvsb+a”的字典序来比较。比如"3"和"30",拼接成"330"和"303",很明显330更大,所以3应该排在30前面。这种自定义比较器的方法是字符串题的一个高频考点,我单独强调一下。
第三个坑是比较器的严格弱序。C++的sort要求比较器必须满足“严格弱序”,如果你写的比较函数返回结果不稳定(比如a < b和b < a对同样的输入某一次同时返回true),就会导致未定义行为,轻则排序乱掉,重则直接崩溃。所以自定义比较器时,要用return a + b > b + a;这种确定性写法,不要在里面搞随机或可变状态。
4.2 字符串分割与拼接的边界问题
分割字符串在笔试和面试里几乎是必出现的基础操作,尤其是在处理命令行参数、CSV、IP地址等场景。力扣没有专门的分割题,但它藏在大量题目里,比如“翻转字符串里的单词”(力扣151)。
分割最常见的问题是连续分隔符和首尾分隔符。C++里如果用istringstream,默认会忽略空白字符,所以多个空格会被当成一个分隔符处理;而如果手动遍历按' '分割,就要自己跳过连续的空格。Java的split默认会丢弃尾部的空字符串(但在某些版本里正则表达式需要小心),Python的split()不传参数时会自动处理多个空格。这些语言差异,考试和面试时必须心里有数。
拼接字符串也有性能坑。在Java和Python中,字符串是不可变的,每一次+拼接都会创建一个新对象。如果在一个循环里拼接大量字符串,时间开销会很大。Java里应该用StringBuilder,Python里更推荐把片段加入列表,最后用''.join(list)。这个优化在数据规模小的时候无所谓,但力扣的测试数据经常会卡常数,养成好习惯能省很多不必要的麻烦。
4.3 大小写转换的“坑”:不是所有字符都有大小写
大小写转换在很多人眼里是无脑操作,但力扣709题“转换成小写字母”就设置了陷阱:题目要求实现一个函数将字符串转换为小写字母,但字符串中可能包含数字、标点、非英文字符。错误做法是直接把每个字符减32,这样会把'5'变成'\u0015'这种控制符。正确做法是先判断字符是否在'A'到'Z'范围内,只有大写字母才减32。同理,'ñ'、'ß'这种非ASCII字符,不同语言的toLowerCase处理方式也不一样,最好明确题目要求。
这条经验延伸到更广的场景:任何时候你要对字符做数学加减,都要先确认它满足你假设的范围(是英文字母、是数字、是ASCII还是UTF-8)。我在写力扣8“字符串转换整数(atoi)”的时候,就因为没有先判断字符是否为数字就贸然做c - '0',结果调试了半天。这种问题不难,但极其考验细心程度。
5. 刷题路线与排坑手册
5.1 力扣字符串题单的正确刷法
很多人问字符串题从哪开始刷,我的建议是不要按题号顺序刷,而是按套路刷。
第一阶段,把双指针和哈希表的基础题吃透:344反转字符串、125验证回文串、387字符串中的第一个唯一字符、242有效的字母异位词、49字母异位词分组。这个阶段目标是熟悉字符串作为字符数组的基本操作,把相向指针、频次统计练成肌肉记忆。
第二阶段,主攻滑动窗口:3无重复字符的最长子串、76最小覆盖子串、567字符串的排列、438找到字符串中所有字母异位词。这几题套路一致,能放在一起对比总结,是性价比最高的一个阶段。
第三阶段,进入动态规划和匹配算法:5最长回文子串、1143最长公共子序列、72编辑距离、28找出字符串中第一个匹配项的下标(KMP)。动态规划的字符串题变化最多,但核心套路就是二维DP和状态转移,建议把每个题的dp表都亲手画一遍。
第四阶段,处理综合题和细节题:151反转字符串中的单词、8字符串转换整数、179最大数、43字符串相乘。这些题不一定需要多高深的算法,但边界条件多,是锻炼代码严谨度的最佳材料。
每天刷的题量不用多,一天2到3道足够了,但每道题都要按“暴力解–优化解–看题解最优解”的顺序过一遍。特别是字符串题,暴力解能帮你理解题目的约束条件,优化解能帮你建立“这个信息可以复用”的感觉,看最优解则是在拓宽思路。
5.2 常见问题与排查技巧速查表
我把刷字符串题常遇到的Bug整理成了表,可以直接当排查清单用。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 越界访问 | 双指针移动前没检查left < right | 在访问s[left]前确保left在范围内 |
| 结果比预期短 | 滑动窗口收缩条件写错,把可行解给丢掉了 | 检查收缩时是否用while判断窗口是否仍然满足条件 |
| 结果比预期长 | 没有及时更新答案,或只在扩展right时更新 | 确认答案是在哪一步取max/min |
| 异位词统计出错 | 用字符串拼接数字做key产生歧义 | 改用数组序列化加分隔符,或直接用排序后的字符串 |
| 大小写不敏感题WA | 忘了先把两个串都转成小写再比较 | 预处理阶段统一调用tolower |
| KMP死循环 | next数组构建时while里没有最终出口 | 在j = next[j-1]后加if (j == 0) break; |
| 动态规划结果偏大 | 初始化时dp[0][j]和dp[i][0]填错 | 把空字符串对应的行和列单独验证 |
| C++ sort崩溃 | 比较器不满足严格弱序 | 自定义比较器里只做确定性的return a > b; |
调试技巧方面,字符串题最推荐的方法是打印中间状态。写滑动窗口的时候,在每次left和right变化后,打印窗口内字符和当前记录值。写DP的时候,打印整个dp表。很多WA你盯着代码看半小时看不出来,一打印立刻发现是某一行的初始化问题。
还有一个通用的小技巧:针对字符串题,可以写一个“简化版测试用例”来自查。比如滑动窗口相关题,用"aa"、"ab"、""(空字符串)、单字符字符串这四类最短用例先跑通,再跑大数据量测试。空字符串、单个字符这些边界在字符串题里出现概率极高,却经常被忽略。
另外,我习惯在写完代码之后,顺手把题目给的示例输入和输出作为注释放在代码附近。因为力扣的样例往往就覆盖了最常见的那几个边界情况,做题时总看样例,调起Bug来心里有数。
5.3 从刷题到面试:字符串题的答题节奏
最后说一下实战节奏。笔试或面试遇到一道字符串题,不要上来就写代码,先按三步走:第一步确认字符集和大小写要求,比如面试官说“假设只有英文字母”或“区分大小写吗”,这两个问题直接决定你后面用数组还是用哈希表,以及要不要做预处理转换。第二步说思路,从暴力解说起,再提出优化,这样可以展示你的思维路径。第三步动手实现,边写边把边界情况说出来,比如“这里left要小于right才能继续”“这里如果ch不在A-Z范围内就直接保留”。
其实很多人会忽略一个潜规则:面试官考察的往往不是你背了多难的算法,而是看你面对边界条件时是否反应迅速。字符串题的边界条件几乎都是明牌,比如空串、首尾字符、连续分隔符、大小写混排。你如果能提前想到并把它们写进测试用例里,就已经能打败不少竞争者了。
我个人刷下来最深的感受是:字符串题是所有算法题里“性价比”最高的一类。它的解法套路少,但延展性强,双指针、滑动窗口、哈希、DP、KMP、回溯都能在字符串上找到典型题目。一份题单刷两遍,你收获的不只是几十道题的答案,而是一整套处理“有序序列匹配与变换”问题的思维方式。这种思维不只在面试里有用,日常写代码做文本处理、日志解析、规则匹配的时候,也处处都有它的影子。所以如果你现在还在为字符串题发愁,别焦虑,按套路分阶段刷,把每个模板题吃透,最多一个月就能看到明显的质变。