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

资讯详情

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

代码随想录Day8:字符串双指针与边界条件全解析

代码随想录Day8:字符串双指针与边界条件全解析 1. 字符串题为什么是面试高频考点先想清楚这几个底层问题字符串这个专题在代码随想录算法训练营里排在Day 8前面数组、链表已经铺完底到了这里你会发现一个很微妙的点字符串的处理方式跟数组高度重合但又有自己的脾气。我在LeetCode上断断续续刷了上千道题最深的感受是面试官特别喜欢在字符串题里考察候选人的边界意识和对语言底层特性的理解。一个简单的反转字符串能写对的人不一定多一道翻转单词顺序能一次跑通的人更少。先说个定位问题字符串到底算简单题还是难题我的判断是——下限极低上限极高。简单的字符串遍历幼儿园级别的for循环就能搞定但字符串匹配里的KMP、高效去空格、原地翻转这类操作能把一大批刷题量不够的人拦住。Day 8的字符串 part 01正好卡在这个分界点上题目都不算难但每一道都在逼你思考一个核心问题——你到底是在操作值还是在操作内存。这个章节有个很有意思的现象。很多人在学字符串之前已经能熟练刷链表和二叉树了但一碰到字符串就露怯。为什么因为链表、二叉树的操作对象非常明确就是节点和指针而字符串在不同语言里呈现出来的“体质”完全不一样。C的string是可变对象你可以直接改某个下标Java和Python的String是不可变的每一次修改都重新生成新对象JavaScript更拧巴字符串本身不可变但可以极其方便地用split转成数组再处理。这种语言差异直接决定了同样一道题的解法在不同语言里完全不是一个难度也决定了面试官在看你写代码时关注点会不一样。这段内容说得残忍一点字符串题考的不是你会多少API而是你在不用API兜底的情况下能不能自己把逻辑链条完整地搭起来。比如反转字符串用Python直接写成s[::-1]确实优雅但面试官下一句大概率是如果不允许切片你怎么办所以Day 8的核心目标就一个把字符串操作里最常见的几种处理模式拆开、揉碎、装进脑子里形成条件反射。适合看这篇内容的人我琢磨了一下大概是这几类刚跟着代码随想录走到Day 8的训练营学员、准备暑期实习或秋招但字符串题还没形成体系的同学以及刷题刷到瓶颈期、每次字符串题目都靠库函数“瞬杀”但心里发虚的选手。这篇文章会照着训练营的节奏把基础部分讲透把每个步骤背后的为什么讲明白再补上我实际调试时踩过的坑。2. 反转字符串类题目什么时候能用库函数、什么时候必须手写2.1 先练手LeetCode 344 反转字符串这道题是字符串part 01的开胃菜。题目非常直白输入一个字符数组s要求原地反转不能申请额外空间而且官方明确要求不要使用库函数。我第一次刷这道题的时候用的就是while循环加双指针但后来发现很多初学者会卡在一个很尴尬的地方LeetCode给的是字符数组[h,e,l,l,o]而不是一个字符串。这两者的区别太大了——如果是字符串你有十个八个API可以用但字符数组就逼着你老老实实处理下标。解法本身极其简单双指针相向而行var reverseString function(s) { let left 0; let right s.length - 1; while (left right) { let temp s[left]; s[left] s[right]; s[right] temp; left; right--; } return s; };这个代码里唯一的门道在于循环终止条件到底是left right还是left right。对于奇数长度的数组比如长度5left走到2、right走到2的时候left right中间那个字符不需要和自己交换如果是偶数长度最后两步必然是left先超过right。实测下来用left right最稳不会多操作一次也不会漏掉任何一对。这题的时间复杂度O(n)空间复杂度O(1)没什么好说的。但有一个点值得单独拎出来交换操作可以不用临时变量。用加减法或者异或运算也能实现交换例如s[left] [s[right], s[right] s[left]][0]; // 不太推荐可读性差或者s[left], s[right] s[right], s[left] # Python里一行搞定Python这种写法本质上是语言层面的元组拆包底层还是临时变量交换。面试时写成这样没问题但如果你在面试C还是老老实实写下标交换的过程别为了炫技写出让人看不懂的代码。2.2 升级版LeetCode 541 反转字符串II这道题是344的兄弟版本也是我在训练营里看讨论区最热闹的题之一。题目说给定字符串s和整数k从头开始每2k个字符反转前k个如果剩余字符少于k个则全部反转如果剩余字符大于等于k个但少于2k个则反转前k个。我个人的经验是做这个题最容易翻车的点不是反转逻辑而是边界条件里的等于到底归谁。很多人会把少于k个和少于2k个弄混导致while循环里case分错。我的写法是var reverseStr function(s, k) { let arr s.split(); for (let i 0; i arr.length; i 2 * k) { let left i; let right Math.min(i k - 1, arr.length - 1); while (left right) { let temp arr[left]; arr[left] arr[right]; arr[right] temp; left; right--; } } return arr.join(); };关键就是那一行right Math.min(i k - 1, arr.length - 1)。这行代码直接把“剩下的不足k个就全反转”这种边界情况吃掉了不需要再写if判断。为什么i需要每次跳2k因为每2k段是一个完整的处理单元反转前k个然后跳过后面k个不动。如果你每次i只加k那就把不该反转的后半段也处理了结果完全不对。这题我在实际提交中踩过一个很隐蔽的坑一开始我把arr.split()写成了s.split()结果逗号分隔符混进去了后来排查了半天才发现是split参数问题。写JavaScript字符串题时split()和split()千万不要混用前者按字符拆后者按整串拆低级错误但真的会犯。2.3 库函数的边界到底怎么判断代码随想录在字符串这一章特别强调了一个方法论我觉得值得单独展开什么时候可以用库函数什么时候必须自己实现。我的判断标准很简单就看一条库函数是不是这道题的核心考点。反转字符串的题用Python的s[::-1]或者Java的StringBuilder.reverse()一行写完问题是面试官让你手写反转的意图是什么他是想看你能不能在没有库函数加持的情况下用指针完成基础操作。所以这种题你不能用库函数。反之如果是把字符串转成大写、判断某个字符是不是数字这种纯工具性操作库函数随便用面试官不会蠢到考你字符编码的ASCII表。再比如LeetCode 344里如果允许用库函数JavaScript里就是s.reverse()一行完事。但训练营刻意要求你手写本质是在训练你对双指针的肌肉记忆。双指针是字符串题里出镜率最高的技能点没有之一。后面翻转单词、替换空格、甚至KMP里都离不开双指针的思想所以前期基础题宁可多写几遍原生的交换也不要用库函数一笔带过。3. 替换空格与双指针从后往前处理的高效套路3.1 剑指Offer 05替换空格为什么不能从前遍历题目实现一个函数把字符串s中的每个空格替换成%20。这道题如果你第一次见到直觉反应大概率是新建一个字符串遇到空格就追加%20这当然能做对。但面试官紧接着会追问一句如果要求原地修改呢这就触及了这道题真正的考点。先解释一下为什么空格要替换成%20而不是别的。HTTP协议里URL路径不能直接包含空格RFC 3986规定空格在URL里是不合法字符需要通过百分号编码变成%20。这个背景知道一下就行不是重点。重点是替换操作的空间和时间开销。如果从前往后遍历并原地修改每次遇到一个空格后续的所有字符都要往后移动两个位置最坏情况下时间复杂度是O(n²)——一个长度为n的字符串假设全是空格每次替换都要搬动n个字符。这是典型的“每次操作都牵连大批元素”的反面教材。训练营第一步就点破了这一点数组/字符串尾部操作不需要搬运元素这是一种免费的空间。所以我见到的最优解是分两步走第一遍先统计原始字符串里有多少个空格通过空格数量推断出替换后字符串的总长度第二遍从后往前填充遇到空格就把%20倒着填进去遇到普通字符就原样复制。3.2 C和JavaScript两种思路对照先写C版本的原地扩展现思路因为这个语言里string确实是可变的最能体现从后往前的精髓string replaceSpace(string s) { int oldLen s.length(); int spaceCount 0; for (char c : s) { if (c ) spaceCount; } int newLen oldLen spaceCount * 2; s.resize(newLen); int i oldLen - 1; int j newLen - 1; while (i 0) { if (s[i] ) { s[j--] 0; s[j--] 2; s[j--] %; } else { s[j--] s[i]; } i--; } return s; }从后往前的核心逻辑就是i和j两个指针i指向旧字符串末尾j指向扩容后的末尾。当s[i]不是空格时把s[i]复制到s[j]当s[i]是空格时依次填入0、2、%。由于j永远小于等于i所以从后往前填充永远不会覆盖还没有被处理的旧字符这是它优于从前往后填充的核心原因。JavaScript里字符串不可变没办法原地resize所以我通常先转数组处理再join回来var replaceSpace function(s) { let arr s.split(); let spaceCount 0; for (let i 0; i arr.length; i) { if (arr[i] ) spaceCount; } let oldLen arr.length; let newLen oldLen spaceCount * 2; let result new Array(newLen); let i oldLen - 1; let j newLen - 1; while (i 0) { if (arr[i] ) { result[j--] 0; result[j--] 2; result[j--] %; } else { result[j--] arr[i]; } i--; } return result.join(); };很多初学JavaScript的人会问为什么不直接s.replaceAll( , %20)答案还是前面说的如果面试官没有刻意屏蔽库函数你可以用但这道题的灵魂在于手写双指针原地修改。用replaceAll虽然一行搞定但你什么都没学到面试官也没办法判断你对数组扩容和指针移动有没有sense。3.3 双指针的两个方向分别适合什么场景做字符串题做多了之后我总结出一个规律双指针的遍历方向取决于你要操作的区域在哪一侧。从前往后遍历适合在字符串尾部追加元素的场景比如收集符合条件的字符放进新数组里这种场景下你不关心是否覆盖旧元素从后往前遍历适合在字符串中插入/替换元素导致长度变的场景因为尾部空间越往后越充足填充过程不会影响还没处理到的地方。替换空格就是典型的“从后往前”场景。这个思路在后面很多题目里都会用到比如合并两个有序数组时如果要求原地合并也是两数组末尾各放一个指针从后往前谁大放谁。你一旦建立了这种思维迁移能力刷题才会真正有体系而不是东一榔头西一棒子。4. 翻转单词与旋转字符串局部反转加整体反转的组合拳4.1 LeetCode 151 翻转字符串里的单词一个降维打击的思路这道题的题目要求是把字符串里的单词顺序完全颠倒同时要去掉字符串开头、结尾以及中间的多余空格。举个例子the sky is blue变成blue is sky the hello world 变成world hello。我第一眼看到这种题的想法是用split( )把字符串拆成数组然后用filter过滤掉空字符串最后reverse再join几秒钟写出来。但训练营的这个题同样是要求不使用辅助空间原地修改。这就逼着你提高一个维度去思考。这里就出现了一个我在刷题过程中见过的极其优雅的思路——先整体反转再逐个单词反转。具体分三步第一步移除字符串里多余的空格包括头部、尾部和中间连续的空格这一步用双指针完成。第二步把整个字符串整体反转比如the sky is blue先变成eulb si yks eht。第三步把每个单词再单独反转回来于是eulb变回bluesi变回isyks变回skyeht变回the。为什么这个思路是降维打击因为你把一个大任务拆成了两个会了就没难度的小任务。整体反转就是344题的双指针单词单独反转还是统一的双指针唯一新学到的点是怎么用一个循环同时维护单词的边界。第三步里每个单词的起止下标都需要动态定位我习惯用一个start指针从0开始遇到空格就停下来反转[start, end-1]区间然后start跳到end1。4.2 LeetCode 151 的具体实现移除空格是个隐藏难点这才是这道题真正卡人的地方。我们先理解为什么不能简单地用split过滤因为如果要求原地那么长度变化本身就是麻烦事。正确做法是用双指针把有效字符覆盖到数组前面同时把多余空格用单空格代替。我用的模板是var reverseWords function(s) { let arr s.split(); // 1. 移除多余空格双指针覆盖法 let slow 0; for (let fast 0; fast arr.length; fast) { if (arr[fast] ! ) { if (slow ! 0) arr[slow] ; // 单词之间补一个空格 while (fast arr.length arr[fast] ! ) { arr[slow] arr[fast]; } } } arr.length slow; // 截断多余部分 // 2. 整体反转 reverseRange(arr, 0, arr.length - 1); // 3. 逐个单词反转 let start 0; for (let i 0; i arr.length; i) { if (i arr.length || arr[i] ) { reverseRange(arr, start, i - 1); start i 1; } } return arr.join(); }; function reverseRange(arr, left, right) { while (left right) { let temp arr[left]; arr[left] arr[right]; arr[right] temp; left; right--; } }这里面有个细节值得特别注意移除空格时if (slow ! 0) arr[slow] 这一行代码的作用是在每个新单词开始之前补一个空格。因为fast跳过连续空格时slow指向的是上一个单词的结尾如果这不是第一个单词就需要补一个空格把单词隔开。这个逻辑我第一次写的时候完全没想到结果处理a good example这种中间有多个空格的输入时输出的单词全粘在一起了。另外我踩过的坑arr.length slow在JavaScript里可以截断数组但这个方法在LeetCode环境里有效在浏览器控制台里也有效只是有些人不习惯这个写法。如果你觉得这种写法太隐蔽可以用arr.splice(slow)代替。核心思想一样把超出slow的部分扔掉。4.3 剑指Offer 58-II左旋转字符串的两条路题目字符串abcdefg左旋2位得到cdefgab。左旋的概念就是把前n个字符移到字符串末尾。这条题如果不用库函数我能想到两条路第一条路是切片拼接这属于思路验证但面试时如果只说这个大概率会被追问“还有没有更优解”。比如def reverseLeftWords(s, n): return s[n:] s[:n]一行代码但对训练来说这题白做了。第二条路才是训练营想让你掌握的东西——局部反转加整体反转。具体操作是三步先反转前n个字符ab - ba再反转后面的字符cdefg - gfedc最后整体反转bagfedc - cdefgab我直接给出JavaScript版本的代码var reverseLeftWords function(s, n) { let arr s.split(); reverseRange(arr, 0, n - 1); reverseRange(arr, n, arr.length - 1); reverseRange(arr, 0, arr.length - 1); return arr.join(); };这个套路在右旋转字符串里同样适用。右旋n位本质上是左旋 length - n 位你只需要把前两次反转的区间换一下就行。所以我在训练营笔记里写了一句话作为总结凡是“把某一段字符串搬到另一端”的题目都可以拆成两次局部反转加一次整体反转。这个组合拳值得刻进DNA。5. 实战踩坑实录边界条件、语言差异和复杂度表达5.1 边界条件速查表字符串题目里的边界条件比数组题更隐蔽因为字符串天然存在“开头、结尾、空格、大小写”这些额外维度。我把这几道题最容易出错的边界情况整理成一张表每次提交前扫一眼能省不少时间边界场景容易出现的错误正确做法空字符串 直接调用s[0]报错或返回undefined先判断length是否为0单字符字符串 a双指针leftright时还执行交换用left right当条件541题里k大于字符串长度剩余字符超过k但不足2k时反转逻辑混乱right Math.min(i k - 1, len - 1) 兜底151题里字符串全是空格 去除空格后数组为空反转后拼接出错移除空格后先判断slow是否为0151题里开头结尾都有空格split后出现空字符串用覆盖法而不是split过滤58-II题里n等于0反转区间0到-1直接报错先判断n 0直接返回原串这些场景不是靠聪明就能避免的只能靠多写多踩。我第一次写541的时候信心满满地提交结果case里一个k大于字符串长度的测试就把我打败了。字符串长度是动态变化的而你的反转区间是基于当前长度计算的这俩必须时刻对齐。5.2 不同语言的实际表现差异同样是字符串反转题C写出来和Python写出来完全是两种面貌。C的string支持下标修改所以344题你可以直接在原串上swap但如果你在Java里尝试类似操作需要先把String转成char[]否则String的值根本变不了。这个差异必须提前确认否则面试现场容易出现“你写的代码在自己电脑上能跑面试官一运行就报错”的尴尬。Python里还有个坑我必须提一下切片 s[::-1] 看起来很万能但一旦你的s是bytes类型或者bytearray类型行为完全不同。而JavaScript里split()会把多字节的Unicode字符拆成两个独立单元例如emoji和某些中文生僻字导致反转后乱码。这时候你得用Array.from(s)或者展开运算符[...s]才能正确处理码点。我在处理LeetCode 541这种纯英文题目时没踩过这个坑但要是业务代码里处理用户昵称反转这绝对是生产事故级别的bug。5.3 面试时怎么讲复杂度写完代码之后面试官几乎必问一句“时间复杂度是多少”字符串题里很多人会答错因为忘了split和join也是O(n)。我见过有人自信地说我的代码是O(n)但仔细一看里面套了两个for循环加一个split加起来其实是O(n)没问题但是如果你每反转一次都调一次split就会变成O(n²)。正确的表达方式是一次遍历统计空格是O(n)一次遍历填充是O(n)整体反转和局部反转加起来仍然是O(n)。为什么因为所有操作都是线性扫描没有嵌套循环和递归。空间复杂度要区分原地修改是O(1)但如果用了split和join语言层面的数组和字符串都会复制一份严格来说是O(n)。面试时主动把这一点说出来会显得你对底层实现有认知而不只是会背模板。6. 字符串题怎么刷才有效刷题顺序与面试答题模板6.1 五道题形成一个闭环Day 8的part 01总共五道核心题344反转字符串、541反转字符串II、剑指05替换空格、151翻转字符串里的单词、剑指58-II左旋转字符串。我按训练营的顺序刷完之后复盘发现这五道题其实是一条完整的能力链路344练双指针基本功541练区间控制剑指05练从后往前151练三步反转组合拳58-II练同一套组合拳的变形应用。链路练完之后再遇到“右旋转字符串”“反转字符串中的元音字母”这类变体基本看一眼就能拆解成熟悉的模式。有一个相当实用的建议先别用库函数刷完一轮然后用库函数再刷一遍。第一轮手写双指针是为了理解核心逻辑第二轮用库函数是为了知道在实际工程里怎么写更简洁。两道题各有价值面试答手写版工作写库函数版两不误。6.2 面试答题的标准流程我总结了一套字符串题的面试答题模板实测在多家公司面试里都能撑起至少十分钟的交流时间第一步复述题目边界。开头就要确认空字符串、长度1、重复字符这些case预期是什么表现这能让面试官觉得你经验老到。第二步先说暴力解再引出优化。比如替换空格你先说“最直接的做法是线性扫描并构建新串时间复杂度O(n)但空间也是O(n”然后补充“如果要求原地那就需要先统计空格数量从后往前双指针填充”。暴力解不是用来写的是用来铺垫的。第三步动手写代码前口头描述一下方案。说出“先整体反转再局部反转”这八个字面试官基本就点头了。方案对了代码对错的容错率反而高。第四步写代码时边写边解释指针含义。比如“slow是最终结果数组的写入指针fast是原数组的扫描指针”这种表达能体现你对变量的掌控力。6.3 下一步学习建议字符串part 01之后的part 02训练营很可能就会上KMP算法了。LeetCode 28找出字符串中第一个匹配项的下标就是KMP的典型代表。如果你part 01的数组、双指针、边界处理还不够熟KMP学起来会非常痛苦因为它不仅涉及字符串还涉及前缀函数、next数组的构造和优化是算法面试里公认的“劝退题”。所以part 01这五道题我建议哪怕你已经会了也至少再手写两遍让自己形成不需要思考就能写好边界条件的条件反射。最后分享一个我在实际刷题中养成的好习惯每刷一道题就在代码注释里写一句“如果限制条件改成XXX我的解法哪里会崩”。这个习惯让我在面试里被追问变体时总能快速说出“这里需要改动XX行代码”。字符串题尤其适合这种玩法因为它的变体往往只差一个边界条件或者一个反转方向而真正的算法框架纹丝不动。能做到这一点Day 8就算真正毕业了。
返回列表