ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++字符串替换经典题:从双指针原地算法到工程实践

C++字符串替换经典题:从双指针原地算法到工程实践 1. 题目本质与解题思路字符串替换到底在考什么1.1 题目原文与核心考点牛客网上这道“字符串替换”题题目描述并不复杂请实现一个函数将一个字符串中的每个空格替换成“%20”。例如输入字符串We Are Happy经过替换后输出We%20Are%20Happy。就这么一道看似基础的题目多年来却反复出现在各大公司的笔试、面试题库中原因在于它考察的远不只是“会不会用 replace”这么简单。题目背后至少藏着三组核心能力字符串内存布局的理解、双指针思维、原地算法与边界条件的把控。先说清楚一个很多人容易忽略的点这道题在牛客网上的原始版本函数的输入往往是一个char*字符指针外加一个可用内存长度int length。也就是说你面对的不是现代 C 里随心所欲的std::string而是一段预先分配好内存的字符数组。这其实是老式 C 风格字符串操作的一种典型场景也是很多 C 初学者觉得“别扭”的地方。如果你只会在std::string上调replace或者拼接新字符串一遇到这个原始版本就会卡壳。这也是为什么我强烈建议每个刷这道题的人先不要急着用库函数而是老老实实自己实现一遍内存级别的拷贝与移动。1.2 为什么推荐“从后往前替换”核心考点在于如果题目要求你在原字符串上完成替换应该选择从前往后还是从后往前操作答案是从后往前原因非常实际。一个空格替换成%20字符长度从 1 变成 3相当于每次替换都会让后续字符整体向后移动 2 个位置。如果从前往后处理每次遇到空格都要把后面的所有字符整体后移循环嵌套下来时间复杂度直接变成 O(n²)。举个例子假设原字符串是a b c从前往后替换时第一个空格后的b c要整体后移第二个空格后遗留的内容又要再移一次字符被反复搬动多次毫无必要。而从后往前操作则完全避免了这个问题。我们可以先遍历一遍原字符串数出空格数量算出扩容后的总长度然后同时用两个指针一个指向原字符串末尾一个指向扩容后字符串末尾从后向前逐个拷贝字符。遇到空格时新字符串末尾的位置依次填入0、2、%其他字符则直接平移。整个过程每个字符最多被移动一次时间复杂度稳定在 O(n)。2. 代码实现三种方案从内存级别到库函数2.1 经典实现一双指针原地扩容兼容牛客网原始函数签名牛客网上这道题的经典函数签名如下很多早期题库都是这么定义的class Solution { public: void replaceSpace(char *str, int length) { if (str nullptr || length 0) { return; } int oldLength 0; int spaceCount 0; // 第一遍遍历统计原字符串长度和空格数量 while (str[oldLength] ! \0) { if (str[oldLength] ) { spaceCount; } oldLength; } // 替换后新字符串长度 原长度 空格数 * 2 int newLength oldLength spaceCount * 2; if (newLength length) { return; // 可用内存不够避免越界 } int p1 oldLength; // 原字符串末尾指向 \0 int p2 newLength; // 新字符串末尾 while (p1 0 p2 p1) { if (str[p1] ! ) { str[p2--] str[p1--]; } else { str[p2--] 0; str[p2--] 2; str[p2--] %; p1--; } } } };这段代码有几个细节值得专门拆开讲。第一为什么用p2 p1作为循环条件之一因为如果原字符串里没有空格新老长度相等两个指针一起往前移动不会交叉一旦有空格p2 一定比 p1 领先等到 p1 指向-1或两者重合时前面的部分已经不需要再移动了。这个条件其实也是一种防止越界的安全兜底。第二为什么循环里先判断str[p1] ! 这是常规分支非空格直接拷贝空格则从后往前依次写入0、2、%。注意%20这 3 个字符的写入顺序是从尾部开始因为我们在从后往前填所以0先落在最后2落中间%落最前合并起来正好是正序的%20。第三长度校验newLength length必须保留。牛客网的测试数据在某些题目版本里不会给得太宽裕如果你不加这个判断一旦测试用例传入的内存长度刚好不够程序就会越界写轻则结果错误重则直接运行时崩溃。2.2 实现二std::string 版本适合牛客网新版题目与本地练习现在牛客网上很多题目版本已经改成了string传参函数签名类似这样class Solution { public: string replaceSpace(string s) { string res; res.reserve(s.size() * 3); // 预分配空间避免反复扩容 for (char c : s) { if (c ) { res %20; } else { res c; } } return res; } };这个实现虽然简单到“有手就行”但里面有一个非常值得保留的好习惯reserve(s.size() * 3)。std::string在追加字符时如果容量不够会自动扩容而扩容通常涉及“申请新内存 拷贝旧数据 释放旧内存”三步字符串越长扩容次数越多性能损耗越明显。先用reserve把容量一次给足后续的operator就只需要做单纯的内存写入效率会高不少。另外这段代码里我对res的传入参数是值传递string s这在牛客网的判题环境下没有任何问题。如果是写生产代码建议把参数改成const string s避免一次无谓的拷贝。但刷题时以通过为准值传递反而写起来更顺手这个差异不用太在意。2.3 实现三通用替换函数支持任意子串替换如果想把这道题的思路延伸一步可以写一个支持“任意源子串替换为目标子串”的通用函数。这道题本质上只是它的一个特例即源子串为空格的替换。#include string std::string replaceAll(std::string s, const std::string from, const std::string to) { if (from.empty()) { return s; // 源子串为空时不做处理避免死循环 } size_t pos 0; while ((pos s.find(from, pos)) ! std::string::npos) { s.replace(pos, from.length(), to); pos to.length(); // 跳过刚替换的部分防止重复匹配 } return s; }这个函数其实就相当于把std::string::find和std::string::replace组合起来循环调用理解起来一点不复杂。关键在于pos to.length()这一行。如果不跳过刚刚替换进去的内容那么当目标子串to中恰好包含源子串from时程序就可能在原地无限循环。举例来说如果要把a替换成aa不跳过的话程序会一直匹配到新写入的a直到内存耗尽。这类坑在生产代码里非常常见值得牢记。3. 边界条件、复杂度分析与牛客网的坑3.1 边界条件测试清单字符串替换这类题代码写对不算本事边界条件不挂才是真本事。根据我多次刷题和辅助他人调试的经验下面这组测试用例建议每一道题都跑一遍用例类型输入示例预期输出容易踩的坑普通场景We Are HappyWe%20Are%20Happy无连续多个空格a ba%20%20b漏掉空格导致只替换一个开头和结尾带空格 hello %20hello%20首尾空格被忽略全是空格 %20%20%20计数错误导致内存分配不准没有空格abcabc指针交叉或越界空字符串第一遍遍历时越界读取空指针nullptrreturn解引用空指针崩溃内存刚好够长度为 4 的数组存a a%20忘记\0占一个字节表格里最后一行是一个隐藏很深的坑。char*版本的题目传入的length通常指的是这块字符数组可使用的总字节数而\0本身也要占一个字节。如果你只按字符串内容的长度去计算忘了给结束符留位置那么当原字符串恰好以空格结尾时新串内容会把\0挤出数组边界打印时就会出现乱码或者越界。3.2 时间与空间复杂度分析双指针原地替换方案的时间复杂度是 O(n)因为整个过程中每个字符最多被读取一次、写入一次无论空格分布如何总操作次数与原字符串长度线性相关。空间复杂度如果按原地替换来算不计入扩容后的那部分内存可以认为是 O(1)因为我们只用了几个整型变量和指针。而std::string拼接方案因为有返回值所以理论上需要 O(n) 的额外空间。牛客网在空间复杂度上卡得不严这个差异不用太担心。真正需要注意的是如果你在本地用char[]跑双指针版本请务必手动在函数外面把数组开大否则很容易出现缓冲区溢出却又在牛客网上通过了——这种本地和在线判题结果不一致的情况多半就是内存操作越界导致的未定义行为。3.3 牛客网判题系统的三个实际注意点第一牛客网的代码编辑器默认不会启用所有 C 新特性。很多题目的编译器是 C11 甚至更早的标准意味着初始化列表、auto键、智能指针这些特性可能可以兼容但一些 C17、C20 的写法就别冒险用了。稳妥的做法是写最普通的 C98/11 风格。第二在线编辑器里你不需要也没法写main函数只需要完成Solution类里的指定方法即可。所以做题时先确认函数签名特别是参数类型。有些题目给的是char* str,int length有些给的是string s两者的解题思路完全不同别拿着一套模板硬套。第三牛客网上有些“关键代码模式”题目的测试数据是随机生成的而且用例之间会重用同一个测试进程。如果代码里定义了全局变量或静态变量上一次用例跑完留下的状态可能会污染下一次用例的结果。刷这种题时尽量把所有状态都控制在单次调用内。4. 从刷题到工程字符串替换的进阶场景4.1 正则替换std::regex_replace 的威力与陷阱很多人在刷完牛客这道题后会产生一个疑问真实工作中谁会手写双指针替换确实工程开发中绝大多数字符串替换需求都可以借助现成库函数完成。C 标准库提供了std::regex_replace可以直接用正则表达式完成非常复杂的替换逻辑。比如把一段文本里所有的空白字符空格、制表符、换行统一替换成%20#include regex #include string std::string replaceWhitespace(const std::string input) { std::regex ws(\\s); return std::regex_replace(input, ws, %20); }这段代码看起来比前面的所有实现都简洁但代价是性能。std::regex的实现通常较慢如果只是在短字符串上用一次倒无所谓但如果放在一个每秒调用成千上万次的热点路径里很可能会成为性能瓶颈。我的建议是能用find和replace解决的问题不要急于上正则正则的强项在于模式匹配比如“把 3 个以上的连续空格压缩成一个”这类规则化需求而不是单纯的字面量替换。4.2 批量替换与字符映射不只是单一空格牛客原题只要求替换空格但真实业务里常常要一次性替换多种特殊字符。比如 URL 编码场景空格变%20中文变%E4%B8...这样的 UTF-8 百分号编码变%26。手动写一堆 if-else 会非常啰嗦这时可以借助映射表来组织逻辑。#include string #include unordered_map std::string urlEncodeReserved(const std::string input) { std::unordered_mapchar, std::string encodeMap { { , %20}, {, %26}, {?, %3F}, {, %3D} }; std::string res; res.reserve(input.size() * 3); for (char c : input) { auto it encodeMap.find(c); if (it ! encodeMap.end()) { res it-second; } else { res c; } } return res; }这种写法的好处是扩展新规则时不需要改循环体只需要往encodeMap里加一条映射即可。代码结构清晰可维护性高。这也是我工作中处理“多对一”替换时最常用的模式。4.3 避免写错替换方向的三种检查方法替换方向上最容易犯的错误是把from和to传反。一旦传反轻则输出结果不符合预期重则把原本合法的字符也一并替换产生脏数据。我分享三个方法第一在代码里使用语义化命名比如sourceSubstr和targetSubstr不要只写a和b。第二写单元测试时覆盖“替换后内容不包含源子串”这一断言。第三如果替换结果会输出给外部系统先拿一小段真实样例手动算一遍预期结果再跑程序对比别直接全量跑完再回头查。5. 面试追问与实战经验总结5.1 面试官常用的四个追问方向这道题作为面试题出现时面试官往往会在你写完代码后立刻追问真正拉开差距的也不是你写得多快而是深挖之后的回答。追问一是“如果原字符串非常长空格非常少你的方案还有优化空间吗”这个问题想考察的是你是否意识到扩容后的内存申请其实可能涉及大块内存的重新分配。如果原始字符数组长度不够必须申请一块更大的内存然后把原数据搬过去——这一步带来的开销和空格数量无关只和字符串长度有关。如果允许额外开辟空间可以考虑分块复制、或者一次只处理一段但工程上更务实的选择是预先估算好容量一次分配到位。追问二是“如果允许使用额外空间你会选择更简单的写法吗”答案是肯定的。额外空间充足的情况下直接从左到右遍历原字符串把结果写入新字符串即可完全不需要双指针倒序处理。这种写法虽然多耗费 O(n) 内存但代码可读性和正确性都更高。这也是为什么牛客网的新版本把参数改成std::string之后很多人感觉难度骤降。追问三是“如果替换后的新字符串长度未知你怎么处理”这其实是在逼迫你考虑动态扩容策略。std::string内部会自动处理但如果你使用的是 C 风格的char*那么必须自己管理内存。常见做法是先遍历一遍计算出最终长度再决定是否重新分配。这与字符数组版本的牛客题思路一脉相承。追问四是“为什么不能从前往后替换如果把两个指针一个放到头一个放到尾这样操作的意义是什么”这个问题回到2.1节讲过的复杂度分析核心是避免字符被重复移动。理解“每个字符最多移动一次”这个概念比背下代码重要得多。5.2 我刷这道题时的三个亲历教训第一次做这道题的时候我犯过一个至今印象深刻的错误我在第一遍统计过程中不判断\0而是直接按照length参数去遍历结果把字符数组里未初始化的垃圾数据也当成有效字符统计了进去导致空格数量被严重高估最终计算出的新长度远超实际需要。后来我才养成了“字符串长度以\0为准外部参数只用于容量校验”的习惯这个问题再也没出现过。第二个教训是关于p1和p2的类型选择。当时我图省事用了无符号整数size_t由于从后往前遍历p1减到负数时不是变成-1而是变成一个极大的正整数导致循环条件判断错误程序直接超时。现在我的习惯是凡是需要用-1作为终止条件的指针或索引一律使用有符号整数int避免这类隐蔽问题。第三个经验不是代码层面的而是状态管理上的不要一开始就追求“最短代码”先把思路写成注释再把注释翻译成代码。比如先写三行注释“1. 统计空格数2. 从后往前双指针3. 遇到空格写%20”再动手实现。写出的代码即便有一两个小错误排查起来也会快很多因为逻辑结构是清晰的。5.3 这题值得背下来的原因经常有人问牛客网上几百道题到底要不要背题我的看法是代码不要背“思路模板”值得记。字符串替换这道题之所以值得反复做是因为它浓缩了 C 处理字符串时最经典的一整类问题模式在原有内存上做变换先扩展容量再倒序迁移数据。这个模式不只适用于空格替换还适用于数组扩容、有序数组合并、链表反转等一大堆看似不同、实则同源的题目。把这一道题吃透比粗刷十道同类题更有价值。至少在面试时当面试官看到你能从“从后往前双指针”一路引申到“如何避免字符重复搬移”再延伸到“工程中如何选择库函数与手写实现”他对你的基本功判断通常是会加分的。刷题之外你也可以在本地编译器里把每个版本都亲手跑一遍尤其建议打开地址消毒器AddressSanitizer之类的工具检查内存操作。这类工具能直观暴露你代码里的越界问题比肉眼排查高效得多。最后说一个小建议做完这道题后试着把空格替换改造成“替换任意字符”再改造成“替换任意子串”再改造成“连续多次替换”。每多走一步你对字符串内存模型的理解都会更扎实一层。这些能力刷一百道“API 调用题”是换不来的。
返回列表