ARTICLE DETAIL

资讯详情

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

字符串刷题核心:双指针反转与原地替换的边界艺术

字符串刷题核心:双指针反转与原地替换的边界艺术 字符串处理是算法刷题里绕不开的基础盘代码随想录训练营第七天这个节点刚好从数组、链表过渡到字符串模块。这天安排的三道题——344.反转字符串、541.反转字符串 II、54.替换数字——难度都不算高但考察的东西非常核心双指针怎么用、边界条件怎么卡、字符和数字怎么互相转换、原地修改和额外开空间之间怎么权衡。我特意把这三题放在一起复盘了一遍发现它们的解法背后其实是同一个思路体系理解了这套东西后面刷字符串相关的题会顺畅很多。这篇文章就按我实际做题的顺序来写先说这三道题为什么会放在同一天再分别拆解每道题的解题思路、代码实现和细节陷阱最后整理一些我踩过的坑和总结出来的刷题习惯。不管你是在跟训练营、自己刷LeetCode还是准备面试这天的题都值得拿出一点时间好好过一遍。1. 三题串讲为什么这几道字符串题值得认真刷1.1 从训练营节奏看字符串模块的定位代码随想录的训练营安排是有讲究的前六天刚把数组、链表这类基础结构啃完第七天就切入字符串。这个节奏踩得很准因为字符串本质上就是一串字符数组很多在数组上练过的技巧——双指针、原地操作、区间切割——换个皮就到了字符串上。这一天从反转字符串起步表面上是三道零散题目实际上是在帮我们建立“字符串也是一种有序结构”的直觉。很多人会觉得“反转字符串”这种题目太简单LeetCode 344甚至可以直接调库函数 reverse() 一行搞定新人容易产生“这题没营养”的错觉。但如果你去面试或者参加笔试就会发现反转类的题目是最高频的变形题源反转句子、反转区间、按块反转……万变不离其宗本质都是在操作数组下标。所以我建议不要因为题简单就跳过反而应该静下心把思路捋清楚。这三题的递进关系也很明显344是全局反转一个双指针走到底541是局部反转多了一个“每隔2k个字符”的分组逻辑54则是遍历字符的同时做替换把数字识别和字符串扩容这两个难点糅在了一起。由浅入深一环扣一环。1.2 三道题的内在逻辑双指针、区间边界与字符处理如果只看题面这三道题各自独立但做题时你会发现它们的解题工具高度重叠。344的核心是双指针从两端向中间夹逼541虽然多了分组但分组内用的仍然是双指针去做子串反转54则是在遍历过程中判断字符类型并处理输出。三题综合下来其实覆盖了字符串题的三类基本功指针移动、区间控制、字符判断与转换。另一个隐藏重点是“原地修改”。344明确要求不额外分配空间541的库函数版虽然可以马上过但核心逻辑仍然是片段的原地反转54在不同语言下的解法差异最大——C里讲究扩容后从后往前填充Java里则更常用 StringBuilder。这些细节不是单纯刷题能感受到的需要亲手写出每一版解法并对比才能真正理解“为什么这么写”而不只是“能过就行”。所以我建议把这天当作一个“字符串基本功检验日”如果你能用至少两种语言把这三题写对并且能说清楚每个边界条件是为什么那么恭喜你字符串模块的地基已经打得很稳了。2. 344. 反转字符串双指针的老祖宗题2.1 题目要求和最容易想到的思路344题面非常短给你一个字符数组 s要求原地反转不能申请额外空间。所谓“原地”就是不要新建一个数组再倒序填回去必须在原数组上通过交换元素实现。这个限制很关键直接过滤掉了最朴素也最浪费空间的解法。我在训练营打卡时先写了一个“看起来没问题”的版本void reverseString(vectorchar s) { int n s.size(); vectorchar temp(n); for (int i 0; i n; i) { temp[i] s[n - 1 - i]; } s temp; }这个写法在功能上完全正确但不满足题目的“原地”要求。遇到这种题首先要想清楚题目到底在考什么。它考的不是你会不会倒序存储而是你能不能想到用交换来省掉额外空间。理解了这一层双指针解法就呼之欲出了。2.2 双指针解法与交换细节双指针的思路非常直观左指针指向开头右指针指向末尾交换这两个位置的字符然后左指针右移、右指针左移直到两个指针相遇或者左指针超过右指针。用代码写出来就是void reverseString(vectorchar s) { int left 0; int right s.size() - 1; while (left right) { swap(s[left], s[right]); left; right--; } }循环条件是 left right这是整个算法最需要注意的地方。如果写成 left right当数组长度为奇数时两指针最终会指向同一个位置自己和自己交换虽然不报错但多了一次无意义的操作当数组长度为偶数时left right 和 left right 效果相同。所以用 left right 是更规范、更省操作的写法。交换这一步也有细节。新手最常见的错误是自己手写交换时忘记用临时变量// 错误示范 s[left] s[right]; s[right] s[left];这样一写s[left] 的原值就丢了相当于把数组里某一段变成了重复字符。正确写法是引入 tempchar temp s[left]; s[left] s[right]; s[right] temp;当然用算法库里的 swap() 更省事但面试时如果你能一边写一边说清楚“交换需要临时变量存值”会让面试官觉得你是真的理解而不是背代码。2.3 这题考场的隐藏陷阱344虽然基础但在面试现场很容易踩坑。第一是很多人看到“字符串反转”就直接调用 std::reverse(s.begin(), s.end())这确实能通过LeetCode但面试官一般会追问“如果不让你用库函数呢”这时候如果写不出双指针就露馅了。我建议练习时就强制自己不用库函数把反转逻辑亲手写熟。第二是语言差异。344的输入在LeetCode里是 vectorchar也就是字符数组可以直接按下标索引修改。但如果题目改成给你一个 string在 Java 里就得先 toCharArray() 再操作最后 new String(chars)。别小看这个转换面试时如果没处理好很容易写出“看似正确但无法通过编译”的代码。第三是整型溢出。这个题用 int 存储下标不会溢出但有些字符串题目如果涉及超长字符串或者 n 很大用 int 可能不够稳。养成使用 size_t 或者在需要时显式转换的习惯能帮你规避很多莫名其妙的问题。2.4 和反转链表的对比补充学习心得如果你前面刚刷过链表反转这天的反转字符串可以当作一个绝佳的对比素材。链表反转比如206.反转链表用的是多指针在节点间游走每次改变节点的 next 指向数组反转则是在连续内存里用两个下标做交换。两者的共同点是都需要维护“当前处理到哪个位置”的信息都需要小心边界链表判空、数组判越界但数组因为有随机访问能力写法上简洁得多。我自己的体会是如果链表反转你已经吃透了再看字符串反转会觉得豁然开朗——原来指针的概念从链表迁移到数组只是换了个形态。这种跨数据结构的类比能力恰恰是刷题训练营最想培养的东西。所以别把344当一道孤零零的水题带着对比意识去刷收获会大很多。3. 541. 反转字符串 II边界条件才是硬骨头3.1 题目理解2k分组的正确姿势541题面比344稍微绕一点给你一个字符串 s 和一个整数 k你需要对字符串每隔 2k 个字符的前 k 个字符进行反转。如果剩余字符少于 k 个则将剩余字符全部反转如果剩余字符小于 2k 但大于等于 k 个则反转前 k 个字符其余保持原样。我第一次读这个题面的时候差点被“剩余字符”三个情况绕晕。后来我换了一种更直接的理解方式每 2k 个字符看作一个区块每个区块只处理前 k 个字符。最后一个区块如果不满 2k那就看它有没有 k 个字符——有 k 个就反转前 k 个不够 k 个就全反转。这个理解方式对应到代码里比一句句if判断要清晰得多。理解了分区模型剩下的事就是找到每个区块的起点。3.2 代码实现循环步长与反转边界我第一次写这个题时用的是最“老实”的写法用一个 count 累计当前处理到哪然后判断剩余情况决定反转范围。代码能跑通但逻辑分支特别多写着写着容易晕。后来我看了训练营的思路发现最优雅的解法是让循环变量每次加 2k这样天然完成了“分组”string reverseStr(string s, int k) { for (int i 0; i s.size(); i 2 * k) { // 反转 [i, ik) 这个区间但要防止越界 if (i k s.size()) { reverse(s.begin() i, s.begin() i k); } else { reverse(s.begin() i, s.end()); } } return s; }这个写法的精髓在于用 i 2k 代替自己维护计数器用“i k 是否超过 s.size()”统一了三种剩余情况。为什么判断的是 i k 而不是 i 2k因为题目要求的是每个区块反转前 k 个如果 i k 都在字符串长度内说明这个区块至少有 k 个字符就反转 k 个如果 i k 已经超出末尾说明这个区块连 k 个字符都不够就反转从 i 到末尾的全部字符。3.3 边界情况梳理与常见错误代码虽然短但边界情况极容易错。我拿几个典型例子测试s abcdefg, k 20-1反转ba4-5反转fed最后剩一个g不够2个所以全转还是g结果应该是bacdfeg。s abcd, k 2i0时反转bai4退出循环结果为bacd。s ab, k 4i0ik4 s.size()2走 else 分支反转整个字符串ba。测试过程中最容易翻车的点有两个。第一个是循环变量直接操作 string 时reverse 的第二个参数是迭代器。如果写成 s.begin() i k当 i k 正好等于 s.size() 时这个迭代器指向的是末尾后一位也就是 end()reverse 是合法的。但如果 i k 大于 s.size()就不能再用这个写法了必须先走 else 分支用 end()。第二个坑是 k 本身可能大于字符串长度。当 k 比整个字符串还长时第一次循环 i0 就会满足 ik s.size()于是把整个字符串反转。这刚好符合题面“剩余字符少于 k 个则全部反转”的规则所以代码不用额外处理但新手很容易在这里加一个多余的 if 导致逻辑混乱。我还建议尽量封装一个“反转区间”的辅助函数。虽然 C 的 algorithm 头文件里自带 reverse但在面试手写场景下自己写一个接受字符串引用和左右下标的版本可以避免边界理解的偏差尤其当你在 541 的基础上再遇到“反转区间内的单词”“反转指定区间的子串”这类变形题时这个辅助函数几乎可以原样复用。4. 54. 替换数字原地扩容的经典套路4.1 题目描述与暴力解法54这道题是卡码网的原题题目本身不复杂输入一个字符串包含字母和数字字符要求把所有的数字字符替换成number并输出。比如输入 a1b2c3输出就是 anumberbnumbercnumber。最直观的暴力解法也最简单遍历原字符串判断每个字符是不是数字是数字就追加number不是就追加原字符。在 Java 里用 StringBuilder 可以很轻松地完成public static String replaceNumber(String s) { StringBuilder sb new StringBuilder(); for (char c : s.toCharArray()) { if (c 0 c 9) { sb.append(number); } else { sb.append(c); } } return sb.toString(); }这段代码能通过测试而且非常好理解。但问题是如果面试官追问“如果要求不能申请额外空间或者要求在原字符串上修改你怎么做”大多数第一次接触这个题的人会卡住。别看反转字符串时“原地”很轻松替换数字时“原地”意味着字符串长度要变长这会带来一个新问题原数组装不下了怎么办4.2 C 双指针从后往前填充的完整推导C 里 string 是动态长度的所以可以先扩容再填充这是“原地修改”的一种可行方案。具体思路分三步第一步遍历原字符串统计数字字符的个数 count。 第二步设原长度为 oldLen把字符串重新分配为 oldLen count * 5 的长度。为什么是乘以5因为 number 这个单词有6个字符而原来的1个数字字符占1个位置每替换一个数字需要多出5个位置。 第三步用两个指针从后往前遍历i 指向旧字符串的末尾j 指向扩容后字符串的末尾。从后往前填充遇到数字就倒着填入 number 的字符遇到普通字符就直接拷贝到 j 的位置。写成代码就是#include iostream #include string using namespace std; int main() { string s; cin s; int oldLen s.size(); int count 0; for (char c : s) { if (c 0 c 9) count; } s.resize(oldLen count * 5); int i oldLen - 1; int j s.size() - 1; while (i 0) { if (s[i] 0 s[i] 9) { s[j--] r; s[j--] e; s[j--] b; s[j--] m; s[j--] u; s[j--] n; } else { s[j--] s[i]; } i--; } cout s endl; }这里最关键的是“从后往前”的方向选择。如果从前往后替换每遇到一个数字就要把后续所有字符往后移动“移动插入”综合下来的时间复杂度会变成 O(n^2)在字符串很长时效率很差。而从后往前时利用已经统计好的新长度每个字符最多移动一次整体是 O(n) 复杂度。这也是很多字符串题目惯用的优化思路先扩容再从后往前倒着填充。4.3 Java 实现与性能对比Java 里 String 是不可变的所以在 Java 中做真正的“原地修改”没有意义但同样的思路可以迁移到一个 char 数组上。先统计数字个数创建一个扩容后的 char 数组再用双指针从后往前填充这样空间上只多开了一个相同规模的数组逻辑上和 C 版本一致public static String replaceNumber(String s) { int count 0; char[] chars s.toCharArray(); for (char c : chars) { if (c 0 c 9) count; } char[] result new char[chars.length count * 5]; int i chars.length - 1; int j result.length - 1; while (i 0) { if (chars[i] 0 chars[i] 9) { result[j--] r; result[j--] e; result[j--] b; result[j--] m; result[j--] u; result[j--] n; } else { result[j--] chars[i]; } i--; } return new String(result); }对比下来Java 更推荐直接使用 StringBuilder 版本代码简洁且可读性强。但为什么还要掌握数组双指针版本因为在某些算法场景比如字符数组传参、竞赛机考中你可能没有现成的 StringBuilder 或者不适合使用它这时候双指针的思维方式就是你的“保底技能”。另外理解了 C 的扩容思路后Java 的数组版本其实只是同一个思路换了语法皮学起来几乎零成本。4.4 为什么从后往前是正解很多第一次接触这个题的人会问我统计好数字个数也知道最终长度直接从前到后一边遍历一边插入不行吗答案是行但要付出整体移动的代价。考虑极端情况如果字符串是 111111...1每个数字都要替换成 number每次从前面插入都会导致后面大量字符右移整体复杂度接近 O(n^2)。而字符串的题目经常出现在大数据量场景测试数据一长这种写法很可能超时。从后往前填充之所以优雅核心在于它利用“旧指针 i”永远不快于“新指针 j”的特性。处理普通字符时 i 和 j 同步走处理数字时 j 会一次多走5步但旧字符串中尚未处理的字符位置始终在 i 的左侧不会被 j 覆盖。这其实是用“倒序写入”规避了经典的“前方数据覆盖”问题。类似的技巧在合并两个有序数组88.合并两个有序数组里也会用到本质上都是在利用“末尾可用空间”来减少数据搬移。5. 训练营第七天的踩坑实录与效率技巧5.1 三个高频报错与排查方法我自己加上训练营群里其他同学反馈这三题最常出现的问题集中在下面几个位置。第一个是 344 的手写交换丢值。报错表现是数组里某个区域的字符变得一模一样。排查时不要急着看逻辑先在纸上用三个字符的小数组手动模拟一遍交换过程。比如 [a,b,c]如果不用 temp 直接 s[0]s[2]、s[2]s[0]结果是 [c,b,c]第二个字符还好好的但最后一个字符丢了。用这个例子一推问题马上就定位了。第二个是 541 的越界错误。常见报错是 iterator range 越界或者访问到非法内存。原因大多是 i k 已经大于末尾还硬用 s.begin() i k 去取迭代器。记住在 reverse 之前必须先判断 i k 和 s.size() 的大小关系。我个人的习惯是先把代码里的区间逻辑写成注释比如“反转 [i, min(ik, n))”这样代码和注释对照着看不容易写串。第三个是 54 的数字字符判断。有人会写成 if (c 0 c 9)这个在 C 里不会报错但永远不成立因为 c 是字符的 ASCII 码0 的 ASCII 码是48不是0。还有人会犯“只判断是数字但忘了它是字符”的错误导致替换逻辑没有触发。记住判断字符是否为数字的标准写法c 0 c 9。5.2 字符串题的高效刷题习惯刷字符串题和刷链表、数组不太一样它特别讲究“小步快跑”。一个字符串题往往能拆成很多小步骤先转成字符数组再定两个指针再写交换逻辑最后处理边界。我建议每写一段代码都先想想这段代码的输入边界是什么。尤其对于区间反转题我强烈建议在脑海里或者草稿纸上跑三个用例空字符串、单个字符、长度刚好等于 k 或 2k 的倍数。训练营打卡时我也会坚持“先写思路再写代码”。不是每次都要写长篇大论但至少要能说清楚“这个题用双指针循环条件是什么反转区间是哪一段”。养成这个习惯后你会发现即使遇到完全没见过的字符串题也能快速构思出方向而不是楞在编辑器前不知道怎么下手。5.3 打卡节奏与背诵策略第七天这个位置其实很微妙前面六天已经积累了不少数组和链表的套路第七天的新知识密度看似不高但很多人就是在这里开始松懈。我的经验是热身题344快速过重点题541反复写工具题54理解原理。不要把时间平均分配在三道题上而是根据每一题的价值调整打磨深度。背诵策略方面我不建议死记硬背代码更建议记住“关键决策点”。比如 541 的关键决策点是“i 2*k 的分组方式”和“ik 越界判断”54 的关键决策点是“统计数字个数、计算扩容长度、双指针从后往前填充”。只要这些决策点记住了代码其实可以现场推出来。这个方法在面试时特别管用。另外如果你发现某道题的某个边界情况总是记不住别硬记去构造一个专门的测试用例。我在学习 541 时就把 abcdefg k2 这个用例反复跑了十几遍直到彻底理解为什么最后会得到 bacdfeg。这种“用具体例子对抗抽象规则”的方式比单纯刷题数要高效得多。这三道题做完字符串模块的大门基本就打开了。后面再遇到反转字符串里的单词、替换空格、实现 strStr() 这类更复杂的题目你会发现底层能力其实都是第七天埋下的会操作字符、会控制区间、会处理边界、会做原地修改。继续往后刷保持手感比纠结“今天题目简单”更重要。
返回列表