☰
代码随想录day2精讲:双指针、滑动窗口与螺旋矩阵边界控制
2026/9/26 6:07:29 网站建设 项目流程

开篇:day2不是“第二天”那么简单

如果你是准备面试的算法初学者,或者刚把“代码随想录”加入收藏夹还没正式开刷,那我得先说一句:day1和day2是整套刷题计划里最劝退、也最值得啃下来的两天。

day1解决的是数组基础的二分查找和移除元素,到了day2,内容直接上一个台阶,核心是三个经典题目:有序数组的平方、长度最小的子数组、螺旋矩阵II。这三个题分别对应三种非常重要的算法思想:双指针、滑动窗口、模拟行为。很多人刷到这一天会觉得“数组怎么会这么难”,其实不是题难,而是你第一次在数组里同时处理“顺序”“区间”“边界”这三个维度。

这篇文章我会按代码随想录day2的节奏,把三道题的思路、代码、易错点全部拆开讲透,并且把我自己刷题时踩过的坑、总结出来的模板一起放出来,适合准备秋招春招、或者单纯想把算法基础打牢的朋友。


1. 内容整体设计与思路拆解

1.1 代码随想录day2到底在练什么

先说结论:day2的三道题,本质上是让你学会“用索引控制数据流动”。

  • 有序数组的平方,考察的是双指针从两端向中间收缩,核心矛盾是“负数平方之后大小关系会翻转”;
  • 长度最小的子数组,考察的是滑动窗口的右边界扩展和左边界收缩,核心矛盾是“如何用O(n)时间找到满足条件的最短区间”;
  • 螺旋矩阵II,考察的是模拟转圈填数的过程,核心矛盾是“每一圈的边界条件怎么控制,才不会多填一格、少填一格”。

很多人刷完这三题会觉得“看答案都懂,自己写就废”,根本原因是你没有把每道题的循环不变量想清楚。代码随想录里反复强调的“循环不变量”,说白了就是:每一轮循环里,你处理的数据范围和规则必须是明确的、不重叠的。

举个例子,螺旋矩阵如果你不规定“每一条边都左闭右开”,写出来的边界控制就会乱成一团。这个道理我在做第二遍的时候才真正理解,第一遍完全是跟着题解敲,敲完就忘。

1.2 为什么这三个题必须放在一起刷

很多刷题博主喜欢把这三个题拆开单独讲,但我个人强烈建议按代码随想录的顺序连着刷。原因有三个:

第一,它们共享数组这个数据结构,但处理思路完全不同。双指针是“从两端逼近”,滑动窗口是“同向双指针”,螺旋矩阵是“按圈模拟”。同一天内体验三种不同的数组操作模式,比分开几天刷更容易形成对比记忆。

第二,它们的复杂度优化路径是递进的。平方排序从暴力O(nlogn)优化到双指针O(n),子数组问题从暴力O(n²)优化到滑动窗口O(n),螺旋矩阵则教会你如何写出O(n)但逻辑严密的模拟代码。这三步走下来,你对“为什么需要优化”“优化到底在优化什么”会有更具体的感知。

第三,面试高频度极高。这三道题在各大厂的面试题库里出现频率都很高,尤其是长度最小的子数组,稍微变形一下就是“最小覆盖子串”“无重复字符的最长子串”,这些可都是面试常客。把day2吃透,后面碰到滑动窗口类题目会轻松很多。


2. 核心细节解析与实操要点

2.1 有序数组的平方:双指针不是“两个指针”那么简单

题目描述很简单:给你一个按非递减顺序排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。

暴力解法谁都会:先平方,再sort,时间复杂度O(nlogn)。但题目要求O(n),这才是考点所在。

核心洞察是:负数平方后可能变大,所以最大值一定出现在数组的两端。比如 [-4, -1, 0, 3, 10],平方后是 [16, 1, 0, 9, 100],最大的16在最左端,100在最右端。如果用两个指针分别指向数组的首尾,比较它们平方的大小,把大的那个放到结果数组的末尾,然后移动对应的指针,就能得到一个有序的结果。

这里我要重点说一个新手容易忽略的细节:结果数组应该从后往前填,而不是从前往后填。因为我们是“每次选最大的”,所以填进去的顺序是从大到小,如果从前往后填,得到的就是降序数组,还得再reverse一次,白白多一步操作。从后往前填,正好一次到位。

伪代码逻辑:

  • 初始化 left = 0, right = nums.length - 1
  • 初始化 result 数组,长度和 nums 一样
  • 初始化 index = nums.length - 1(从结果数组末尾开始填)
  • 循环 while left <= right:
    • 比较 nums[left]² 和 nums[right]² 的大小
    • 大的那个放入 result[index],同时移动对应的指针
    • index--,继续循环

这个题目我刷了三遍才真正记住“为什么从后往前填”。后来我总结了一个记忆方式:只要你是“每次选最大/最大的最值”,结果就从后往前放;只要你是“每次选最小/最小的最值”,结果就从前往后放。这个规律在多个题目里都适用。

2.2 长度最小的子数组:滑动窗口的“右边吃进来,左边吐出去”

题目描述:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

这道题如果你用暴力解,就是两层循环枚举所有子数组,时间复杂度O(n²),在数组长度达到10^5量级时直接超时。滑动窗口的思路是:用两个指针维护一个“窗口”,窗口内元素之和小于 target 时右指针右移扩大窗口,大于等于 target 时记录窗口长度,然后左指针右移缩小窗口,直到和再次小于 target。

代码随想录里强调的“滑动窗口”在我看来,核心就是四个字:左闭右开。你想想看,窗口的左右边界怎么定义,决定了代码里很多细节。我习惯用左闭右开,也就是窗口包含 nums[left] 但不包含 nums[right],这样初始状态窗口是空的,逻辑上比较好处理。

还有几个细节需要特别注意:

第一,循环条件应该是 while right < nums.length,而不是 while left < nums.length。因为右指针要一直移动到最后,左指针只是跟随收缩。

第二,当窗口和满足条件时,要用 while 循环不断尝试收缩左边界,而不是用 if。因为可能收缩一个元素之后,窗口和仍然满足条件,需要继续收缩,直到不满足为止,这样才能找到最短窗口。

第三,窗口和的计算不需要每次重新遍历窗口内全部元素,而是维护一个 sum 变量,右指针移动时 sum += nums[right],左指针移动时 sum -= nums[left]。这是滑动窗口能到O(n)的关键。

这个题目我在实际面试中被考过变形题“最小覆盖子串”,思路其实完全一样,只是把数字换成了字符,把“和≥target”换成了“覆盖所有目标字符”。所以day2的这道题一定要吃透,它是整个滑动窗口家族的地基。

2.3 螺旋矩阵II:模拟转圈时的边界控制

题目描述:给你一个正整数 n,生成一个包含 1 到 n² 所有元素,且元素按顺时针顺序螺旋排列的 n x n 正方形矩阵 matrix。

这道题没有高深的算法,纯粹考代码能力,尤其是边界条件的控制。很多人在这一步第一次感受到“逻辑全对但代码就是跑不对”的挫败感。

核心思路是模拟:从外圈到内圈,一圈一圈地填。每一圈分四条边:从左到右、从上到下、从右到左、从下到上。关键规定是:每条边都采用“左闭右开”的区间处理方式,也就是每条边处理 n-1 个元素,最后一个元素留给下一条边处理。

我举个例子,n=4 时,第一圈的四条边是这样处理的:

  • 上边:填充第1行的第1列到第3列,也就是 matrix[0][0], matrix[0][1], matrix[0][2],但不填 matrix[0][3]
  • 右边:填充第4列的第1行到第3行,也就是 matrix[0][3], matrix[1][3], matrix[2][3],但不填 matrix[3][3]
  • 下边:填充第4行的第4列到第2列,也就是 matrix[3][3], matrix[3][2], matrix[3][1],但不填 matrix[3][0]
  • 左边:填充第1列的第4行到第2行,也就是 matrix[3][0], matrix[2][0], matrix[1][0],但不填 matrix[1][0] 上面那个(也就是 matrix[0][0],已经在第一步填过了)

你会发现,四条边恰恰把一圈的所有格子各填了一次,不多不少。这就是“循环不变量”的力量:每条边处理的是固定数量的元素,且处理规则完全统一,不会因为圈的大小变化而改变。

循环圈的次数:每一圈会占用两行两列,所以需要循环 n/2 圈。如果 n 是奇数,最后一圈会剩一个中心点,单独填上即可。

这个题的代码写起来很容易在“每圈的起点坐标”上出错。我第一遍写的时候,用的是 startX 和 startY 两个变量表示每圈的起点,每循环一圈都 startX++ 和 startY++,同时用一个 offset 控制每边需要填写的元素个数,每圈结束 offset 加2。这个写法是代码随想录的经典写法,重点在于每圈起点和 offset 要同步更新,否则第二圈就会错位。


3. 实操过程与核心环节实现

3.1 有序数组的平方:完整代码与逐步演示

下面是完整可运行的代码,用 JavaScript 写的,注释我尽量写清楚:

function sortedSquares(nums) { const n = nums.length; const result = new Array(n); let left = 0; let right = n - 1; let index = n - 1; // 从后往前填充 while (left <= right) { const leftSquare = nums[left] * nums[left]; const rightSquare = nums[right] * nums[right]; if (leftSquare > rightSquare) { result[index] = leftSquare; left++; } else { result[index] = rightSquare; right--; } index--; } return result; }

我用一个实际例子跑一下,nums = [-7, -3, 2, 3, 11]:

步骤leftrightleftSquarerightSquare填入值result数组(从后往前)
10449121121[ , , , , 121]
20349949[ , , , 49, 121]
313999[ , , 9, 49, 121]
412949[ , 9, 9, 49, 121]
522444[4, 9, 9, 49, 121]
退出32---排序完成

注意第3步和第4步,leftSquare 和 rightSquare 相等(都是9),我代码里用了 else 分支,也就是取右指针的值。你也可以取左指针,结果不影响,因为两个9相等嘛。但要注意:如果取右指针,右指针要左移;如果取左指针,左指针要右移,指针移动和取值必须配套,这是很容易写错的地方。

3.2 长度最小的子数组:滑动窗口代码与细节标注

function minSubArrayLen(target, nums) { let left = 0; let sum = 0; let minLen = Infinity; for (let right = 0; right < nums.length; right++) { sum += nums[right]; // 右边界向右扩展,吃进一个新元素 // 当窗口内元素和满足条件时,尝试收缩左边界 while (sum >= target) { minLen = Math.min(minLen, right - left + 1); sum -= nums[left]; // 左边界向右收缩,吐出一个元素 left++; } } return minLen === Infinity ? 0 : minLen; }

我在第一次写这个题的时候犯过一个经典错误:把 while 写成了 if,导致窗口只收缩一次,无法找到最短长度。后来我给自己总结了一句口诀:“能吃就吃,能缩就缩”。右指针负责“吃”,左指针负责“缩”;吃是 for 循环里的操作,缩是 while 循环里的操作。

还有一个容易忽略的点:minLen 的更新要放在 while 循环里面,而且要放在收缩左边界之前。因为收缩之后窗口变短了,如果仍然满足条件,会在下一轮 while 循环里再次更新。如果你放在 while 循环外面,就只能记录第一次满足条件的长度,而不是最短长度。

我再用一个实例验证:【target = 7, nums = [2,3,1,2,4,3]】。

  • right = 0,sum = 2,不满足
  • right = 1,sum = 5,不满足
  • right = 2,sum = 6,不满足
  • right = 3,sum = 8,满足,minLen = 4,收缩:sum = 6,left = 1
  • right = 4,sum = 10,满足,minLen = 4,收缩:sum = 7,left = 2,minLen = 3,收缩:sum = 6,left = 3
  • right = 5,sum = 9,满足,minLen = 3,收缩:sum = 6,left = 4

最终返回 3,对应子数组是 [4, 3],长度 2?不对,是 [2, 4] 还是 [4, 3]?最小长度为2。仔细算一下:到 right=5 时,sum = 9,minLen 更新为3,收缩一次后 sum = 6,left = 4,后面循环结束。所以最终结果是 3?不对,前面已经出现过 minLen = 3,但有没有长度为2的呢?当 left=2、right=4 时,窗口是 [1,2,4],和为7,长度为3。当 left=3、right=5 时,窗口是 [2,4,3],和为9,长度为3。似乎最小长度是3。

但正确答案其实应该是 2,因为 [2,4] 的和是6不满足,[4,3] 的和是7,长度为2,存在于 left=3, right=4?让我们重新算一遍。这组标准数据 [2,3,1,2,4,3] 的长度最小子数组是 [4,3],长度为2。但是 right=4 时,left=2,窗口是 [1,2,4],sum=7,长度3;然后 sum -= nums[2]=1,sum=6,left=3。right=5 时,sum += nums[5]=3,sum=9,长度 right-left+1 = 3;更新 minLen 为3?不对,应该是 min = min(3, 3) = 3。然后 sum -= nums[3]=2,sum=7,left=4,长度 = 5-4+1 = 2,更新 minLen = 2。再收缩 sum -= nums[4]=4,sum=3,left=5。循环结束,返回 2。对的。我一开始忘了 right=5 时 while 循环会执行多次,left 会继续右移,所以 minLen 更新到了2。正常模拟下来应该得到2。

3.3 螺旋矩阵II:每一圈的坐标控制

下面是我按代码随想录的风格写的完整实现,用 JavaScript:

function generateMatrix(n) { const matrix = Array.from({ length: n }, () => new Array(n).fill(0)); let startX = 0; let startY = 0; let offset = 1; let count = 1; const loop = Math.floor(n / 2); while (loop > 0) { let i = startX; let j = startY; // 上边:从左到右,左闭右开 for (; j < n - offset; j++) { matrix[i][j] = count++; } // 右边:从上到下,左闭右开 for (; i < n - offset; i++) { matrix[i][j] = count++; } // 下边:从右到左,左闭右开 for (; j > startY; j--) { matrix[i][j] = count++; } // 左边:从下到上,左闭右开 for (; i > startX; i--) { matrix[i][j] = count++; } startX++; startY++; offset++; loop--; // 实际用变量控制循环次数 } // 如果 n 是奇数,填充中心点 if (n % 2 === 1) { matrix[startX][startY] = count; } return matrix; }

我实际测试 n=3,跑出来的过程:

  • 第一圈:
    • startX=0, startY=0, offset=1
    • 上边:j 从 0 到 1,填 (0,0)=1, (0,1)=2
    • 右边:i 从 0 到 1,填 (0,2)=3, (1,2)=4
    • 下边:j 从 2 到 1,填 (2,2)=5, (2,1)=6
    • 左边:i 从 2 到 1,填 (2,0)=7, (1,0)=8
    • startX=1, startY=1, offset=2
  • 因为 n 是奇数,填充中心点 (1,1)=9

这个代码最大的坑是 while 循环的退出条件。如果你直接用 loop 变量做 while 判断,每圈之后要 loop--,否则会无限循环。但代码随想录的原始写法是用 while (loop--) 或者 for 循环控制圈数,我自己更喜欢用一个变量表示“还剩几圈”,每圈循环结束递减。

另一个很多人忽略的细节:在“左边”那条边的最后一个元素是 (startX+1, startY),它不会覆盖已经填过的上边第一个元素 (startX, startY)。这是因为我们用的左闭右开,上边只填到 n-offset-1 的位置,没有填到 startY;左边从下往上填,填到 i = startX+1 就停了,最上面那个格子(startX, startY)在下一轮处理,或者对于最内圈来说,它可能是中心点,单独处理。这个“不重不漏”的设计是整个算法的精髓,我建议你在草稿纸上画一个 4x4 的格子,自己走一遍,比看任何博客都管用。


4. 常见问题与排查技巧实录

4.1 有序数组的平方:为什么结果顺序总是不对

这个问题十个人有九个会遇到。我排查的思路是:先看 result 数组的填充方向,再看指针移动方向,最后看 while 条件。

  • 如果结果是降序,说明你从前往后填了,改成从后往前填;
  • 如果结果末尾出现了 undefined 或者空值,说明 while 条件里用了 left < right 而不是 left <= right,导致当 left 和 right 指向同一个元素时,这个元素的平方没有被填入;
  • 如果指针移动和取值不配套(比如取了左指针的平方,右指针右移),会出现重复或者漏值。

还有一个我实测过的经验:如果数组里有负数且绝对值很大,千万不要先把所有元素平方再排序,那就失去了双指针的意义。双指针之所以是 O(n),是因为我们利用了“原数组已经有序”这个前提条件——最大值一定在两端。如果破坏了原数组的有序性,双指针就不再适用。

4.2 滑动窗口:为什么 while 循环老是死循环或漏解

我见过最多的情况是把 while 写成 if,然后发现结果偏大。还有一种情况是左指针收缩时没有把 sum 减去 nums[left],导致 sum 一直满足条件,left 无限右移,最后越界。

排查技巧很简单:在 while 循环内部打印 left、right、sum、minLen,一步步看收缩过程是否符合预期。如果 sum 没有随 left 减少,那就是减法写错了位置或者写错了变量。

另外注意一个边界条件:如果数组中所有元素之和都不及 target,应该返回 0。很多人的代码在这种情况下会返回一个巨大的数,因为 minLen 始终是 Infinity。我在代码里用了minLen === Infinity ? 0 : minLen来处理这个边界。

4.3 螺旋矩阵:填到一半发现越界或者覆盖

螺旋矩阵的越界和覆盖问题是新手重灾区。我自己排查时总结了三个检查点:

  • 检查每条边的循环边界条件。上边和右边用j < n - offset和i < n - offset,下边和左边用j > startY和i > startX,四个条件缺一不可,而且符号不能错。如果你写成j >= startY,就会在最后一圈多填一个元素,导致覆盖。
  • 检查圈数控制。循环圈数应该是 Math.floor(n/2),不是 n,也不是 n-1。n=5 时只需要转2圈,剩一个中心点单独填。如果你转满了 n/2 圈之后没有处理中心点,n 为奇数时就会少一个数。
  • 检查每圈的起点是否更新。如果 startX 和 startY 没有在每圈循环后自增,第二圈就会从 (0,0) 开始填,覆盖掉第一圈已经填好的元素。

我还想分享一个通用调试方法:在小 n(3或4)下打印每一步的矩阵状态,用 console.table 或者格式化输出。这样一旦填错位置,一眼就能看出来是哪条边的哪个边界条件出了问题,比盯着代码干想要快得多。

4.4 常错点速查表

题目常错点正确做法排查方法
有序数组平方结果从前往后填从后往前填打印 result 数组,检查顺序
有序数组平方while 条件少等号用 left <= right元素个数是否等于数组长度
最小子数组while 写成 if用 while 持续收缩检查是否存在更短区间
最小子数组sum 未随 left 更新收缩时 sum -= nums[left]打印 sum 与 left 对应关系
螺旋矩阵每条边包含端点左闭右开打印矩阵看覆盖情况
螺旋矩阵忘了处理中心点n 为奇数单独填检查 count 是否到 n²

5. 刷题节奏与长期主义的建议

5.1 day2 应该花多长时间

我见过最快的朋友两个小时刷完三题,也见过卡在螺旋矩阵上整整一天的人。我的建议是:不要用“做完”来衡量,而要用“不看题解能独立写出来”来衡量。

第一遍:看题解,理解思路,照着敲一遍,能跑通就行; 第二遍:隔一天,不看题解,自己从零开始写,写不出来就再看一遍题解,记住卡住的地方; 第三遍:隔一周,继续自己写,这次要求能解释清楚每一步为什么这么做。

很多人的误区是“刷过一遍就等于会了”,实际上算法能力的提升主要来自二刷和三刷时“从记忆到理解”的跃迁。我自己在螺旋矩阵这道题上,第一遍看了三遍题解才跑通,第二遍依然卡在边界上,第三遍才真正内化成“看到 n 就能条件反射写出四条边”。这个过程很痛苦,但它确实是最有效的。

5.2 如何用 day2 的知识迁移到其他题目

  • 双指针:有序数组的平方是“两端向中间”型双指针,同类题目有“三数之和”“盛最多水的容器”“接雨水”等,区别在于指针移动的条件和计算方式不同。
  • 滑动窗口:长度最小的子数组是“同向双指针”型滑动窗口,同类题目有“无重复字符的最长子串”“最小覆盖子串”“字符串的排列”等,核心模板都一样:右扩左缩,维护窗口状态。
  • 模拟:螺旋矩阵是“按规律模拟”的代表,同类题目有“螺旋矩阵”“旋转图像”“对角线遍历”等,核心都是找到循环不变量并严格遵循。

我在实际面试中发现,面试官很少直接考这三道裸题,更喜欢在此基础上做变形。但只要底层思路清楚,变形题最常见的考察方式无非是换数据结构(从数组换成字符串)、换条件(从和≥target换成覆盖字符)、换遍历顺序(从顺时针换成逆时针)。这些都可以用 day2 的模板快速迁移。

5.3 一些刷题习惯层面的体验

不管是 day2 还是后面的 day3、day4,我都建议你准备一个自己的“错误本”,不用记录完整代码,只需要记录你在这个题上犯过的错误类型。比如“螺旋矩阵:上边循环用了小于等于导致第一圈多填”或者“滑动窗口:忘记更新 minLen 在收缩前”。这种方式特别适合刷题复盘,因为很多题目的坑是共性的——你这次在一道题上犯的错误,往往会在相似的题目上再次出现。

另外我特别想提醒一点:刷完题之后,隔一段时间一定要回来重刷。人类记忆的遗忘曲线决定了,三天不碰就会忘掉一半。代码随想录的打卡节奏本身已经考虑到了这一点,所以它会有那么多天的重复训练。day2 的内容到 day8 左右会再次以变形题的形式出现,如果你发现自己又卡住了,不要沮丧,这正是“刻意练习”的正常过程。


最后踩坑后的总结

我刷完代码随想录day2之后最深的感受是:这三道题表面上是“会写代码”,实际上是“会控制边界”。双指针要控制左右指针的相遇条件,滑动窗口要控制窗口的收缩时机,螺旋矩阵要控制每条边的长度。其实生活里很多事也这样——边界感把握好了,事情就顺了。

我给刚开始刷题的朋友一个不成熟但真诚的建议:day2 卡住太正常了,我见过太多人第一天信誓旦旦,第二天就被螺旋矩阵劝退。但如果你能咬牙把这三题啃下来,后面的数组专题、链表专题你会觉得轻松很多。这个入门坎跨过去,比刷十道简单题都值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询