ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

字符串算法刷题三大基本功:双指针、步长循环与ACM模式扩容填充

字符串算法刷题三大基本功:双指针、步长循环与ACM模式扩容填充

说实话,字符串类的算法题在刷题圈里属于最容易被低估的一类。一来题目描述短,二来示例简单,大多数人瞄一眼就觉得自己会了。可真到训练营第八天,把 LeetCode344 反转字符串、LeetCode541 反转字符串 II、卡码网 KamaCoder54 替换数字这三道题放在一起复盘时,才发现字符串题想写得干净、稳定、不露边界破绽,远比想象中讲究。

这三道题表面看都是处理字符序列,实际上覆盖了字符串题最核心的三种基本功:双指针原地操作、步长循环处理区间、以及扩容后从后往前的反向填充。如果你正在刷题补基础,或者准备面试想把手上的字符串题练扎实,又或者刚接触 ACM 模式被输入输出搞得头晕,这篇文章应该能帮上忙。我会把每道题的解题思路、为什么这样写、以及实测踩过的坑都摊开讲清楚。

1. 第八天为什么把这三道题放在一起:字符串基本功的三种考验

很多人在刷题初期会有个错觉:字符串题就是 API 调用题,反正语言库里都有现成函数。这个想法在业务开发里没毛病,但在算法训练里恰恰是陷阱。训练营把这三道题安排在同一天,其实是在用三道题分别敲打三个不同层面的能力。

LeetCode344 考的是原地修改。所谓反转字符串,核心限制不在"反转",而在"不能用额外空间"。这是对双指针手感的直接训练,也是后面一系列字符串难题的基础。

LeetCode541 考的是规则翻译成循环。题目给了一条看起来有点绕的规则,如果你真的按规则一步步 if-else 去模拟,很容易写出一堆边界条件。更聪明的做法是找到规则里的周期,用一步循环步长解决掉。

卡码网54 考的是ACM 模式下的完整处理能力。这道题给了你在 LeetCode 答题框里体会不到的东西:自己读输入、自己处理字符串扩容、自己输出结果。而且在解法上,从前往后替换和从后往前替换,性能差距是 O(n²) 和 O(n) 的差距。

三题串起来,正好是一条从"会写"到"会想"再到"会在完整环境里跑通"的递进链路。

我当时的安排是:344 花 20 分钟,541 花 30 分钟,54 花 40 分钟,剩下的时间全部用于把三道题的边界情况列成表格对照。后面会讲,这 40 分钟里至少有 15 分钟是耗在调试输入输出上。

题号模式核心考点建议投入时间
LeetCode344 反转字符串核心代码模式双指针、原地修改20 分钟
LeetCode541 反转字符串 II核心代码模式步长循环、区间边界30 分钟
卡码网54 替换数字ACM 模式整行 IO、扩容、反向填充40 分钟

2. LeetCode344 反转字符串:先管住调用 reverse 的手

2.1 这道题到底在考什么

题目本身特别简单:给你一个字符数组,要求原地把顺序反转,不开辟额外空间。我第一次看到的时候心想这不是 std::reverse 一行的事吗?确实,std::reverse(s.begin(), s.end())能过,但这就失去了训练的意义。

训练营提醒得很到位:LeetCode344 的本意是让你徒手实现反转逻辑,因为后面很多字符串题目里,反转是一个中间步骤,你不能每次都依赖库函数,而且面试时面试官经常会让你手动写一遍反转,看看你对索引和边界的敏感度。

这道题的关键约束是"原地"和"O(1) 额外空间"。所谓原地,意味着你只能通过交换字符来改变顺序,不能新开一个等长数组再拷贝回去。如果你写过vector<char> ans = s;再 reverse 回去,本质上还是额外开了一份空间,不符合要求。

2.2 双指针写法的边界细节

思路一句话就能说完:左指针指向开头,右指针指向结尾,交换这两个位置的字符,然后左指针右移、右指针左移,直到两指针相遇。

class Solution { public: 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更符合直觉,也更好解释。

如果你喜欢 for 循环风格,也可以写成:

for (int i = 0, j = s.size() - 1; i < j; i++, j--) { swap(s[i], s[j]); }

Python 版本同理,只是注意 Python 的字符串是不可变对象,题目输入给的是 list 才能原地修改:

class Solution: def reverseString(self, s: List[str]) -> None: left, right = 0, len(s) - 1 while left < right: s[left], s[right] = s[right], s[left] left += 1 right -= 1

2.3 为什么我不建议直接用库函数交差

这不是说库函数不好。std::reverse本身封装得很好,笔试时该用就用。但作为训练,如果你连一次手写反转都没有做过,后面做反转单词、反转链表、反转括号内字符串这些变体题时,很容易在索引计算上翻车。

我的判断标准是这样的:如果拿到题目第一反应是"这个函数的底层原理是什么",那就先手写一遍再说;如果第一反应是"这题本身就是在考某个库函数的用法",那直接调库也合理。344 明显属于前者,它是想让你理解双指针,而不是考察你是否知道 reverse 函数。

另外补充一个小知识点:std::reverse接收的是迭代器区间,而且是左闭右开区间,也就是[begin, end)。这个惯例在 C++ 里大量出现,后面 541 题也会用到,所以建议先对"左闭右开"这个概念熟悉起来。

3. LeetCode541 反转字符串 II:用循环步长消化复杂规则

3.1 先把规则翻译成人话

541 题的规则是这样的:给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 个字符中的前 k 个字符。如果剩余字符少于 k 个,则将剩余字符全部反转。如果剩余字符大于等于 k 个但小于 2k 个,则反转前 k 个字符,其余字符保持不变。

乍一看很绕,其实可以精简成两句话:

  • 每 2k 个字符为一组,组内前 k 个反转,后 k 个不动。
  • 最后一组如果凑不满 k 个,那就全反转;如果凑得满 k 个但凑不满 2k 个,依然只反转前 k 个。

这里的核心技巧是:不要用计数器去数"我已经处理了几个字符",而是直接在 for 循环里让下标每次跳 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) { int left = i; // 反转区间是 [i, i+k-1],但不能超过字符串末尾 int right = min(i + k, n) - 1; while (left < right) { swap(s[left], s[right]); left++; right--; } } return s; } };

这段代码最妙的地方在right = min(i + k, n) - 1。我第一遍看题解的时候没太理解为什么直接减 1,后来自己推导了一遍才算彻底明白。

左边界是i,因为每组起点就是当前循环到的位置。右边界本应该是i + k - 1,也就是组内前 k 个字符的最后一个。但如果最后一组剩余字符不足 k 个,i + k - 1就会超出字符串实际长度,所以要先用min(i + k, n)把右边界截到字符串末尾,再减 1 变成合法的数组下标。

你可以用一个例子验证:s = "abcdefg", k = 2,n = 7。

  • 第一轮 i=0,right = min(2, 7) - 1 = 1,交换下标 0 和 1,结果是 "bacdefg"。
  • 第二轮 i=4,right = min(6, 7) - 1 = 5,交换下标 4 和 5,结果是 "bacdferg"。

等等,"bacdferg" 只有 7 个字符吗?数一下:b a c d f e r g,确实是 7 个。最后一组从下标 4 开始,剩余 "efg" 共 3 个字符,大于等于 k=2,所以反转前 2 个 "ef",变成 "fe",第三字符 "g" 不动,得到 "bacdfeg"。我上面随手写的顺序打错了,你写代码跑一遍就会得到正确结果 "bacdfeg"。

这就是为什么我强烈建议这种题要自己手推一轮:眼睛看会了不等于手算对了,尤其是边界条件下标特别容易错。

3.3 实操中的两类典型错误

第一类错误是把简单规则写成分段讨论。比如有同学写:

if (剩余字符个数 >= k) { // 反转前 k 个 } else { // 全部反转 }

逻辑上没问题,但这种写法需要一个额外的变量记录剩余字符数,而且每次循环都要重新计算,代码变得又长又容易漏情况。比如"剩余字符大于等于 k 但小于 2k"这种情况,用上面的判断也能覆盖,但如果你再细分出"大于 k 小于 2k"和"大于 2k"的分支,就会把很简单的事情搞复杂。

第二类错误是min(i + k, n)忘减 1。我第一次写的时候用的是while (left <= right),并且right = min(i + k, n),结果每次都多交换了一个字符。原因是 right 作为数组下标应该是最后一个有效索引,而min(i + k, n)其实是"下一个段的起点",它在语义上对应的是"最后一个反转字符的再下一个位置",所以必须减 1。

如果你实在怕搞混,也可以直接用 STL 版本:

reverse(s.begin() + i, s.begin() + min(i + k, n));

这里正好利用了std::reverse左闭右开的特性,不需要手动减 1。我建议你先手写双指针逻辑,确认理解了边界原理之后,笔试时再大胆用这个简化版。

4. 卡码网54 替换数字:从核心代码模式到 ACM 模式的第一课

4.1 ACM 模式暴露的问题:输入输出都不会写

给你看个真实场景。前七天在 LeetCode 上刷题,函数签名都是现成的,你只需要把逻辑填进函数体,输入早就被平台处理好了。但训练营第八天模拟笔试突然换成卡码网这种 ACM 模式,要求你自己写main()、自己读标准输入、自己打印结果,很多人的第一反应是懵的。

卡码网54 的题目描述是这样的:给定一个字符串 s,将其中的数字字符替换为 "number" 字符串。比如输入a1b2c3,输出anumberbnumbercnumber。

别笑,这道题真有不少人挂在读输入上。用cin >> s还是getline(cin, s),取决于输入是否包含空格。这道题给的字符串不含空格,所以cin完全够用。但如果你不确认题目描述,保险起见用getline也没毛病:

#include <iostream> #include <string> using namespace std; int main() { string s; cin >> s; // 主逻辑... cout << s << endl; return 0; }

ACM 模式真正考验的,不只是你知道怎么替换,而是你能不能在一个没有测试用例逐步提示的环境里把完整程序写对。LeetCode 里你函数返回值对了就行,这里你必须自己负责整个程序的生命周期,稍有疏忽就是编译错或者格式错。

4.2 为什么必须从后往前填充

把数字替换成 "number",直觉上的做法是遍历字符串,遇到数字就调用 insert 方法插进去。但这在 C++ 里是个性能陷阱。

std::string::insert会在插入位置把后续字符整体后移,最坏情况下每个数字字符插入都导致后面所有字符搬一次家。假设字符串里有一半是数字,复杂度会退化到 O(n²)。训练营里反复强调的"慎用 insert/erase",这就是原因之一。

正确的思路分三步:

  • 先遍历一遍字符串,统计数字字符的个数 count。
  • 原字符串扩容,每个数字替换成 6 个字符的 "number",所以新长度是oldSize + count * 5(原来是 1 个字符,替换后变 6 个,净增 5 个)。
  • 从后往前填充:一个指针 i 指向旧字符串末尾,一个指针 j 指向扩容后字符串末尾。遇到数字就反着写入 "number",遇到普通字符就原样搬过去。

代码如下:

#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++; } } s.resize(oldSize + count * 5); int i = oldSize - 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'; i--; } else { s[j--] = s[i--]; } } cout << s << endl; return 0; }

为什么从后往前填充能避免覆盖?因为扩容之后,新字符串的末尾一定大于旧字符串的末尾。从后往前处理时,j 指针的位置始终在 i 指针的右边,也就是说我们写进去的新内容不会碰到还没处理的旧字符。反过来,如果从前往后填充,每次写入都可能把未处理的旧字符覆盖掉,那才真的叫灾难。

你可以把它想象成往已经装了一半书的箱子里再塞新书:只有先把后半部分腾空,从末尾往前一本一本挪,才不会把前面的书挤坏。

4.3 字符串扩容与填充的易错点

这道题有几个坑,我挨个说。

第一个坑:resize之后直接在原字符串上继续遍历。resize会改变s.size(),如果你在扩容前没有保存oldSize,后面用for (int i = 0; i < s.size(); i++)遍历时,会把扩容出来的空字符也当作有效内容处理,逻辑直接错乱。所以务必先int oldSize = s.size();再统计和扩容。

第二个坑:填充 "number" 时的顺序。从后往前写,"number" 要反着写:先写 r,再写 e,再写 b,再写 m,再写 u,最后写 n。很多人写着写着就把s[j--] = 'n'; s[j--] = 'u';写成正序,结果字符串变成 "rebmun",调试半天才发现顺序反了。我后来习惯先在注释里写好n u m b e r,再对照反序抄写。

第三个坑:数字字符判断。建议用c >= '0' && c <= '9',不要用isdigit(c)的直接版本。isdigit的参数要求是int且必须是 EOF 或 0-255 之间的值,如果字符被当成有符号负数传进去,行为就不确定。字符范围比较是最朴素也最安全的方式。

如果非要用 Python 写这个题,由于字符串不可变,最自然的方式是转成 list:

s = input().strip() res = [] for c in s: if c.isdigit(): res.append("number") else: res.append(c) print("".join(res))

注意这里额外用了一个 list,空间是 O(n)。Python 的字符串不可变,无法在原字符串上原地扩容,这是语言特性决定的,面试时说明白即可。

5. 三道题合起来复盘:字符串题通用法则与踩坑记录

5.1 判断能不能用库函数的三条标准

这一天的三题做完,最值得沉淀的就是"什么时候可以调库函数"这个判断力。我自己总结了三条标准,每次遇到字符串题先快速过一遍:

  • 这个题的核心考点是库函数本身吗?如果是,比如考察你知道某个 STL 函数的使用,那直接用没问题。
  • 手写会不会让我更理解底层?如果会,哪怕笔试能调库,训练时也要手写一遍。344 和 541 都属于这种。
  • 面试场景下对方期待的是什么?如果面试官明确说"你实现一下反转",那调reverse就是答非所问。

我在 541 题里其实给了一个折中方案:手写双指针理解边界,最终笔试用std::reverse保证速度和准确率。学习阶段和应试阶段可以有不同的策略,关键是你心里清楚每个函数背后做了什么。

5.2 实测踩过的两个典型坑

第一个坑是resize 之后用 s.size() 做遍历边界。我在写替换数字这道题时,一开始统计完 count 就直接s.resize(...),然后想当然地写了一个 for 循环去处理,结果把扩容后的空位也遍历进去了。最后靠打印每一步字符串才发现问题。解决方案就是我前面说的:先保存oldSize,循环和指针都以 oldSize 计算。

第二个坑是541 题反转区间右边界算错。我第一次写的是right = min(i + k, n),然后用while (left <= right)交换,本来只想反转 k 个字符,结果因为 left 和 right 都指向有效下标,多交换了一个。这个错误很隐蔽,因为字符串短的时候结果看起来"差不多对",直到我用s = "abcdef", k = 2手工推演才暴露。

这两个坑我后来都记录在自己的错题本里,两周后又遇到类似题,明显反应快了很多。强烈建议你也把错的版本和正确版本并排贴在一起,下次复习时一眼就能看到差异。

5.3 复杂度视角的合并复盘

把三道题放在一张表里看:

题目时间复杂度空间复杂度核心技巧
LeetCode344O(n)O(1)双指针左右交换
LeetCode541O(n)O(1)步长循环 + 区间边界截断
卡码网54O(n)O(1)扩容 + 从后往前双指针

不难发现,三题的共同点都是 O(n) 时间和 O(1) 额外空间。字符串题的复杂度估算有个简单套路:如果没有嵌套循环,就是线性扫描;如果出现 insert/erase 这种需要搬移的操作用法,就要警惕退化。你想把空间压到 O(1),本质上都是在原字符串上做交换或迁移动作,而不是新建字符串。

另外,关于“替换数字”这种题,我平时练的时候会顺手多想一想:如果替换的不是固定长度的 "number",而是长度不固定的某个变量,还能不能从后往前?答案是需要先知道新总长度,而新总长度往往需要额外遍历一次统计,这就是这类题的基本功,值得多做两遍形成肌肉记忆。

第八天结束之后,我把三道题又默默重写了一遍。能有这样的复盘习惯,比多刷五道新题更有价值。特别是 54 题,我后来在另一个模拟环境里又遇到了几乎一样的替换类题目,直接套用从后往前的模板,几分钟就写完了,连边界都没卡过。这种手感,就是靠当天一次次的推演和错题积累出来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进