ARTICLE DETAIL

资讯详情

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

字符串双指针实战:反转字符串与替换数字的解题套路

字符串双指针实战:反转字符串与替换数字的解题套路 Day7 打卡今天这三道题放在一起我愿称之为“字符串双指针的一天”344. 反转字符串541. 反转字符串 II还有卡码网的 54. 替换数字。前两题是 LeetCode 上的经典第三题是 ACM 模式下的字符串处理题。刷完这一组之后你会发现它们表面上看起来都是基础操作实际上正好把双指针的几种用法串了一遍对撞、区间控制、扩容后逆向往回搬。这篇就把我的完整思路、代码写法、还有踩过的坑一起整理出来。如果你正在刷代码随想录或者按专题刷题这个组合可以作为“字符串入门”的一环。适合的人群很直接刚开始刷 LeetCode 的初学者、准备机考/面试想补基本功的同学以及想搞明白“为什么替换数字要从后往前倒着处理”的人。下面直接进入正题。1. 为什么这三道题值得放在一起刷一条完整的“字符串基本功”线1.1 三道题的底层逻辑其实是同一个套路先说 344. 反转字符串。它要求原地反转一个字符数组不能额外开数组也不能用库函数一步到位。一旦用双指针从两端往中间走你就拿到了一个对撞指针的最小模型。再看 541. 反转字符串 II。它描述了一个带条件的规则每隔 2k 个字符反转前 k 个如果剩余字符少于 k 个就把剩余的全部反转。表面上是新题但内核还是在“局部区间内做双指针反转”只不过多了一个区间定位的步骤。最后是卡码网 54. 替换数字。题面大概是给你一个字符串 s里面可能包含数字要求把所有数字字符替换为 number 这个单词。这里数字字符不算多但“1 个字符变成 6 个字符”意味着字符串长度会变化。最稳的解法是先统计数字个数然后从后往前用双指针搬字符。这同样是一道双指针题只是方向和前两题相反344 是从左边往中间合54 是从右往左移动。所以一天之内刷这三题并不是随意拼凑而是一个相对完整的训练闭环先理解反转的边界条件再理解规则化分段的边界条件最后理解“长度动态变化时怎么避免覆盖旧数据”。把这三件事想清楚很多字符串题你都不会再怕。1.2 我在做题时的选题顺序建议如果你真的准备照这个节奏来我建议先做 344再做 541最后做 54。理由很简单344 只需要写一个 while 循环代码量最少适合作为热身541 依赖 344 的反转逻辑可以复用同一段代码片段重点放在“判断反转区间”54 的思维难度略高一点因为它不是单纯反转而是“替换扩容”需要想清楚新旧位置的关系。我自己的习惯是每道题先用一句话写下核心思路再动手写代码。比如 344 就是“左边换右边直到相遇”541 是“每 2k 一组组内前 k 个用对撞反反转”54 是“先数数再从尾巴倒着迁移”。这么写的好处是过几天回来复习时看一句话就能快速回忆起解法。2. 344. 反转字符串最容易忽略的“原地修改”到底在考什么2.1 没人不会反转字符串但 LeetCode 给的是一个字符数组题目描述很简洁编写一个函数其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出要求不要给另外的数组分配额外空间你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。注意两个关键词。第一输入是 char[]不是 String。如果是 StringJava 里字符串不可变你只能重新创建一个字符串那就不叫“原地”了。LeetCode 故意把输入设计成数组就是为了让你没法靠 concat 或 substring 偷懒。第二必须使用 O(1) 的额外空间。这意味着你不能用另一个数组接收结果也不能用列表拼接出一个新字符串再转回数组。唯一可行的大方向就是交换数组里的元素。我做这道题时看到很多人直接写def reverseString(self, s: List[str]) - None: s.reverse()在 LeetCode 上Python 的s.reverse()确实能原地反转在 C 里也可以写reverse(s.begin(), s.end())Java 里用Collections.reverse()也能对列表生效。但是作为题解练习我还是建议你手写双指针因为后续很多题都需要你手动控制交换范围不可能每道题都靠库函数。2.2 双指针对撞的两种写法最基础的双指针写法大概是这样的以 C 为例class Solution { public: void reverseString(vectorchar s) { int left 0, right s.size() - 1; while (left right) { char tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } } };逻辑非常好理解left 从最左边开始right 从最右边开始每次交换一对字符然后 left 向右移动、right 向左移动。循环继续的条件是 left right。当数组长度为偶数时两指针会在中间交叉时结束当数组长度为奇数时两指针会相等此时中间元素不需要交换。也可以把交换写成用临时变量的简化写法比如 C 的swap(s[left], s[right])Java 也可以用char tmp s[left]; s[left] s[right]; s[right] tmp;。核心目的只有一个不能直接赋值否则会丢掉被覆盖的值。时间复杂度是 O(n)每个位置最多被交换一次额外空间是 O(1)只有临时变量。2.3 这道题容易在哪儿出错第一个坑用 for 循环时把循环变量当成真实数组元素来改。这种问题在 Java 增强 for 里尤其常见for (char c : s) { // 想通过修改 c 来改变数组做不到 }因为这里的 c 只是数组元素的拷贝改它不会影响原数组。哪怕是普通 for 循环你也可以写出s[i] s[j]这种顺序错误的问题切记先保存一个再覆盖另一个。第二个坑混淆 String 和 char[]。LeetCode 的输入是数组但有些同学在本地自己写 main 测试时先声明了一个 String再传入函数编译器直接报错。正确做法是char[] arr {h,e,l,l,o}; solution.reverseString(arr); System.out.println(Arrays.toString(arr));第三个坑忽略长度为 0 或 1 的输入。其实 while 循环天然能处理这两种情况因为 left right 不成立代码不会进入循环。但如果你写的是left right会多做一次毫无意义的自己交换自己虽然不报错但不干净。还有一个值得说的经验反转字符串在实际开发里最常见的用途之一是判断回文串的前置步骤。比如先反转再比较或者反向遍历取字符串最后几个字符。刷完这道题之后建议顺手做一道“验证回文串”会发现思路完全能迁移过去。3. 541. 反转字符串 II这道题不考智商考你能不能读懂 “每 2k 个” 这句话3.1 把题目规则翻译成下标关系541 的题目描述有点绕我直接拆给你看。给定一个字符串 s 和一个整数 k从字符串开头算起每计数至 2k 个字符就反转这 2k 字符中的前 k 个字符。如果剩余字符少于 k 个则将剩余字符全部反转如果剩余字符小于 2k 但大于或等于 k 个则反转前 k 个字符其余字符保持原样。初次读题很多人会被“每计数至 2k 个字符”这句话卡住。其实它就是在说从下标 0 开始每 2k 个字符作为一组每个组内只反转前 k 个最后一组如果不足 2k 个再按剩余字符的情况单独判断。举个例子。假设 s abcdefgk 2。先看下标 0 到 3 这 4 个字符2k 4反转前 2 个字符ab 变 ba结果变成 bacdefg再看下一组从下标 4 开始剩余 efg 只有 3 个字符。3 大于等于 k 2所以反转前 2 个字符ef 变 fe最后一个 g 不变结果为 bacdfeg。如果 s abcdefghk 3那么第一组是下标 0~5反转前 3 个字符 abc 为 cba剩下 defgh第二组从下标 6 开始剩余 gh 只有 2 个字符少于 3所以全部反转得到 cbaedfhg。发现没有做题的关键不是“怎么反转”而是“反转哪个区间”。这个区间就是根据当前位置 i 和 k 算出来的。3.2 用 i 2k 控制循环避免重复处理我第一次做这道题时用的是 i 外层循环然后想办法判断当前字符是否处于某个组的“前 k 个”位置写出来代码又臭又长还容易把边界搞错。后来我发现最清晰的方式是让外层循环直接按“组”来走每一组长度为 2k所以每处理完一组i 直接加 2k而不是加 1。这里给出我习惯的解法class Solution { public: string reverseStr(string s, int k) { for (int i 0; i s.size(); i 2 * k) { int left i; int right min(i k - 1, (int)s.size() - 1); while (left right) { swap(s[left], s[right]); left; right--; } } return s; } };核心就两行i 2 * k直接跳到下一组的开头right min(i k - 1, n - 1)如果当前组内的前 k 个字符超过了字符串末尾就只处理到末尾。这个写法的好处在于你不需要在循环里写 if-else 判断剩余字符是“少于 k”还是“在 k 到 2k 之间”min 函数已经自动兼顾了两种边界情况。当剩余字符不足 k 时i k - 1大于n - 1right 被截断到最后一个下标相当于全部反转当剩余字符在 k 到 2k 之间时right 正常取i k - 1只会反转前 k 个。如果你更习惯 Java代码长这样class Solution { public String reverseStr(String s, int k) { char[] arr s.toCharArray(); for (int i 0; i arr.length; i 2 * k) { int left i; int right Math.min(i k - 1, arr.length - 1); while (left right) { char tmp arr[left]; arr[left] arr[right]; arr[right] tmp; left; right--; } } return new String(arr); } }注意这里必须先s.toCharArray()因为 Java 的 String 不可变所有修改只能在字符数组上进行最后再转回 String。这一点和 C 直接传引用不一样。3.3 这道题的边界条件和调试技巧这种模拟题最怕边界条件我给你几个可以直接拿来测的用例输入k输出说明abc2bac剩余 1 个字符少于 k不反转 cabcd2bacd正好 2k 个只反转前 k 个abcdefg2bacdfeg最后一组剩余 3 个反转前 2 个a1ak 1 时每 2 个反转 1 个其实相当于都不反转3空串不要报错调试技巧方面我最常用的是“手动模拟 打印区间”。如果你写完代码不确定结果对不对就在本地输出每一轮的i、left、right对照题目给的例子走一遍。这个过程不是白费功夫因为机考或面试时很多边界问题就是靠这种模拟才发现的。还有一个很多人会漏掉的点k 的取值范围可能很大甚至大于字符串长度。此时i k - 1会超过整型范围吗在正常范围内不会因为 s.size() 和 k 都是 intk 一般也就 10^4 量级。但如果你用 Python要注意字符串切片反转不会原地修改需要重新拼接class Solution: def reverseStr(self, s: str, k: int) - str: result list(s) for i in range(0, len(s), 2 * k): result[i:ik] reversed(result[i:ik]) return .join(result)这里第二行的切片写法本质和双指针是一样的只是 Python 的切片更优雅而已。4. 卡码网 54. 替换数字为什么不能从前向后直接改这题的核心考点4.1 题目背景与最容易踩的思维误区卡码网 54 题是这样的给定一个字符串 s其中包含数字字符要求把字符串中的数字字符替换为 number 字符串。比如输入a1b2c3输出应该是anumberbnumbercnumber。这题放在 LeetCode 上其实对应的是“替换空格”那种类型的变体只不过空格替换成%20这里数字替换成number替换后的字符串变长了。我第一次拿到这题时第一反应是“这有什么难的从头到尾遍历一次遇到数字就替换成 number 字符串”。然后我写了个 Java 的StringBuilderpublic static String replaceDigits(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(); }这段代码运行结果完全正确。如果是在日常业务里我强烈推荐你直接这么写简单、清晰、不会错。但题目如果要求你“不能使用额外的新字符串空间必须在这个字符串内部完成修改”或者更严格一点“不能直接使用 StringBuilder/StringBuffer 辅助”那你必须换思路。这是这道题最重要的隐藏考点字符串/数组长度扩容时怎样原地修改而不覆盖掉还没处理的字符。4.2 从后往前搬字符的原理为什么要倒着走先想一个问题如果不用额外空间直接在原数组上做替换从前向后遍历会怎样假设原数组是[a, 1, b]要替换1为number。如果从前向后处理当你在下标 1 这个位置写入number的六个字符时数组需要连续六个坑位但原数组只有三个坑位后面下标 2 里原本是b一定会在写入过程中被覆盖掉。等你处理完b已经没了数据就丢了。所以正确思路是先扩容数组到足够的长度然后从后向前遍历把旧数组的字符搬到新数组的末尾位置。这样做的原因是从后向前移动时我们移动的旧字符总是位于当前位置的左侧而写入的新字符总在当前位置的右侧两者不会互相干扰。画个图理解一下。假设原始字符串是a1b其中数字字符1替换成number。先统计数字个数字符串里只有一个数字字符。每个数字字符会被替换成 6 个字符所以扩容后的长度 原长度 数字个数 × 5。这里 5 是number的长度 6 减去原来数字字符占的 1 个长度。原长度为 3数字个数为 1扩容后长度为 8。我们初始化一个长度为 8 的字符数组假设旧指针 oldIndex 指向原字符串的最后一个字符b新指针 newIndex 指向扩容后数组的最后一个位置下标 7。第一步读取 oldIndex 指向的b它不是数字直接放到 newIndex 位置然后 oldIndex 和 newIndex 都减 1。第二步读取 oldIndex 指向的1它是数字需要在 newIndex 处从右往左写入number先写r再写e然后b、m、u最后写n。写完n后newIndex 总共后退了 6 位oldIndex 只后退 1 位。第三步读取 oldIndex 指向的a它不是数字放到 newIndex 位置。最终数组内容就是anumberb。整个过程没有覆盖任何未处理的旧字符。这就是从后往前搬字符的核心价值旧字符只往右挪新字符只往右写旧数据不会因为“往前写”而被提前冲掉。你可以把这种操作想象成搬家时先把大件家具搬进空房间再从里面往门口摆不会挡到还没搬的东西。如果你面试时遇到原题“把空格替换成 %20”思路一模一样只是每个空格替换成 3 个字符扩容长度 原长度 空格数 × 2。4.3 多语言实现C、Java、Python 怎么落地先给 C 版本。因为 C 的 string 支持直接 resize做这种原地扩展很方便。#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(s.size() count * 5); int oldIndex oldLen - 1; int newIndex s.size() - 1; while (oldIndex 0) { if (s[oldIndex] 0 s[oldIndex] 9) { s[newIndex--] r; s[newIndex--] e; s[newIndex--] b; s[newIndex--] m; s[newIndex--] u; s[newIndex--] n; } else { s[newIndex--] s[oldIndex]; } oldIndex--; } cout s endl; return 0; }注意写入number的顺序我先写r再写e再写b最后写n。由于 newIndex 从右往左移动所以写入顺序必须保证最终结果从左到右读是number。Java 版本需要注意String 不可变所以不能用 resize。如果你要严格模拟“原地扩容”只能先s.toCharArray()但 Java 的 char[] 长度也是固定的于是更多人会选择先把字符串变成StringBuilder然后用 setCharAt 来修改。这里给一个我认为最清晰的抽象实现import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); String s sc.next(); int count 0; for (char c : s.toCharArray()) { if (Character.isDigit(c)) count; } char[] oldChars s.toCharArray(); char[] newChars new char[oldChars.length count * 5]; int oldIndex oldChars.length - 1; int newIndex newChars.length - 1; String num number; while (oldIndex 0) { if (Character.isDigit(oldChars[oldIndex])) { for (int j num.length() - 1; j 0; j--) { newChars[newIndex--] num.charAt(j); } } else { newChars[newIndex--] oldChars[oldIndex]; } oldIndex--; } System.out.println(new String(newChars)); } }Python 的写法更活络。因为 Python 字符串不可变你没法真的在 py 里原地改字符串所以通常直接构造新列表s input() res [] for ch in s: if ch.isdigit(): res.append(number) else: res.append(ch) print(.join(res))就算你想模拟“从后往前搬”也可以先把字符串转成 list扩展后从后往前填。但实际工程中直接 append 然后 join 已经够快不会成为性能瓶颈。刷题时为了练思路可以模拟但别踩进“Python 必须重演 C 流程”的坑里。4.4 这道题的时间复杂度与延伸应用无论哪种实现时间复杂度都是 O(n)统计数字遍历一次从后往前搬移又遍历一次实际是两次线性扫描合起来还是 O(n)。额外空间取决于选型C 的原地 resize 是 O(1) 额外空间不算扩容本身Java 因为数组不可变严格说多了 O(n) 的新数组空间。这里多说一句“从后往前双指针”这类技巧你们一定会在后面很多场景再碰到合并两个有序数组时从后往前放可以避免覆盖合并两个有序链表时尾插法哑节点也是为了不丢失指针操作系统里整理内存碎片、日志文件追加写入本质也涉及“新数据写在末尾旧数据不能丢”的约束。所以别小看这道“替换数字”它锻炼的是你在长度变化场景下对下标关系的敏感度。这个敏感度刷 diff 题、模拟题时特别重要。5. 三题连刷后的复盘双指针的一鱼三吃与刷题记录技巧5.1 同一个双指针三种完全不同的用法刷完今天这三道题如果只记住一个东西我建议你记住“双指针不是一种固定模板而是一种思想”。同样是双指针方向和处理时机完全不同344 反转字符串left 和 right 从两端向中间收缩属于“对撞指针”解决的是对称交换问题541 反转字符串 II外层循环通过i 2k来定位区间内层反转变成了对撞指针属于“区间模拟 对撞指针”的组合54 替换数字oldIndex 和 newIndex 都是从右往左但走的步子不一样属于“逆向同步指针”解决的是扩容场景下的原地修改问题。看到这里你可能会发现双指针并不是什么高深算法它只是在告诉你有时候多用一个指针就能减少一层循环或者避免一次额外空间开销。后续你还会遇到快慢指针链表找环、滑动窗口子串问题、相向指针两数之和、盛水容器都是同一思想的不同变体。我在刷题时会特意在笔记里给每道题打标签比如#双指针 #对撞、#模拟 #边界条件、#逆向双指针 #数组扩容。等到刷满一两百题后再按标签看会非常清楚自己擅长哪类、薄弱哪类。5.2 做题记录与复盘不要只存一个 AC 代码不少人是“提交通过就下一题”过两周发现全忘了。我个人的做法是每个题解下面至少保留三行东西——第一行是核心思路的一句话描述第二行是复杂度分析第三行是自己踩过的一个坑或一个巧妙的写法。拿今天这三题举例344 的一句话思路对撞交换直到 left right。541 的一句话思路i 每次加 2k反转区间是[i, min(i k - 1, n - 1)]。54 的一句话思路先数数字个数再 resize最后从后往前双指针搬。这三个笔记只要看一眼就能唤起记忆比我写几百字都管用。如果你是在代码随想录或者其他专题课程里刷题可以顺手把题目编号和知识点关联起来方便二次检索。5.3 关于单词的积累reverse、replace digits 这些英文题面别怵另外想提一个隐藏收获这三道题让你顺带熟悉了几个常见的英文表达。reverse反转2k 个字符2k characters剩余字符remaining characters数字字符digit character替换replaceLeetCode 的英文题面并不难但如果你没有刻意积累碰到Do not allocate extra space for another array这种表述时可能会犹豫一下“到底能不能用辅助数组”。其实这句话就是“别开新数组”的意思对应的解法空间复杂度是 O(1)。看多了就自然熟了。6. 今天刷完下一步你可以这样衔接6.1 推荐几道可以顺手巩固双指针的题如果你做完今天这几道还有余力可以把这几个题目加进明天的计划里344 的兄弟题LeetCode 7. 整数反转反转对象从数组变成整数注意溢出处理541 的升级版LeetCode 917. 反转字母只反转字母其他字符位置不动需要额外一个指针从头找字母54 的同类变体LeetCode 剑指 Offer 05. 替换空格把空格替换成%20和替换数字几乎是一个模板。这几道题都不算难适合用来检验你是不是真的掌握了今天的方法。如果能在不查题解的情况下独立写出来说明双指针的基础已经比较稳了。6.2 一个小技巧本地写题时如何快速验证最后分享一个非常实用的习惯。LeetCode 或卡码网这类刷题网站提交时会自动处理输入输出但本地调试时你得自己写 main。我的建议是准备一个固定的测试代码模板把所有边界用例放进一个 list循环跑一遍观察输出是否符合预期。比如 344 的测试用例char[][] cases { {}, {a}, {h,e,l,l,o}, {A,B,C,D} };然后把每个用例传进函数输出结果和期望值对比。这样做的好处是越快暴露边界问题越能避免反复提交浪费时间。每次提交前先在本地把常见边界过一遍基本上一次就能通过。我在实际刷题过程中发现很多人不是不会写解法而是经常在空数组、单元素数组、k 取极值这几种情况上栽跟头。以后遇到任何题都建议你先问自己三个问题输入为空时怎么办输入只有一个元素时怎么办参数取最大值时会不会溢出这三个问题想清楚代码的健壮性会明显上一个台阶。
返回列表