代码随想录数组章节核心解析:二分法、双指针、滑动窗口与模拟行为
刷题圈子里一直流传着一句话代码随想录yyds。但yyds归yyds真正把它从头到尾啃完的人其实没那么多。原因很简单大部分刷题资料的通病是“给了答案但没给思路”你抄完代码过两天再遇到同类题照样一脸懵。代码随想录不一样的地方在于它每个章节都先讲核心方法论再用一串同类型题目反复强化让你真正建立“这一类题该怎么想”的条件反射。数组章节是代码随想录开篇的第一个大章节也是最容易被低估的一个章节。很多人觉得数组简单不就是下标访问吗结果一刷题就原形毕露二分法边界搞不明白、双指针代码写出来慢指针快指针分不清、滑动窗口的窗口边界维护得乱七八糟、螺旋矩阵更是直接把四个方向搞混。这些都是我真实踩过的坑。这篇文章我结合自己的刷题记录把代码随想录数组章节的四个核心模块——二分法、双指针、滑动窗口、模拟行为——串一遍重点讲那些容易被忽略、又特别容易出问题的细节。我会把每一类题的“为什么”讲清楚配合可直接照抄的C模板代码再附上我刷题时踩过的坑和排查经验。不管你是刚开始刷题的新手还是刷了一两百道题想回头补基础的老手这篇文章都值得你花十分钟读完。1. 内容整体设计与思路拆解1.1 为什么数组章节适合作为刷题的第一站先聊点“废话”但是是我认为很重要的废话。很多人一上来就刷二叉树、刷动态规划结果刷到怀疑人生。原因很简单动态规划和二叉树这种专题理解的跳跃性太大。你连二分法的边界都还没摸清楚就去背动归的状态转移方程这不叫刷题叫玄学。数组章节恰恰相反它是所有数据结构里最“朴素”的存在。它不依赖复杂的结构不需要你先掌握递归、栈、队列这些前置知识本质就是一个连续的内存空间里面存放同类型元素。你只要理解了索引和连续这两个概念数组篇里八成以上的题你就能动笔。另外数组是“思维热身”的最佳场所。数组题目里出现的双指针、滑动窗口、模拟行为这些并不是数组独有的而是会在链表、字符串、甚至矩阵题里反复出现的通用思想。所以你在数组章节把这些思想磨扎实了后面学链表、学字符串会顺畅很多。我自己的体验是把数组章节按模块拆开之后每一块其实都可以当成一个独立的“套路”来学。二分法解决有序数组的查找问题双指针处理移除和排序覆盖滑动窗口处理连续子数组问题模拟行为用来训练循环控制能力。这四个模块看起来各不相同但它们的底子是同一个边界意识。边界对了算法就对了边界错了代码就是再漂亮也白搭。很多人在数组题里栽跟头根本不是不知道解题思路而是没有把边界吃透。代码随想录在数组章节里反复强调的也是这种“边界感”。它的题解会明确告诉你左闭右闭和左闭右开有什么区别while循环里到底是left小于right还是left小于等于right。这些细节看起来烦人却是能让你从“看得懂答案”进化到“自己写得对”的关键一步。数组作为刷题的第一站还有一个隐藏好处它的题目普遍不难容易建立起刷题的正反馈。你想想一天解出一道二叉树的中等题你可能会觉得只是运气好但一天解出三道数组题你会觉得“我是真的掌握了”。这种心理上的正反馈对于坚持刷题的人来说非常宝贵。信心是刷题路上最稀缺的资源而数组章节能稳定地给你提供这种资源。1.2 代码随想录数组章节的模块化拆解代码随想录的数组章节整体上是按“题目类型”来组织内容的每个类型下面都有对应的核心方法论和配套练习题。我刷完之后习惯把这章的内容拆成四个核心模块来看同时也把每个模块对应到的典型题目列出来方便你们参考。第一个模块是二分法。这个模块解决的是“有序数组中的查找”问题从最传统的704.二分查找到35.搜索插入位置再到34.在排序数组中查找元素的第一个和最后一个位置。它的核心是理解“循环不变量”说白了就是你每次循环都要保持区间定义不变。左闭右闭的话right就要等于middle减一左闭右开的话right就直接等于middle。这套东西初看很绕一旦你画过一遍区间变化的图就再也不会错了。第二个模块是双指针法。这个模块解决的是“无序数组中的元素移除”代表题目有27.移除元素、26.删除排序数组中的重复项、283.移动零。核心思路一句话快指针遍历原数组找新元素慢指针指向新数组的写入位置。这个思想的精髓在于你用O(n)的时间复杂度原地就完成了数组元素的筛选不需要额外开数组。很多人在暴力解法里用了erase结果发现删除一个元素要移动大量的后续元素时间复杂度瞬间拉满这就是没有建立“原地操作”的概念。第三个模块是滑动窗口。这个模块解决的是“连续子数组的最值问题”代表题目是209.长度最小的子数组以及904.水果成篮。滑动窗口的关键是搞清楚窗口的起点和终点分别怎么动。很多新手会犯一个错误终点一旦满足条件就把窗口内的和全部重新算一遍结果把O(n)的算法写成O(n²)甚至更糟。正确的做法是维护窗口内的状态比如和、数量然后移动起点把移出去的元素从状态中减掉。这里有个最容易被忽视的点什么时候移动窗口的起点终点要不要回头。滑动窗口的起点是单调递增的终点也是单调递增的两边都不回退这就是所谓的“双指针同向移动”。第四个模块是模拟行为。这个模块在代码随想录里单独拎出来讲了一道题59.螺旋矩阵II以及它的变体54.螺旋矩阵。它的难度不在于算法思想而在于循环控制。你需要在四个方向上进行边界收缩每一圈的上下左右边界都得单独维护。很多人一开始写这道题都会陷入“边界混乱”的泥潭。代码随想录给出的解法是“左闭右开循环不变量”也就是每一条边都只处理第一个到倒数第二个元素留下最后一个给下一条边处理。这样每一条边的处理逻辑是一致的不会出现边界重叠或者漏元素的情况。这四个模块拆开来看都不难组合在一起就构成了数组题的核心题型。你如果问我数组章节到底刷什么我觉得就是刷这四个模块的“边界感”。当你能够不看题解随手就能把螺旋矩阵II写出来的时候说明你对边界的控制已经合格了可以放心进入链表章节。2. 核心细节解析与实操要点2.1 二分法边界条件的死磕才见真功夫二分查找大概是每个程序员都会写的算法但能一次写对的人真的不多。LeetCode上704.二分查找的通过率其实不算高原因就出在边界条件的处理上。我先把左闭右闭的写法放出来这是我在代码随想录里学到的第一套标准模板也是我个人最推荐的写法因为它的区间定义最直观最不容易产生歧义。int search(vectorint nums, int target) { int left 0; int right nums.size() - 1; // 定义target在左闭右闭区间 [left, right] while (left right) { // 当leftright时区间依然有效 int middle left ((right - left) / 2); // 防止溢出等价于 (leftright)/2 if (nums[middle] target) { right middle - 1; // target在左区间所以更新为 [left, middle-1] } else if (nums[middle] target) { left middle 1; // target在右区间所以更新为 [middle1, right] } else { return middle; } } return -1; }你注意看几个关键点第一right的初始值是size减一不是size。第二while循环的条件是left小于等于right。第三当nums[middle]大于target时right更新为middle减一。这三个点绑在一起就是在贯彻“左闭右闭”这个区间定义。很多人会问为什么right不直接等于size原因很简单size是一个不存在的索引如果你把它放进区间那你的区间就变成了左闭右开。一旦区间定义变了后面所有的边界更新逻辑都得跟着变。这就是代码随想录反复强调的循环不变量。你的区间定义一旦确定循环体内每一步都必须严守这个定义不能一会儿闭一会儿开。我再把左闭右开的写法贴出来方便大家对照int search(vectorint nums, int target) { int left 0; int right nums.size(); // 定义target在左闭右开区间 [left, right) while (left right) { // 当leftright时区间为空退出循环 int middle left ((right - left) / 2); if (nums[middle] target) { right middle; // target在左区间更新为 [left, middle) } else if (nums[middle] target) { left middle 1; // target在右区间更新为 [middle1, right) } else { return middle; } } return -1; }左闭右开和左闭右闭的核心区别就两个点一是right初始值不同二是right更新方式不同。你千万别小看这两处差别做题时一旦混用就会出现死循环或者漏判。我个人在刷题时会做一个小练习同一道二分查找左右各写一遍用随机生成的数组和随机target去跑跑完再对比两种写法的结果。这样做几次你对边界条件的肌肉记忆就有了以后再也不会在二分法上翻车。说到底二分法不是背代码而是背“区间定义”。你心里要时刻清楚当前搜索区间是闭还是开代码自然就写对了。这里我特别建议新手用一个笨办法——在纸上画区间把每一次二分后的[left, right]区间写下来画个三四道题你对二分的理解会立刻提升一个档次。很多时候你觉得“懂了”其实只是“眼睛懂了”动笔一画漏洞全暴露。2.2 双指针法快慢指针到底在“快”什么数组的双指针在代码随想录里被拆成了两类。一类是左右指针比如有序数组的两数之和从两端往中间走另一类是快慢指针比如移除元素一个指针在前面探路一个指针在后面收编。数组篇里最常用的是快慢指针它处理的核心问题是“原地筛选”。我们看27.移除元素这道题。题目要求原地移除所有数值等于val的元素返回移除后数组的新长度。暴力的思路是先算长度再用erase逐个删但这样每删除一个元素后面的元素都要前移一位时间复杂度能到O(n²)。双指针的思路则不同int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { // 快指针找到了需要保留的元素 nums[slow] nums[fast]; // 慢指针指向写入位置 slow; } } return slow; }这段代码的逻辑非常直白快指针负责扫描整个数组慢指针负责维护“新数组”的写入位置。只要快指针遇到的值不是目标值就把它复制到慢指针指向的位置然后慢指针前进一位。当快指针扫描完毕慢指针的值就是新数组的长度。整个过程中数组前slow个位置就是我们想要的答案后面的元素即使还在也已经没有意义了。这里有一个关键点快指针是无条件移动的而慢指针只有在发生写入时才移动。很多初学者写着写着会把快指针的移动也放进条件分支里这样就会出现死循环或者漏元素。记住一句话快指针代表“读取”慢指针代表“写入”读永远是主动的写是被动的。同类型的还有26.删除排序数组中的重复项。这道题其实就是移除元素的变体。区别在于移除元素的判定条件是“不等于val”而删除重复项的判定条件是“不跟前一个保留元素相等”。同样的双指针框架换个条件就能解决不同的问题这就是“思想复用”的价值。再比如283.移动零它要求把零移到末尾非零保持原有顺序。你完全可以先用双指针把所有非零元素搬到前面剩下的位置补零即可。这些题全是同一个套路刷起来很有成就感。我建议大家在刷双指针题的时候不要急着看题解而是先在纸上画一遍指针的移动过程。我之前画过那张图数组[3, 2, 2, 3]val为3两个指针从起始位置出发每轮循环快指针往前走遇到等于val的时候慢指针原地等待遇到不等于val的就把值覆盖过去。画完这张图你对双指针的理解会从“背代码”变成“看图说话”。另外提醒一个容易忽略的细节覆盖之后旧值其实还残留在数组的后面位置但这不影响最终结果因为返回值只取前slow个元素。这也是为什么很多解法里根本不重置后面的元素。2.3 滑动窗口窗口边界的进退艺术滑动窗口是数组章节里我个人觉得最容易“思路懂了代码写崩”的部分因为它的难点不在思想而在维护。思想就一句话用两个同向移动的指针维护一个“连续子数组”通过调整左右指针来满足题目条件。但真正实现的时候左右指针的移动时机、窗口内状态的更新方式全是坑。我们拿209.长度最小的子数组来举例题目要求找出该数组中满足其和大于等于target的长度最小的连续子数组返回其长度。暴力的做法是双重循环枚举所有子数组求个最小值时间复杂度O(n²)。用滑动窗口可以做到O(n)int minSubArrayLen(int target, vectorint nums) { int result INT_MAX; int sum 0; int left 0; for (int right 0; right nums.size(); right) { sum nums[right]; // 右指针扩展窗口纳入新元素 while (sum target) { // 窗口满足条件尝试收缩 result min(result, right - left 1); sum - nums[left]; // 左指针收缩窗口移出元素 left; } } return result INT_MAX ? 0 : result; }核心逻辑拆开看就三步第一右指针右移把新元素加进窗口窗口的和随之增加第二判断当前窗口是否满足条件如果满足就记录窗口长度并尝试移动左指针缩小窗口看有没有更短的满足条件的子数组第三窗口收缩时要把移出窗口的元素从sum中减掉。我最开始写这道题的时候犯过一个低级的错误记录窗口长度的时候直接用right减left忘了加一。这个错误非常隐蔽因为当right和left相等的时候窗口其实还有元素但right减left等于零。从那以后我每次算窗口长度都会默念一遍窗口长度是right-left1这是一个包含端点的区间长度问题。如果你心里用的是左闭右闭区间那长度就是right-left1别少算。滑动窗口最大的坑在于while循环的退出条件。你要搞清楚什么时候left该动什么时候不该动。拿209举例while循环的条件是sum大于等于target意味着只要窗口还满足条件就一直往左收缩。很多人会误写成if结果窗口满足条件后只收缩一次就停止了导致漏掉了更短的子数组。逻辑上只要还满足条件就必须持续收缩直到条件被破坏所以这里必须是while。还有一个常见的疑问滑动窗口会不会漏掉某些子数组答案是不会。因为右指针固定时左指针已经收缩到了能让窗口满足条件的最小范围再往左移动一步窗口的长度更小即使满足条件长度也已经被记录过了。所以滑动窗口遍历到的所有可能成为最优解的子数组一个不落。这一点想通了滑动窗口就基本拿下了。为了加深理解我自己刷题时会做这样的练习给定数组[2, 3, 1, 2, 4, 3]和target7手写模拟一遍left和right的移动过程把每一轮sum的值、窗口区间、当前最小长度都记下来。做完一遍你对滑动窗口的每一步就了然于心了。2.4 模拟行为螺旋矩阵的边界美学模拟行为这个模块在代码随想录数组章节中只占了一道题但这一道题非常值得认真对待。螺旋矩阵值得认真写的原因在于它的代码量不长却能把你对循环边界的控制力、对循环不变量的理解力都测一遍。先看题目59.螺旋矩阵II给定一个正整数n生成一个包含1到n²所有元素且元素按顺时针顺序螺旋排列的正方形矩阵。也就是说从外到内一圈一圈地填入数字。直接说解法。我们定义上下左右四个边界初始时top0bottomn-1left0rightn-1然后按“上边、右边、下边、左边”的顺序遍历一圈每遍历完一边就收缩对应的边界。核心技巧是坚持左闭右开的区间分割换句话说每条边只遍历“起始元素到倒数第二个元素”把最后一个元素留给下一条边。这样一来四条边的遍历逻辑完全一致不会出现元素被重复处理或者遗漏的情况。vectorvectorint generateMatrix(int n) { vectorvectorint matrix(n, vectorint(n, 0)); int top 0, bottom n - 1, left 0, right n - 1; int num 1; while (top bottom left right) { // 上边从左到右左闭右开 for (int i left; i right; i) matrix[top][i] num; // 右边从上到下左闭右开 for (int i top; i bottom; i) matrix[i][right] num; // 下边从右到左左闭右开 for (int i right; i left; i--) matrix[bottom][i] num; // 左边从下到上左闭右开 for (int i bottom; i top; i--) matrix[i][left] num; top; bottom--; left; right--; } if (n % 2 1) { matrix[n / 2][n / 2] num; // 奇数阶矩阵最后剩下中心一个格子 } return matrix; }这段代码里最容易出错的地方有四个第一每一圈的起点和终点要严格遵循“左闭右开”比如上边是从left到right-1右边是从top到bottom-1。如果你在上边循环里写成了i小于等于right那么右上角的元素会被上边写入右边循环又会从top1开始导致上边和右边都尝试写入同一个格子数字就会被覆盖。第二外层循环的条件。很多人写while的时候只写top小于等于bottom结果n为偶数时还好n为奇数时会发现中心元素没有被处理。因为奇数阶矩阵在最后一圈收缩完之后上下左右边界都指向了正中心这时候循环应该再执行一次把中心填上。代码随想录给出的方案是在循环后单独处理中心元素但如果你把循环条件写成top小于bottom中心元素就会漏掉。所以要么在循环里判断top等于bottom的特例要么在循环后补上。第三for循环里的边界表达式不要自作聪明。比如下边那个循环从right开始到left结束如果你写成i大于等于left左下角就会被下边写入然后左边循环又从bottom-1开始两者再次重叠。还是那句话左闭右开让别人处理最后一个元素。第四也是我踩过最深的坑n为1的时候。这时候topbottomleftright0外层的while条件top小于等于bottom成立但四个for循环的边界会导致它们根本不执行循环结束后num还是1。如果你直接矩阵[n/2][n/2]赋值为num也是1没问题。但如果你在循环内写了matrix[top][i]这样的赋值并且没有加if判断n1时就会数组越界或者覆盖错误。所以写这种模拟题边界用例一定要自己先跑一遍尤其是n1、n2这种最小规模。模拟行为的题目本质上考的是“循环不变量”的执行力。你规定好每条边的处理区间然后全程老老实实按规矩执行不越界、不重叠代码就能一次跑对。如果哪次写崩了不要急着改代码先回到四个for循环的边界上一个一个检查是不是有人越权了。3. 实操过程与核心环节实现3.1 LeetCode 704.二分查找的完整实操记录这一节我以704.二分查找为例完整走一遍我平时刷这道题的实操过程包括用什么语言、怎么写测试、怎么看边界。首先确定语言。刷题首选C原因是C的标准库提供了vector调试时可以直接打印vector内容对比预期的区间变化非常方便。Python当然也行但LeetCode上用C的社区氛围和标准答案都更成熟遇到问题更容易找到参考。拿到题目后我先不急着写代码。第一步先判断题目中的数组是“升序”还是“降序”。这里有一个小细节LeetCode的题面说“升序排列”那我可以直接按递增写如果题目只说了“有序”没说明方向我会先打印两个相邻元素确认方向因为方向不同二分比较的符号可能就要反过来。第二步确定区间定义。这一步非常重要我建议你在代码注释里把区间定义写清楚。左边写“左闭右闭[left, right]”还是“左闭右开[left, right)”决定了你后面所有的细节。我平时更习惯用左闭右闭因为直觉上好理解right从size-1开始循环条件是left小于等于right更新时right等于middle减一。如果你喜欢左闭右开也完全可以但一定要全程一致。第三步写代码。写完以后我习惯在本地编译器里跑一遍基础用例比如nums[-1,0,3,5,9,12]target9预期返回下标4再跑target2预期返回-1。这两个测试过了再补一组边界target等于nums[0]target等于nums[nums.size()-1]target比nums[0]还小target比最后一个元素还大。这四组边界用例全部通过我才会提交。第四步提交后如果超时或者超内存先别急着怀疑算法看看是不是死循环。二分法死循环的经典现场是left和right相邻时middle等于left如果target大于nums[middle]但更新时left错误地赋值为middle而不是middle加一那么left就永远不会前进陷入死循环。所以遇到超时优先检查区间更新。其实二分查找还有无数变体比如查找第一个大于等于target的位置、查找最后一个小于等于target的位置、旋转数组中的二分等等。这些变体在代码随想录后面的章节里也都会碰到但万变不离其宗只要你把循环不变量掌握扎实变体就是换一下边界更新的条件而已。3.2 LeetCode 27.移除元素与双指针的现场复盘移除元素这道题我刚开始刷的时候第一反应是开一个新数组把不等于val的元素全部放进去。但LeetCode的要求是“不使用额外的数组空间必须原地修改输入数组”这就逼着你必须用双指针。所以这道题教会我的第一件事是读题的时候先看空间复杂度要求这决定了你能不能偷懒。写双指针解法的时候我习惯在注释里标出快指针和慢指针各自的职责。快指针是“探索者”它负责看每个位置的元素是否需要保留慢指针是“记录者”它负责指向下一个保留元素应该存放的位置。代码逻辑如下我看这段代码的时候就会想着两个指针的角色。int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }刷完这道题后我复盘出的心得有这么几条第一返回值是slow而不是slow加一。因为slow本身已经在写入后自增了它最终指向的就是“下一个空闲位置”也就是新数组的长度。这跟很多人使用的index需要加1的做法不同关键看你把慢指针的“当前指向”定义成“待写入位置”还是“已写入位置”。代码随想录里是前者所以返回slow本身即可。第二不需要考虑数组末尾残留的旧值。比如原数组是[3,2,2,3]val3走完双指针后数组可能变成[2,2,2,3]前两位是有效长度2。后面的2和3虽然还残留但因为是原地修改且返回值只取前两位所以不影响判断。如果面试官较真你可以顺带解释一遍展示你对空间回收的理解。第三面试时遇到了这题可以跟面试官讨论“覆盖”和“交换”两种思路的区别。覆盖是快指针发现非val元素后直接赋值给slow位置慢指针后续的元素顺序会被改变交换是遇到val时把末尾的元素交换过来可以保持相对顺序但会改变末尾元素。哪个更好视题目要求而定。如果题目要求保持相对顺序就选覆盖如果没有这个要求交换可以少写几次赋值。话虽如此大多数情况下覆盖已经够用面试时条理清晰更重要。3.3 LeetCode 209.长度最小的子数组与滑动窗口的调试心得滑动窗口的调试最麻烦的地方在于你很难一眼看出当前窗口的边界对不对。所以我在调试时一定会加打印把每一轮循环里right的值、left的值、当前的sum、当前的窗口长度全部打出来然后再跟手动推演的结果对比。就拿209这道题来演示。输入nums[2,3,1,2,4,3]target7。第一轮right0sum2sum小于target继续。窗口为[0,0]长度1。 第二轮right1sum5sum小于target继续。窗口为[0,1]长度2。 第三轮right2sum6sum小于target继续。窗口为[0,2]长度3。 第四轮right3sum8sum大于等于target进入while。记录长度4。sum减掉nums[0]2left变成1sum6sum小于target退出while。窗口为[1,3]长度3。 第五轮right4sum10sum大于等于target记录长度4。sum减掉nums[1]3left2sum7依然大于等于target继续记录长度3。sum减掉nums[2]1left3sum6退出while。窗口为[3,4]长度2。 第六轮right5sum9sum大于等于target记录长度3。sum减掉nums[3]2left4sum7继续记录长度2。sum减掉nums[4]4left5sum3退出while。最终结果是2。我建议你也照着这个方式把每一轮结果手写一遍这比看任何讲解都更管用。写完你会发现滑动窗口里的right指针和left指针都只往前走从不回头这正是它能把时间复杂度压到O(n)的原因。调试中还有一个经典错误result初始值。如果你把result初始化成0那么min(0, 长度)永远是0最后你根本判断不出有没有找到答案。正确做法是初始化成INT_MAX或者一个足够大的数最后如果result还是INT_MAX说明没有满足条件的子数组返回0。这个坑我在初学时踩过印象极其深刻。如果你做完209觉得不过瘾可以顺手把76.最小覆盖子串也做一下。那道题是滑动窗口在字符串上的应用思路一脉相承但窗口内维护的不再是数字之和而是一个字符频次表。你会发现一旦你掌握了20976的核心框架几乎不用重新学只改窗口的维护方式就行。这就是滑动窗口这个套路的价值同一个思想在不同数据结构上反复复用。3.4 LeetCode 59.螺旋矩阵II与边界收缩的实操演示螺旋矩阵II我用C写完之后第一次提交就wrong answer了。问题出在n等于3的时候中心元素永远填不进去。我当时在想明明边界收缩得好好的为什么中心会有空洞后来仔细推演发现是外层while循环条件的问题。我用n3走一遍初始top0bottom2left0right2。第一圈走完填入1到8边界收缩为top1bottom1left1right1。此时top等于bottomleft等于right循环条件top小于等于bottom依然成立所以第二圈开始。但第二圈四个for循环里i的起始和结束条件让它们都变成了空循环一个元素都没填。循环结束后num还等于9正好是中心数字。如果我在循环后直接写matrix[top][left]num也就是matrix[1][1]9答案就对了。所以这道题的正确写法有两种。一种是像上面代码那样循环条件写top小于等于bottom循环后处理n为奇数时的中心格子。另一种是把循环条件改成top小于bottom在循环内部处理每一圈最后如果n是奇数再单独填中心。两种写法都可以但第二种更容易让人困惑因为边界收缩和中心处理的逻辑混在一起。我建议采用第一种先利用循环条件保证每一圈都处理再把中心作为特殊情况在循环外统一处理。还有一个细节要提醒题干里的n是正整数所以不需要考虑n等于0的情况。但在LeetCode上写代码时我依旧会习惯性地判一下n等于0的情况返回空数组。这不是必须的却是一个好习惯因为某些边界用例是平台自动生成的你永远不知道测试数据里有没有n0。多做防御性检查能省很多无谓的提交。螺旋矩阵的变体是54.螺旋矩阵那个不是让你生成矩阵而是要求你按螺旋顺序输出已有的m*n矩阵。思路基本一样但因为是矩形而不是正方形边界收缩时top、bottom、left、right四个边界的收缩方向相同但循环的条件要额外处理行数和列数不等的情况。做完59再来做54你会觉得顺理成章。4. 常见问题与排查技巧实录4.1 二分查找中的“死循环”与区间逃逸问题我见过太多人包括我自己早期在二分查找上犯同一个错while循环条件写成了left小于right但是更新right时却用了middle减一。这在左闭右开定义下是错的会造成区间逃逸。什么叫区间逃逸就是你搜索的区间已经收缩到一个点但循环却没有终止或者在更新之后把目标元素排除到了区间之外。举个例子数组[1,3,5,7,9]target7。如果右边界用了左闭右开定义right5循环条件是left小于right。第一次循环middle2nums[2]5小于7所以left3第二次循环middle4nums[4]9大于7如果这时候你把right更新为middle减1也就是3那你的区间变成[3,3]但是7在4的位置已经被排除了。实际上在左闭右开定义下发现9大于7时应该更新right等于middle也就是4区间[3,4)middle3nums[3]7命中。但在错误的写法下区间变成[3,3]循环结束返回-1答案错得莫名其妙。这就是区间定义不一致导致的“逃逸”。排查这类问题我的经验是写完后不要急着提交先在纸上走一个很短的用例把每一次middle的变化写下来同时标明当前区间是左闭右闭还是左闭右开。只要区间的更新方式跟定义对不上号问题就一目了然了。另外死循环还有一个常见触发场景是middle计算的溢出。在Java里(leftright)/2在left和right接近极端值时可能溢出所以刷题规范里都要求写成left加(right减left)除以2或者用位运算。C里虽然int溢出也时常发生但LeetCode的测试数据通常不会让int溢出不过养成写防溢出版本的习惯总没错。4.2 移除元素后数组“残留值”让人困惑很多初学者提交27.移除元素后看到测试用例的输出里还带着旧值心里就不踏实觉得自己代码写错了。比如输入[3,2,2,3]val3双指针跑完后数组可能是[2,2,2,3]输出长度2。有人就想不通为什么后面的2和3不清理干净这里要明确一点LeetCode这类题目只关心返回值代表的有效长度以及前length个元素是否满足题意不关心数组末尾的残留值。因为C里数组或者vector的长度是固定的你必须原地操作所以移除元素本质上是“覆盖有效前缀”的过程而不是真正意义上的“删除”。当你返回了新长度调用方只会读取前length个元素。残留的旧值既不影响判断也不需要清理。如果面试时面试官追问你可以理直气壮地回答数组的物理长度并没有变化变化的是逻辑长度。逻辑长度等于返回值物理长度等于原数组长度。数组里所有无效元素都放在了逻辑长度之后它们相当于被“逻辑删除”了。这套回答在面试里非常加分因为说明你理解了数据结构的物理存储跟逻辑视图的区别。4.3 滑动窗口漏解与重复计数问题滑动窗口有一个很常见的错误在更新答案的时候把更新放在while循环外面只记录右指针移动后的长度。这样做的问题在于窗口可能已经收缩到最小了你却忘了记录收缩后的长度。举个例子nums[1,1,1,1,1,1,1,1]target8如果你只在每次右指针移动后记录一次长度那你会记录到整个数组长度8然后左指针一路收缩的过程全被忽略返回的答案依然是8但实际的最短子数组长度也是8。这个例子不明显但如果target小一些比如7答案就是7你只在右指针移动后记录第一次满足条件时窗口长度是8收缩到7的时候忘了更新答案就会偏大。所以答案更新必须发生在每次成功收缩之后而不是只在窗口扩展之后。还有一种重复计数问题。有些人在记录窗口长度的时候拿right减left结果窗口长度为0时也记录了一次导致结果偏小。解决方法是统一使用right-left1并且在脑子里明确左右指针都是闭区间端点。如果你调试时发现答案跟预期差一个或多个建议打印每一轮的left、right和sum。滑动窗口题目的调试绝不靠瞪眼一定要靠输出。我刷题时经常在循环体里临时加一行cout打印完就删效率非常高。与其盯着代码冥思苦想不如让程序自己告诉你每一轮它在干什么。4.4 螺旋矩阵“中心元素丢失”与“越界覆盖”双坑螺旋矩阵II的高频报错我总结下来就两类中心元素丢失和边界覆盖。中心元素丢失在n为奇数时最明显。你用n5跑前两圈都正常第三圈边界收缩到中心时如果循环条件写的是top小于bottom进入不了循环中心就空着。解决办法我在前面已经说了要么循环条件用top小于等于bottom然后最后单独处理要么循环内加判断。总之奇数中心必须有一个明确的归属。边界覆盖更隐蔽。比如上边循环里你用了i小于等于right那么右上角元素会被赋值为当前num。紧接着右边循环从top开始也会把右上角赋值为下一个num右上角的值就被覆盖了。这种错误比较难发现因为输出的矩阵看起来只是个别值不对不会像中心丢失那样一眼看穿。排查方法就是把生成矩阵的过程打印出来逐行检查每一圈的边界值是否正确。只要看到任何一个位置被写了两次必定是边界区间没有贯彻“左闭右开”。我自己刷模拟题之后最大的心得是模拟行为题代码里每个for循环的边界都像一份合同起始条件写明了谁负责开头结束条件写明了谁负责收尾。如果你在任意一个循环里多管了“最后一个元素”的闲事就必然会跟下一个循环产生冲突。守住约定比写对任何一个单独循环都更重要。4.5 数组越界与下标访问的常见现场数组越界在LeetCode上不一定会导致运行时错误因为C对vector的越界访问是未定义行为可能直接崩溃也可能返回垃圾数据。所以很多人在本机跑得好好的一提交就莫名报错其实就是越界访问。最容易越界的场景有三个第一二分法里middle被用来访问nums[middle]但你在更新区间时没有控制好right的边界导致middle可能大于size减一第二滑动窗口里right等于size时还去访问nums[right]或者left在收缩时越过了right导致窗口为空后还在访问nums[left]第三螺旋矩阵里四个for循环的边界没有收住导致索引超出top到bottom、left到right的范围。排查越界有一个通用办法在关键访问前后加断言或者干脆把访问下标打印出来。比如for循环内打印i跑一个规模很小的测试用例看有没有下标飞出合法范围。这个方法虽然土但排查效率极高。另外刷题时尽量使用vector的at方法代替方括号访问虽然性能略差但至少能抛异常提示你越界而不是给你一个莫名其妙的崩溃。提交时再换回方括号访问。5. 关于刷题资料搭配与扩展练习5.1 代码随想录搭配什么资料一起用代码随想录作为主线教材很好用但如果你只依赖它思路会被框在一个模板里。我自己的搭配方式是主线用代码随想录按章节顺序吃透每一类题的框架和边界辅线用LeetCode官方题解或者Discussion区的高赞题解专门去补充那些代码随想录里没有提到的非常规解法。举个例子同样是二分查找代码随想录给出了左闭右闭和左闭右开两套标准模板足够应对90%的题目。但LeetCode官方题解里还会提到一种“寻找左侧边界”和“寻找右侧边界”的二分变体使用的区间更新方式跟标准模板并不完全一样。如果你只做代码随想录的题可能不会接触到这些变体但后续做34.在排序数组中查找元素的第一个和最后一个位置时你就需要这套变体的思路。所以主线打完基础辅线做变体拓视野这个组合我觉得是最省力的。5.2 数组章节的扩展练习路线数组章节本身题目不多但为了巩固四个模块的思想我会推荐几条扩展练习路线第一二分法方向。做完704和35之后继续做34、69.x的平方根、367.有效的完全平方数。这些题目能帮你把二分查找从“找某个值”扩展到“找边界值”“找近似值”思维完全打开。第二双指针方向。27、26、283这三道做完再做844.比较含退格的字符串、977.有序数组的平方。尤其977它考察的是左右指针而非快慢指针的用法正好可以跟前面形成对比。第三滑动窗口方向。209做完做904.水果成篮、76.最小覆盖子串。904是水果成篮其实就是一个维护两个“类别”的滑动窗口思路很巧妙能帮你理解窗口内状态维护的多样性。等后期刷到字符串章节滑动窗口还会频繁出现提早打好基础很划算。第四模拟行为方向。59做完做54。这两道题一生成、一读取刚好把螺旋矩阵的两种考法覆盖到位。如果还有余力可以试试48.旋转图像和73.矩阵置零它们虽然不完全是模拟行为但同样非常考验你对矩阵下标变换的理解。我当年把数组章节刷完用了大概一周每天三道题搭配复盘笔记。刷完之后最大的感受是后面学链表时双指针思想直接无缝衔接学字符串时滑动窗口也顺理成章就连学哈希表时很多数组相关的题目也能套用之前的思路。所以如果你正打算开始刷题我真心建议从数组章节开始别跳过也别觉得简单就大意。基础章节的价值不在于题目难度而在于它奠基的思维方式。做这套题的时候我还习惯把每个模块的模板代码单独存档比如二分模板、双指针模板、滑动窗口模板、螺旋矩阵模板。后面遇到类似题目先翻模板再动手效率能提高不少。模板的意义不是让你死记硬背而是帮你快速回忆起边界条件把精力留给真正需要思考的部分。这大概也是我刷完数组章节最大的收获不是会做这几道题而是建立了一套属于自己的“解法工具箱”。