先问一个问题:你在面试现场被要求"写一个反转字符串的函数"时,第一反应是不是上来就写s.reverse()?如果你点头了,那这篇文章你值得花十分钟看完。LeetCode 344这道题我刷过不止一次,也从面试官视角见过现场翻车的候选人,老实说,这道题的通过率虽然常年稳居高位,但能把它讲明白、写干净、说清楚为什么用双指针的人,并没有想象中那么多。
这道题的全貌是这样的:给你一个字符数组s,要求原地反转,不能申请额外的数组空间,额外空间复杂度必须控制在 O(1)。一句话总结就是——用双指针从数组两端往中间走,交换左右两个字符,直到相遇。道理谁都能看懂,但"看懂"和"写对"之间隔着不少细节,这篇文章我把原理、实现、易错点、其他解法的对比,以及这道题在整个反转类题目里的位置全部拆开讲一遍。
1. 题目到底在考什么:LeetCode 344的考点拆解
1.1 题面里的三个隐藏要求
先看这个题的原版描述:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组s的形式给出。不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
很多第一次做这道题的人会被"输入字符串以字符数组s的形式给出"这句话带偏,以为这不就是转成数组嘛。实际上这句话是整个题目的核心约束之一——它意味着你拿到的就是一个可以原地修改的可变字符序列,而不是 Java 里那种不可变的String对象。在 C++ 里对应的是vector<char>,在 Python 里是List[str],本质都是同一个东西:一块连续的、可以按下标访问和修改的内存。
三个隐藏要求拆开看是这样的:
- 原地修改:禁止新开一个等长数组然后把原数组倒着填进去。这是题目最基本的红线。
- O(1) 额外空间:除了几个临时变量,不允许使用随输入规模增长的额外存储。这句话直接毙掉了用栈、用递归、用新字符串拼接这一类方案。
- 输入形式是字符数组:这规定了你不能把题目转化成"字符串 API 调用"来解决。你想调用
StringBuilder.reverse(),可以,但你得先把char[]转成String再转回来,这个操作本身就因为两次转换产生了额外空间,已经不满足题意了。
1.2 为什么反转一个字符串还需要"算法"
这是我觉得 344 最容易被低估的地方。反转字符串本身没有任何高深的数学原理,它甚至不需要你懂什么数据结构,但它恰好是双指针思想里最简洁、最适合作为入门模板的题目。
双指针在算法题里是一个庞大的家族:有快慢指针(链表判环)、有左右对撞指针(有序数组的两数之和)、有滑动窗口(最长无重复子串)。344 属于"左右对撞指针"这一类里最朴素的代表——一个指针从左往右,一个指针从右往左,两个指针向中间逼近,直到相遇。
你可以把双指针理解成两个人从一根绳子的两端同时往中间收绳子,每收一步就把两端对应的字符交换一次,最后整根绳子就被"掉了个个儿"。这个比喻虽然简单,但它能帮助你记住一个关键信息:两个指针是同时移动的,而且它们移动的次数是 n/2 次,不是 n 次。很多人在写循环的时候下意识写成遍历整个数组,结果反转完之后又反转回去了,这就是没有理解"指针从两端对向移动"这个本质。
1.3 这道题在 LeetCode 上的定位
344 在 LeetCode 上被标记为"简单",通过率常年维持在 70% 上下。但这个数字有迷惑性——因为提交的人里有大量第一次刷题的新手,也有大量直接调用库函数通过的。真正到了面试场景,面试官不会满足于你"调 API 调对了",他会追问你:如果不允许用库函数呢?如果输入特别长怎么办?你能不能分析一下时间和空间复杂度?
所以我的建议是:这道题一定要以"能徒手写出双指针代码 + 能完整解释原理 + 能说出边界条件"为标准去准备,不要以"提交通过"为标准。LeetCode 的绿色勾是底线,不是目标。
2. 双指针法的核心思路:从两端向中间逼近
2.1 用一个具体例子走一遍全过程
假设输入是['h', 'e', 'l', 'l', 'o'],长度为 5。定义两个指针,left指向下标 0,right指向下标 4。
第一步:交换s[0]和s[4],数组变成['o', 'e', 'l', 'l', 'h'],然后left加 1 变成 1,right减 1 变成 3。
第二步:交换s[1]和s[3],数组变成['o', 'l', 'l', 'e', 'h'],然后left变成 2,right变成 2。
第三步:此时left和right指向同一个下标 2,不需要再交换。循环结束,反转完成。
如果你仔细观察这个过程,会发现交换的次数正好是n / 2(向下取整)。长度为 5 时交换了 2 次,中间的'l'自己跟自己交换没有意义。长度为 4 时,比如['a', 'b', 'c', 'd'],left 和 right 会依次经过 (0,3) 和 (1,2),交换 2 次,然后 left 变成 2、right 变成 1,指针交错,循环结束。
这里有一个非常重要的细节:循环的终止条件是left < right,不是left <= right。如果是<=,奇数长度的数组会在最后多做一次自己跟自己交换,虽然不影响结果,但逻辑上多了一次无意义的操作,面试如果问到这一步,能说出"等于的时候不需要交换"会是一个加分项。
2.2 双指针代码的标准写法
Java 版本:
class Solution { public void reverseString(char[] s) { int left = 0; int right = s.length - 1; while (left < right) { char temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } } }C++ 版本:
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--; } } };Python 版本:
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你可以看到,三个主流语言的实现逻辑完全一致,差别只在交换的语法糖上。C++ 里有 STL 的swap,Python 里有元组解包,Java 则需要自己写临时变量。这个区别本身也说明了为什么算法题推荐用自己最熟悉的语言去刷——写交换语句这种高频操作,应该做到肌肉记忆级别,不要在考场上还要想"Python 该怎么交换两个变量"。
2.3 复杂度分析的两个层面
时间复杂度和空间复杂度是这道题面试时几乎必被追问的两个问题。
时间复杂度是 O(n),因为每个字符最多被访问一次。严格来说是 n/2 次交换,每次交换涉及两次读取和两次写入,但常系数在渐进分析里可以被忽略,所以就是 O(n)。这里如果你能主动说一句"虽然每个元素被移动了一次,但实际上只有 n/2 次交换操作",会显得你理解得比标准答案更深一层。
空间复杂度是 O(1)。除了left、right和交换用的临时变量temp之外,没有使用任何与输入规模相关的额外存储。三个变量是固定大小,不随 n 变化,所以是常数级空间。这里要特别注意的是,如果你用递归来实现双指针,空间复杂度就会变成 O(n)(递归调用栈的深度),这就违反了题目的约束。关于这一点,后面第 4 节会详细展开。
3. 那些我在刷题和面试里真实踩过的坑
3.1 坑一:把双指针写成了"两个循环"
这个坑我在刚开始刷题的时候踩过,也在帮别人 review 代码的时候见过很多次。核心问题是写成这样:
for (int i = 0; i < s.length; i++) { char temp = s[i]; s[i] = s[s.length - 1 - i]; s[s.length - 1 - i] = temp; }这段代码表面上也能得到正确的结果,但有一个严重问题:它遍历了整个数组,也就是说对于长度为 n 的数组,它做了 n 次交换。而事实上,当你交换了s[0]和s[n-1]之后,s[n-1]和s[0]就已经各归其位了,等到循环走到i = n-1的时候,它会再把这两个位置交换一次,结果等于什么都没做。我在本地跑过这个代码,输入['a','b','c','d']输出还是['a','b','c','d'],完全没变。
用双指针的核心价值之一就是用 n/2 次操作完成原本需要 n 次操作的工作。当你发现自己的代码做了 n 次交换,基本可以断定哪里写错了。一个简单的自查方法:反转后的数组和原数组在对称位置上是互换关系,如果你遍历全部下标,每个位置会被交换两次,等于没有交换。
3.2 坑二:用s.length当作循环终止条件导致的时间浪费
这个坑比较隐蔽,出现在一种"看起来很像双指针"的写法里:
int left = 0; int right = s.length - 1; while (left < s.length) { // 交换逻辑 left++; right--; }这种写法的问题在于:left会一直增加到s.length,而right会一直减少到-1,循环结束后两个指针都已经越界了。虽然交换逻辑本身在 left < right 时是正确的,但循环条件写错了会让代码多做一半的无用功,而且越界访问在 C++ 里是未定义行为,在 Java 里会直接抛ArrayIndexOutOfBoundsException。
正确的循环条件只有一个标准:while (left < right)。原因前文已经提过——当 left 等于 right 时(奇数长度)或 left 超过 right 时(偶数长度),所有需要交换的位置都已经处理完了。这个条件不仅正确,而且从数学上严格对应了"最多只需要处理 n/2 次"这个事实。
3.3 坑三:调用库函数导致的空间违规
在 LeetCode 的讨论区里,你会看到不少"一行代码解决"的提交,比如调用Collections.reverse(Arrays.asList(s)),或者直接用StringBuilder的 reverse。这些提交能通过,多半是因为测试用例没有严格审查空间使用,而且 Java 的Collections.reverse内部其实也是双指针实现的。但如果你在面试中写这种东西,我建议你做好被追问的准备。
追问通常是这个套路:面试官先问"这个 API 内部是怎么实现的",如果你答不上来,他会接着问"你觉得这个 API 的空间复杂度是多少",然后问"如果题目要求你不能用这个 API 呢"。这一串问题下来,你基本上是招架不住的。
所以我的建议是:刷题阶段可以看一眼别人的"一行代码解法"开拓思路,但提交之后一定要自己手写一遍双指针版本。库函数能帮你通过用例,但帮不了你通过面试。
3.4 坑四:把 String 和 char[] 混为一谈
最后这个坑更多是概念层面的。因为题目描述里说的是"反转字符串",有些第一次刷题的人就直接写:
public String reverseString(String s) { return new StringBuilder(s).reverse().toString(); }这在 LeetCode 344 里是通不过的,因为函数的签名要求的是void reverseString(char[] s)。也就是说,这道题考察的不是"你会不会封装一个字符串反转工具",而是"你能不能在一个已经给你可变内存空间的前提下,完成原地修改"。Java 的 String 是不可变对象,每次修改都会生成新对象,所以在 Java 里凡是涉及大量字符串修改的场景,都应该优先考虑 StringBuilder 或 char[]。原型设计模式的思想在这里也有点影子:如果你要在一个固定对象上反复修改,就不要每次创建新对象,而是操作对象内部的状态。
面试中如果你能把"为什么题目选择 char[] 而不是 String"这个问题答清楚,面试官对你好感度会明显提升。答案是:如果输入是 String,那这道题在 Java 里根本无法做到 O(1) 空间反转,因为 String 不可变,任何"反转"操作都必须创建新字符串。所以题目才用 char[] 作为输入形式,让"原地修改"成为可能。
4. 双指针不是唯一解,但它是这道题的最优解
4.1 库函数解法:能用但等于没做
以 Java 为例,你可以通过Collections.reverse配合Arrays.asList来完成反转:
public void reverseString(char[] s) { List<Character> list = new ArrayList<>(); for (char c : s) list.add(c); Collections.reverse(list); for (int i = 0; i < s.length; i++) s[i] = list.get(i); }这种写法最后确实反转了数组,但它至少犯了两个错误:第一,new ArrayList<>()本身就是 O(n) 的额外空间;第二,这种写法完全没有体现解决这个问题的思路,面试官问一句"讲讲原理"你就卡壳了。
所以我通常会把这个方案定性为:它不是解法,它是对 API 的背诵。如果你在刷题阶段是为了锻炼算法能力,请跳过这种写法。
4.2 栈解法:思路直观但空间不达标
栈是一个天然适合反转的数据结构——你把字符依次压入栈中,再依次弹出,弹出的顺序就是原来的逆序。
public void reverseString(char[] s) { Stack<Character> stack = new Stack<>(); for (char c : s) stack.push(c); for (int i = 0; i < s.length; i++) s[i] = stack.pop(); }这个方案思路很简单,代码也很短,但它使用了 O(n) 的额外空间(栈里存了所有字符),完全违反了题目对 O(1) 空间的要求。如果你去面试,面试官让你"反转字符串",你写了个栈,他大概率会追问一句"你能不能不用额外空间实现",这时候你再切到双指针,反而会让整个对话显得是被推着走的。
我的建议是:栈解法作为思维方式了解一下就行,比如你完全可以用它来理解"后进先出"的概念,但不要把这种解法当作 344 的正式答案。在 344 这道题里,O(1) 空间是硬性约束,所有不满足这个约束的解法都应该直接排除。
4.3 递归解法:代码优雅但空间爆炸
递归写法是一个非常经典的"错误示范":
public void reverseString(char[] s) { reverseHelper(s, 0, s.length - 1); } private void reverseHelper(char[] s, int left, int right) { if (left >= right) return; char temp = s[left]; s[left] = s[right]; s[right] = temp; reverseHelper(s, left + 1, right - 1); }这段代码逻辑上完全正确,而且写法很简洁——每递归一层就交换一对字符,然后缩小指针范围。但它有一个致命问题:递归的深度是 n/2,而每次递归都会占用一层调用栈,所以空间复杂度是 O(n)。如果输入是一个 10 万字符的数组,这段代码会直接栈溢出。
我能理解有些人喜欢用递归解法是因为它"看起来更像是在用双指针",但算法题不是表演,不是代码越短越好,也不是递归越优雅越好。面试官希望你最优地解决问题,而不是炫技。双指针的迭代写法在所有方面都优于递归写法,所以这一节没有争议。
4.4 解法横向对比
为了让你更直观地理解为什么双指针是 344 的最优解,我把几种常见解法放在一起对比一下:
| 解法 | 时间复杂度 | 空间复杂度 | 是否满足题意 | 面试推荐指数 |
|---|---|---|---|---|
| 双指针(迭代) | O(n) | O(1) | 满足 | 强烈推荐 |
| 库函数 | O(n) | O(n) | 不满足 | 不推荐 |
| 栈 | O(n) | O(n) | 不满足 | 了解即可 |
| 递归 | O(n) | O(n) | 不满足 | 了解即可 |
从这里可以清晰地看出,唯一同时满足"O(n) 时间"和"O(1) 空间"两个条件的就是双指针迭代解法。它也是这道题真正的标准答案。
5. 从 344 延伸出去:反转类题目和字符串操作的进阶
5.1 字符串逆序在不同语言里的输出姿势
344 这道题是一个起点,但从它延伸出去的字符串逆序问题比想象中更常见。比如很多初学者想问"字符串逆序输出 c 语言怎么做",如果你已经理解了双指针,你会发现这个问题的本质不是"怎么输出",而是"怎么在字符串内部交换字符"。C 语言里没有现成的字符串类,操作的是char[]或者char*,所以双指针的思想非常直接地适用。
下面是一个 C 语言的字符串逆序实现:
void reverseString(char* s, int sSize) { int left = 0; int right = sSize - 1; while (left < right) { char temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } }再比如 C++ 的std::reverse函数,它内部就是双指针实现的。这些语言层面的函数本质上都在做同一件事:对向逼近,交换字符。理解了 344,你对它们的理解会从"会用函数"变成"知道它为什么这么实现"。
5.2 LeetCode 541:反转字符串 II——分段反转的思维升级
344 的进阶版本是 LeetCode 541:给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。如果剩余字符少于 k 个,则将剩余字符全部反转;如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
这个题的解法思路建立在 344 的基础之上:把字符串按 2k 一段切分,在每一段里用双指针反转前 k 个字符。
public String reverseStr(String s, int k) { char[] arr = s.toCharArray(); for (int start = 0; start < arr.length; start += 2 * k) { int left = start; int right = Math.min(start + k - 1, arr.length - 1); while (left < right) { char temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } return new String(arr); }你可以看到,这里依然用到了双指针交换的核心逻辑,只是交换的范围从整个数组变成了分段范围。如果你把 344 彻底搞懂了,541 对你来说就只是在外面套一层循环的问题。
5.3 LeetCode 151:翻转字符串里的单词——双指针的另一个维度
另一道经典题是 LeetCode 151,给定一个字符串,逐个翻转字符串中的每个单词。这道题有一个著名的解法思路:先反转整个字符串,再反转每个单词。
举个例子:输入"the sky is blue",先整体反转变成"eulb si yks eht",再逐个单词反转变成"blue is sky the"。这个过程里,反转部分的代码核心还是双指针——不管是反转整个字符串还是反转每个单词区间,都是在某个 [left, right] 范围内做对称交换。
这道题还涉及字符串处理和去空格的细节,但如果你能意识到它其实是在多次调用"344 的核心逻辑",你的算法思维就提升了一个层次。刷题最忌讳的是刷一个记一个,没有把知识点串起来;而双指针恰好是串联这些题目的一条主线路。
5.4 字符串类型问题的通用思维框架(以 344 为例)
如果你把 344 扩展到一个更大的视角,可以总结出一个处理字符串数组类题目的通用思考框架:
- 第一步,确认输入输出形式:是可变数组还是不可变字符串?这决定了你能不能原地修改。
- 第二步,确认空间约束:O(1) 空间意味着你只能使用有限几个变量,基本可以排除栈、哈希表、递归。
- 第三步,寻找对称性或单调性:反转问题找对称位置,查找问题找单调关系,子串问题找窗口特性。
- 第四步,用双指针或滑动窗口实现核心逻辑,并在循环条件里处理好边界。
这个框架不是 344 独有的,它适用于大部分字符串和数组问题。每次拿到新题,先走一遍这个思考流程,解题方向会清晰很多。
6. 面试实战:当 344 出现在你面前
6.1 面试官真正想考察的东西
从面试官视角看,344 是一道绝佳的"开局题"。它足够简单,可以让候选人进入状态;但它又足够多细节,能区分"背过答案"和"真正理解"的候选人。
我会观察以下几点:
- 沟通确认:候选人是否在写代码前确认了输入输出形式、是否问清楚能否使用额外空间。344 几乎把所有约束都写在题目里了,但如果候选人上来就写,说明他可能没有读题的习惯。
- 边界处理:空数组、单元素数组、奇偶长度是否都能正确覆盖。检查循环条件是
left < right还是left <= right,能看出候选人有没有真的推演过边界。 - 复杂度分析:能否清晰说出时间和空间复杂度,并解释为什么。
- 语言基本功:交换语句是否熟练,能否在白板上写对语法。
如果你能做到以上四点,这道题基本上就稳了。
6.2 一个完整的现场回答示范
我给你演示一段面试时可以采用的回答思路,供参考:
"首先,题目明确要求原地修改和使用 O(1) 额外空间,所以我会排除所有需要新建数组或字符串的方案。输入是 char[] 而不是 String,这让我可以直接修改数组元素。我会使用双指针法:定义 left 指向数组开头,right 指向数组末尾,在 left 小于 right 的条件下循环,每次把 left 和 right 指向的字符交换,然后 left 右移、right 左移。当 left 大于等于 right 时,说明所有需要交换的位置都已经处理完毕。时间复杂度是 O(n),因为每个字符最多被访问一次;空间复杂度是 O(1),因为我只使用了三个额外变量。"
这段话看起来简单,但它包含了:确认约束 → 排除错误方案 → 解释算法流程 → 分析复杂度。这就是面试官想听到的完整逻辑链。
6.3 关于刷题顺序和复习节奏的建议
最后分享一点个人经验。我在刷 LeetCode 的时候,并不推荐一上来就按照题号从 1 开始刷到底,而是建议按专题刷——数组、链表、哈希表、双指针、滑动窗口、动态规划,一个专题一个专题地过。344 就是"双指针"专题里非常好的入门题,刷完它可以接着刷 27(移除元素)、283(移动零)、125(验证回文串)这一系列同类型题,形成一个小的知识闭环。
在这个专题中,你还会遇到字面意义上的"变种",比如"字符串比较是否相等"这个看似基础的问题,在 Java 里就分成equals比较内容和==比较引用两个维度;又比如"字符串分割"在很多语言里会生成一个新数组,这同样涉及空间开销的考量。这些内容看着和 344 无关,但它们共同组成了一个程序员处理字符串问题的基本素养——知道什么时候能原地操作,知道什么时候必须新建对象。
回到 344 这道题本身,我的最终建议只有一句话:不要因为它是"简单题"就跳过,用手写板把它写到滚瓜烂熟,并且能讲出每一种替换方案的失败原因。很多看似基础的能力,恰恰是面试中最能反映功底的部分。等你把双指针练成了肌肉记忆,再遇到任何反转类、对撞类、区间类的问题,都会有一种"这题我见过"的从容感。