先说说我对这三道题的整体感觉。很多朋友刷到字符串专题时,总觉得“反转字符串”这种题太简单,看一眼就会写,但真正上手跑测试用例、丢到面试官面前手写白板时,各种边界问题就全冒出来了。尤其是第541题那种“每2k个反转前k个”的规则,理解题意和写出正确的边界判断完全是两码事。而卡码网那道替换数字,表面上是个模拟题,实际上牵扯到字符串扩容、指针移动方向、不同语言的字符串不可变特性,可以说一道题就把“存储与性能”的底子给考透了。
这三道题放在训练营第八天,刚好是“字符串操作”从入门到进阶的完整过渡。今天我把自己的解题过程、踩过的坑、以及一些在题解里不太会写但实际很重要的经验,完整拆开揉碎讲一遍。
1. 先看清这三道题到底在考什么
1.1 字符串题目的底层逻辑:一切皆指针
很多人在刷字符串题时容易陷入一个误区:总想着怎么构造新字符串、怎么拼接、怎么用库函数。但实际上,字符串在底层就是一串连续存储的字符,所谓反转、替换、截取,本质上都是操作“指针在字符数组上的位置”。
莱文斯坦距离这种复杂问题先不谈,就这三道题而言,核心能力就三个:
- 你能不能在数组/字符数组上熟练地移动左右指针?
- 你能不能把题目里的区间规则翻译成边界条件?
- 你能不能意识到不同语言的字符串在内存里的差异,并写出兼顾正确性和效率的代码?
这三点其实就是算法基本功的具象化。LeetCode 344考的是第一点和双指针思想本身;541考的是第二点,边界和区间的拆分;卡码网54考的则是第三点,字符串的扩容和填充顺序。
1.2 三道题的结构关系:从单一操作到区间操作再到空间换时间
我建议不要把这三道题孤立地刷。它们之间有一条清晰的递进线:
344是全字符串整体反转,操作对象简单直接,用来建立“双指针相向移动”的肌肉记忆。541是在344的基础上加了“分段”规则,让同一种反转操作在多个区间内重复执行,本质上是“循环 + 区间定位 + 调用反转”。卡码网54则换了个方向,不再是反转,而是按字符类型做替换,关键从“指针移动”变成了“空间分配和从后往前的回填”。
如果只刷344不刷541,你对双指针的理解会停留在“单个区间”,很难适应“多个区间轮转”的节奏。如果不刷卡码网54,你可能永远不会认真思考“为什么字符串拼接在某些语言里很慢”这种工程问题。所以第八天这三道题,彼此之间的关系不是随机的,而是一条完整的进阶路径。
我自己的习惯是:每道题刷完,先不看题解,自己把“这道题如果换一种规则,代码哪里要改”想一遍。比如344改成“反转前k个字符”,双指针的起始和终止条件要怎么动?想清楚这个问题,541其实就解了一大半。
2. 反转字符串为什么要手写,双指针实操拆解
2.1 别急着用 reverse(),否则你什么都没练到
先说一个非常现实的问题:LeetCode 344这道题,Python直接s[::-1],Java用StringBuilder.reverse(),C++用std::reverse(),一行代码就能过。但如果你真这么干,这道题对你就完全失去了意义。
这就像你明明想练扎马步,结果直接躺平了。反转函数当然是在工程里最该用的东西,因为性能好、不出错。但作为算法练习,手写双指针反转的过程,目的不是“造一个更好的reverse”,而是让你建立那种在字符数组上精确控制索引的手感。这种手感在后面的翻转单词、反转链表、回文串判断里全都要用到。
这道题的正确打开方式,是用双指针从两端往中间走。
2.2 完整实现与奇偶长度的边界验证
我用C++写一个最标准的版本,等下逐行解释:
class Solution { public: void reverseString(vector<char>& s) { int left = 0; int right = s.size() - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } } };这里最关键的边界条件是left < right,而不是left <= right。你如果写过,应该知道当字符串长度是奇数时,用<=会导致中间那个字符和自己交换一次,白白浪费一次操作。虽然结果一样,但这不是好的循环条件设计。
我们可以验证一下:
- 长度为4的字符串,比如
[a, b, c, d],初始left=0,right=3,循环1交换a和d,left变1,right变2;循环2交换b和c,left变2,right变1,循环条件2 < 1不成立,结束。正好两对交换。 - 长度为5的字符串,比如
[a, b, c, d, e],初始left=0,right=4;交换a和e,left=1,right=3;交换b和d,left=2,right=2;此时2 < 2为false,循环结束,中间的c原地不动。
这看起来很简单,但很多人在现场一紧张,就把边界写成<=或者漏掉交换后的自增自减。还有一点:交换操作不要画蛇添足。有人喜欢用异或交换的写法:
s[left] ^= s[right]; s[right] ^= s[left]; s[left] ^= s[right];我劝你在算法笔试和面试里老老实实用临时变量。异或交换在字符数组上虽然能跑通,但它有三个问题:一是可读性差,面试官看得费劲;二是如果left和right指向同一个位置,异或会直接把字符变成0,虽然后续会恢复,但中间状态难以调试;三是在某些语言里整型提升还会引入额外开销。临时变量是最笨但最稳的方案。
2.3 现场常见的两个问题
我当时在练这道题时,自己犯过两个错误,也看别人犯过。
第一个:把right = s.size() - 1写成了right = s.size()。这时候循环第一次交换,就会访问越界,在C++里直接ub,在Java/Python里会抛异常。这类错误一旦发生,在LeetCode上就是妥妥的数组越界报错,排查起来其实很简单,看报错位置定位到swap那行,再一看right的值,立刻就能反应过来。
第二个:忘记在交换后更新指针。这个错误特别隐蔽,因为如果循环体里没有自增自减,对于长度大于2的字符串,你会发现交换只在最外层发生了一次,然后left和right永远不变,死循环或者只交换了首尾。这种“错也错得不明显”的情况最可怕,因为单测结果有时恰好只检查了头尾变成了尾头,看不出问题。
我的建议是:刷这题的时候,别光在脑子里过,拿张纸写下每一轮left和right的变化,至少做两组用例,一组偶数长度一组奇数长度。整个过程五分钟以内,但能帮你彻底固化这个循环的边界理解。
3. LeetCode 541:反转字符串 II 的区间边界功夫
3.1 题目规则的翻译:永远从 2k 的倍数位置开始
541题的原意是:给定字符串s和整数k,从字符串开头算起,每计数至2k个字符,就反转这2k个字符中的前k个字符。如果剩余字符少于k个,则反转剩余的所有字符;如果剩余字符大于或等于k个但小于2k个,则反转前k个字符,其余保持原样。
很多人在第一次读题时会被“剩余字符少于k个”“大于等于k但小于2k”这两句话绕晕。我翻译成人话就是:从左到右以2k为一个大块来推进,每个大块只看前k个,剩余段就根据长度决定反转全部还是只反转前k个。
从这里就可以得到一个非常重要的经验:这类分段问题的起点不是0,不是k,而是0、2k、4k、6k...这样的等差数列。换句话说,循环变量应该按i += 2 * k来走,而不是i++。这一步想通了,整个题的主体框架就出来了。
3.2 区间判断的三种写法与推荐
先看我直接写的版本:
class Solution { public: string reverseStr(string s, int k) { for (int start = 0; start < s.size(); start += 2 * k) { int left = start; int right = min(start + k - 1, (int)s.size() - 1); while (left < right) { swap(s[left], s[right]); left++; right--; } } return s; } };这个写法之所以简洁,核心就一行:right = min(start + k - 1, s.size() - 1)。
它的含义是:当前区块需要反转的右边界,要么是前k个字符的末尾(即start+k-1),要么是整个字符串的末尾(如果剩余字符不满足k个)。min直接帮我们处理了“剩余字符少于k个”的情况,不需要在循环里写额外的if分支。
很多人第一次会写成:
if (剩余字符 >= k) 反转从start开始的k个; else 反转从start到末尾的所有;这种写法当然也对,但代码量会变多,而且容易在判断“剩余字符”时自己把自己绕晕。比如计算剩余字符数到底是s.size() - start还是s.size() - start + 1,一不小心就出现差一错误。
我知道有些“纯手写党”会觉得用min是取巧,认为应该把所有情况都显式写出来。我的看法是:刷题时用什么技巧都不算取巧,只要能保证正确性和可读性,min的语义在“区间取交集”这个场景下是极其清晰的,面试时你只要解释一句“用min保证右边界不会越界”,面试官完全能理解。
3.3 边界条件实测:k=1 和剩余长度等于 k
这道题最值的测试的边界条件有两个。
第一个是k=1。当k=1时,每隔2个字符反转前1个字符,反转长度为1的反转等于没反转。所以任意输入字符串,结果应该和原字符串完全一样。拿这个用例去验证代码,能立刻暴露循环起点对不对、反转区间长度有没有越界。
第二个是剩余长度恰好等于k。比如s长度为7,k=3,那么处理完0到5这一块(反转0,1,2)之后,start走到6,此时剩余字符只有1个,start+k-1=8,min(8, 6)=6,left=6,right=6,while条件不成立,相当于剩余那1个字符不反转。这个结果符合题意吗?我们来算:前面6个字符是一整块2k,反转前k个即0、1、2;剩余1个字符小于k,按规则应该反转剩余的所有字符,也就是反转这1个字符自己,反转完还是它自己。所以结果上是一致的。
再看一个情况:s长度为8,k=3,处理完0到5后,start=6,剩余字符为s[6]和s[7]两个,小于k,按规则应该反转这两个字符。此时right = min(6+3-1, 7) = min(8, 7) = 7,反转left=6,right=7,交换。结果正确。
这两个用例跑完,基本可以确定边界逻辑是健壮的。我个人还喜欢再测一个:s长度等于k的情况。比如s长度为4,k=4,那么start=0时,right=min(0+4-1, 3)=3,整串反转。符合规则。
这道题除了核心逻辑外,还有一个隐性的考点:你能不能在面试时快速说清“为什么循环步长是2k,而不是先步长k处理反转再步长k处理跳过”。这背后是对“区间块”概念的清晰度。如果步长用k,那你必须额外维护一个“是否反转当前块”的布尔状态,代码立刻复杂一倍。步长为2k的本质是把“反转块”和“跳过块”打包成一个处理单元,这也是这类周期性问题的最佳实践思路。
4. 卡码网54:替换数字的扩容与逆向填充
4.1 不同语言的字符串差异
卡码网这一题的要求很经典:给定一个字符串s,将其中每个数字字符替换成"number",然后输出结果。比如"a1b2c3",替换后就是"anumberbnumbercnumber"。
这种题在不同语言里写起来差异巨大。在Java里约定俗成用StringBuilder拼接;在Python里可以先构造列表再join;但C++和Go这类接近底层的语言里,就有一个绕不开的问题:字符串在内存里是固定长度的,把单个数字字符替换成6个字符("number"),必然涉及到原来的存储空间不够。
如果你用C++,最直观的写法可能是:
string result; for (char c : s) { if (isdigit(c)) result += "number"; else result += c; }这个写法很安全,也完全正确。但这道题之所以被很多训练营选入,恰恰是因为它想让你思考一个更高效、更能体现“指针控制”的方案:先统计数字个数,把字符串扩容,然后从后往前填充。
4.2 先扩容再逆向填充的原因
为什么要先扩容、再从后往前填?很多人第一次不太理解,觉得正着遍历一个个替换不就行了吗?问题在于:从前往后替换数字字符,会导致后续字符整体向右移动,这个移动如果原地做,每替换一个数字就要把后面的字符全部搬一次,最坏情况下复杂度变成O(n²)。
举个例子:s="a1b1c1d1e1",如果你遇到第一个'1',把它变成6个字符"number",那"b1c1d1e1"这8个字符全部要往后移5格,接着遇到第二个'1',又要移动6个字符……数字越多,移动的总量越大。这种“频繁搬移”操作在很多语言里还伴随着频繁的内存拷贝,性能很差。
所以正确做法是:先数一遍有几个数字,就知道最终字符串要多长,直接resize到目标长度,然后从末尾开始倒着填。为什么要倒着填?因为从后往前时,当前位置之后的区域不会被覆盖,你只需要把字符放到正确的新位置上,不需要担心“后面的字符还没搬就被覆盖”的问题。
这就好比搬家时如果从新家的客厅开始一件一件往里塞,很容易把还没进来的家具挤坏;但如果先把大件家具从最里面的卧室放起,后面的空间自然不会被挤压到。倒着填的每一步都是在已经确定不会再变的位置上操作。
4.3 双指针从后往前的完整流程
C++参考实现:
#include <iostream> #include <string> using namespace std; int main() { string s; cin >> s; int count = 0; for (char c : s) { if (c >= '0' && c <= '9') count++; } int oldLen = s.size(); s.resize(oldLen + count * 5); // 每个数字替换成6个字符,多出5个 int newLen = s.size(); for (int i = oldLen - 1, j = newLen - 1; i >= 0; i--) { if (s[i] >= '0' && s[i] <= '9') { s[j--] = 'r'; s[j--] = 'e'; s[j--] = 'b'; s[j--] = 'm'; s[j--] = 'u'; s[j--] = 'n'; } else { s[j--] = s[i]; } } cout << s << endl; return 0; }这段代码的要点有几个。
第一,统计数字个数的循环要写在resize之前,因为你去重线性扫一遍字符串,数字个数决定扩容大小。别想着边扫描边resize,那会让索引计算变得混乱。
第二,resize的长度 = 原长度 + 数字个数×5。为什么是5不是6?因为原来的位置已经占了一个字符,替换成"number"一共6个字符,需要在原基础上再多占5个位置。很多人在这一步容易算错,直接写了count×6,结果多出一大截空位。
第三,倒着填的时候,j从新串末尾开始,i从旧串末尾开始。遇到数字时,填"number"的每个字符,注意从最后一个字母往前写,所以代码里顺序是'r','e','b','m','u','n',刚好在赋值时拼出"number"。一开始容易顺手写反,结果拼成"rebmun"这种倒序字符串,我当时就踩过一次,排查了半天才意识到是写入顺序反了。
第四,for循环的终止条件是i >= 0。这里要注意删除字符和整型变量的坑。i是int,s.size()返回size_t,一旦把i声明成size_t,i >= 0永远为true,循环永远不会正常结束,因为无符号整型减到0后再减1会回绕到最大值。所以for循环里的i和j一定要用int。这是C++新手最容易踩的深坑,我用过太多时间在这上面,必须单独拎出来说。
再提一句Python版本,如果你用Python,原地修改字符串是不可能的,因为Python字符串不可变。但你依然可以用类似思路:先统计数字个数,然后用list保存字符,从后往前填充。思路是一样的,只是在Python里需要先list(s)转换一下,最后用''.join()回来。殊途同归,核心仍然是“先扩容定位,再逆向填充”。
5. 面试与工程实践中的避坑指南
5.1 这几道题在面试中的问法
面试出现这三道题时,通常不是给你一段代码让你跑,而是边写边讲思路。你必须能说清楚几个点。
344题会被追问:双指针反转的时间复杂度和空间复杂度?答案是O(n)时间和O(1)额外空间,因为所有操作都在原数组上。有人会答错成O(n)空间,理由是用了临时变量tmp,这其实不叫额外空间,它只是常数级。这个“常数级额外空间”的概念在算法题里很重要。
541题会被追问:为什么步长是2k?以及能不能用递归实现?我的建议是,不要说“我用递归也行”,因为这道题用递归写既不自然又容易超栈,面试官问这个大概率是考察你能不能坚持用最简单的循环表达逻辑。
卡码网54会被追问:为什么选择从后往前填充?这里你最好能主动把“从前往后会导致后续字符反复搬移”的复杂度劣势讲清楚,这会比单纯背诵答案好很多。
5.2 写题时的检查清单
我刷完这三道题后给自己总结了一个检查清单,每次写字符串操作题都会过一遍:
- 循环变量的类型有没有用int,避免无符号整型在边界判断时的回绕问题?
- 边界条件用的是
<还是<=,对应的语义对不对? - resize新长度有没有计算成“多出5个”,而不是“总共6个”?
- 从后往前填充时,旧索引和新索引的初始值是不是旧长度-1和新长度-1?
- 有没有先跑一组边界用例(空串、单个字符、全是数字、没有数字)?
这个清单看着琐碎,但每次写完代码后跑一遍,能拦下90%的低级错误。尤其在做LeetCode时你不会因为“交卷”这个动作就掉以轻心,因为单个错误用例往往就是差在边界上。
5.3 从题目到工程的延伸
这三道题刷完以后,我建议你顺手做一个延伸练习:准备一段文本,统计其中字母和数字的数量,然后写一个函数,把数字统一替换成占位符“N”。这个需求几乎每天都在真实项目中出现,比如日志脱敏、用户输入过滤、配置模板渲染。你会发现“先统计再扩容再逆向填充”这套思路,在那些需要高性能字符串处理的场景里,比一遍遍的replace调用要可靠得多。
另外一个延伸是:试着把541的反转逻辑封装成“按步长反转任意区间”的函数,参数化区间长度。这可以帮你形成一个通用的字符串区间操作模板,后面遇到类似“每n个字符做m操作”的题目,都能直接往这个模板上套。
6. 最后再分享几个实际刷题中的体会
这三道题本身都不难,但我每次带人到训练营这一站时都会强调一句话:字符串题的难点从来不在“会不会翻”,而在“边界能不能卡准”。你写一百个反转字符串的正确版本,不如亲自跑一遍长度等于0、等于1、等于k、等于2k、等于2k+1这五种用例,把循环变量的每一步变化都看明白。
我个人的建议是,如果你刚开始刷题,不用急着一天内把这三道题的所有语言版本都撸一遍,先选一门你面试最可能用的语言,把原理弄透。我用C++做完了这三道题,之后用Python复现的时候几乎没有任何障碍,因为指针操作、区间思想、扩容思路这些都是一通百通的。
再提醒一个地方:别在循环体里同时修改旧索引和数组本身的size。有时候你想省事,在循环里resize、在循环里动态追加,这样写出来的代码往往bug百出,而且极难调试。老老实实分三步,统计、扩容、填充,每一步只做一件事。
这些题目刷完,你对双指针、区间划分和字符串底层存储这三块的理解,就基本能满足面试里的字符串基础题了。后面无论遇到翻转单词、最长回文子串、实现strStr这类进阶题,今天这些基本功都会派上用场。