Day7 打卡,今天这三道题放在一起,我愿称之为“字符串双指针的一天”:344. 反转字符串,541. 反转字符串 II,还有卡码网的 54. 替换数字。前两题是 LeetCode 上的经典,第三题是 ACM 模式下的字符串处理题。刷完这一组之后你会发现,它们表面上看起来都是基础操作,实际上正好把双指针的几种用法串了一遍:对撞、区间控制、扩容后逆向往回搬。这篇就把我的完整思路、代码写法、还有踩过的坑一起整理出来。
如果你正在刷代码随想录或者按专题刷题,这个组合可以作为“字符串入门”的一环。适合的人群很直接:刚开始刷 LeetCode 的初学者、准备机考/面试想补基本功的同学,以及想搞明白“为什么替换数字要从后往前倒着处理”的人。下面直接进入正题。
1. 为什么这三道题值得放在一起刷:一条完整的“字符串基本功”线
1.1 三道题的底层逻辑其实是同一个套路
先说 344. 反转字符串。它要求原地反转一个字符数组,不能额外开数组,也不能用库函数一步到位。一旦用双指针从两端往中间走,你就拿到了一个对撞指针的最小模型。
再看 541. 反转字符串 II。它描述了一个带条件的规则:每隔 2k 个字符,反转前 k 个;如果剩余字符少于 k 个,就把剩余的全部反转。表面上是新题,但内核还是在“局部区间内做双指针反转”,只不过多了一个区间定位的步骤。
最后是卡码网 54. 替换数字。题面大概是:给你一个字符串 s,里面可能包含数字,要求把所有数字字符替换为 "number" 这个单词。这里数字字符不算多,但“1 个字符变成 6 个字符”意味着字符串长度会变化。最稳的解法是先统计数字个数,然后从后往前用双指针搬字符。这同样是一道双指针题,只是方向和前两题相反:344 是从左边往中间合,54 是从右往左移动。
所以一天之内刷这三题,并不是随意拼凑,而是一个相对完整的训练闭环:先理解反转的边界条件,再理解规则化分段的边界条件,最后理解“长度动态变化时怎么避免覆盖旧数据”。把这三件事想清楚,很多字符串题你都不会再怕。
1.2 我在做题时的选题顺序建议
如果你真的准备照这个节奏来,我建议先做 344,再做 541,最后做 54。理由很简单:
- 344 只需要写一个 while 循环,代码量最少,适合作为热身;
- 541 依赖 344 的反转逻辑,可以复用同一段代码片段,重点放在“判断反转区间”;
- 54 的思维难度略高一点,因为它不是单纯反转,而是“替换+扩容”,需要想清楚新旧位置的关系。
我自己的习惯是每道题先用一句话写下核心思路,再动手写代码。比如 344 就是“左边换右边,直到相遇”;541 是“每 2k 一组,组内前 k 个用对撞反反转”;54 是“先数数,再从尾巴倒着迁移”。这么写的好处是,过几天回来复习时,看一句话就能快速回忆起解法。
2. 344. 反转字符串:最容易忽略的“原地修改”到底在考什么
2.1 没人不会反转字符串,但 LeetCode 给的是一个字符数组
题目描述很简洁:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出,要求不要给另外的数组分配额外空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
注意两个关键词。
第一,输入是 char[],不是 String。如果是 String,Java 里字符串不可变,你只能重新创建一个字符串,那就不叫“原地”了。LeetCode 故意把输入设计成数组,就是为了让你没法靠 concat 或 substring 偷懒。
第二,必须使用 O(1) 的额外空间。这意味着你不能用另一个数组接收结果,也不能用列表拼接出一个新字符串再转回数组。唯一可行的大方向,就是交换数组里的元素。
我做这道题时看到很多人直接写:
def reverseString(self, s: List[str]) -> None: s.reverse()在 LeetCode 上,Python 的s.reverse()确实能原地反转,在 C++ 里也可以写reverse(s.begin(), s.end()),Java 里用Collections.reverse()也能对列表生效。但是作为题解练习,我还是建议你手写双指针,因为后续很多题都需要你手动控制交换范围,不可能每道题都靠库函数。
2.2 双指针对撞的两种写法
最基础的双指针写法大概是这样的(以 C++ 为例):
class Solution { public: void reverseString(vector<char>& s) { int left = 0, right = s.size() - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } } };逻辑非常好理解:left 从最左边开始,right 从最右边开始,每次交换一对字符,然后 left 向右移动、right 向左移动。循环继续的条件是 left < right。当数组长度为偶数时,两指针会在中间交叉时结束;当数组长度为奇数时,两指针会相等,此时中间元素不需要交换。
也可以把交换写成用临时变量的简化写法,比如 C++ 的swap(s[left], s[right]),Java 也可以用char tmp = s[left]; s[left] = s[right]; s[right] = tmp;。核心目的只有一个:不能直接赋值,否则会丢掉被覆盖的值。
时间复杂度是 O(n),每个位置最多被交换一次;额外空间是 O(1),只有临时变量。
2.3 这道题容易在哪儿出错
第一个坑:用 for 循环时,把循环变量当成真实数组元素来改。这种问题在 Java 增强 for 里尤其常见:
for (char c : s) { // 想通过修改 c 来改变数组?做不到 }因为这里的 c 只是数组元素的拷贝,改它不会影响原数组。哪怕是普通 for 循环,你也可以写出s[i] = s[j]这种顺序错误的问题,切记先保存一个再覆盖另一个。
第二个坑:混淆 String 和 char[]。LeetCode 的输入是数组,但有些同学在本地自己写 main 测试时,先声明了一个 String,再传入函数,编译器直接报错。正确做法是:
char[] arr = {'h','e','l','l','o'}; solution.reverseString(arr); System.out.println(Arrays.toString(arr));第三个坑:忽略长度为 0 或 1 的输入。其实 while 循环天然能处理这两种情况,因为 left < right 不成立,代码不会进入循环。但如果你写的是left <= right,会多做一次毫无意义的自己交换自己,虽然不报错,但不干净。
还有一个值得说的经验:反转字符串在实际开发里最常见的用途之一,是判断回文串的前置步骤。比如先反转再比较,或者反向遍历取字符串最后几个字符。刷完这道题之后,建议顺手做一道“验证回文串”,会发现思路完全能迁移过去。
3. 541. 反转字符串 II:这道题不考智商,考你能不能读懂 “每 2k 个” 这句话
3.1 把题目规则翻译成下标关系
541 的题目描述有点绕,我直接拆给你看。
给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。
- 如果剩余字符少于 k 个,则将剩余字符全部反转;
- 如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
初次读题,很多人会被“每计数至 2k 个字符”这句话卡住。其实它就是在说:从下标 0 开始,每 2k 个字符作为一组,每个组内只反转前 k 个;最后一组如果不足 2k 个,再按剩余字符的情况单独判断。
举个例子。假设 s = "abcdefg",k = 2。
- 先看下标 0 到 3 这 4 个字符(2k = 4),反转前 2 个字符,"ab" 变 "ba",结果变成 "bacdefg";
- 再看下一组从下标 4 开始,剩余 "efg" 只有 3 个字符。3 大于等于 k = 2,所以反转前 2 个字符,"ef" 变 "fe",最后一个 "g" 不变,结果为 "bacdfeg"。
如果 s = "abcdefgh",k = 3,那么第一组是下标 0~5,反转前 3 个字符 "abc" 为 "cba",剩下 "defgh";第二组从下标 6 开始,剩余 "gh" 只有 2 个字符,少于 3,所以全部反转,得到 "cbaedfhg"。
发现没有,做题的关键不是“怎么反转”,而是“反转哪个区间”。这个区间就是根据当前位置 i 和 k 算出来的。
3.2 用 i += 2k 控制循环,避免重复处理
我第一次做这道题时,用的是 i++ 外层循环,然后想办法判断当前字符是否处于某个组的“前 k 个”位置,写出来代码又臭又长,还容易把边界搞错。
后来我发现,最清晰的方式是让外层循环直接按“组”来走:每一组长度为 2k,所以每处理完一组,i 直接加 2k,而不是加 1。
这里给出我习惯的解法:
class Solution { public: string reverseStr(string s, int k) { for (int i = 0; i < s.size(); i += 2 * k) { int left = i; int right = min(i + k - 1, (int)s.size() - 1); while (left < right) { swap(s[left], s[right]); left++; right--; } } return s; } };核心就两行:
i += 2 * k:直接跳到下一组的开头;right = min(i + k - 1, n - 1):如果当前组内的前 k 个字符超过了字符串末尾,就只处理到末尾。
这个写法的好处在于:你不需要在循环里写 if-else 判断剩余字符是“少于 k”还是“在 k 到 2k 之间”,min 函数已经自动兼顾了两种边界情况。当剩余字符不足 k 时,i + k - 1大于n - 1,right 被截断到最后一个下标,相当于全部反转;当剩余字符在 k 到 2k 之间时,right 正常取i + k - 1,只会反转前 k 个。
如果你更习惯 Java,代码长这样:
class Solution { public String reverseStr(String s, int k) { char[] arr = s.toCharArray(); for (int i = 0; i < arr.length; i += 2 * k) { int left = i; int right = Math.min(i + k - 1, arr.length - 1); while (left < right) { char tmp = arr[left]; arr[left] = arr[right]; arr[right] = tmp; left++; right--; } } return new String(arr); } }注意这里必须先s.toCharArray(),因为 Java 的 String 不可变,所有修改只能在字符数组上进行,最后再转回 String。这一点和 C++ 直接传引用不一样。
3.3 这道题的边界条件和调试技巧
这种模拟题最怕边界条件,我给你几个可以直接拿来测的用例:
| 输入 | k | 输出 | 说明 |
|---|---|---|---|
| "abc" | 2 | "bac" | 剩余 1 个字符,少于 k,不反转 "c" |
| "abcd" | 2 | "bacd" | 正好 2k 个,只反转前 k 个 |
| "abcdefg" | 2 | "bacdfeg" | 最后一组剩余 3 个,反转前 2 个 |
| "a" | 1 | "a" | k = 1 时每 2 个反转 1 个,其实相当于都不反转 |
| "" | 3 | "" | 空串不要报错 |
调试技巧方面,我最常用的是“手动模拟 + 打印区间”。如果你写完代码不确定结果对不对,就在本地输出每一轮的i、left、right,对照题目给的例子走一遍。这个过程不是白费功夫,因为机考或面试时,很多边界问题就是靠这种模拟才发现的。
还有一个很多人会漏掉的点:k 的取值范围可能很大,甚至大于字符串长度。此时i + k - 1会超过整型范围吗?在正常范围内不会,因为 s.size() 和 k 都是 int,k 一般也就 10^4 量级。但如果你用 Python,要注意字符串切片反转不会原地修改,需要重新拼接:
class Solution: def reverseStr(self, s: str, k: int) -> str: result = list(s) for i in range(0, len(s), 2 * k): result[i:i+k] = reversed(result[i:i+k]) return "".join(result)这里第二行的切片写法,本质和双指针是一样的,只是 Python 的切片更优雅而已。
4. 卡码网 54. 替换数字:为什么不能从前向后直接改?这题的核心考点
4.1 题目背景与最容易踩的思维误区
卡码网 54 题是这样的:给定一个字符串 s,其中包含数字字符,要求把字符串中的数字字符替换为 number 字符串。比如输入"a1b2c3",输出应该是"anumberbnumbercnumber"。
这题放在 LeetCode 上其实对应的是“替换空格”那种类型的变体,只不过空格替换成%20,这里数字替换成"number",替换后的字符串变长了。
我第一次拿到这题时,第一反应是“这有什么难的?从头到尾遍历一次,遇到数字就替换成 number 字符串”。然后我写了个 Java 的StringBuilder:
public static String replaceDigits(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(); }这段代码运行结果完全正确。如果是在日常业务里,我强烈推荐你直接这么写,简单、清晰、不会错。
但题目如果要求你“不能使用额外的新字符串空间,必须在这个字符串内部完成修改”,或者更严格一点“不能直接使用 StringBuilder/StringBuffer 辅助”,那你必须换思路。这是这道题最重要的隐藏考点:字符串/数组长度扩容时,怎样原地修改而不覆盖掉还没处理的字符。
4.2 从后往前搬字符的原理:为什么要倒着走
先想一个问题:如果不用额外空间,直接在原数组上做替换,从前向后遍历会怎样?
假设原数组是['a', '1', 'b'],要替换"1"为"number"。如果从前向后处理,当你在下标 1 这个位置写入"number"的六个字符时,数组需要连续六个坑位,但原数组只有三个坑位,后面下标 2 里原本是'b',一定会在写入过程中被覆盖掉。等你处理完,'b'已经没了,数据就丢了。
所以正确思路是先扩容数组到足够的长度,然后从后向前遍历,把旧数组的字符搬到新数组的末尾位置。这样做的原因是:从后向前移动时,我们移动的旧字符总是位于当前位置的左侧,而写入的新字符总在当前位置的右侧,两者不会互相干扰。
画个图理解一下。假设原始字符串是"a1b",其中数字字符'1'替换成"number"。
先统计数字个数:字符串里只有一个数字字符。每个数字字符会被替换成 6 个字符,所以扩容后的长度 = 原长度 + 数字个数 × 5。这里 5 是"number"的长度 6 减去原来数字字符占的 1 个长度。
原长度为 3,数字个数为 1,扩容后长度为 8。我们初始化一个长度为 8 的字符数组,假设旧指针 oldIndex 指向原字符串的最后一个字符'b',新指针 newIndex 指向扩容后数组的最后一个位置下标 7。
第一步,读取 oldIndex 指向的'b',它不是数字,直接放到 newIndex 位置,然后 oldIndex 和 newIndex 都减 1。
第二步,读取 oldIndex 指向的'1',它是数字,需要在 newIndex 处从右往左写入"number":先写'r',再写'e',然后'b'、'm'、'u',最后写'n'。写完'n'后,newIndex 总共后退了 6 位,oldIndex 只后退 1 位。
第三步,读取 oldIndex 指向的'a',它不是数字,放到 newIndex 位置。
最终数组内容就是"anumberb"。整个过程没有覆盖任何未处理的旧字符。
这就是从后往前搬字符的核心价值:旧字符只往右挪,新字符只往右写,旧数据不会因为“往前写”而被提前冲掉。你可以把这种操作想象成搬家时先把大件家具搬进空房间,再从里面往门口摆,不会挡到还没搬的东西。
如果你面试时遇到原题“把空格替换成 %20”,思路一模一样,只是每个空格替换成 3 个字符,扩容长度 = 原长度 + 空格数 × 2。
4.3 多语言实现:C++、Java、Python 怎么落地
先给 C++ 版本。因为 C++ 的 string 支持直接 resize,做这种原地扩展很方便。
#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(s.size() + count * 5); int oldIndex = oldLen - 1; int newIndex = s.size() - 1; while (oldIndex >= 0) { if (s[oldIndex] >= '0' && s[oldIndex] <= '9') { s[newIndex--] = 'r'; s[newIndex--] = 'e'; s[newIndex--] = 'b'; s[newIndex--] = 'm'; s[newIndex--] = 'u'; s[newIndex--] = 'n'; } else { s[newIndex--] = s[oldIndex]; } oldIndex--; } cout << s << endl; return 0; }注意写入"number"的顺序:我先写'r'再写'e'再写'b',最后写'n'。由于 newIndex 从右往左移动,所以写入顺序必须保证最终结果从左到右读是"number"。
Java 版本需要注意:String 不可变,所以不能用 resize。如果你要严格模拟“原地扩容”,只能先s.toCharArray(),但 Java 的 char[] 长度也是固定的,于是更多人会选择先把字符串变成StringBuilder,然后用 setCharAt 来修改。这里给一个我认为最清晰的抽象实现:
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.next(); int count = 0; for (char c : s.toCharArray()) { if (Character.isDigit(c)) count++; } char[] oldChars = s.toCharArray(); char[] newChars = new char[oldChars.length + count * 5]; int oldIndex = oldChars.length - 1; int newIndex = newChars.length - 1; String num = "number"; while (oldIndex >= 0) { if (Character.isDigit(oldChars[oldIndex])) { for (int j = num.length() - 1; j >= 0; j--) { newChars[newIndex--] = num.charAt(j); } } else { newChars[newIndex--] = oldChars[oldIndex]; } oldIndex--; } System.out.println(new String(newChars)); } }Python 的写法更活络。因为 Python 字符串不可变,你没法真的在 py 里原地改字符串,所以通常直接构造新列表:
s = input() res = [] for ch in s: if ch.isdigit(): res.append("number") else: res.append(ch) print("".join(res))就算你想模拟“从后往前搬”,也可以先把字符串转成 list,扩展后从后往前填。但实际工程中,直接 append 然后 join 已经够快,不会成为性能瓶颈。刷题时为了练思路可以模拟,但别踩进“Python 必须重演 C++ 流程”的坑里。
4.4 这道题的时间复杂度与延伸应用
无论哪种实现,时间复杂度都是 O(n):统计数字遍历一次,从后往前搬移又遍历一次,实际是两次线性扫描,合起来还是 O(n)。额外空间取决于选型,C++ 的原地 resize 是 O(1) 额外空间(不算扩容本身),Java 因为数组不可变,严格说多了 O(n) 的新数组空间。
这里多说一句,“从后往前双指针”这类技巧,你们一定会在后面很多场景再碰到:
- 合并两个有序数组时,从后往前放可以避免覆盖;
- 合并两个有序链表时,尾插法+哑节点也是为了不丢失指针;
- 操作系统里整理内存碎片、日志文件追加写入,本质也涉及“新数据写在末尾,旧数据不能丢”的约束。
所以别小看这道“替换数字”,它锻炼的是你在长度变化场景下对下标关系的敏感度。这个敏感度,刷 diff 题、模拟题时特别重要。
5. 三题连刷后的复盘:双指针的一鱼三吃与刷题记录技巧
5.1 同一个双指针,三种完全不同的用法
刷完今天这三道题,如果只记住一个东西,我建议你记住“双指针不是一种固定模板,而是一种思想”。同样是双指针,方向和处理时机完全不同:
- 344 反转字符串:left 和 right 从两端向中间收缩,属于“对撞指针”,解决的是对称交换问题;
- 541 反转字符串 II:外层循环通过
i += 2k来定位区间,内层反转变成了对撞指针,属于“区间模拟 + 对撞指针”的组合; - 54 替换数字:oldIndex 和 newIndex 都是从右往左,但走的步子不一样,属于“逆向同步指针”,解决的是扩容场景下的原地修改问题。
看到这里你可能会发现,双指针并不是什么高深算法,它只是在告诉你:有时候多用一个指针,就能减少一层循环,或者避免一次额外空间开销。后续你还会遇到快慢指针(链表找环)、滑动窗口(子串问题)、相向指针(两数之和、盛水容器),都是同一思想的不同变体。
我在刷题时会特意在笔记里给每道题打标签,比如#双指针 #对撞、#模拟 #边界条件、#逆向双指针 #数组扩容。等到刷满一两百题后,再按标签看,会非常清楚自己擅长哪类、薄弱哪类。
5.2 做题记录与复盘:不要只存一个 AC 代码
不少人是“提交通过就下一题”,过两周发现全忘了。我个人的做法是:每个题解下面至少保留三行东西——第一行是核心思路的一句话描述,第二行是复杂度分析,第三行是自己踩过的一个坑或一个巧妙的写法。
拿今天这三题举例:
- 344 的一句话思路:对撞交换,直到 left >= right。
- 541 的一句话思路:i 每次加 2k,反转区间是
[i, min(i + k - 1, n - 1)]。 - 54 的一句话思路:先数数字个数,再 resize,最后从后往前双指针搬。
这三个笔记只要看一眼就能唤起记忆,比我写几百字都管用。如果你是在代码随想录或者其他专题课程里刷题,可以顺手把题目编号和知识点关联起来,方便二次检索。
5.3 关于单词的积累:reverse、replace digits 这些英文题面别怵
另外想提一个隐藏收获:这三道题让你顺带熟悉了几个常见的英文表达。
- reverse(反转)
- 2k 个字符:
2k characters - 剩余字符:
remaining characters - 数字字符:
digit character - 替换:
replace
LeetCode 的英文题面并不难,但如果你没有刻意积累,碰到Do not allocate extra space for another array这种表述时,可能会犹豫一下“到底能不能用辅助数组”。其实这句话就是“别开新数组”的意思,对应的解法空间复杂度是 O(1)。看多了就自然熟了。
6. 今天刷完,下一步你可以这样衔接
6.1 推荐几道可以顺手巩固双指针的题
如果你做完今天这几道还有余力,可以把这几个题目加进明天的计划里:
- 344 的兄弟题:LeetCode 7. 整数反转,反转对象从数组变成整数,注意溢出处理;
- 541 的升级版:LeetCode 917. 反转字母,只反转字母,其他字符位置不动,需要额外一个指针从头找字母;
- 54 的同类变体:LeetCode 剑指 Offer 05. 替换空格,把空格替换成
%20,和替换数字几乎是一个模板。
这几道题都不算难,适合用来检验你是不是真的掌握了今天的方法。如果能在不查题解的情况下独立写出来,说明双指针的基础已经比较稳了。
6.2 一个小技巧:本地写题时如何快速验证
最后分享一个非常实用的习惯。LeetCode 或卡码网这类刷题网站,提交时会自动处理输入输出,但本地调试时你得自己写 main。我的建议是:准备一个固定的测试代码模板,把所有边界用例放进一个 list,循环跑一遍,观察输出是否符合预期。比如 344 的测试用例:
char[][] cases = { {}, {'a'}, {'h','e','l','l','o'}, {'A','B','C','D'} };然后把每个用例传进函数,输出结果和期望值对比。这样做的好处是越快暴露边界问题,越能避免反复提交浪费时间。每次提交前先在本地把常见边界过一遍,基本上一次就能通过。
我在实际刷题过程中发现,很多人不是不会写解法,而是经常在空数组、单元素数组、k 取极值这几种情况上栽跟头。以后遇到任何题,都建议你先问自己三个问题:输入为空时怎么办?输入只有一个元素时怎么办?参数取最大值时会不会溢出?这三个问题想清楚,代码的健壮性会明显上一个台阶。