“代码随想录”的字符串章节,我前前后后刷了三遍。第一遍觉得简单,第二遍发现真正难的其实是“指针移动的时机”和“边界条件”,第三遍才摸到整套题的骨架:大部分字符串题,表面上是字符串,实际上要么是双指针问题,要么是匹配问题,要么纯粹是语言细节问题。尤其现在刷题网站和面试题里,字符串逆序、判断子串、回文串、字符串分割几乎成了标配。这篇就把我刷字符串题的经验、踩过的坑,以及平时在项目里处理字符串的那些真实需求一起整理出来,给你一条可以直接照着走的路线。
这篇文章适合三类人:准备面试、正在按《代码随想录》刷题的;刚学完基础语法、想搞清楚字符串到底有多少种考法的;以及工作中经常被字符串处理卡住、想系统补一课的。我会把原理、代码、易错点揉在一起讲,尽量说人话。
1. 字符串题为什么值得专门刷一遍
1.1 字符串是数据结构题的“包装纸”
很多人一开始不重视字符串题,觉得不就是一堆字符摆在那里吗?实际刷下来你会发现,字符串题几乎能把所有基础算法都包装一遍:双指针、哈希表、滑动窗口、动态规划、回溯、KMP,全都能在字符串上做文章。字符串只是载体,真正考的是你“能不能把一个连续字符序列当成一个可操作的线性结构”。
比如说“反转字符串”,本质就是在数组上做首尾交换;“判断回文串”,本质是双指针往中间走;“无重复字符的最长子串”,本质是滑动窗口;“分割回文串”,本质是回溯。如果你只把字符串当成“一串字符”而不是“一个数组”,很多题的解法根本想不出来。
这也是《代码随想录》把字符串单独列一章的原因。它不是在教你怎么背 API,而是教你“当看到字符串题时,第一反应应该是往什么数据结构上靠”。我刷完这章的体会是:字符串题是训练“识别算法模型”能力最好的素材,因为它的包装层很薄,核心逻辑露出来一半,另一半藏在边界条件里。
1.2 《代码随想录》字符串章节的刷题顺序建议
我自己的刷题顺序是这样安排的,也和这套题的目的比较匹配:
- 先做“反转字符串”系列,理解双指针在字符串上的基本操作;
- 再做“替换空格”,练习从后往前填充的思路;
- 然后做“翻转字符串里的单词”,综合了去除空格、整体反转、局部反转三步操作;
- 接着做“左旋转字符串”,体会局部反转加整体反转的通用技巧;
- 最后做 KMP 相关的匹配题,剩下的时间全部用来反复练 next 数组。
这个顺序是层层递进的。前面几题让你熟悉“改字符”“移动指针”,后面综合题考的是“多种操作组合不乱”。KMP 放到最后是因为它需要前面建立的“字符比较”直觉,直接上手容易劝退。
有一个我自己的经验:字符串题不要只追求把答案写出来,要追求“能不能用双指针原地操作”。很多题如果用库函数一行就搞定了,比如s = reversed(s),那你就完全没练到点上。刷题阶段,库函数是给项目用的,不是给你偷懒用的,这一点后面细说。
2. 反转类题目:双指针和“三步翻转法”
2.1 反转字符串:从左右交换说起
反转字符串是最基础的一题,但很多人写的时候还是会翻车。题目要求原地修改,不能开额外数组。思路就一个:左指针从 0 开始,右指针从len-1开始,两边字符交换,然后向中间靠拢,直到左右指针相遇。
C++ 的写法长这样:
void reverseString(vector<char>& s) { int left = 0, right = s.size() - 1; while (left < right) { swap(s[left], s[right]); left++; right--; } }Python 因为字符串不可变,一般用list(s)转成数组再操作,最后''.join()转回来:
def reverse_string(s): s = list(s) left, right = 0, len(s) - 1 while left < right: s[left], s[right] = s[right], s[left] left += 1 right -= 1 return ''.join(s)这题刷完,你要建立的直觉是:只要是对称交换类操作,优先想双指针。后面很多看起来花里胡哨的题,比如判断回文串、倒置字符串,都是这个基本操作的变形。
热词里那个“字符串逆序输出c”,就对应着 C 语言里更容易出错的一种版本:字符数组操作。C 的字符串本质上是一个以'\0'结尾的char数组,strlen返回的长度不包含结束符,交换字符时千万不要动最后一个'\0'。如果直接拿char*指针从头扫到尾,很容易把结束符也交换到头部去,输出直接乱掉。正确做法是先把右指针定位到strlen(s) - 1,再开始交换。
2.2 左旋转字符串:局部反转加整体反转
这题是《代码随想录》里的老朋友了。给定一个字符串s和一个整数k,把字符串前k个字符移到末尾。比如s = "abcdefg",k = 2,结果是"cdefgab"。
最直接的做法是拼接s.substr(k) + s.substr(0, k),但这又是在用库函数取巧。真正想练算法,要用“三步翻转法”:
- 反转前
k个字符; - 反转
k到末尾的字符; - 反转整个字符串。
为什么这样能成立?可以拿数学上的“逆序”性质来理解:对一个序列先部分反转、再整体反转,等价于把两段顺序调换。这个技巧在很多字符串旋转题里都能复用,包括“翻转字符串里的单词”。
C++ 代码:
string reverseLeftWords(string s, int k) { int n = s.size(); k %= n; // 防止 k 大于 n reverse(s.begin(), s.begin() + k); reverse(s.begin() + k, s.end()); reverse(s.begin(), s.end()); return s; }这里的k %= n就是典型的边界处理。很多测试用例会故意给k > n的情况,不做取模直接反转就会越界或者结果错误。这种细节,就是刷题和做项目的本质区别。
2.3 按单词反转:先整体后局部
“翻转字符串里的单词”是这一节里综合度最高的题。题目要求:把字符串里的单词顺序反转,同时把多余空格去掉。比如" hello world! "变成"world! hello"。
我一开始做这题时,直接用了split大法,Python 里就是:
' '.join(s.split()[::-1])一行完事。但《代码随想录》这类题的训练目标是双指针,所以我后来重新用双指针写了一遍,流程分三步:
- 去除多余空格(包括首尾空格、单词间的多个空格);
- 整体反转字符串;
- 对每个单词再做一次反转。
第三步是精髓:整体反转后,单词顺序反了,但单词内部的字母也反了。于是再对每个单词做一次局部反转,单词内部就恢复正常,而单词顺序保持反序,恰好得到正确答案。
这题的易错点几乎都集中在第一步。比如去除空格时,要保证“单词之间只留一个空格”,并且开头和结尾不能有空格。很多人的写法在最后一个单词后面多插一个空格,结果整体反转后开头多了一个空格,测试用例直接红。
3. 匹配与子串:KMP 手动实现一次就够
3.1 什么时候该手写 KMP
字符串匹配在生活中太常见了:java 判断字符串中是否包含某个字符串、oracle判断字符串是否包含某个字符串、js判断字符串是否包含,这些热词说明大家天天都在做包含判断。但刷题场景不一样,面试官问 KMP,不是为了让你在生产环境里手写一遍,而是想确认你理解“如何避免重复比较”。
朴素匹配的写法是双重循环,主串从每个位置开始,逐个字符和模式串比较。最坏情况下复杂度是 O(n*m),比如主串是"aaaaaab",模式串是"aaab",每次都要比到最后一个字符才失败,然后再从头开始,非常浪费。
KMP 的核心思想是:匹配失败时,主串指针不回溯,只让模式串指针向前跳到某个位置继续比。这个“跳”的依据来自 next 数组,也叫前缀表,它记录了模式串每个位置之前的最长相等前后缀长度。
我的建议是:刷题阶段至少手写一次 KMP,但不一定每道匹配题都用它。很多题用哈希表或者库函数就够,KMP 的价值更多体现在“让你理解匹配的本质”。
3.2 next 数组的构建和常见翻车点
next 数组是 KMP 最容易崩的地方。《代码随想录》推荐的构建方式是使用前缀表,同时用“回退”的思路来求。
以 C++ 为例:
vector<int> buildNext(const string& needle) { int n = needle.size(); vector<int> next(n, 0); int j = 0; for (int i = 1; i < n; i++) { while (j > 0 && needle[i] != needle[j]) { j = next[j - 1]; } if (needle[i] == needle[j]) { j++; } next[i] = j; } return next; }这里容易犯两个错误。
第一,j回退时写成了j--。正确的回退是j = next[j - 1],因为当前的j位置匹配不上,要跳到前面“最长的相等前后缀”位置再比。写成j--只是在退一步,完全没有利用前缀表的信息,而且可能陷入死循环。
第二,新建 next 数组时初始长度不为 0。习惯上先把第一个元素设为 0,因为第一个字符没有真正意义上的“前后缀”。如果让next[0] = -1也能写出一版 KMP,但逻辑会更绕,我不推荐新手一开始就搞两种流派,认准一种写熟就好。
3.3 判断包含与查找子串的工程实现
至于工程上的“包含判断”,就没有必要手写 KMP 了。各语言的实现都很成熟,关键是选对方法。
这里我把常见的几个场景整理成一张表:
| 语言/场景 | 常用写法 | 备注 |
|---|---|---|
| Java 判断包含 | str.contains(sub)/str.indexOf(sub) != -1 | contains 内部就是 indexOf |
| Python 判断包含 | sub in str/str.find(sub) != -1 | 推荐用in,可读性好 |
| JavaScript | str.includes(sub)/str.indexOf(sub) !== -1 | includes 返回布尔值 |
| C 语言 | strstr(s, sub) != NULL | 返回指针位置 |
| Oracle SQL | INSTR(str, sub) > 0/LIKE '%sub%' | INSTR 性能更可控 |
| SQL Server | CHARINDEX(sub, str) > 0 | 与 Oracle 的 INSTR 类似 |
| C# | str.Contains(sub)/str.IndexOf(sub) >= 0 | 注意大小写可加 StringComparison |
这张表是我平时写代码时经常翻的清单。尤其要注意 SQL 和编程语言在“大小写是否敏感”上的差异,Oracle 默认不区分大小写,但 MySQL 的默认排序规则又可能区分,这就是典型的“看起来一样的操作,换个环境结果完全不同”。
4. 多语言字符串细节对照:刷题之外的真实需求
4.1 C/C++:字符数组、指针和结束符
C 语言字符串的坑,从热词里就能看出来:“cstring字符串结束符”“codeblock 字符串 宽字符l 表示 出错”“指针数组存放字符串”“c语言字符串函数”。说实话,大学里 C 语言的字符串题,一半的 Bug 都出在结束符和指针上。
字符串字面量在 C 里是只读的,存在只读数据段,如果你试图通过指针修改它,行为是未定义的。常见做法是用char s[] = "hello"这样的字符数组,这样内容存在栈上,可以修改。char* p = "hello"和这个问题完全不是一回事。
strlen不计结尾的'\0',而sizeof计。很多人用strlen当数组长度去复制,结果拷贝字符串时少复制了一个结束符,后面的输出就出现乱码。写字符数组初始化时,最安全的习惯是:
char s[32] = {0};这样即使你操作失误,字符串也能安全结束,不会一路读到未知内存。
另外,“宽字符”或者中文环境下,听说有人用wchar_t或者L"..."表示宽字符串。这里的问题在于一个宽字符可能占 2 个或 4 个字节,但strlen是按字节数的,不能直接用来处理宽字符。如果你做的是跨平台界面开发,比如 Qt 里经常要在QString和std::string之间转换,就得先搞清楚编码是 UTF-8、UTF-16 还是本地编码,否则中文一传就是乱码。
4.2 Java、Python:不可变字符串的注意事项
Java 和 Python 的字符串都是不可变对象。这意味着你不能直接修改字符串内部的某个字符。
热词“python字符串直接赋值更改”说的就是这个问题。Python 里这样写会直接报TypeError:
s = "hello" s[0] = "H"正确做法是重新生成字符串:
s = "hello" s = "H" + s[1:]如果你要反复修改,就老老实实转成list再操作,最后''.join()回来。
Java 也有类似情况,String不可变,所以拼接大量字符串要优先用StringBuilder。还有一个常见的困惑是字符串比较:Java 里==比较的是引用地址,不是内容,内容比较一定要用equals()。而 Python、C#、C++ 的string用==比较内容。很多人从 Java 转 Python 后会怀疑“怎么 Python 的 == 能直接比较字符串了”,这就是语言细节的差异。
常用操作对照:
// Java String[] arr = "a,b,c".split(","); String joined = String.join(",", arr); boolean isDigit = Character.isDigit('1'); boolean isLetterOrDigit = Character.isLetterOrDigit('a');# Python arr = "a,b,c".split(",") joined = ",".join(arr) is_digit = "1".isdigit() is_alnum = "a1".isalnum()4.3 数据库、脚本和嵌入式场景中的字符串处理
刷题之外,字符串问题在工作中非常高频,热词里能看到大量这种真实需求。
SQL Server 里把字符串转数字,常见写法是CAST('123' AS INT)或者CONVERT(INT, '123')。但如果字符串里混有非数字字符,比如'123abc',直接转换会报错,这时候要用TRY_CAST或者TRY_CONVERT,它转换失败时返回 NULL,而不是中断运行。
Oracle 判断字符串是否包含某个字符串,可以用INSTR,也可以用LIKE。区别在于LIKE通常配合通配符,适合模糊匹配;INSTR适合判断“是否出现”以及获取出现位置。数据量大的时候,建议先确认字段是否走索引,否则LIKE '%xxx%'这种写法可能会全表扫描。
C# 里字符串转 ASCII 码,要用Encoding.ASCII.GetBytes(str),拿到的byte[]里每个元素就是对应字符的 ASCII 值。而“把字符串基于指定字符成数组”,对应的方法是:
string[] parts = "a,b,c".Split(',');注意Split的默认行为在不同重载下有细微差别,如果字符串里连续出现两个分隔符,默认会生成一个空字符串元素,需要的时候可以加StringSplitOptions.RemoveEmptyEntries。
Qt 里double转字符串,用QString::number(value, 'f', 2)可以指定保留两位小数。直接转的话,默认精度可能输出一长串有效数字,反而不符合需求。
Vim 里查找字符串就是/关键词回车,按n向下继续查找,按N向上回退。这个太基础了,但很多人确实第一次用 Vim 时会卡住。
安卓开发里要判断字符串包含某个字,和普通 Java 一样用contains()或者indexOf(),但要注意contains是区分大小写的。如果要做不区分大小写的包含判断,可以用toLowerCase()统一转小写后再比。
工业触摸屏脚本,比如昆仑通态的脚本里要让字符串换行,不同型号的脚本引擎处理方式不一样,有的支持\n,有的需要\r\n。最稳妥的做法是先查对应脚本手册里的“字符串换行”章节,或者在脚本里拼一个换行符常量赋值给显示控件。这类环境没有标准答案,只能靠查手册和试错。
“枚举类型转换为字符串”这个需求也很常见。C# 里直接enumValue.ToString()就能得到枚举名,也可以用Enum.GetName(typeof(EnumType), value)。Java 里枚举有内置的name()方法。不要把枚举的toString()和展示文案混在一起,业务上显示“已完成”这种,建议单独写映射表,而不是靠枚举名硬拼。
“ODBC连接字符串”也是字符串处理的重灾区。连接字符串本质是一个带分号分隔的配置文本,比如:
Driver={SQL Server};Server=myServer;Database=myDb;Uid=myUser;Pwd=myPass;这里最容易出问题的是:密码或数据库名本身包含分号、大括号、空格,导致解析错误。常规做法是把敏感信息放到配置中心或者环境变量里,不要在代码里硬编码,同时连接字符串的拼装要使用专门的构建类,而不是手写字符串拼接。
5. 刷字符串题最容易踩的 5 个坑
5.1 库函数能救你,也能毁了你
很多题用库函数确实几行搞定,比如反转、分割、去空格。但面试的时候,如果你一上来就split、reverse,面试官大概率会追问:如果不让你用这些 API,你怎么实现?这不是为了刁难你,而是想看你对底层逻辑的理解。
我的做法是:刷题时先想“如果用最底层的字符操作怎么做”,写完后,再对比库函数写法,总结两者差异。这样既练了逻辑,也熟悉了 API。工作中我绝对支持你多用库函数,那是效率问题;刷题时要少用,那是训练问题。
5.2 边界条件和结束符
字符串题跑挂,十有八九是边界问题。空字符串、单字符字符串、全是空格、目标字符在开头或结尾、k大于字符串长度,这些用例一定要自己先在心里跑一遍。
C 系语言则要格外注意结束符。strlen返回长度不包含'\0',但在构造字符串时要自己补;sprintf、strcpy这类函数会自动补结束符,而一些自定义的字符操作不会。我见过太多人用memcpy拷贝字符串后不补'\0',最后输出乱炖字符。
5.3 字母数字判断:需求要问清楚
热词“java 判断字符串中是否不是字母和数字”看起来很简单,但仔细想,这里的需求可能是“判断字符串中是否存在非字母数字字符”,也可能是“判断字符串是否全部由字母数字组成”,还可能是“判断是否一个字母/数字都没有”。三句话三个含义。
Java 里常用Character.isLetterOrDigit(char),配合遍历。C 语言里就是isalnum()。Python 里是s.isalnum(),但它检查的是“整个字符串是否全部由字母数字组成”,不是“是否存在至少一个”。
所以遇到这类需求,第一步不是写代码,而是确认判断标准:是针对单个字符还是整个字符串?是否允许空格?下划线算不算?大小写是否敏感?这些细节没问清楚,写出来的代码大概率要返工。
5.4 中文、编码与字符串长度
“字符串长度”“汉字算一个字符,英文字母和数字两个算一个字符”这些说法,其实是业务里非常典型的统计需求。但注意,这里的“一个字符”在不同编码下含义完全不同。
Java 里的String.length()返回的是 UTF-16 编码下的代码单元数量。一个汉字在 UTF-16 里通常占 1 个代码单元,但生僻字或者 emoji 可能占 2 个,如果你用length()去限制用户输入,很可能会误判。按字节数统计要用getBytes("UTF-8").length,中文在 UTF-8 下通常占 3 个字节。
业务上“汉字算一个,字母数字算半个或者两个”这种需求,本质是一种显示宽度的估算,不是真正的字符长度。实现时一般要自己写统计函数,根据字符的 Unicode 码位范围加权计算。这里最容易犯的错是直接拿String.length()当显示宽度用,结果输入法输入一串英文时布局和中文对不齐。
做题时如果题目没说字符集,默认按 ASCII 或者纯小写字母处理即可;一旦涉及中文字符,先确认编码再动手。
5.5 不可变字符串的赋值陷阱
Java 和 Python 里,字符串是不可变的,直接给某个位置赋值会报错或无效。很多人刚开始刷题时用 Python 写反转,习惯性写s[i] = s[j],结果提示TypeError,这就是没转过弯来。
处理方式前面也提过,要么转成列表,要么用切片重新创建字符串。还有一点:Java 里大量使用+拼接字符串,底层会不断创建新对象,在循环里尤其明显。刷题时无所谓,但项目里如果循环几千次拼接,一定要换成StringBuilder。
6. 一道综合题完整复盘:去除空格并反转单词
6.1 题目设定与解题思路
前面讲了那么多套路,这里我用一道综合题完整走一遍流程,题目就是前面提到的“翻转字符串里的单词”。限定条件:
- 输入字符串可能包含首尾空格、单词之间多个空格;
- 输出要求单词之间只保留一个空格;
- 要求原地操作,不开额外数组(C++ 的 string 可以原地修改)。
这道题把双指针、整体反转、局部反转全考了一遍,非常适合作为字符串章节的收尾复盘。
6.2 代码实现与关键步骤说明
完整代码我整理成了这样:
string reverseWords(string s) { // 第一步:去除多余空格 int slow = 0; for (int i = 0; i < s.size(); i++) { if (s[i] != ' ') { if (slow != 0) { s[slow++] = ' '; } while (i < s.size() && s[i] != ' ') { s[slow++] = s[i++]; } } } s.resize(slow); // 第二步:整体反转 reverse(s.begin(), s.end()); // 第三步:逐个单词局部反转 int start = 0; for (int i = 0; i <= s.size(); i++) { if (i == s.size() || s[i] == ' ') { reverse(s.begin() + start, s.begin() + i); start = i + 1; } } return s; }第一步里的slow指针是核心。它记录了“有效字符应该放到的位置”,i指针负责向前扫描。遇到非空格字符时,如果slow不是 0,说明这不是第一个单词,要在前面补一个空格,然后一口气把当前单词的所有字符搬过来。这样,连续多个空格会自动被压缩成单词之间的一个空格,首尾空格也不会出现。
第二步整体反转后,字符串变成“单词逆序+每个单词字母逆序”。
第三步遍历整个字符串,遇到空格就说明一个单词结束了,把这个单词区间反转回来。注意循环条件是i <= s.size(),因为最后一个单词后面没有空格,需要靠i == s.size()来触发反转。很多人就是这里写成了<,导致最后一个单词永远没有反转。
6.3 测试用例与易错点回顾
这道题我实际跑过的测试用例有这些:
| 输入 | 预期输出 | 注意点 |
|---|---|---|
"the sky is blue" | "blue is sky the" | 标准情况 |
" hello world " | "world hello" | 去除首尾空格 |
"a good example" | "example good a" | 连续多个空格 |
"a" | "a" | 单字符 |
"" | "" | 空字符串 |
" " | "" | 全空格 |
每个用例我都建议实际敲一遍,不要只靠 IDE 跑。特别是“全空格”输入,如果第一步里没有处理好,resize之后可能留下一个或多个空格,输出就不是空串。
还有一个经验:写完这题之后,把“去除多余空格”这一步单独抽出来,写成一个小函数。后面做“按单词长度反转”“整理 SQL 字段”这类需求时,你都能直接复用。字符串题的很多能力不是靠背题,而是靠这种“把高频操作沉淀成函数”的迁移意识。
这段时间刷下来,我个人体会最深的一点是:字符串题切忌“一看就会,一写就废”。它不像图论题那样需要很复杂的算法设计,但非常考验代码的边界处理能力和细心程度。把《代码随想录》字符串章节的题按“反转、替换、匹配、综合”四类各练几遍,每种题型沉淀出一套自己的标准写法,之后再遇到五花八门的字符串题,你会发现自己已经不太需要看题解了。