1. 字符串题为什么是面试高频考点:先想清楚这几个底层问题
字符串这个专题,在代码随想录算法训练营里排在Day 8,前面数组、链表已经铺完底,到了这里你会发现一个很微妙的点:字符串的处理方式跟数组高度重合,但又有自己的脾气。我在LeetCode上断断续续刷了上千道题,最深的感受是:面试官特别喜欢在字符串题里考察候选人的边界意识和对语言底层特性的理解。一个简单的反转字符串,能写对的人不一定多;一道翻转单词顺序,能一次跑通的人更少。
先说个定位问题:字符串到底算简单题还是难题?我的判断是——下限极低,上限极高。简单的字符串遍历,幼儿园级别的for循环就能搞定;但字符串匹配里的KMP、高效去空格、原地翻转这类操作,能把一大批刷题量不够的人拦住。Day 8的"字符串 part 01"正好卡在这个分界点上:题目都不算难,但每一道都在逼你思考一个核心问题——你到底是在操作值,还是在操作内存。
这个章节有个很有意思的现象。很多人在学字符串之前,已经能熟练刷链表和二叉树了,但一碰到字符串就露怯。为什么?因为链表、二叉树的操作对象非常明确,就是节点和指针;而字符串在不同语言里呈现出来的“体质”完全不一样。C++的string是可变对象,你可以直接改某个下标;Java和Python的String是不可变的,每一次修改都重新生成新对象;JavaScript更拧巴,字符串本身不可变,但可以极其方便地用split转成数组再处理。这种语言差异直接决定了同样一道题的解法在不同语言里完全不是一个难度,也决定了面试官在看你写代码时关注点会不一样。
这段内容说得残忍一点:字符串题考的不是你会多少API,而是你在不用API兜底的情况下,能不能自己把逻辑链条完整地搭起来。比如反转字符串,用Python直接写成s[::-1]确实优雅,但面试官下一句大概率是:如果不允许切片,你怎么办?所以Day 8的核心目标就一个:把字符串操作里最常见的几种处理模式拆开、揉碎、装进脑子里,形成条件反射。
适合看这篇内容的人,我琢磨了一下大概是这几类:刚跟着代码随想录走到Day 8的训练营学员、准备暑期实习或秋招但字符串题还没形成体系的同学,以及刷题刷到瓶颈期、每次字符串题目都靠库函数“瞬杀”但心里发虚的选手。这篇文章会照着训练营的节奏,把基础部分讲透,把每个步骤背后的为什么讲明白,再补上我实际调试时踩过的坑。
2. 反转字符串类题目:什么时候能用库函数、什么时候必须手写
2.1 先练手:LeetCode 344 反转字符串
这道题是字符串part 01的开胃菜。题目非常直白:输入一个字符数组s,要求原地反转,不能申请额外空间,而且官方明确要求不要使用库函数。我第一次刷这道题的时候用的就是while循环加双指针,但后来发现很多初学者会卡在一个很尴尬的地方:LeetCode给的是字符数组["h","e","l","l","o"],而不是一个字符串。这两者的区别太大了——如果是字符串,你有十个八个API可以用;但字符数组就逼着你老老实实处理下标。
解法本身极其简单,双指针相向而行:
var reverseString = function(s) { let left = 0; let right = s.length - 1; while (left < right) { let temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } return s; };这个代码里唯一的门道在于循环终止条件到底是left < right还是left <= right。对于奇数长度的数组,比如长度5,left走到2、right走到2的时候,left === right,中间那个字符不需要和自己交换;如果是偶数长度,最后两步必然是left先超过right。实测下来用left < right最稳,不会多操作一次,也不会漏掉任何一对。
这题的时间复杂度O(n),空间复杂度O(1),没什么好说的。但有一个点值得单独拎出来:交换操作可以不用临时变量。用加减法或者异或运算也能实现交换,例如:
s[left] = [s[right], s[right] = s[left]][0]; // 不太推荐,可读性差或者:
s[left], s[right] = s[right], s[left] # Python里一行搞定Python这种写法本质上是语言层面的元组拆包,底层还是临时变量交换。面试时写成这样没问题,但如果你在面试C++,还是老老实实写下标交换的过程,别为了炫技写出让人看不懂的代码。
2.2 升级版:LeetCode 541 反转字符串II
这道题是344的兄弟版本,也是我在训练营里看讨论区最热闹的题之一。题目说:给定字符串s和整数k,从头开始每2k个字符反转前k个;如果剩余字符少于k个,则全部反转;如果剩余字符大于等于k个但少于2k个,则反转前k个。
我个人的经验是:做这个题最容易翻车的点不是反转逻辑,而是边界条件里的"等于"到底归谁。很多人会把"少于k个"和"少于2k个"弄混,导致while循环里case分错。我的写法是:
var reverseStr = function(s, k) { let arr = s.split(''); for (let i = 0; i < arr.length; i += 2 * k) { let left = i; let right = Math.min(i + k - 1, arr.length - 1); while (left < right) { let temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } return arr.join(''); };关键就是那一行right = Math.min(i + k - 1, arr.length - 1)。这行代码直接把“剩下的不足k个就全反转”这种边界情况吃掉了,不需要再写if判断。为什么i需要每次跳2k?因为每2k段是一个完整的处理单元,反转前k个,然后跳过后面k个不动。如果你每次i只加k,那就把不该反转的后半段也处理了,结果完全不对。
这题我在实际提交中踩过一个很隐蔽的坑:一开始我把arr.split('')写成了s.split(),结果逗号分隔符混进去了,后来排查了半天才发现是split参数问题。写JavaScript字符串题时,split('')和split()千万不要混用,前者按字符拆,后者按整串拆,低级错误但真的会犯。
2.3 库函数的边界到底怎么判断
代码随想录在字符串这一章特别强调了一个方法论,我觉得值得单独展开:什么时候可以用库函数,什么时候必须自己实现。
我的判断标准很简单,就看一条:库函数是不是这道题的核心考点。反转字符串的题,用Python的s[::-1]或者Java的StringBuilder.reverse(),一行写完,问题是面试官让你手写反转的意图是什么?他是想看你能不能在没有库函数加持的情况下,用指针完成基础操作。所以这种题你不能用库函数。反之,如果是把字符串转成大写、判断某个字符是不是数字这种纯工具性操作,库函数随便用,面试官不会蠢到考你字符编码的ASCII表。
再比如LeetCode 344里如果允许用库函数,JavaScript里就是s.reverse()一行完事。但训练营刻意要求你手写,本质是在训练你对双指针的肌肉记忆。双指针是字符串题里出镜率最高的技能点,没有之一。后面翻转单词、替换空格、甚至KMP里都离不开双指针的思想,所以前期基础题宁可多写几遍原生的交换,也不要用库函数一笔带过。
3. 替换空格与双指针:从后往前处理的高效套路
3.1 剑指Offer 05:替换空格为什么不能从前遍历
题目:实现一个函数,把字符串s中的每个空格替换成"%20"。
这道题如果你第一次见到,直觉反应大概率是新建一个字符串,遇到空格就追加%20,这当然能做对。但面试官紧接着会追问一句:如果要求原地修改呢?这就触及了这道题真正的考点。
先解释一下为什么空格要替换成%20而不是别的。HTTP协议里URL路径不能直接包含空格,RFC 3986规定空格在URL里是不合法字符,需要通过百分号编码变成%20。这个背景知道一下就行,不是重点。重点是替换操作的空间和时间开销。
如果从前往后遍历并原地修改,每次遇到一个空格,后续的所有字符都要往后移动两个位置,最坏情况下时间复杂度是O(n²)——一个长度为n的字符串,假设全是空格,每次替换都要搬动n个字符。这是典型的“每次操作都牵连大批元素”的反面教材。训练营第一步就点破了这一点:数组/字符串尾部操作不需要搬运元素,这是一种免费的空间。
所以我见到的最优解是分两步走:第一遍先统计原始字符串里有多少个空格,通过空格数量推断出替换后字符串的总长度;第二遍从后往前填充,遇到空格就把%20倒着填进去,遇到普通字符就原样复制。
3.2 C++和JavaScript两种思路对照
先写C++版本的原地扩展现思路,因为这个语言里string确实是可变的,最能体现从后往前的精髓:
string replaceSpace(string s) { int oldLen = s.length(); int spaceCount = 0; for (char c : s) { if (c == ' ') spaceCount++; } int newLen = oldLen + spaceCount * 2; s.resize(newLen); int i = oldLen - 1; int j = newLen - 1; while (i >= 0) { if (s[i] == ' ') { s[j--] = '0'; s[j--] = '2'; s[j--] = '%'; } else { s[j--] = s[i]; } i--; } return s; }从后往前的核心逻辑就是:i和j两个指针,i指向旧字符串末尾,j指向扩容后的末尾。当s[i]不是空格时,把s[i]复制到s[j];当s[i]是空格时,依次填入0、2、%。由于j永远小于等于i,所以从后往前填充永远不会覆盖还没有被处理的旧字符,这是它优于从前往后填充的核心原因。
JavaScript里字符串不可变,没办法原地resize,所以我通常先转数组处理再join回来:
var replaceSpace = function(s) { let arr = s.split(''); let spaceCount = 0; for (let i = 0; i < arr.length; i++) { if (arr[i] === ' ') spaceCount++; } let oldLen = arr.length; let newLen = oldLen + spaceCount * 2; let result = new Array(newLen); let i = oldLen - 1; let j = newLen - 1; while (i >= 0) { if (arr[i] === ' ') { result[j--] = '0'; result[j--] = '2'; result[j--] = '%'; } else { result[j--] = arr[i]; } i--; } return result.join(''); };很多初学JavaScript的人会问:为什么不直接s.replaceAll(' ', '%20')?答案还是前面说的:如果面试官没有刻意屏蔽库函数,你可以用;但这道题的灵魂在于手写双指针原地修改。用replaceAll虽然一行搞定,但你什么都没学到,面试官也没办法判断你对数组扩容和指针移动有没有sense。
3.3 双指针的两个方向分别适合什么场景
做字符串题做多了之后,我总结出一个规律:双指针的遍历方向,取决于你要操作的区域在哪一侧。从前往后遍历适合在字符串尾部追加元素的场景,比如收集符合条件的字符放进新数组里,这种场景下你不关心是否覆盖旧元素;从后往前遍历适合在字符串中插入/替换元素导致长度变的场景,因为尾部空间越往后越充足,填充过程不会影响还没处理到的地方。
替换空格就是典型的“从后往前”场景。这个思路在后面很多题目里都会用到,比如合并两个有序数组时,如果要求原地合并,也是两数组末尾各放一个指针,从后往前谁大放谁。你一旦建立了这种思维迁移能力,刷题才会真正有体系,而不是东一榔头西一棒子。
4. 翻转单词与旋转字符串:局部反转加整体反转的组合拳
4.1 LeetCode 151 翻转字符串里的单词:一个降维打击的思路
这道题的题目要求是:把字符串里的单词顺序完全颠倒,同时要去掉字符串开头、结尾以及中间的多余空格。举个例子:"the sky is blue"变成"blue is sky the"," hello world "变成"world hello"。
我第一眼看到这种题的想法是:用split(' ')把字符串拆成数组,然后用filter过滤掉空字符串,最后reverse再join,几秒钟写出来。但训练营的这个题同样是要求不使用辅助空间,原地修改。这就逼着你提高一个维度去思考。
这里就出现了一个我在刷题过程中见过的极其优雅的思路——先整体反转,再逐个单词反转。具体分三步:
- 第一步,移除字符串里多余的空格,包括头部、尾部和中间连续的空格(这一步用双指针完成)。
- 第二步,把整个字符串整体反转,比如"the sky is blue"先变成"eulb si yks eht"。
- 第三步,把每个单词再单独反转回来,于是"eulb"变回"blue","si"变回"is","yks"变回"sky","eht"变回"the"。
为什么这个思路是降维打击?因为你把一个大任务拆成了两个会了就没难度的小任务。整体反转就是344题的双指针;单词单独反转还是统一的双指针;唯一新学到的点是怎么用一个循环同时维护单词的边界。第三步里,每个单词的起止下标都需要动态定位,我习惯用一个start指针从0开始,遇到空格就停下来,反转[start, end-1]区间,然后start跳到end+1。
4.2 LeetCode 151 的具体实现:移除空格是个隐藏难点
这才是这道题真正卡人的地方。我们先理解为什么不能简单地用split过滤:因为如果要求原地,那么长度变化本身就是麻烦事。正确做法是用双指针把有效字符覆盖到数组前面,同时把多余空格用单空格代替。我用的模板是:
var reverseWords = function(s) { let arr = s.split(''); // 1. 移除多余空格(双指针覆盖法) let slow = 0; for (let fast = 0; fast < arr.length; fast++) { if (arr[fast] !== ' ') { if (slow !== 0) arr[slow++] = ' '; // 单词之间补一个空格 while (fast < arr.length && arr[fast] !== ' ') { arr[slow++] = arr[fast++]; } } } arr.length = slow; // 截断多余部分 // 2. 整体反转 reverseRange(arr, 0, arr.length - 1); // 3. 逐个单词反转 let start = 0; for (let i = 0; i <= arr.length; i++) { if (i === arr.length || arr[i] === ' ') { reverseRange(arr, start, i - 1); start = i + 1; } } return arr.join(''); }; function reverseRange(arr, left, right) { while (left < right) { let temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }这里面有个细节值得特别注意:移除空格时,if (slow !== 0) arr[slow++] = ' '这一行代码的作用是在每个新单词开始之前补一个空格。因为fast跳过连续空格时,slow指向的是上一个单词的结尾,如果这不是第一个单词,就需要补一个空格把单词隔开。这个逻辑我第一次写的时候完全没想到,结果处理"a good example"这种中间有多个空格的输入时,输出的单词全粘在一起了。
另外我踩过的坑:arr.length = slow在JavaScript里可以截断数组,但这个方法在LeetCode环境里有效,在浏览器控制台里也有效,只是有些人不习惯这个写法。如果你觉得这种写法太隐蔽,可以用arr.splice(slow)代替。核心思想一样:把超出slow的部分扔掉。
4.3 剑指Offer 58-II:左旋转字符串的两条路
题目:字符串"abcdefg"左旋2位得到"cdefgab"。左旋的概念就是把前n个字符移到字符串末尾。
这条题如果不用库函数,我能想到两条路:
第一条路是切片拼接,这属于思路验证,但面试时如果只说这个,大概率会被追问“还有没有更优解”。比如:
def reverseLeftWords(s, n): return s[n:] + s[:n]一行代码,但对训练来说这题白做了。
第二条路才是训练营想让你掌握的东西——局部反转加整体反转。具体操作是三步:
- 先反转前n个字符:"ab" -> "ba"
- 再反转后面的字符:"cdefg" -> "gfedc"
- 最后整体反转:"bagfedc" -> "cdefgab"
我直接给出JavaScript版本的代码:
var reverseLeftWords = function(s, n) { let arr = s.split(''); reverseRange(arr, 0, n - 1); reverseRange(arr, n, arr.length - 1); reverseRange(arr, 0, arr.length - 1); return arr.join(''); };这个套路在右旋转字符串里同样适用。右旋n位本质上是左旋 length - n 位,你只需要把前两次反转的区间换一下就行。所以我在训练营笔记里写了一句话作为总结:凡是“把某一段字符串搬到另一端”的题目,都可以拆成两次局部反转加一次整体反转。这个组合拳值得刻进DNA。
5. 实战踩坑实录:边界条件、语言差异和复杂度表达
5.1 边界条件速查表
字符串题目里的边界条件,比数组题更隐蔽,因为字符串天然存在“开头、结尾、空格、大小写”这些额外维度。我把这几道题最容易出错的边界情况整理成一张表,每次提交前扫一眼能省不少时间:
| 边界场景 | 容易出现的错误 | 正确做法 |
|---|---|---|
| 空字符串 "" | 直接调用s[0]报错或返回undefined | 先判断length是否为0 |
| 单字符字符串 "a" | 双指针left===right时还执行交换 | 用left < right当条件 |
| 541题里k大于字符串长度 | 剩余字符超过k但不足2k时反转逻辑混乱 | right = Math.min(i + k - 1, len - 1) 兜底 |
| 151题里字符串全是空格 " " | 去除空格后数组为空,反转后拼接出错 | 移除空格后先判断slow是否为0 |
| 151题里开头结尾都有空格 | split后出现空字符串 | 用覆盖法而不是split过滤 |
| 58-II题里n等于0 | 反转区间0到-1,直接报错 | 先判断n === 0直接返回原串 |
这些场景不是靠聪明就能避免的,只能靠多写多踩。我第一次写541的时候信心满满地提交,结果case里一个k大于字符串长度的测试就把我打败了。字符串长度是动态变化的,而你的反转区间是基于当前长度计算的,这俩必须时刻对齐。
5.2 不同语言的实际表现差异
同样是字符串反转题,C++写出来和Python写出来完全是两种面貌。C++的string支持下标修改,所以344题你可以直接在原串上swap;但如果你在Java里尝试类似操作,需要先把String转成char[],否则String的值根本变不了。这个差异必须提前确认,否则面试现场容易出现“你写的代码在自己电脑上能跑,面试官一运行就报错”的尴尬。
Python里还有个坑我必须提一下:切片 s[::-1] 看起来很万能,但一旦你的s是bytes类型或者bytearray类型,行为完全不同。而JavaScript里split('')会把多字节的Unicode字符拆成两个独立单元(例如emoji和某些中文生僻字),导致反转后乱码。这时候你得用Array.from(s)或者展开运算符[...s]才能正确处理码点。我在处理LeetCode 541这种纯英文题目时没踩过这个坑,但要是业务代码里处理用户昵称反转,这绝对是生产事故级别的bug。
5.3 面试时怎么讲复杂度
写完代码之后,面试官几乎必问一句“时间复杂度是多少”,字符串题里很多人会答错,因为忘了split和join也是O(n)。我见过有人自信地说我的代码是O(n),但仔细一看里面套了两个for循环加一个split,加起来其实是O(n)没问题,但是如果你每反转一次都调一次split,就会变成O(n²)。
正确的表达方式是:一次遍历统计空格是O(n),一次遍历填充是O(n),整体反转和局部反转加起来仍然是O(n)。为什么?因为所有操作都是线性扫描,没有嵌套循环和递归。空间复杂度要区分:原地修改是O(1),但如果用了split和join,语言层面的数组和字符串都会复制一份,严格来说是O(n)。面试时主动把这一点说出来,会显得你对底层实现有认知,而不只是会背模板。
6. 字符串题怎么刷才有效:刷题顺序与面试答题模板
6.1 五道题形成一个闭环
Day 8的part 01总共五道核心题:344反转字符串、541反转字符串II、剑指05替换空格、151翻转字符串里的单词、剑指58-II左旋转字符串。我按训练营的顺序刷完之后复盘,发现这五道题其实是一条完整的能力链路:344练双指针基本功,541练区间控制,剑指05练从后往前,151练三步反转组合拳,58-II练同一套组合拳的变形应用。链路练完之后,再遇到“右旋转字符串”“反转字符串中的元音字母”这类变体,基本看一眼就能拆解成熟悉的模式。
有一个相当实用的建议:先别用库函数刷完一轮,然后用库函数再刷一遍。第一轮手写双指针是为了理解核心逻辑,第二轮用库函数是为了知道在实际工程里怎么写更简洁。两道题各有价值,面试答手写版,工作写库函数版,两不误。
6.2 面试答题的标准流程
我总结了一套字符串题的面试答题模板,实测在多家公司面试里都能撑起至少十分钟的交流时间:
第一步,复述题目边界。开头就要确认空字符串、长度1、重复字符这些case预期是什么表现,这能让面试官觉得你经验老到。
第二步,先说暴力解,再引出优化。比如替换空格,你先说“最直接的做法是线性扫描并构建新串,时间复杂度O(n),但空间也是O(n)”,然后补充“如果要求原地,那就需要先统计空格数量,从后往前双指针填充”。暴力解不是用来写的,是用来铺垫的。
第三步,动手写代码前,口头描述一下方案。说出“先整体反转,再局部反转”这八个字,面试官基本就点头了。方案对了,代码对错的容错率反而高。
第四步,写代码时边写边解释指针含义。比如“slow是最终结果数组的写入指针,fast是原数组的扫描指针”,这种表达能体现你对变量的掌控力。
6.3 下一步学习建议
字符串part 01之后的part 02,训练营很可能就会上KMP算法了。LeetCode 28找出字符串中第一个匹配项的下标,就是KMP的典型代表。如果你part 01的数组、双指针、边界处理还不够熟,KMP学起来会非常痛苦,因为它不仅涉及字符串,还涉及前缀函数、next数组的构造和优化,是算法面试里公认的“劝退题”。所以part 01这五道题,我建议哪怕你已经会了,也至少再手写两遍,让自己形成不需要思考就能写好边界条件的条件反射。
最后分享一个我在实际刷题中养成的好习惯:每刷一道题,就在代码注释里写一句“如果限制条件改成XXX,我的解法哪里会崩”。这个习惯让我在面试里被追问变体时,总能快速说出“这里需要改动XX行代码”。字符串题尤其适合这种玩法,因为它的变体往往只差一个边界条件或者一个反转方向,而真正的算法框架纹丝不动。能做到这一点,Day 8就算真正毕业了。