后台经常有人跑来问我:双指针反转字符串这题到底该怎么写?说实话,第一次看到这道题,我也觉得简单到有点“无聊”,一个for循环倒着拷贝不就行了。但你真去面一次试或者认真刷一遍题就知道,这题考的根本不是“会不会反转”,而是你懂不懂原地操作、能不能把边界和类型处理干净。LeetCode 344 那道题,几乎每个刷题的人都写过,但我面试别人的时候,超过一半的人讲不清楚“为什么双指针能原地反转”。这篇内容就是想把这件事彻底掰开揉碎讲清楚,从最底层的思路,到 C++ 的几种写法,再到扩展应用和踩坑经验,适合刚学算法的同学,也适合准备面试的人做一次查漏补缺。
1. 项目概述:一个看似简单却值得拆解的题目
1.1 题目到底在问什么
常见的题目描述是:编写一个函数,输入一个字符数组s,将字符串反转,但不能额外分配另一个数组,必须原地修改输入数组,使用O(1)的额外空间完成。有的变体会给string,有的要求返回新字符串,但核心永远是“原地”两个字。
很多第一次接触的人会愣住:不新开数组怎么反转?其实仔细想想就明白了。反转的本质是“把第一个字符和最后一个字符对调,把第二个字符和倒数第二个对调,一直对调到中间”。两个人面对面,左边的人往右走,右边的人往左走,走到碰头就算结束。这就是双指针最纯粹的样子:一个指针指向待处理区间的最左端,另一个指针指向最右端,在循环中逐步向中间收敛。
这道题的价值不在“反转”这个动作本身,而在于它逼着你建立一种空间敏感度。你写一个临时数组当然也能得到正确结果,但复杂度面试官不会满意;双指针解法只用两个下标或者迭代器,不申请任何额外数组,让空间复杂度从O(n)降到O(1),这才是题目真正想考察的东西。
1.2 为什么双指针是“标准答案”
如果先写一个最朴素的版本,思路会非常直白:新建一个等长的字符数组,然后从原数组尾部开始遍历,按顺序填进新数组:
vector<char> copy(s.rbegin(), s.rend()); // 反序拷贝这个写法不是不行,但它开了新空间,违背了“原地反转”的核心约束。双指针解决问题的优势就在这里:每次只交换两个位置上的元素,并且这两个位置的信息都已经被“指针”记了下来。不需要额外数组去保存“等一下要用哪个位置的元素”,因为左右指针自己就知道该往哪移动。
你可以把反转过程想成“翻煎饼”。一排煎饼从左到右依次放着,你想让顺序颠倒,不需要把它们全部端到另一个盘子里,只需要从两头开始,把最左和最右对调,然后不断向中间推进。每对煎饼只需要一次交换,交换完的位置就固定了,后续操作不会再碰它们。这个思路放到字符数组上,就是标准双指针。
1.3 整体方案选型:先副本、再双指针、最后标准库
我的建议是练习时按三个阶段走。第一遍,允许自己写副本法,把结果做对;第二遍,强迫自己改成原地双指针,体会空间复杂度从O(n)变成O(1)的过程;第三遍,再去看std::reverse的源码和实现思路,理解标准库为什么长这样。
第一次就背std::reverse的模板没有意义,因为你不知道它内部是怎么收敛的。实际刷题时,如果题目没有特殊限制,直接用std::reverse当然是最稳妥、最不容易出错的;但面试官一旦追问“你能手动实现一下吗”,你肚子里得有东西。所以这篇博文的主角,永远是那两根指针。
2. 双指针原理拆解:核心逻辑与复杂度分析
2.1 一左一右,交换,然后向中间走
拿最经典的例子hello来走一遍流程。字符串有 5 个字符,初始时left = 0,指向h;right = 4,指向o。
- 第一轮:交换
s[0]和s[4],得到oellh。然后left加 1 变成 1,right减 1 变成 3。 - 第二轮:交换
s[1]和s[3],交换的是e和l,得到olleh。然后left变成 2,right变成 2。 - 第三轮:此时
left不再小于right,循环结束。
如果是偶数长度的字符串,比如abcd,过程更对称:a和d换,b和c换,等到left已经跑到right的右边,循环结束。奇数长度时最中间那个字符不需要动,因为它本身就是反转后的中心位置。
我用一个表格把hello的过程记录下来,方便你在脑子里建立一个画面:
| 轮次 | left | right | 当前字符串 | 交换结果 |
|---|---|---|---|---|
| 初始 | 0 | 4 | hello | 无 |
| 1 | 0 | 4 | h<->o | oellh |
| 2 | 1 | 3 | e<->l | olleh |
| 3 | 2 | 2 | 循环结束 | olleh |
看到没,整个过程一共只执行了n / 2次交换,整除向下取整。5 个字符交换 2 次,中间字符原地不动,四个字符交换 2 次,全部完成。
2.2 为什么可以原地完成反转
很多人第一次会觉得奇怪:不借助额外空间,那交换的时候不是需要一个临时变量吗?这个临时变量算不算额外空间?这里要澄清:语言层面用于交换临时值的单个变量,在算法分析中通常不被算作“额外数组空间”,因为它的空间是O(1)常量级。双指针原地反转的核心是交换操作只涉及两个确定位置上的元素,不需要缓存整串数据。
从信息论角度看,反转一个序列本质上是改变元素之间的排列关系,而不是生成新数据。既然目标只是重排,那就可以通过“对称交换”实现。左边第i个位置最终应该放原串第n-1-i个位置上的字符,双指针恰好把这两个位置配对,每对处理完就再也不会被重复访问,所以信息不丢失,也不需要留备份。这就是“原地”能成立的底层原因。
2.3 时间复杂度与空间复杂度到底是多少
循环的次数是n / 2,每一次swap不管用标准库还是手写临时变量,都是常数时间操作,所以总时间复杂度是O(n)。这个复杂度跟副本法一样,毕竟任何反转算法最少也得检查每个字符一次。真正的区别在空间:副本法申请了一个n大小的新数组,空间O(n);双指针法只用了两个下标和一个临时变量,空间O(1)。
我在面试别人时最常听到的回答是“时间复杂度是 O(n/2)”,这也不能算错,但算法分析里常数因子会被忽略,写成O(n)更标准。如果面试官从O(n/2)追问下去,你只要回答“常数项可以省略”就行。复杂度分析的目的不是抠算多少次交换,而是看数据规模变大时,时间增长的趋势是线性的、平方的还是对数的。
3. C++ 代码实现与实操细节
3.1 最标准的 while 双指针写法
写 C++ 解法时,最常见的输入是vector<char>& s。最标准的代码长这样:
void reverseString(vector<char>& s) { int n = (int)s.size(); if (n < 2) return; int left = 0; int right = n - 1; while (left < right) { swap(s[left], s[right]); ++left; --right; } }这里有个很容易被忽略的点:s.size()返回的是size_t,也就是无符号整数。如果你直接写成int right = s.size() - 1;,当s是空字符串时,0 - 1会变成一个非常大的无符号数,之后循环行为会完全失控。所以稳妥的做法是先把size()转成int,或者单独判空。
循环条件用left < right而不是left <= right。原因很简单:当两个指针相遇指向同一个元素时,这个元素已经在正确位置了,没必要再和自己交换一次。虽然用<=在配合std::swap时不会报错,但它多做了一次无意义操作,并且破坏了“循环次数严格等于 n/2”的直觉。如果将来你手痒换成异或交换,left <= right就是灾难,后面我会专门说。
3.2 基于迭代器的双指针写法
C++ 里另一个常见写法是用迭代器。vector<char>的迭代器是随机访问迭代器,支持加法和减法,所以可以这么做:
void reverseString(vector<char>& s) { auto left = s.begin(); auto right = s.end(); if (left == right) return; --right; while (left < right) { swap(*left, *right); ++left; --right; } }这个写法在语义上更贴近 STL 风格,而且不容易出现“int 和 size_t 比较”的类型问题。需要注意,s.end()指向的是最后一个元素的下一位,不是最后一个元素本身,所以必须先--right再进入循环。
迭代器写法的局限也很明显:它依赖随机访问迭代器。如果你把vector<char>换成list<char>,left < right这个比较就没法编译了,因为链表迭代器只支持!=判断。好在反转字符串场景基本不会用list,所以这个写法平时用完全没问题。我个人的习惯是:刷题时用下标版,因为更好解释;写工程代码时用迭代器版,因为更通用,思路也更清晰。
3.3 用 std::swap 还是手写临时变量
标准库的std::swap内部通常做了优化和类型萃取,对基本类型来说非常高效,而且异常安全。刷题和面试时直接用swap(s[left], s[right])是最优选择。但有些面试官会故意问:“你能不用std::swap实现吗?”这时候你要能写出:
char tmp = s[left]; s[left] = s[right]; s[right] = tmp;这其实就是std::swap的朴素版本。手写时最大的坑是类型不匹配,比如左边是char,右边你写了个auto就可能导致拷贝错误,不过char类型很简单,一般不会有事。
网上还有一种流传很广的“异或交换”写法:
s[left] ^= s[right]; s[right] ^= s[left]; s[left] ^= s[right];我强烈不建议在反转字符串里用。原因有两个:第一,当left和right指向同一个地址时,异或会把元素清成 0,而前面提到有人喜欢用left <= right,一旦相等就中招;第二,异或交换可读性差,别人看代码要反应很久才能明白这是在交换。算法题讲究清晰、准确,不是炫技,老老实实用临时变量是最好的人效比。
3.4 反转之后怎么打印出来(C++ 篇)
“字符串反转怎么打印出来 c++”是不少人搜过的关键词。很多新手搞混了“反转存储”和“打印输出”。如果函数已经原地修改了s,打印只需要:
for (char c : s) { cout << c; } cout << endl;如果s是string类型,直接cout << s << endl;就行;如果s是vector<char>,直接输出cout << s是不行的,因为vector没有重载这个输出运算符,必须遍历或者先构造一个string:
string result(s.begin(), s.end()); cout << result << endl;还有一种比较“STL 风格”的写法:
copy(s.begin(), s.end(), ostream_iterator<char>(cout));这种写法本质上是把每个字符依次交给ostream_iterator输出。日常项目里我不太建议为了打印一个字符串搞这么花哨,但知道有这么个东西,看别人代码时不至于懵。打印这件事的重点是确认结果符合预期,后面我会单独说测试用例。
4. 扩展应用:双指针思想不只是反转
4.1 反转字符串里的单词顺序
双指针反转字符串的经典变体是“反转字符串中的单词顺序”,比如the sky is blue要变成blue is sky the。很多第一次看到这题的人会想:先把所有单词塞到一个栈里,再弹出来拼成新串。这样做当然可以,但空间复杂度是O(n),不是最优。更巧妙的做法就是:先整体反转整个字符串,再从前往后逐个反转每个单词。
举个例子,原串the sky is blue整体反转后变成eulb si yks eht,然后你再把每个连续字母段反转一次:eulb反转成blue,si反转成is,yks反转成sky,eht反转成the,最终得到blue is sky the。思路的核心仍然是双指针,不过第一次是全串范围,第二次是以空格为边界的局部范围。
这个过程里最难处理的其实是空格边界。连续多个空格怎么办?字符串首尾有空格怎么办?不同题目要求不同,有的要求保留空格数量,有的要求删除多余空格。我的建议是:先把“去掉多余空格”和“整体反转”分开写,不要混在一次循环里,否则调试会很痛苦。写完基础版再去优化边界条件,这样思路不会乱。
4.2 判断回文:双指针的另一面
反转字符串还有一种特别常见的姊妹题:判断一个字符串是不是回文。回文的定义就是正着读和反着读一样,比如racecar。用双指针判断时,一个指针从头走,一个指针从尾走,逐个比较字符是否相等,一旦发现不一样就返回false。
如果题目进一步要求“忽略大小写、忽略非字母数字字符”,代码会稍微复杂一点,但整体框架不变:
bool isPalindrome(string s) { int left = 0, right = (int)s.size() - 1; while (left < right) { while (left < right && !isalnum(s[left])) ++left; while (left < right && !isalnum(s[right])) --right; if (tolower(s[left]) != tolower(s[right])) return false; ++left; --right; } return true; }这个题和反转字符串的区别在于:反转是“交换两端的值”,回文判断是“比较两端的值”。但指针移动的模式一模一样,都是向中间收敛。你如果能把反转字符串的双指针写熟,回文判断基本就是顺手的事。
4.3 快慢指针:双指针的另一种形态
除了相向而行的双指针,还有“快慢指针”,也就是一个指针走得快,一个指针走得慢,典型应用是“移动零”、“数组去重”、“找链表中间节点”。虽然方向和反转字符串不同,但底层思想一致:用两个指针维护不同的语义位置,在一次遍历里完成操作。
拿“移动零”举例:要求把所有 0 移到数组末尾,同时保持非零元素的相对顺序。这时可以用一个慢指针slow指向“下一个可以放非零元素的位置”,一个快指针fast从头遍历。快指针负责找非零元素,找到后放到slow位置,slow前进。一趟下来,非零元素全部靠前,最后把剩余位置填 0。你回头看反转字符串的双指针,会发现都是“两指针协作、避免 O(n) 额外空间”的模式。这就是算法学习里常说的“做一题会一类”。
5. 常见踩坑与排查心得
5.1 中文输入和编码的坑
如果你拿这道题去处理中文字符串,比如你好,直接用vector<char>反转会得到一堆乱码。这不是你代码写错了,而是 C++ 里char只占一个字节,而中文在 UTF-8 编码下通常占 3 个字节。你的 UTF-8 编码大致是E4 BD A0,好的编码大致是E5 A5 BD,整个字符串在内存里是E4 BD A0 E5 A5 BD。双指针反转的是单个char字节,结果字节顺序完全颠倒,解码出来自然是乱码。
这个问题没有“一行代码”的简单解法。真正的处理方式是使用宽字符类型std::wstring和wchar_t,或者在拿到字符串后先按 Unicode 码点拆成字符数组,再反转,最后重新编码。但在 LeetCode 这类平台上,输入几乎都是纯 ASCII 的英文串,不需要考虑中文。如果你在自己的项目里做字符串反转,一定要先问清楚数据是什么编码。我踩过这个坑之后养成了一个习惯:只要有中文字符串处理,先用一个最简单的测试用例跑一遍,看输出是否正常再写后续逻辑。
5.2 小心 size_t 和无符号数的下溢
前面提到了s.size() - 1的经典问题。size_t是无符号类型,你如果写出这样的代码:
int right = s.size() - 1;当s为空时,s.size()等于 0,0 - 1在无符号运算里会变成18446744073709551615这样的巨大整数,然后转成int,多半变成-1或者某个诡异的数。更安全的是:
int n = (int)s.size(); if (n == 0) return; int left = 0, right = n - 1;一开始就在代码入口写好边界返回,能省掉很多调试时间。你可能会觉得“我测试的时候传的都是非空字符串,没问题”,但面试官最爱干的事就是拿空字符串测试你的候选代码。边界条件不是可有可无的装饰,而是代码质量的一部分。
5.3 while(left <= right) 到底行不行
我一直强调用left < right,那写成<=会怎样?如果配合std::swap,在left == right时只是让同一个元素和自己交换,结果不变,程序不会报错。所以很多人觉得无所谓。但我见过不止一个新手在某个瞬间“灵机一动”,把交换逻辑改成了异或运算,然后在left == right时直接把元素改成 0,查半天查不出来。
还有一点,left <= right会让循环多走一次,虽然那一次不产生实质变化,但在白板推演复杂度时容易说错。面试时候选人说“我循环执行了 n/2 次,因为 left < right”,会比“反正转对了”显得更清晰。所以我还是建议统一写<,既省事又严谨。
5.4 测试用例该怎么设计
这道题虽然小,但测试用例覆盖好了能体现你的严谨性。我整理了一张表,你可以直接拿去当自测清单:
| 输入 | 预期输出 | 用途 |
|---|---|---|
"" | "" | 空串边界 |
"a" | "a" | 单字符 |
"ab" | "ba" | 偶数长度 |
"abc" | "cba" | 奇数长度,验证中间字符 |
"ab cd" | "dc ba" | 带空格的正常字符 |
"AAbb" | "bbAA" | 大小写混合 |
跑用例的时候不要只盯着结果对不对,还要注意有没有额外报错,比如空串下right初始值异常、循环越界等等。拿这几个用例去跑自己的代码,基本能暴露 90% 的边界问题。
6. 个人心得:把这道题内化成自己的技能
我自己刷这道题的时候,第一反应也是“这么简单的题有什么好写的”,后来在给别人讲的时候才发现:越是简单的题,越能看出一个程序员对基础概念的掌握程度。双指针反转字符串,本质上串联起了四个基础能力:数组索引、循环边界、原地交换、复杂度分析。每一项都不难,但合在一起,很多人就讲不清楚了。
有一个小习惯对我帮助很大:不要只盯着代码看,而是拿一张纸画出每一步的数组状态。把left和right的位置标出来,把交换后数组的内容写下来,你就会发现“指针怎么移动”比“指针是什么”重要得多。遇到奇数长度时中间元素不动、遇到偶数长度时两个指针刚好交错,这些细节只靠脑内模拟很容易忽略。
另外,面试时别急着写代码。先把思路用口语说一遍:“我会用一左一右两个下标,往中间走,依次交换。”这句话一说完,面试官通常就会点头。接着你再开始写代码,并把空串、单字符这些边界条件随手处理掉,原地的空间复杂度也顺带提一句,整个题就回答得很完整了。如果时间允许,你还可以顺势说一句“这个思路还可以用在回文判断和反转单词顺序上”,展示你的迁移能力。
这道题写在简历上可能什么都不是,但它真的是一把很好的钥匙。用它打开双指针的大门,后面再遇到滑动窗口、链表操作、原地哈希,你都会有一种熟悉感:不过是两根指针,配合不同的移动规则罢了。希望这篇能帮你少走一点弯路,也欢迎你有自己的想法时多动手验证,纸上得来终觉浅。