字符串反转,听起来是C++入门教程里排在前三的例题,好像谁都会:循环一顿操作,然后打印出来,完事。但我在实际写代码和帮人排查问题的时候发现,这个看似简单的操作,恰恰是很多人栽跟头的地方——不是不会写,而是写出来的代码在边界情况、性能表现、中文处理上都藏着雷。这篇文章就围绕字符串反转这个基础操作展开,从C++标准库的现成方案讲到手写实现,再讲到按单词反转、区间反转这些衍生变体,最后分享几个实际踩过的坑。无论你是刚学C++的学生,还是在准备笔试面试的求职者,都能从里面找到可以直接用的结论。
1. 先别急着写代码:字符串反转到底在考什么
1.1 同一个需求,两种完全不同的定义
很多刚接触的人以为字符串反转就是"把字符串倒过来打印",但其实在不同场景下,"反转"这两个字对应的是两种不同的要求,写代码前必须先分清楚。
第一种是原地反转整个字符串:比如把"hello"变成"olleh",要求不能额外申请一份等长的字符串,只能在原字符串内部做交换。LeetCode 344题就是这种,它直接把要求写在题目里:不要给另外的数组分配额外的空间。
第二种是返回一个新字符串:原字符串保持不变,把反转后的结果放到一个新的对象里返回。C++里的reverse_copy或者手写循环构造新串,走的就是这条路线。LeetCode 557题反转字符串中的单词、151题反转字符串中的单词,以及很多业务代码里其实都是这种。
我见过不少人在刷题时不看题目要求,一上来就reverse_copy生成新串,面试官追问一句"如果内存只有原来的两倍大小怎么办"就懵了。所以字符串反转的第一步,不是写代码,而是确认需求:你要原地改,还是要新串?
顺带提一个高频追问:如果字符串定义成const char*,还能原地反转吗?答案是看情况。如果是字符串字面量,写入是未定义行为,很可能直接段错误;如果是可写的字符数组,那可以,但要自己管理长度,不能调strlen之外的东西。这个坑我后面还会详细说。
1.2 反转操作的真实业务场景
有些同学觉得反转属于算法题专属,平时写业务根本用不到,其实不然。从我自己的项目经验来看,至少有三类场景很常见。
第一类是回文判断。判断一个字符串是不是回文,最直接的方式就是反转后比较,s == reversed一行搞定。虽然最优解是双指针从两头往中间扫,但反转法写起来快、不容易错,适合快速实现和测试。
第二类是大数运算。手写高精度加减乘法时,我们通常把数字存成字符串,并且习惯把最低位放在下标0的位置。数字字符串天然是高位在前,所以处理前往往要先反转一次,这样进位、借位就能顺着下标一路推下去,不用每次都在字符串尾部操作。这个反转操作在高精度计算里几乎是个固定套路。
第三类是数据脱敏与格式化展示。一些日志系统需要把手机号、身份证号、银行卡号做倒序掩码输出;某些协议文档要求签名串按字节逆序后再哈希;还有一些和字节序相关的底层处理,思路也和字符串反转同源。虽然这些场景不会让你写一个通用的reverse函数,但你得知道反转操作的时间代价和正确写法,否则很容易在处理大量数据时写出性能很差的代码。
2. 四种C++反转写法逐一拆解:从库函数到手写算法
2.1 首选方案:std::reverse与reverse_copy
如果你问我在C++里反转一个std::string最推荐什么,我的答案永远是标准库算法。代码短、正确性有保障、可读性也最好:
#include <algorithm> #include <iostream> #include <string> int main() { std::string s = "hello world"; std::reverse(s.begin(), s.end()); std::cout << s << std::endl; // 输出 dlrow olleh return 0; }std::reverse是<algorithm>里的一个模板函数,它接受一对迭代器,把[first, last)范围内的元素原地反转。因为它操作的是迭代器,所以不止std::string能用,std::vector、std::deque、普通数组也都能用。写std::reverse(v.begin(), v.end())反转vector同样是合法的。
如果你需要保留原字符串、生成一份反转后的新串,标准库也提供了现成方案:
std::string s = "hello"; std::string reversed; reversed.resize(s.size()); std::reverse_copy(s.begin(), s.end(), reversed.begin()); std::cout << reversed << std::endl; // 输出 olleh这里有个细节:reverse_copy不会自动分配空间,你必须先resize或者reserve并保证目标空间足够,否则就是越界写入。我见过有人忘记resize,结果程序没报错,但输出的字符串长度是0——因为reverse_copy往一个空字符串的begin()位置写,属于未定义行为,很多时候表现为静默失败。这个坑值得记一下。
还有一个常见的追问:既然是C++,为什么不用std::string的成员函数?因为std::string本身没有提供reverse成员函数,标准库的设计思路是把算法和容器解耦。这个设计也是面试官喜欢问的点:算法操作的是迭代器,而不是容器本身。
2.2 手写双指针交换:最容易被问到边界条件
如果你刷过题,大概率见过这种写法:
void reverseString(std::string& s) { int left = 0; int right = s.size() - 1; while (left < right) { std::swap(s[left], s[right]); ++left; --right; } }思路很直白:左右两个指针,一个从开头往右走,一个从结尾往左走,每走一步交换一次,直到两个指针相遇。时间复杂度 O(n),空间复杂度 O(1),是所有实现里最标准的答案。
但这段代码有个经典的坑:当字符串为空时,s.size()返回的是无符号整数,0 - 1的结果不是 -1,而是 4294967295(在 64 位系统上是更大的数)。这时候right变成巨大的无符号整型,while (right >= 0)会形成死循环或者越界访问。具体表现可能是崩溃,也可能是莫名其妙的随机字符。
正确的做法是先用if (s.empty()) return;做保护,或者把size_t转成有符号类型再计算:
void reverseString(std::string& s) { if (s.empty()) return; int left = 0; int right = static_cast<int>(s.size()) - 1; while (left < right) { std::swap(s[left], s[right]); ++left; --right; } }在笔试和面试里,这道题考察的重点恰恰就是这种边界条件。我自己面试别人的时候,只要对方写了双指针,我下一句一定会问:"字符串是空串怎么办?长度是1怎么办?"能答上来的人不多。
另外提醒一下:交换两个字符除了std::swap,还可以用临时变量,甚至用异或:
s[left] ^= s[right]; s[right] ^= s[left]; s[left] ^= s[right];异或交换看起来很酷,但不建议用在生产代码里——可读性差,而且如果用同一个变量做异或交换(比如s[0]和s[0]),会把字符清零。双指针循环里因为left < right不会出现这种情况,但手写异或交换这个技巧本身在实际工程里的价值不大,了解即可。
2.3 递归反转:理解调用栈才能不踩爆栈的坑
递归版本的代码可以写得很短,很多教科书里喜欢展示这一版:
std::string reverseRecursive(const std::string& s) { if (s.empty()) return ""; return s.back() + reverseRecursive(s.substr(0, s.size() - 1)); }逻辑非常清晰:每次取最后一个字符放到开头,剩下的子串继续递归。但性能非常差:substr每次都会拷贝一份子串,时间复杂度和空间复杂度都退化成 O(n²)。这个版本只适合用来理解递归思想,不适合任何实际场景。
稍微优化一点的版本是使用辅助函数,在原字符串上做递归交换:
#include <algorithm> void reverseHelper(std::string& s, int left, int right) { if (left >= right) return; std::swap(s[left], s[right]); reverseHelper(s, left + 1, right - 1); } void reverseStringRecursive(std::string& s) { if (s.empty()) return; reverseHelper(s, 0, static_cast<int>(s.size()) - 1); }这个版本是原地反转,但有个绕不开的问题:递归深度等于字符串长度的一半。当字符串很长时,调用栈会爆掉。我实测过一个100万字符的字符串,用递归版本直接栈溢出崩溃,而双指针版本秒回。所以递归反转的重点不在于"能不能用",而在于"为什么不能随便用"——这本身就是面试官想听到的回答。
2.4 基于栈的反转:用空间换清晰的逻辑
栈的特点是先进后出,拿它来做反转是天然的思路:把字符串每个字符依次压入栈,再依次弹出拼接,弹出的顺序正好是反转后的顺序。
#include <stack> #include <string> std::string reverseWithStack(const std::string& s) { std::stack<char> st; for (char c : s) { st.push(c); } std::string res; res.reserve(s.size()); while (!st.empty()) { res.push_back(st.top()); st.pop(); } return res; }这段代码的空间复杂度是 O(n),因为额外用了一个栈和一个结果字符串。优点是逻辑清晰、边界条件少,几乎不会写错;缺点是空间开销大,所以只适合在笔试里展示思路,或者用来强调"栈可以天然实现反转"这个特性。有些数据处理场景里,如果你本来就需要用栈做符号配对、括号匹配,顺手把反转也做了,那这个写法就很自然。
3. 复杂度不是唯一指标:性能实测与隐形成本
3.1 四种方法的时间和空间复杂度对照
很多入门文章会给你一张表,说这些方法都是 O(n),然后就没有然后了。但我建议你把这张表多看几遍,因为其中几个"O(n)"的含义完全不同:
| 方法 | 时间复杂度 | 空间复杂度 | 是否原地 | 适用场景 |
|---|---|---|---|---|
std::reverse | O(n) | O(1) | 是 | 通用首选,工程代码 |
| 双指针交换 | O(n) | O(1) | 是 | 笔试手写,面试考察 |
| 递归(辅助函数) | O(n) | O(n)(调用栈) | 是 | 理解递归思想 |
| 递归(substr版本) | O(n²) | O(n²) | 否 | 仅用于演示,慎用 |
| 栈 | O(n) | O(n) | 否 | 笔试思路展示 |
注意递归那一行,空间复杂度 O(n) 不是因为你申请了 O(n) 的数组,而是调用栈每一层都要保存参数、返回地址和局部变量。递归深度越大,栈空间消耗越大。这也是为什么很多公司的编码规范里禁止在处理大数组时使用递归。
3.2std::string的修改开销与 capacity 陷阱
手写双指针时,std::swap(s[left], s[right])看起来只是交换两个字符,但要明白std::string的底层是一个连续内存块,s[left]返回的是内部缓冲区的字符引用,所以交换就是对这块内存的两次读和两次写。字符串规模不大时无所谓,但如果字符串是很多 KB 甚至很多 MB,这个循环的 cache 友好性也要考虑——好在双指针访问是顺序的,缓存命中率通常不错。
另一个隐形成本来自std::string的扩容机制。如果你在循环里用reversed += s[i]来构造新字符串,字符串内部缓冲区会按需扩容。C++标准没有规定具体的扩容策略,常见实现是容量翻倍,但频繁扩容意味着频繁的重新分配和拷贝。一个100万字符的字符串,如果不用reserve,可能要经历大约20次扩容,总拷贝量接近字符串长度的两倍,不算致命,但完全可以用reserve一次性避免:
std::string reversed; reversed.reserve(s.size()); // 一次分配到位 for (int i = s.size() - 1; i >= 0; --i) { reversed.push_back(s[i]); }这里又有一个边界细节:i的类型。如果用int,当s.size()大于INT_MAX时,static_cast<int>(s.size())会溢出。虽然现实中很少遇到超过21亿字符的字符串,但笔试里如果有心人把字符串设成大长度,这就是一个漏洞。稳妥的做法是用size_t,但size_t是0到很大的数,倒序循环时判退出条件比较别扭。我通常这样写:
for (size_t i = s.size(); i > 0; --i) { reversed.push_back(s[i - 1]); }这段代码用i > 0作为退出条件,配合i - 1访问字符,既避免了size_t的无符号下溢问题,又不会在空串时越界。这类细节写多了就会形成肌肉记忆。
3.3 打印输出的正确姿势:回应"字符串反转怎么打印出来"
热搜词里有一个特别具体的问题:字符串反转怎么打印出来。我猜问这个问题的朋友,多半是写了reverse之后不知道怎么看结果,或者打印出来发现不对。
最常规的做法就是用std::cout:
std::string s = "hello"; std::reverse(s.begin(), s.end()); std::cout << s << std::endl;如果要用 C 风格的printf,注意printf("%s", s)在C++里是不合法的,std::string不会隐式转换成const char*。必须用s.c_str():
printf("%s\n", s.c_str());另一个常见的"打印出来是乱码"问题,跟反转本身没关系,而是控制台字符集和源文件字符集不一致。比如源文件保存为 UTF-8,Windows 控制台默认是 GBK,那么带中文的字符串直接打印就会乱。这在反转中文时尤其容易触发,我后面第4章会专门展开讲。
再分享一个调试技巧:反转前后各打印一次,对比着看。
std::cout << "before: " << s << std::endl; std::reverse(s.begin(), s.end()); std::cout << "after : " << s << std::endl;不要觉得打印调试低级,在实际开发里,很多反转后的字符串拼接了肉眼看不出来的不可见字符,没有对比输出你根本不知道问题出在哪一步。
4. 真正容易翻车的变体:按单词反转、区间反转与中文乱码
4.1 按单词反转:先整体反转再逐个单词反转
字符串反转最常见的进阶题型是按单词反转:给定"hello world",要求返回"world hello",而不是"dlrow olleh"。很多第一次见到这个题的人会觉得要写复杂的状态机,实际上思路非常简单,分两步走:
- 先把整个字符串反转,得到
"dlrow olleh"; - 再把每个单词内部的字母反转回来,得到
"world hello"。
#include <algorithm> #include <iostream> #include <string> void reverseRange(std::string& s, int begin, int end) { while (begin < end) { std::swap(s[begin], s[end]); ++begin; --end; } } std::string reverseWords(std::string s) { std::reverse(s.begin(), s.end()); int n = static_cast<int>(s.size()); int i = 0; while (i < n) { // 跳过空格 while (i < n && s[i] == ' ') ++i; int start = i; // 找单词终点 while (i < n && s[i] != ' ') ++i; reverseRange(s, start, i - 1); } return s; } int main() { std::string s = "hello world"; std::cout << reverseWords(s) << std::endl; // 输出 world hello return 0; }这个实现保留了原字符串的空格数量和位置。面试里如果题目要求去掉多余空格(比如 LeetCode 151),只需要在扫描过程中把多个空格压缩成一个,同时处理掉前导和尾随空格即可,核心的反转逻辑不变。
我建议你亲手把这道题写一遍,因为"整体反转 + 局部反转"这个套路很通用,后面处理旋转数组、旋转字符串时也能用上。
4.2 区间反转:边界条件的魔鬼细节
区间反转是字符串反转的另一个高频变体,题目会给你左右下标,让你只反转这一部分。看似简单,边界条件却经常翻车。
假设需求是反转闭区间[left, right]内的字符,一个稳妥的实现是:
void reverseRange(std::string& s, int left, int right) { if (s.empty()) return; int n = static_cast<int>(s.size()); if (left < 0 || right >= n || left >= right) return; while (left < right) { std::swap(s[left], s[right]); ++left; --right; } }这里有三个必须检查的条件:left不能小于0,right不能超过s.size() - 1,且left必须小于right。漏掉任何一个,都可能越界或者做出预期之外的行为。尤其是第三个条件,当left == right时循环本身可以不执行,但如果不做保护直接进while,某些写法会在left和right交叉后继续交换,把已经反转过的部分又换回去,最后得到的结果和原串一样——这种隐蔽错误非常难调试。
你看完会发现,区间反转的本质还是双指针,只是多了坐标合法性检查。这类题目的价值就在于提醒你:基础操作不等于没有操作细节,边界条件才是基本功最直观的体现。
4.3 中文与UTF-8字符串:"反转"没那么简单
中文处理是我见过翻车率最高的地方,也是字符串反转为什么能成为一个"基础但不简单"题目的重要原因。
C++的std::string本质上是一个字节容器,它不知道"字符"的存在。对于ASCII字符,一个字符就等于一个字节,反转字符串就是反转字节序列,结果完全符合直觉。但在 UTF-8 编码下,一个汉字占3个字节(部分生僻字占4个字节),当你直接std::reverse一个中文字符串时,实际反转的是字节顺序,而不是字符顺序。
举个具体例子,"你好"在 UTF-8 下的字节是E4 BD A0 E5 A5 BD,反转后变成BD A5 E5 A0 BD E4,这串字节打印出来就是一堆乱码。问题不是打印方式,而是反转的粒度错了。
如果你确实需要处理中文,有几种思路。简单练习可以直接用宽字符,但在 Linux 下std::wstring的编码依赖 locale,跨平台行为并不统一。更通用的做法是按 UTF-8 字符边界拆分后反转。我常写的版本如下:
#include <iostream> #include <string> #include <vector> #include <algorithm> std::vector<std::string> splitUtf8(const std::string& s) { std::vector<std::string> chars; for (size_t i = 0; i < s.size();) { unsigned char c = static_cast<unsigned char>(s[i]); size_t len = 1; if ((c & 0xE0) == 0xC0) { len = 2; // 2字节字符 } else if ((c & 0xF0) == 0xE0) { len = 3; // 3字节字符,大部分中文在这里 } else if ((c & 0xF8) == 0xF0) { len = 4; // 4字节字符,如部分emoji和生僻字 } chars.push_back(s.substr(i, len)); i += len; } return chars; } std::string reverseUtf8(const std::string& s) { auto chars = splitUtf8(s); std::reverse(chars.begin(), chars.end()); std::string res; for (const auto& c : chars) { res += c; } return res; } int main() { std::string s = "你好世界"; std::cout << s << std::endl; std::cout << reverseUtf8(s) << std::endl; // 输出 界世好你 return 0; }splitUtf8的核心是读取每个字节的高位:如果最高位是0,说明是单字节ASCII字符;如果高位是110,说明这是一个2字节字符的开头;1110是3字节;11110是4字节。识别出首字节长度后,用substr把这个完整字符拆出来。这个函数能正确处理绝大多数中文,但它不校验非法 UTF-8 序列,生产环境建议直接用成熟的 Unicode 库,不要自己造轮子。
我把这个例子写在这里,是想强调一个观点:字符串反转的难点从来不在于"如何交换字符",而在于你操作的对象到底是字节、码点还是完整的Unicode字素。面试官如果看到你能有意识地区分这三种层次,这一题基本就算拿下了。
5. 从这道基础题延伸出去:我踩过的一些坑
最后不写什么总结了,就分享几个我在实际开发里因为"太熟悉字符串反转"而翻过的跟头,给你提个醒。
第一个坑是重复交换。有一次我在一个日志组件里做字符串逆序,手写双指针时循环内除了两个指针的++/--之外,还在每次交换后额外写了一句s[i] = s[j],相当于交换了两次,最后的结果和原串一模一样。我当时盯着输出看了半天没看出问题,后来加了一行调试打印才发现是重复赋值。这件事给我的教训是:基础操作也要跑测试,不要因为觉得简单就跳过验证。
第二个坑是在循环里反复构造新字符串。一个业务统计模块需要对大量短字符串做反转,最初版本用的是递归substr写法,数据量小时没问题,线上数据一上来就明显变慢,分析日志后发现时间几乎都花在字符串拷贝上。后来改成双指针原地反转,同样的逻辑耗时降了一个量级。性能问题多数不出在算法复杂度上,而是出在数据拷贝上。
第三个坑和printf有关。早期写代码时图省事写了printf("%s", str),结果编译能过但运行时输出乱码,而且不是每次都崩。原因是std::string在内存里是一个对象,但printf("%s")把对象地址当成const char*去解析,行为完全不可控。后来我养成了一个习惯:用printf打印字符串一律写成printf("%s", str.c_str()),能省去一大类问题。
第四个是工程里更实际的场景:大文件处理中的反转。比如你要反转一个几百MB的文本文件,用std::string把整个文件读进内存再反转,内存占用会非常高。这时候正确的思路是分块处理或者从文件尾部倒着读、倒着写,而不是一次性加载。这类问题已经超出入门范畴,但思路是相通的:反转一个数据序列,核心永远是"从两端往中间交换",不管两端是数组下标、迭代器还是文件偏移量。
再给你一个小技巧:判断回文时,不必真的把整个字符串反转再比较。原地反转后再比较会额外消耗 O(n) 空间(或者需要先拷一份原串),而双指针从两端往中间扫只需要 O(1) 空间。但反转法也有它的价值——当你想测试一个字符串反转后再反转能否得到原串时,这个性质本身就是一道很经典的递归题。
字符串反转这个题目,真正值钱的不是那几行代码,而是你对边界条件的敏感度、对数据规模的认识、对字符编码的理解,以及对标准库的信心。把这几个点吃透,以后再遇到什么旋转字符串、反转链表、反转数字,你会发现底层的思路都是同一套:找到可以对称交换的端点,然后一步一步往中间收拢。这个感觉一旦找到,很多看似不同的题就都通了。