字符串刷题核心:双指针反转与原地替换的边界艺术
2026/9/17 5:34:56 网站建设 项目流程

字符串处理是算法刷题里绕不开的基础盘,代码随想录训练营第七天这个节点刚好从数组、链表过渡到字符串模块。这天安排的三道题——344.反转字符串、541.反转字符串 II、54.替换数字——难度都不算高,但考察的东西非常核心:双指针怎么用、边界条件怎么卡、字符和数字怎么互相转换、原地修改和额外开空间之间怎么权衡。我特意把这三题放在一起复盘了一遍,发现它们的解法背后其实是同一个思路体系,理解了这套东西,后面刷字符串相关的题会顺畅很多。

这篇文章就按我实际做题的顺序来写:先说这三道题为什么会放在同一天,再分别拆解每道题的解题思路、代码实现和细节陷阱,最后整理一些我踩过的坑和总结出来的刷题习惯。不管你是在跟训练营、自己刷LeetCode,还是准备面试,这天的题都值得拿出一点时间好好过一遍。

1. 三题串讲:为什么这几道字符串题值得认真刷

1.1 从训练营节奏看字符串模块的定位

代码随想录的训练营安排是有讲究的,前六天刚把数组、链表这类基础结构啃完,第七天就切入字符串。这个节奏踩得很准,因为字符串本质上就是一串字符数组,很多在数组上练过的技巧——双指针、原地操作、区间切割——换个皮就到了字符串上。这一天从反转字符串起步,表面上是三道零散题目,实际上是在帮我们建立“字符串也是一种有序结构”的直觉。

很多人会觉得“反转字符串”这种题目太简单,LeetCode 344甚至可以直接调库函数 reverse() 一行搞定,新人容易产生“这题没营养”的错觉。但如果你去面试或者参加笔试,就会发现反转类的题目是最高频的变形题源:反转句子、反转区间、按块反转……万变不离其宗,本质都是在操作数组下标。所以我建议不要因为题简单就跳过,反而应该静下心把思路捋清楚。

这三题的递进关系也很明显:344是全局反转,一个双指针走到底;541是局部反转,多了一个“每隔2k个字符”的分组逻辑;54则是遍历字符的同时做替换,把数字识别和字符串扩容这两个难点糅在了一起。由浅入深,一环扣一环。

1.2 三道题的内在逻辑:双指针、区间边界与字符处理

如果只看题面,这三道题各自独立,但做题时你会发现它们的解题工具高度重叠。344的核心是双指针从两端向中间夹逼,541虽然多了分组,但分组内用的仍然是双指针去做子串反转,54则是在遍历过程中判断字符类型并处理输出。三题综合下来,其实覆盖了字符串题的三类基本功:指针移动、区间控制、字符判断与转换。

另一个隐藏重点是“原地修改”。344明确要求不额外分配空间,541的库函数版虽然可以马上过,但核心逻辑仍然是片段的原地反转,54在不同语言下的解法差异最大——C++里讲究扩容后从后往前填充,Java里则更常用 StringBuilder。这些细节不是单纯刷题能感受到的,需要亲手写出每一版解法并对比,才能真正理解“为什么这么写”而不只是“能过就行”。

所以我建议把这天当作一个“字符串基本功检验日”:如果你能用至少两种语言把这三题写对,并且能说清楚每个边界条件是为什么,那么恭喜你,字符串模块的地基已经打得很稳了。

2. 344. 反转字符串:双指针的老祖宗题

2.1 题目要求和最容易想到的思路

344题面非常短:给你一个字符数组 s,要求原地反转,不能申请额外空间。所谓“原地”就是不要新建一个数组再倒序填回去,必须在原数组上通过交换元素实现。这个限制很关键,直接过滤掉了最朴素也最浪费空间的解法。

我在训练营打卡时先写了一个“看起来没问题”的版本:

void reverseString(vector<char>& s) { int n = s.size(); vector<char> temp(n); for (int i = 0; i < n; i++) { temp[i] = s[n - 1 - i]; } s = temp; }

这个写法在功能上完全正确,但不满足题目的“原地”要求。遇到这种题,首先要想清楚题目到底在考什么。它考的不是你会不会倒序存储,而是你能不能想到用交换来省掉额外空间。理解了这一层,双指针解法就呼之欲出了。

2.2 双指针解法与交换细节

双指针的思路非常直观:左指针指向开头,右指针指向末尾,交换这两个位置的字符,然后左指针右移、右指针左移,直到两个指针相遇或者左指针超过右指针。用代码写出来就是:

void reverseString(vector<char>& s) { int left = 0; int right = s.size() - 1; while (left < right) { swap(s[left], s[right]); left++; right--; } }

循环条件是 left < right,这是整个算法最需要注意的地方。如果写成 left <= right,当数组长度为奇数时,两指针最终会指向同一个位置,自己和自己交换,虽然不报错但多了一次无意义的操作;当数组长度为偶数时,left < right 和 left <= right 效果相同。所以用 left < right 是更规范、更省操作的写法。

交换这一步也有细节。新手最常见的错误是自己手写交换时忘记用临时变量:

// 错误示范 s[left] = s[right]; s[right] = s[left];

这样一写,s[left] 的原值就丢了,相当于把数组里某一段变成了重复字符。正确写法是引入 temp:

char temp = s[left]; s[left] = s[right]; s[right] = temp;

当然用算法库里的 swap() 更省事,但面试时如果你能一边写一边说清楚“交换需要临时变量存值”,会让面试官觉得你是真的理解而不是背代码。

2.3 这题考场的隐藏陷阱

344虽然基础,但在面试现场很容易踩坑。第一是很多人看到“字符串反转”就直接调用 std::reverse(s.begin(), s.end()),这确实能通过LeetCode,但面试官一般会追问“如果不让你用库函数呢”,这时候如果写不出双指针就露馅了。我建议练习时就强制自己不用库函数,把反转逻辑亲手写熟。

第二是语言差异。344的输入在LeetCode里是 vector<char>,也就是字符数组,可以直接按下标索引修改。但如果题目改成给你一个 string,在 Java 里就得先 toCharArray() 再操作,最后 new String(chars)。别小看这个转换,面试时如果没处理好,很容易写出“看似正确但无法通过编译”的代码。

第三是整型溢出。这个题用 int 存储下标不会溢出,但有些字符串题目如果涉及超长字符串或者 n 很大,用 int 可能不够稳。养成使用 size_t 或者在需要时显式转换的习惯,能帮你规避很多莫名其妙的问题。

2.4 和反转链表的对比(补充学习心得)

如果你前面刚刷过链表反转,这天的反转字符串可以当作一个绝佳的对比素材。链表反转(比如206.反转链表)用的是多指针在节点间游走,每次改变节点的 next 指向;数组反转则是在连续内存里用两个下标做交换。两者的共同点是都需要维护“当前处理到哪个位置”的信息,都需要小心边界(链表判空、数组判越界),但数组因为有随机访问能力,写法上简洁得多。

我自己的体会是:如果链表反转你已经吃透了,再看字符串反转会觉得豁然开朗——原来指针的概念从链表迁移到数组只是换了个形态。这种跨数据结构的类比能力,恰恰是刷题训练营最想培养的东西。所以别把344当一道孤零零的水题,带着对比意识去刷,收获会大很多。

3. 541. 反转字符串 II:边界条件才是硬骨头

3.1 题目理解:2k分组的正确姿势

541题面比344稍微绕一点:给你一个字符串 s 和一个整数 k,你需要对字符串每隔 2k 个字符的前 k 个字符进行反转。如果剩余字符少于 k 个,则将剩余字符全部反转;如果剩余字符小于 2k 但大于等于 k 个,则反转前 k 个字符,其余保持原样。

我第一次读这个题面的时候,差点被“剩余字符”三个情况绕晕。后来我换了一种更直接的理解方式:每 2k 个字符看作一个区块,每个区块只处理前 k 个字符。最后一个区块如果不满 2k,那就看它有没有 k 个字符——有 k 个就反转前 k 个,不够 k 个就全反转。

这个理解方式对应到代码里,比一句句if判断要清晰得多。理解了分区模型,剩下的事就是找到每个区块的起点。

3.2 代码实现:循环步长与反转边界

我第一次写这个题时,用的是最“老实”的写法:用一个 count 累计当前处理到哪,然后判断剩余情况决定反转范围。代码能跑通,但逻辑分支特别多,写着写着容易晕。后来我看了训练营的思路,发现最优雅的解法是让循环变量每次加 2k,这样天然完成了“分组”:

string reverseStr(string s, int k) { for (int i = 0; i < s.size(); i += 2 * k) { // 反转 [i, i+k) 这个区间,但要防止越界 if (i + k <= s.size()) { reverse(s.begin() + i, s.begin() + i + k); } else { reverse(s.begin() + i, s.end()); } } return s; }

这个写法的精髓在于用 i += 2k 代替自己维护计数器,用“i + k 是否超过 s.size()”统一了三种剩余情况。为什么判断的是 i + k 而不是 i + 2k?因为题目要求的是每个区块反转前 k 个,如果 i + k 都在字符串长度内,说明这个区块至少有 k 个字符,就反转 k 个;如果 i + k 已经超出末尾,说明这个区块连 k 个字符都不够,就反转从 i 到末尾的全部字符。

3.3 边界情况梳理与常见错误

代码虽然短,但边界情况极容易错。我拿几个典型例子测试:

  • s = "abcdefg", k = 2:0-1反转"ba",4-5反转"fed",最后剩一个"g"不够2个所以全转(还是"g"),结果应该是"bacdfeg"。
  • s = "abcd", k = 2:i=0时反转"ba",i=4退出循环,结果为"bacd"。
  • s = "ab", k = 4:i=0,i+k=4 > s.size()=2,走 else 分支反转整个字符串"ba"。

测试过程中最容易翻车的点有两个。第一个是循环变量直接操作 string 时,reverse 的第二个参数是迭代器。如果写成 s.begin() + i + k,当 i + k 正好等于 s.size() 时,这个迭代器指向的是末尾后一位,也就是 end(),reverse 是合法的。但如果 i + k 大于 s.size(),就不能再用这个写法了,必须先走 else 分支用 end()。

第二个坑是 k 本身可能大于字符串长度。当 k 比整个字符串还长时,第一次循环 i=0 就会满足 i+k > s.size(),于是把整个字符串反转。这刚好符合题面“剩余字符少于 k 个则全部反转”的规则,所以代码不用额外处理,但新手很容易在这里加一个多余的 if 导致逻辑混乱。

我还建议尽量封装一个“反转区间”的辅助函数。虽然 C++ 的 algorithm 头文件里自带 reverse,但在面试手写场景下,自己写一个接受字符串引用和左右下标的版本,可以避免边界理解的偏差,尤其当你在 541 的基础上再遇到“反转区间内的单词”“反转指定区间的子串”这类变形题时,这个辅助函数几乎可以原样复用。

4. 54. 替换数字:原地扩容的经典套路

4.1 题目描述与暴力解法

54这道题是卡码网的原题,题目本身不复杂:输入一个字符串,包含字母和数字字符,要求把所有的数字字符替换成"number"并输出。比如输入 "a1b2c3",输出就是 "anumberbnumbercnumber"。

最直观的暴力解法也最简单:遍历原字符串,判断每个字符是不是数字,是数字就追加"number",不是就追加原字符。在 Java 里用 StringBuilder 可以很轻松地完成:

public static String replaceNumber(String s) { StringBuilder sb = new StringBuilder(); for (char c : s.toCharArray()) { if (c >= '0' && c <= '9') { sb.append("number"); } else { sb.append(c); } } return sb.toString(); }

这段代码能通过测试,而且非常好理解。但问题是如果面试官追问“如果要求不能申请额外空间,或者要求在原字符串上修改,你怎么做?”大多数第一次接触这个题的人会卡住。别看反转字符串时“原地”很轻松,替换数字时“原地”意味着字符串长度要变长,这会带来一个新问题:原数组装不下了怎么办?

4.2 C++ 双指针从后往前填充的完整推导

C++ 里 string 是动态长度的,所以可以先扩容再填充,这是“原地修改”的一种可行方案。具体思路分三步:

第一步,遍历原字符串,统计数字字符的个数 count。 第二步,设原长度为 oldLen,把字符串重新分配为 oldLen + count * 5 的长度。为什么是乘以5?因为 number 这个单词有6个字符,而原来的1个数字字符占1个位置,每替换一个数字需要多出5个位置。 第三步,用两个指针从后往前遍历:i 指向旧字符串的末尾,j 指向扩容后字符串的末尾。从后往前填充,遇到数字就倒着填入 "number" 的字符,遇到普通字符就直接拷贝到 j 的位置。

写成代码就是:

#include <iostream> #include <string> using namespace std; int main() { string s; cin >> s; int oldLen = s.size(); int count = 0; for (char c : s) { if (c >= '0' && c <= '9') count++; } s.resize(oldLen + count * 5); int i = oldLen - 1; int j = s.size() - 1; while (i >= 0) { 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]; } i--; } cout << s << endl; }

这里最关键的是“从后往前”的方向选择。如果从前往后替换,每遇到一个数字就要把后续所有字符往后移动,“移动+插入”综合下来的时间复杂度会变成 O(n^2),在字符串很长时效率很差。而从后往前时,利用已经统计好的新长度,每个字符最多移动一次,整体是 O(n) 复杂度。这也是很多字符串题目惯用的优化思路:先扩容,再从后往前倒着填充。

4.3 Java 实现与性能对比

Java 里 String 是不可变的,所以在 Java 中做真正的“原地修改”没有意义,但同样的思路可以迁移到一个 char 数组上。先统计数字个数,创建一个扩容后的 char 数组,再用双指针从后往前填充,这样空间上只多开了一个相同规模的数组,逻辑上和 C++ 版本一致:

public static String replaceNumber(String s) { int count = 0; char[] chars = s.toCharArray(); for (char c : chars) { if (c >= '0' && c <= '9') count++; } char[] result = new char[chars.length + count * 5]; int i = chars.length - 1; int j = result.length - 1; while (i >= 0) { if (chars[i] >= '0' && chars[i] <= '9') { result[j--] = 'r'; result[j--] = 'e'; result[j--] = 'b'; result[j--] = 'm'; result[j--] = 'u'; result[j--] = 'n'; } else { result[j--] = chars[i]; } i--; } return new String(result); }

对比下来,Java 更推荐直接使用 StringBuilder 版本,代码简洁且可读性强。但为什么还要掌握数组双指针版本?因为在某些算法场景(比如字符数组传参、竞赛机考)中,你可能没有现成的 StringBuilder 或者不适合使用它,这时候双指针的思维方式就是你的“保底技能”。另外,理解了 C++ 的扩容思路后,Java 的数组版本其实只是同一个思路换了语法皮,学起来几乎零成本。

4.4 为什么从后往前是正解

很多第一次接触这个题的人会问:我统计好数字个数,也知道最终长度,直接从前到后一边遍历一边插入不行吗?答案是行,但要付出整体移动的代价。考虑极端情况:如果字符串是 "111111...1",每个数字都要替换成 "number",每次从前面插入都会导致后面大量字符右移,整体复杂度接近 O(n^2)。而字符串的题目经常出现在大数据量场景,测试数据一长,这种写法很可能超时。

从后往前填充之所以优雅,核心在于它利用“旧指针 i”永远不快于“新指针 j”的特性。处理普通字符时 i 和 j 同步走,处理数字时 j 会一次多走5步,但旧字符串中尚未处理的字符位置始终在 i 的左侧,不会被 j 覆盖。这其实是用“倒序写入”规避了经典的“前方数据覆盖”问题。类似的技巧在合并两个有序数组(88.合并两个有序数组)里也会用到,本质上都是在利用“末尾可用空间”来减少数据搬移。

5. 训练营第七天的踩坑实录与效率技巧

5.1 三个高频报错与排查方法

我自己加上训练营群里其他同学反馈,这三题最常出现的问题集中在下面几个位置。

第一个是 344 的手写交换丢值。报错表现是数组里某个区域的字符变得一模一样。排查时不要急着看逻辑,先在纸上用三个字符的小数组手动模拟一遍交换过程。比如 ['a','b','c'],如果不用 temp 直接 s[0]=s[2]、s[2]=s[0],结果是 ['c','b','c'],第二个字符还好好的,但最后一个字符丢了。用这个例子一推,问题马上就定位了。

第二个是 541 的越界错误。常见报错是 iterator range 越界或者访问到非法内存。原因大多是 i + k 已经大于末尾,还硬用 s.begin() + i + k 去取迭代器。记住:在 reverse 之前,必须先判断 i + k 和 s.size() 的大小关系。我个人的习惯是先把代码里的区间逻辑写成注释,比如“反转 [i, min(i+k, n))”,这样代码和注释对照着看,不容易写串。

第三个是 54 的数字字符判断。有人会写成 if (c >= 0 && c <= 9),这个在 C++ 里不会报错但永远不成立,因为 c 是字符的 ASCII 码,'0' 的 ASCII 码是48,不是0。还有人会犯“只判断是数字但忘了它是字符”的错误,导致替换逻辑没有触发。记住判断字符是否为数字的标准写法:c >= '0' && c <= '9'。

5.2 字符串题的高效刷题习惯

刷字符串题和刷链表、数组不太一样,它特别讲究“小步快跑”。一个字符串题往往能拆成很多小步骤:先转成字符数组,再定两个指针,再写交换逻辑,最后处理边界。我建议每写一段代码都先想想这段代码的输入边界是什么。尤其对于区间反转题,我强烈建议在脑海里或者草稿纸上跑三个用例:空字符串、单个字符、长度刚好等于 k 或 2k 的倍数。

训练营打卡时,我也会坚持“先写思路再写代码”。不是每次都要写长篇大论,但至少要能说清楚“这个题用双指针,循环条件是什么,反转区间是哪一段”。养成这个习惯后,你会发现即使遇到完全没见过的字符串题,也能快速构思出方向,而不是楞在编辑器前不知道怎么下手。

5.3 打卡节奏与背诵策略

第七天这个位置其实很微妙,前面六天已经积累了不少数组和链表的套路,第七天的新知识密度看似不高,但很多人就是在这里开始松懈。我的经验是:热身题(344)快速过,重点题(541)反复写,工具题(54)理解原理。不要把时间平均分配在三道题上,而是根据每一题的价值调整打磨深度。

背诵策略方面,我不建议死记硬背代码,更建议记住“关键决策点”。比如 541 的关键决策点是“i += 2*k 的分组方式”和“i+k 越界判断”,54 的关键决策点是“统计数字个数、计算扩容长度、双指针从后往前填充”。只要这些决策点记住了,代码其实可以现场推出来。这个方法在面试时特别管用。

另外,如果你发现某道题的某个边界情况总是记不住,别硬记,去构造一个专门的测试用例。我在学习 541 时就把 "abcdefg" + k=2 这个用例反复跑了十几遍,直到彻底理解为什么最后会得到 "bacdfeg"。这种“用具体例子对抗抽象规则”的方式,比单纯刷题数要高效得多。

这三道题做完,字符串模块的大门基本就打开了。后面再遇到反转字符串里的单词、替换空格、实现 strStr() 这类更复杂的题目,你会发现底层能力其实都是第七天埋下的:会操作字符、会控制区间、会处理边界、会做原地修改。继续往后刷,保持手感,比纠结“今天题目简单”更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询