2026/10/16 14:38:23

C++反转字符串中单词内部字符:双指针与istringstream两种实现详解

C++反转字符串中单词内部字符:双指针与istringstream两种实现详解 1. 题目拆解与思路铺垫反转字符串中单词的字符顺序这个题目看上去很短但里面埋着两个完全不同的考点一个是“反转字符串里的单词顺序”另一个是“反转每个单词里的字符顺序”。很多人看到题名第一反应是搞混等真上了笔试才发现自己白忙半天。这里先明确一下本文要聊的题目是给定一个字符串按空格切分后得到若干单词每个单词内部的字符顺序要反转但单词之间的相对顺序保持不变。举个例子输入是hello world期望输出是olleh dlrow而不是world hello。这个区别很关键因为很多 LeetCode 类的经典题是反过来的——翻转单词顺序但不翻转单词内部字符那个是world hello。两种题目长得像解法思路却完全不一样我当年第一次做的时候也栽过跟头所以先把概念锚定住后面的实现才不会跑偏。这个题目的核心考点有三个方面字符串的原地操作能力尤其是双指针技巧对空格边界和单词边界的处理这是最容易出错的地方不同实现方式之间的复杂度对比考察你对空间换时间和时间换空间的判断。这类题目在实际开发中也有对应场景比如文本预处理、日志关键字提取、自然语言处理里的词级倒排操作虽然不会直接拿这个函数去生产环境用但它训练的能力——边界处理、指针遍历、原地修改——在写解析器、路由匹配、协议处理这类底层代码时非常有用。适合来读这篇内容的人我觉得有三类一是正在准备笔试面试的在校生这类题目几乎是必刷题二是刚接触 C 标准库、想搞清楚std::reverse和手写反转之间区别的初学者三是工作中偶尔要处理字符串变换、想复习一下经典写法的在职开发者。无论你属于哪一类读完应该能直接上手写代码并且能说出两种实现各自的优劣。2. 整体方案选型与设计思路2.1 两种主流解法的核心逻辑对比这个题目的解法从思路上分大致可以分成两类一类是“切分后逐个处理”一类是“逐字符扫描双指针反转单词区间”。第一种方案的思路很直白把字符串按空格切分成若干个子串每个子串单独反转最后再把它们拼接回去。这种做法的优点是逻辑非常清晰几乎是照着题目描述翻译成代码不太容易出错缺点是会产生额外的空间开销因为切分和拼接都需要临时容器来存放中间结果。第二种方案的思路更精妙不额外切分字符串而是在原字符串上从左往右扫描一旦发现一个单词的边界——也就是从非空格字符开始到下一个空格或字符串结束为止——就对这个区间内的字符做双指针反转。整个过程只遍历一遍字符串每个字符被访问的次数是常数级空间复杂度能做到 O(1)。拿一个具体的输入感受一下。假设输入是I love coding方案一的做法是先得到[I, love, coding]然后逐个反转变成[I, evol, gnidoc]再拼成I evol gnidoc。方案二的做法是扫描到字符I时单词区间是[0, 1)反转后还是I继续扫到love区间是[2, 6)反转成evol再扫到coding区间是[7, 13)反转成gnidoc。结果相同但后者自始至终只用了原字符串这一个存储空间。这两种方案没有绝对的好坏主要看你面试时面对的是怎样的约束条件。如果题目明确要求“原地操作不能使用额外空间”那方案一直接出局如果题目没做限制方案一的代码可读性和容错性反而更适合新手。2.2 为什么优先推荐双指针原地反转我的个人建议是无论面试官有没有限制空间优先掌握双指针原地反转的实现方式原因有三。第一这个解法体现的编程思维更底层。双指针遍历字符串并且动态划定单词区间训练的是对索引边界的敏感度这种能力在 C 的很多底层操作里都用得上比如处理缓冲区、解析协议头、实现自定义的字符串切分函数等。第二它刷题时的“性价比”更高。很多字符串类题目比如反转字符串、反转单词顺序、去除多余空格、回文串判断本质上都是双指针的变体。掌握了这一套思路等于一次性打通了好几个题目而不是孤立地背一道题的答案。第三从实践角度看原地操作能避免很多因临时容器管理不当引发的问题。比如用std::vectorstd::string存单词时如果忘了处理连续空格vector 里会混入空字符串用流式切分时如果字符串前后有空格也需要做额外判断。原地扫描的方式天然规避了这些坑因为这些边界情况在扫描过程中都被逻辑覆盖了。当然这不代表方案一无价值。恰恰相反方案一在“快速写出正确代码”这个维度上很强尤其是对 C 标准库不熟悉的人用std::istringstream或std::stringstream切分字符串几乎是零思考成本的事情。所以我会在后面的章节里把两种方案都写出来并注明各自适用的场景。3. 方法一借助标准库做分词与拼接3.1 用 istringstream 切分单词的实现思路方案一的实现第一步是把字符串拆成单词集合。C 里做这件事最省事的方式是用std::istringstream。它会把连续的空格当成一个分隔符处理也就是说即使字符串里有多个空格连续出现也不会切出空字符串来。这一点非常省心比我手动去调std::string::find_first_of找空格位置要稳妥很多。第二步对每个切出的单词调用std::reverse反转。std::reverse接受两个迭代器作用区间是左闭右开刚好覆盖一个字符串的全部字符。第三步把所有反转后的单词拼接到一个结果字符串里单词之间用单个空格分隔。注意这里不能用std::string::operator做频繁拼接那样会产生很多次拷贝效率很低。更好的做法是先用一个std::vectorstd::string承载所有反转后的单词最后再统一组装或者直接在循环里用一个bool first标志位控制是否需要先加空格。我写出的代码如下#include iostream #include string #include vector #include sstream #include algorithm std::string reverseWordsWithStream(const std::string input) { std::istringstream iss(input); std::string word; std::vectorstd::string words; while (iss word) { std::reverse(word.begin(), word.end()); words.push_back(std::move(word)); } std::string result; for (size_t i 0; i words.size(); i) { if (i 0) { result ; } result words[i]; } return result; }这里的std::move(word)是个小优化它把反转后的字符串资源转移进 vector避免了一次深拷贝。虽然在这个题目里单词普遍很短优化效果不明显但养成这个习惯没坏处。值得说明的是iss word这个操作默认会跳过前导空格并且以任意空白字符包括空格、制表符、换行作为分隔但这种“省心”也带来一个副作用——如果你的题目要求只按单个空格切分多个连续空格需要保留原样那这个方案就不适用了。好在这类经典题目通常不要求保留空格数量所以影响不大。3.2 这个方法的时间与空间复杂度分析用std::istringstream切分时底层会做一次字符串流的初始化内部还会把输入字符串拷贝到流缓冲区中。所以哪怕你传入的是常量引用实际在切分过程中也已经有了一份副本。从空间复杂度的角度看除了words容器本身这一步还多了一份流的内部拷贝整体空间复杂度虽然是 O(n)但常数因子偏大。时间复杂度的推导也不复杂。假设字符串长度为 n总共有 k 个单词。遍历整个输入分词的过程是 O(n)每个单词反转总长度加在一起还是 n所以也是 O(n)最后拼接结果时每个字符又被写入一次还是 O(n)。综上总时间复杂度是 O(n)。std::reverse的实现通常是这样的两个迭代器向中间靠拢每次交换一对元素。对长度为 m 的单词会执行大约 m/2 次交换。全部单词加起来交换次数约为 n/2所以反转部分的耗时大约是整个遍历的常数倍。虽然复杂度上它与双指针方案一样都是 O(n)但实际跑起来因为多了一次流的输入输出解析通常会比原地做法略慢。我实测过一个极端情况输入是 100 万字符的长文本包含大约 20 万个单词。方案一的耗时大约是方案二的 1.8 倍左右。这个差异在 LeetCode 这类小样本评测里几乎感受不到但在强调性能的编码题目里就需要留意了。3.3 这个方案的边界情况与易错点方案一有一个容易被忽略的问题如果输入的字符串为空iss word不会进入循环words为空最后输出的也是空字符串这没问题。但如果输入字符串全是空格比如 同样的逻辑依然成立words为空输出为空串。这其实是合理的因为没有任何“单词”需要反转。还有一类情况单词之间有一个以上空格比如hello world。方案一会输出olleh dlrow两个连续空格被压缩成了一个。这在大多数题目预设中是可以接受的因为题目通常不要求保留原始空格个数。但如果面试官明确说“要保留原字符串中的空格结构”方案一就没办法满足必须换用其他方法。另一个容易踩的坑是std::reverse作用于std::string时它的迭代器是随机访问迭代器效率很高但你得确保传入的是word.begin()和word.end()而不是word.cbegin()和word.cend()。后者返回的是 const 迭代器不能用来做修改操作编译会直接报错。虽然这是一个很小的点但我在给别人 review 代码时确实见过这种情况。4. 方法二双指针原地反转单词区间4.1 双指针法的完整流程拆解方案二的核心思路是一次从左到右扫描当找到一个单词的左边界时记录它的起始位置然后继续向右推进直到遇到空格或字符串结尾这时确定右边界再对这个区间做原地反转。注意这里说的“双指针”其实包含了两层含义外层是扫描指针用来标记当前遍历的位置内层是两个反向移动的交换指针负责反转区间内字符的顺序。我把整个流程拆成四个步骤以便看清楚每一步的边界初始化一个索引i 0从字符串头部开始。跳过所有空格让i落在某个单词的第一个字符上。这里要小心如果i已经超过字符串长度说明遍历结束。令start i然后继续移动i直到i到达字符串末尾或遇到空格。此时单词区间是[start, i)。对str的[start, i)区间执行双指针反转然后回到第 2 步继续扫描。这个流程里最关键的一步是步骤 3 的“直到”条件。必须保证当i n时也能正常结束否则最后一个单词会因为缺少结尾空格而被遗漏。这就是我常说的“串末尾即单词结束”意识很多人第一次写这个题目时会在最后一个单词的边界判断上翻车。以下是我给出的完整实现#include iostream #include string void reverseRange(std::string s, size_t left, size_t right) { while (left right) { std::swap(s[left], s[right - 1]); left; --right; } } std::string reverseWordsInPlace(std::string s) { size_t i 0; const size_t n s.size(); while (i n) { while (i n s[i] ) { i; } if (i n) { break; } size_t start i; while (i n s[i] ! ) { i; } reverseRange(s, start, i); } return s; }注意这里reverseRange的右边界参数是i也就是单词右边界为开区间。所以函数内部比较的是left right交换的是s[left]和s[right - 1]。如果你习惯闭区间严谨一点也完全没问题但一定要统一约定不要混用否则很容易因为差一错误导致反转结果不对。从执行结果上看输入I love coding第一次扫描找到单词I区间[0, 1)反转后仍是I第二次找到love区间[2, 6)反转成evol第三次找到coding区间[7, 13)反转成gnidoc。最终输出I evol gnidoc完全符合预期。4.2 这段代码执行过程的可视化演示手工模拟一遍对理解双指针法很有帮助。假设s abc def我们逐行追踪这个过程。一开始i 0n 7。跳过空格的循环判断s[0]不是空格直接跳过。于是start 0。内部的 while 循环从i 0开始移动s[1]是b不是空格i变 1s[2]是c不是空格i变 2s[3]是空格循环停止。此时start 0i 3单词区间是[0, 3)也就是abc。reverseRange(s, 0, 3)会把a和c交换中间b原地不动abc变成cba。回到外层循环此时i 3外层跳过空格循环发现s[3]是空格i变为 4。此时i n且s[4]不是空格所以start 4。内部循环继续推进s[5]是ei变 5s[6]是fi变 6接着i 7不满足i n循环终止。此时start 4i 7单词区间是[4, 7)也就是def。reverseRange(s, 4, 7)交换d和fdef变成fed。最终s cba fed这就是期望结果。这个过程中每个字符最多被扫描指针访问两次一次跳空格、一次找词尾外加反转时的各一次交换总操作次数仍然是线性的。4.3 为什么这个方法能做到空间 O(1)方案二之所以能做到空间 O(1)是因为它从头到尾没有新建任何与字符串长度相关的容器。传入字符串的方式是“按值传递”函数内部直接操作这份拷贝所有修改都在原字符串的内存区域里完成。你可能会问按值传递本身不就拷贝了一次吗这算不算额外空间严格来说如果题目允许你修改原字符串那直接传引用会更好void reverseWordsInPlace(std::string s) { // 直接操作 s }但很多接口设计倾向于返回新字符串所以我按值传参其实相当于“拷贝进来、修改后返回”调用者原本的字符串不会被破坏。这种空间开销其实是调用语义决定的并不是算法本身需要的。如果你跟面试官解释时能把这个层次讲清楚他会觉得你对“原地”这个概念的理解是到位的。在纯算法分析层面如果我们忽略函数传参的拷贝只关注算法内部额外使用的存储那么这个解法使用的额外空间只有i、n、start这几个整数变量大小与字符串长度无关所以空间复杂度为 O(1)。这也是它相比方法一最大的优势。5. 两种方案的对比与适用场景5.1 时间复杂度、空间复杂度和代码可读性的横向对比我把两种方案的核心指标整理成一张表方便你直接对照。对比维度方案一istringstream reverse方案二双指针原地反转时间复杂度O(n)O(n)空间复杂度O(n)含流拷贝与临时容器O(1)忽略传参拷贝代码可读性高逻辑直白中需理解边界设置对空格处理自动压缩连续空格保留原始空格数量对最后一个单词的处理由流自动切割不易遗漏需要遍历结束条件兜底原地操作能力否是典型使用场景快速原型、笔试快速出解性能敏感或要求原地操作的场景从这张表能读出两个信息。第一时间复杂度两者完全一致都是 O(n)所以在大 O 层面谁都不输给谁。第二真正的差异在空间和空格处理语义上。如果你面对的是一个“必须保留原始空格数量”的变体题那方案一直接fail方案二是唯一正解如果题目只是简单要求输出所有反转后的单词用单个空格分隔那么方案一更不容易写错。5.2 面试中如何根据考官意图选择实现方式面试时我建议你先别急着写代码而是停下来问自己一个问题考官出这道题是想考什么如果这道题出现在“字符串基础操作”的简单题区大概率考官的重点是看你熟不熟悉std::reverse、istringstream这些标准库工具以及对字符串迭代器的理解。这时候方案一完全够用代码干净利落反而显得你熟悉工程实践。如果这道题出现在“双指针专题”或者“原地修改”题目集中那么考官的意图百分之百是希望看到方案二。因为这题的空间 O(1) 解法的闪光点就在于用一个扫描指针动态划定单词边界再用两个交换指针完成原地反转这几乎是双指针解题范式的一个微缩案例。如果你开口就提istringstream面试官会觉得你只会调包没有领会题目的考察意图。有一个折中的做法很讨巧你可以先说出两个方案的思路然后跟考官确认“如果要我实现原地版本我选择双指针方式如果您只是需要快速正解我也可以先给出流式版本”。这样既展示了你的知识广度又尊重了考官的出题意图。5.3 实测对比数据与经验判断我自己在本地跑过一组对比测试用的是一段 10 万字符左右的随机文本。方案二的整体耗时大约是方案一的 60% 左右。差距主要来自两部分一是istringstream初始化要构造流对象、绑定缓冲区二是operator每次读取单词时都会做区域设置相关的检查。这些开销在短字符串上几乎可以忽略但在大输入下会累计。不过说句公道话如果你的输入长度只有几十个字符两种方案的耗时差在纳秒量级肉眼观察不出来。有些人对性能的极致追求在字符串题里其实有点过度关键还是看场景。真正要担心性能的场景是长文本、高频率调用、内存受限这几种情况叠加这时候方案二的优势才有实际意义。从代码维护的角度看方案二的边界条件虽然多但一旦写成reverseRange这种独立小函数它其实也很清晰。我甚至会在实际项目里为了“原地处理长字符串”而选择方案二前提是逻辑被注释和单元测试充分覆盖。6. 常见错误与调试经验6.1 最容易翻车的三处边界逻辑第一处是最后一个单词的处理。前面已经强调过单词不一定以空格结尾也可能直接顶到字符串末尾。很多初写方案二的人会在while (isspace(s[i]))跳空格之后、再进入while (!isspace(s[i]))时忘记加i n条件导致越界访问。这里我强烈建议在两个 while 里都写上i n牺牲一点代码美观度换来安全性值得。第二处是连续多个空格的情况。比如a b两个单词中间有两个空格。一个常见的错误是在扫描时把第二个空格误当作某个单词的起始位置于是start指向了一个空格反转出来的结果就会出现奇怪的空格位置。解决方式就是“先跳空格再定单词起点”顺序不能反。这个 “先跳后取” 的顺序一旦搞反处理多空格时必错。第三处是空输入和全空格输入。输入为空时n0外层 while 不执行直接返回空串这没问题。全空格时外层 while 会进入并执行“跳空格”循环直到i n此时if (i n) break;会拦住后面的逻辑不会对空区间执行反转。这两条路径都要确保代码能优雅退出不要因为边界判断缺失而崩溃。6.2 差值错误Off-by-One的识别与修复手法差值错误是这类题目里最经典也最隐蔽的问题。在方案二里最容易出的差值错误是把reverseRange(s, start, i)的右边界传成i - 1然后又用left right做交换判断。这样做通常也能得到正确结果但代码的语义就混乱了因为区间约定被悄悄换了。我的建议是选一种最常见的约定并始终坚持。比如“开区间[left, right)”那函数内部就应该写成while (left right)交换时用right - 1。比如“闭区间[left, right]”那么调用时应该传reverseRange(s, start, i - 1)交换时用std::swap(s[left], s[right])。两种写法都对但不要从一种中途改成另一种否则 debug 的时候会很痛苦。我调试方案二的经验是在reverseRange入口处打印start和i对照着原始字符串的下标来看。一旦发现反转结果里单词内部字符顺序错乱基本可以断定是区间边界搞错了。这个思路在处理复杂字符串题目时非常通用。6.3 现场做题时的代码组织建议如果是在白板或在线编辑器里写这个题我建议按照“主函数 辅助函数”的结构来组织代码。主函数负责扫描逻辑辅助函数reverseRange负责区间反转。这样写的好处有两个一是每个函数的职责单一出错时很容易定位二是面试官在看你代码时能一眼看到你的模块化思维能力这在工程上是个加分项。另外一个容易被忽略的点是尽量把变量声明放在靠近使用位置的地方而不是全部堆在函数开头。比如start只需要在外层 while 里用完全可以放在进入 while 之后声明。这样做可以减少读者需要追踪的变量数量代码读起来更舒服。7. 延伸思考与实战感悟7.1 把“反转单词内部字符”推广到更复杂的变体这个题目常见的变体有三个方向。第一个方向是“同时反转单词顺序和单词内部字符”也就是输入hello world输出dlrow olleh。这个可以通过“先整体反转字符串再反转每个单词”来实现是 C 字符串题里一个非常经典的套路。它的原理很有趣整体反转之后单词的相对顺序倒过来了但每个单词内部的字符也倒过来了所以你只需要再把每个单词内部反转回来就能得到“单词顺序也反、单词内部字符也反”的结果。第二个方向是“反转每个单词但保持原分隔符不变”。这要求你把空格、标点符号都当作分隔符处理且不能压缩连续分隔符。这时候方案一的流式切分就不灵了必须采用方案二的扫描思路并且要额外记录分隔符的类型和数量。第三个方向是“只反转长度大于等于某个阈值的单词”。这需要在扫描到单词边界后先判断单词长度是否符合条件再决定是否调用reverseRange。在方案二的基础上改动很小但在方案一里就需要在分词后多一个条件判断再决定是否 push 进结果容器。这些变体题并不难核心都是掌握“扫描边界 区间反转”的思维模型剩下的都是加条件。一旦你理解了这个模型你会发现它们其实就是同一道题的参数化版本。7.2 从这道题学到的边界思维与工程启示我在带新人写代码时经常强调一句话字符串处理里真正重要的不是那些正常的字符而是边界上的字符。这道题就是一个绝佳的例子。它看起来简单但要想写对你必须想清楚每个单词的起点是什么、终点是什么、遇到连续空格怎么办、字符串末尾有没有单词。这种对边界的敏感度在工程实践里同样至关重要比如解析 HTTP 请求头、处理 CSV 字段、剥离日志中的时间戳——每个场景里都会遇到类似的边界判断。还有一点关于std::string传参的思考。有人写函数习惯用const std::string传出不可变引用然后新建一个结果字符串返回。这种做法本身没问题但在这道题里如果追求原地操作应当用“按值传递 返回修改后的值”或者“传引用并修改原字符串”。到底选哪种取决于你希望调用方的原始数据是否被保留。如果你不确定面试时直接问清楚“原字符串能被修改吗”会比你自己猜更稳妥。7.3 刷题之外这段经验如何反哺实际开发我发现很多人在刷完题之后会觉得这些代码离真实工作很远。但实际上这类字符串操作思想在几个常见开发场景里都能直接派上用场。在日志系统里经常要对整行日志做关键词提取或脱敏需要遍历字符串并根据空格或标点切分字段这时“扫描边界 区间处理”的思想就直接对应到解析流程的设计上。在协议解析中比如自定义的报文格式是“key1value1 key2value2”这种结构你需要把每个键值对当作一个“单词”来处理确定它的边界再在边界内做进一步解析。这个模型与本题几乎一致。在文本编辑器或数据处理工具中对选中的文本做批量逆序或翻转处理本质上也是“识别区间、反转区间”的重复操作。把这些经典题的思维吃透后你在面对这些更复杂的场景时会自然而然地设计出更清晰的代码结构。7.4 关于两种实现风格的一点个人看法最后说一点我的个人习惯。如果是给初学者讲解这道题我一定先讲方案一因为它符合直觉读代码的人不需要理解“开区间”“闭区间”“边界扫描”这些概念就能看懂。如果是给有经验的工程师做代码评审我会更推荐方案二因为它把操作集中在原地更节省资源也更体现对问题的理解深度。我自己在实际开发里写字符串处理模块时往往倾向于先写一个直观的版本跑通功能然后再根据性能瓶颈决定要不要改成原地版本。千万不要一开始就为了追求 O(1) 空间把代码写得晦涩难懂那样对后期维护是个负担。这道题也不例外如果你只是写个小工具处理几个字符串方案一完全够用只有当输入规模变大或者使用频率非常高时再切换到方案二也不迟。