尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++字符串反转全解析:从std::reverse到中文UTF-8避坑指南

C++字符串反转全解析:从std::reverse到中文UTF-8避坑指南 字符串反转听起来是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_castint(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_castint(s.size()) - 1); }这个版本是原地反转但有个绕不开的问题递归深度等于字符串长度的一半。当字符串很长时调用栈会爆掉。我实测过一个100万字符的字符串用递归版本直接栈溢出崩溃而双指针版本秒回。所以递归反转的重点不在于能不能用而在于为什么不能随便用——这本身就是面试官想听到的回答。2.4 基于栈的反转用空间换清晰的逻辑栈的特点是先进后出拿它来做反转是天然的思路把字符串每个字符依次压入栈再依次弹出拼接弹出的顺序正好是反转后的顺序。#include stack #include string std::string reverseWithStack(const std::string s) { std::stackchar 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::reverseO(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_castint(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::coutstd::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-8Windows 控制台默认是 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_castint(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_castint(s.size()); if (left 0 || right n || left right) return; while (left right) { std::swap(s[left], s[right]); left; --right; } }这里有三个必须检查的条件left不能小于0right不能超过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::vectorstd::string splitUtf8(const std::string s) { std::vectorstd::string chars; for (size_t i 0; i s.size();) { unsigned char c static_castunsigned 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) 空间。但反转法也有它的价值——当你想测试一个字符串反转后再反转能否得到原串时这个性质本身就是一道很经典的递归题。字符串反转这个题目真正值钱的不是那几行代码而是你对边界条件的敏感度、对数据规模的认识、对字符编码的理解以及对标准库的信心。把这几个点吃透以后再遇到什么旋转字符串、反转链表、反转数字你会发现底层的思路都是同一套找到可以对称交换的端点然后一步一步往中间收拢。这个感觉一旦找到很多看似不同的题就都通了。
返回列表