如果你问我算法训练营第八天有什么特别的,我的回答是:这一天的三道题单独拿出来都不算难,但合在一起却把字符串处理里最重要的三种思维全串起来了。代码随想录把344.反转字符串、541.反转字符串II和替换数字安排在同一天,不是随便排的。前者是最基础的双指针应用,中间是双指针加上区间划分的进阶,后者则是字符串扩容与倒序填充的经典模板。很多朋友刷到这一天会觉得“就这?”——字符串反转谁不会?但真正动手写,能一次通过的人并没有想象中那么多。
- 这天的核心价值不在于题目本身,而在于帮你建立三种可复用的思维模型:相向双指针、边界条件分析、从后向前覆盖。
- 这三种模型在后面刷题时会反复遇到,比如反转字符串里的单词、移动零、链表反转,甚至做后端开发时处理大字符串替换,都能用上。
- 这篇文章我不只讲题解,还会把每道题的易错点、为什么这样写、以及面试官真正想考察什么,全部拆开说清楚。
如果你是跟着训练营节奏走的新手,建议把这天的三道题当作“字符串基本功考核”来对待;如果你是有经验的开发者,也可以重点看看替换数字那道题的倒序填充思路,它在写高性能字符串处理代码时非常实用。
1. 思路拆解:为什么这三道题安排在同一天
1.1 从题目难度看学习路径
先看这三道题的定位。344.反转字符串是一道LeetCode简单题,要求原地修改字符数组,不能开辟额外空间。541.反转字符串II是简单到中等之间的题,在反转的基础上增加了“每2k个字符反转前k个”的规则。替换数字则是一道典型的笔试风格题目,要求把字符串里的每个数字字符替换成“number”。
这个难度梯度是有意设计的。如果你第一天就上手替换数字那种需要预先统计、扩容、倒序填充的题,很容易被绕晕。但先做344,你掌握了双指针交换的基本功;再做541,你学会了区间划分和边界判断;最后做替换数字,你已经有能力理解“为什么要从后往前填充”。这就是代码随想录训练营安排题目的逻辑——每一道题都在给下一道题铺路。
很多人在541这道题上翻车,不是因为反转逻辑复杂,而是因为边界条件处理不熟练。这也是这一天训练的真正目的:让你习惯在写循环之前,先把所有边界情况列出来,而不是写完之后靠调试去猜。
1.2 每道题背后的核心思维
我把这三道题对应到三种底层思维,后面刷题你会不断遇到它们:
- 双指针思维:用一个左指针和一个右指针从两端向中间逼近,或者用两个指针一前一后同步移动。344用到的是相向双指针,替换数字用到的是同向双指针。
- 区间划分思维:把一个大问题按规则划分成若干小段,对每段做独立处理。541的核心不是“怎么反转”,而是“怎么正确地切分区间”。
- 扩容与倒序填充思维:当字符串长度会发生变化时,先扩容到最终大小,再从末尾向前填充,避免反复插入元素导致移动大量数据。
这三种思维都不是只服务这一天的题目。你后面做“反转字符串中的单词”时,会发现它是“整体反转+局部反转”的组合拳,正是344和541思路的叠加;做数组相关的题目时,双指针更是出场率最高的技巧之一。所以这一天看起来是三道字符串题,实际上是在给你后面所有需要处理“区间”和“指针”的题目打底。
2. 344.反转字符串:双指针思想的第一次正式登场
2.1 题目还原与最直观的暴力思路
题目要求很简单:输入一个字符数组,比如['h','e','l','l','o'],要求原地反转成['o','l','l','e','h'],并且不能使用额外的数组空间。
刚接触这道题的人第一反应往往是新开一个数组,从后往前遍历填入。这个方案在工程实现上完全没问题,但题目明确限制了空间复杂度为O(1),所以必须原地修改。所谓原地,就是只允许在输入数组上操作,最多使用几个临时变量。
另一个朴素思路是:通过reverse(s.begin(), s.end())一行搞定。很多新手觉得这是最聪明的做法,但在训练营里这样做等于白做。因为这道题考察的就是你知不知道reverse底层是怎么实现的。如果让你把它当作面试手撕题,面试官下一句就会问:“那你不用库函数,手写一个反转给我看看。”所以手写双指针是绕不开的。
2.2 双指针解法与三种交换方式的取舍
双指针的思路非常直观:左边一个指针指向数组开头,右边一个指针指向数组末尾,交换两个位置的字符,然后左指针右移、右指针左移,直到两个指针相遇或者错过去。
class Solution { public: void reverseString(vector<char>& s) { int left = 0; int right = s.size() - 1; while (left < right) { // 交换 left 和 right 指向的字符 char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } } };这里最核心的一段就是交换。我见过不少人在交换上出问题,所以展开说一下。
- 最稳妥的写法是用临时变量,也就是上面代码里的
tmp。这种方式在任何语言里都是通用的,不会出错。 - 有人为了炫技,用加减法交换两个整数变量:
a = a + b; b = a - b; a = a - b;。这在字符数组上虽然可行,但一旦数据量很大,存在整数溢出的风险。 - 还有人用异或交换:
a ^= b; b ^= a; a ^= b;。这种方式看起来很简洁,但可读性差,面试时容易解释不清,而且对新手来说自己写着写着就容易绕晕。
我的建议是:面试和刷题用临时变量就够了。不要为了“看起来高级”去用异或或者加减法,代码是给人读的,可读性和正确性永远排在第一位。这道题的时间复杂度是O(n),空间复杂度是O(1),因为只用了常数个临时变量。
2.3 库函数到底能不能用,关键看考察点
关于库函数的使用,代码随想录里有一个非常经典的原则:如果题目考察的是某个API的底层原理,你就不能直接用这个API;如果题目本身和库函数无关,那用库函数反而能提升效率。
用reverse反转字符串就属于第一种情况。面试官出这道题时,想知道你懂不懂双指针和原地修改,而不是想知道你知不知道有reverse这个函数。所以一旦你在代码里调用了reverse,这道题就失去了考察意义。
但反过来,如果你做的是业务相关的开发,比如处理一段文本,需要反转某个范围内的字符,直接用std::reverse完全没问题。工程师的时间和性能都很宝贵,没必要自己造轮子。核心判据只有一个:这道题考不考这个函数的底层实现。考,就手写;不考,就放心用。
在Java和Python里也有类似的对应操作。Java的Collections.reverse、Python的切片s[::-1]或者reversed(),在做项目时都很方便,但刷这道题时同样要手写双指针。我见过太多人在面试时习惯性调用库函数,然后被面试官追问实现原理,最后答不上来,非常可惜。
3. 541.反转字符串II:边界处理才是隐藏考点
3.1 题意中的“每2k个”到底怎么理解
541的题目描述有点绕:给定一个字符串s和一个整数k,从字符串开头算起,每计数至2k个字符,就反转这2k个字符中的前k个字符。
我用大白话翻译一下:
- 把字符串按2k个一组来划分。
- 对于每一组,只反转前k个字符,后面k个保持原样。
- 如果最后一组不足k个字符,那么把这不足k个的全部反转。
- 如果最后一组大于等于k个但不足2k个,那么只反转前k个,剩下的不动。
看一个官方示例,s = "abcdefg", k = 2。此时2k等于4,所以第一组是abcd,反转前2个字符ab变成ba,得到bacd;此时已经处理到下标4。剩余字符串是efg,长度是3,大于等于k且小于2k,所以反转前2个字符ef变成fe,最终得到bacdfeg。
这个题最迷惑人的一点是:它并不是“每2k个反转一次整个区间”,而是“每2k个只反转前k个”。很多人在这一步理解错了,后面代码怎么写都不对。
3.2 一种简洁的循环写法
有了上面的理解,代码其实很短。关键是怎么写出清晰、不容易出错的循环。我比较推荐这种写法:
class Solution { public: string reverseStr(string s, int k) { int n = s.size(); for (int i = 0; i < n; i += 2 * k) { // 剩余字符大于等于k个,反转前k个 if (i + k <= n) { reverse(s.begin() + i, s.begin() + i + k); } else { // 剩余字符不足k个,全部反转 reverse(s.begin() + i, s.end()); } } return s; } };这个写法里,循环变量i每次增加2*k,天然地把字符串切成了若干个段。每一轮进入循环时,i都是当前段开始的下标。然后检查i + k是否还在字符串长度范围内:如果在,说明当前段至少有k个剩余字符,反转[i, i+k);如果不在,说明不足k个,直接把[i, end)全部反转。
这里有个容易踩的细节:判断条件是i + k <= n,而不是i + k < n。因为当i + k == n时,正好剩k个字符,按照题目规则也应该反转前k个,所以边界情况要包含等号。我见过不少人是这里写错,导致字符串长度刚好是k的整数倍时结果不对。
3.3 边界条件的完整推导与实际模拟
为了让你真正理解边界,我手动模拟两个极端场景。
场景一:s = "abcd", k = 2。n等于4,第一轮i = 0,i + k = 2 <= 4,所以反转[0, 2),也就是ab变成ba,得到bacd。第二轮i = 4,循环条件i < n不成立,退出。最终结果是bacd。这里注意,虽然字符串正好可以组成一组2k,但只反转前k个,后面两个字符不参与反转。
场景二:s = "abcd", k = 5。n等于4,第一轮i = 0,i + k = 5 > 4,进入else分支,把整个字符串反转,得到dcba。这对应“剩余字符不足k个全部反转”的规则。
如果手写一个通用版本,不用C++的reverse而是自己实现区间反转,核心思想是一样的:左指针从区间起点开始,右指针从区间终点前一个位置开始,相向交换,直到相遇。把区间反转封装成一个独立函数reverseRange(s, start, end)会让代码更清晰,面试时也更容易和面试官讨论。
这个题的另一个常见解法是先把整个字符串切分成数组,再用循环处理每组数据。但我觉得那种写法比较绕,因为切分本身要处理边界,不如直接在原字符串上通过下标操作来得简洁。代码随想录里Carl老师也强调过:能用下标原地操作,就不要先切分,切分操作往往带来额外的空间开销和逻辑复杂度。
4. 替换数字:从后向前填充的经典模板
4.1 最朴素的解法为什么不行
替换数字这道题,题目要求是:给定一个字符串,把里面所有的数字字符(0到9)替换成“number”。比如输入"a1b2c3",输出"anumberbnumbercnumber"。
先说为什么朴素解法不行。很多人第一次看到这道题,第一反应是用一个空字符串遍历原字符串,遇到数字就拼接"number",遇到字母就直接拼接。这种写法没有错,结果也是对的。但这道题如果放在面试里,通常会有一个附加要求:在原有字符串上操作,且不开辟新的字符串空间。
如果你使用类似string newStr或者StringBuilder来拼接,空间复杂度就是O(n),这在一般工程开发中完全没问题。但面试官考察的其实是你能不能做到O(1)额外空间,也就是原字符串上原地修改。这时候就有个难题:每替换一个数字,字符串就会变长,直接在原字符串中间插入字母,会导致后续所有字符整体后移,时间复杂度会退化成O(n²)。
如果你用过C语言操作字符数组,就会深有体会:字符串在内存里是一段连续的空间,中间插入内容是很麻烦的。而C++的string虽然提供了insert接口,但它在内部也是通过移动元素来实现的。所以这道题的正确思路,是先扩容,再从后往前填充,压根不在中间插。
4.2 统计、扩容、从后往前填充三步走
正确解法分三步。
第一步,先遍历一遍原字符串,统计数字字符的个数。因为我们要预判最终字符串的长度。每个数字字符会被替换成6个字符"number",而它原来占1个字符,所以每个数字会让字符串额外增加5个字符。
第二步,把字符串扩容到最终长度。C++里可以调用resize方法。
第三步,也是最核心的一步,从后往前填充。用一个指针j指向原字符串的末尾,一个指针i指向扩容后字符串的末尾,然后从后往前遍历。如果当前字符是数字,就把"number"逆序填入i指向的位置及之前的5个位置;如果是字母,就直接复制到i指向的位置。
可能有人会问:为什么必须从后往前,不能从前往后?因为从前往后填充时,一旦遇到数字并插入"number",后面的所有字符都要整体后移,产生了大量重复移动。而从后往前填充时,每个字符只需要被复制一次,时间复杂度是O(n)。这个思路跟“数组原地删除元素时从后往前移动”是一个道理。
4.3 完整代码与复杂度对比
下面给出一个完整的C++实现。这段代码以标准输入输出示例为模板,方便你直接本地运行验证。
#include <iostream> #include <string> using namespace std; int main() { string s; cin >> s; int oldSize = s.size(); int count = 0; // 第一步:统计数字个数 for (char c : s) { if (c >= '0' && c <= '9') { count++; } } // 第二步:扩容,每个数字多出5个位置 s.resize(oldSize + 5 * count); // 第三步:从后往前填充 int i = s.size() - 1; int j = oldSize - 1; while (j >= 0) { if (s[j] >= '0' && s[j] <= '9') { s[i] = 'r'; s[i - 1] = 'e'; s[i - 2] = 'b'; s[i - 3] = 'm'; s[i - 4] = 'u'; s[i - 5] = 'n'; i -= 6; } else { s[i] = s[j]; i--; } j--; } cout << s << endl; return 0; }我手动验证一下:输入"a1b2c3",统计出3个数字,扩容后长度从6变成21。然后倒序遍历,从末尾开始,遇到3填入"number",遇到c直接复制,遇到2填"number",遇到b直接复制,遇到1填"number",遇到a直接复制。最终结果是"anumberbnumbercnumber",完全正确。
如果换成正序遍历加insert的写法,每次插入的时间复杂度是O(n),n个数字就是O(n²)。而倒序填充只有O(n)。这种优化在日常开发里意义很大,尤其是处理大文本或者日志字符串时,性能差距会非常明显。
代码随想录里还提到过这道题的原型是“替换空格”,把空格替换成%20。思路完全一样,只是每个空格额外增加2个字符。我建议你把今天替换数字的做法记成模板,下次遇到类似“替换”类题目,直接套三步走:统计变化量、扩容、倒序填充。
5. 高频易错点与排查实录
5.1 344反转字符串的易错点
344这道题看起来简单,实际有三个容易被批评的地方。
第一个是循环条件写成left <= right。当字符串长度为偶数时,写成小于等于会导致左右指针在中间位置交错之后再交换一次,把已经交换好的字符又换回来,最终结果等于没有反转。所以正确的写法永远是left < right。
第二个是忘记更新指针,导致死循环。有些新手交换完两个字符后没有写left++和right--,于是程序在同一个位置反复交换,永远跳不出循环。这段代码虽然短,但笔试时越简单的题越容易被粗心毁掉。
第三个是为了炫技使用异或交换。我之前说过,异或交换在数组整数场景下有几率出现隐藏问题,字符数组本身也有可读性问题。面试时你写一长串异或,面试官第一反应不是觉得你厉害,而是觉得你在秀操作、容易埋坑。老老实实用临时变量才是正解。
5.2 541反转字符串II的易错点
541的易错点主要集中在区间边界判断。
最经典的一个错误是循环条件写成了for (int i = 0; i < s.size(); i++),然后每轮用i % (2 * k)去判断当前是否到达反转点。这种写法虽然也能做出来,但代码会复杂很多,而且很容易在i恰好是k的倍数或者2k的倍数时出错。
另一个常见的错误是判断剩余字符是否大于等于k时,漏掉了等号。比如写成if (i + k < n),那么当i + k == n时,等于说剩余正好k个字符,按照规则应该反转,但你的代码走到了else分支,把整个剩余部分反转了。如果剩余部分恰好就是k个,那结果一样;但如果剩余部分小于k,结果就是错的。这种边界错误在测试用例里非常难发现,因为大部分常规输入不会卡在这个点上。
5.3 替换数字的易错点
替换数字这道题的易错点稍微多一些。
第一,扩容大小容易算错。每个数字变成6个字符,原先是1个,所以多5个位置,不是多6个。我见过有人resize(oldSize + 6 * count),结果多了一个空位,导致最后输出时多出一个空格或者乱码。
第二,填充时忘了i -= 6。倒序填入"number"后,必须把i往回移动6个位置,才能保证下一次写入覆盖到正确的位置。如果忘记减,后面写入的内容会覆盖掉刚写的"number"。
第三,判断数字字符时,要写成s[j] >= '0' && s[j] <= '9',不能直接写s[j] >= 0 && s[j] <= 9。因为字符数组里存的是字符,要和字符的字面值比较。这是新手最常见的错误。
第四,如果输入字符串里有其他非数字也非字母的情况,比如符号、空格,要提前想清楚是否需要处理。这道题通常只考字母和数字,但面试时可别自己给自己加戏。
5.4 这几个考点的面试追问清单
最后整理一份面试官经常追问的问题,你可以拿来自测:
- 344题:你为什么要用双指针?空间复杂度是多少?如果不允许用额外数组,你能做到吗?
- 541题:如果k大于字符串长度,你的代码会怎么执行?如果k等于0呢,会不会死循环?
- 替换数字:为什么不能用正序插入?
insert的时间复杂度是多少?如果要求不能用resize,你还有别的扩容方案吗?
这些问题并不难,但能当场答清楚的候选人,说明他是真的理解了代码背后的逻辑,而不是背答案。我强烈建议你在刷完当天的题之后,闭上眼睛把每道题的思路和代码流程口述一遍。能说清楚,才是真的会了。
6. 训练营第八天的沉淀与后续延伸
6.1 三道题的内在逻辑串联
这三道题表面上是三个独立题目,实际上是一条完整的训练链条。
344让你学会用双指针原地修改字符数组的顺序。541在此基础上加入了“分段处理”规则,让你学会在循环里灵活控制步长,并且正确判断每一段的边界。替换数字则更进一步,让你在面对字符串长度动态变化时,用统计加扩容加倒序填充的思路,把复杂度从O(n²)降到O(n)。
如果你把这三道题融会贯通,你就会发现一个普遍的规律:只要涉及到字符串的区间操作,先画坐标、列边界、再写循环,基本不会出错。这个习惯比背任何模板都重要。
6.2 接下来你可以继续挑战的题目
如果第八天的题你已经完全掌握了,我建议你马上去做以下几道题,检验一下今天学到的东西:
- 剑指Offer 05:替换空格。和替换数字几乎一样,把空格替换成
%20,正好练习同一个模板。 - 151.反转字符串中的单词。这道题是今天的终极应用:先整体反转整个字符串,再逐个单词反转,组合了344和541的思路。
- 27.移除元素。数组版的双指针移动问题,和替换数字的倒序填充思路有异曲同工之妙。
我个人在实际操作中的体会是,刷题最忌讳的是“看过题解觉得自己会了”。代码随想录训练营之所以强调“卡码网”上去重复提交,就是让你逼自己脱离题解把代码写出来。以第八天的三道题为例,你哪怕只看懂了解析,亲手写一遍也很可能会在541的等号、替换数字的扩容大小上卡住。卡住是好事,卡住说明你找到了自己的知识盲区。
最后再分享一个小技巧:每天刷完题之后,花十分钟用纸笔画一下这三道题的数据变化过程。画的时候不要偷懒,把每一步交换、每一个指针位置都标出来。把图画顺了,代码自然就写得顺。这个习惯我保持了很长时间,对复杂边界问题的理解帮助非常大。