☰
数组算法进阶:双指针、滑动窗口与螺旋矩阵核心解析
2026/9/30 3:48:34 网站建设 项目流程

代码随想录这个系列,刷过算法题的朋友应该都听说过。Day1的内容是数组基础,二分查找和移除元素这两道题,核心是把双指针和区间定义这两个概念先立起来。到了Day2,Carl安排的是三道看起来风格完全不同的题:有序数组的平方、长度最小的子数组、螺旋矩阵II。很多人第一次打开这三道题会有点懵——一个让平方排序,一个让找最短区间,一个让画螺旋矩阵,这三者有什么关系?

实际上,这三道题就是数组专题里最核心的三个子方向:相向双指针、滑动窗口、循环不变量模拟。Day1解决的是"快慢双指针"在数组遍历中的应用,Day2则是把双指针的思想推向了三个更加复杂的场景。如果你能把这三天吃透,数组这块地基本就稳了。无论你是正在准备校招面试、跳槽刷题,还是想系统提升算法基本功,这段路线图都值得认真走一遍。

这篇文章我就按Day2的实际训练节奏来拆解,讲清楚每道题的思路是怎么推出来的、代码为什么这么写、边界条件到底该怎么卡,再把我自己刷这几道题踩过的坑一并倒出来。

1. Day2到底在训练什么:从Day1到Day2的进阶逻辑

1.1 数组专题的能力模型

很多人刷题有个毛病:今天做二分,明天做链表,后天做二叉树,题目刷了不少,但遇到新题还是没思路。问题不在题量,在于没有把"方法"和"题目"之间的关系看清楚。

数组这个数据结构本身没什么玄机,连续的内存空间、随机访问、O(1)的查改,这就决定了它能玩的花样其实有限。数组类题目真正考的就是两件事:一是怎么遍历,二是怎么处理区间。遍历的优化方向基本就是双指针,区间的处理则涉及边界定义和窗口维护。

Day1的二分查找,本质是在一个有序区间里用左右边界不断缩小搜索范围,这里其实已经用到了"维护一段区间"的思路。Day1的移除元素,则是快慢双指针在同一个方向上的协作。到了Day2,三道题把这两个基础能力分别加码:

  • 有序数组的平方,把快慢同向变成相向而行;
  • 长度最小的子数组,把静态区间变成动态滑动的窗口;
  • 螺旋矩阵II,把单维区间扩展到二维矩阵的边界控制。

所以Day2的底层逻辑就是:从简单双指针出发,螺旋式地拓展你对"指针"和"边界"这两个概念的理解。

1.2 三道题的定位与考核点

我刷题时习惯先把题分类。Day2这三道题的分类很典型:

  • 有序数组的平方(LeetCode 977):输入数组是非递减排列,平方之后重新排序。暴力做是O(n log n),最优解是O(n)的相向双指针。
  • 长度最小的子数组(LeetCode 209):正数数组,找出满足sum >= target的最短连续子数组。暴力是O(n^2),滑动窗口O(n)。
  • 螺旋矩阵II(LeetCode 59):生成一个n x n的矩阵,按顺时针螺旋顺序填入1到n^2。这种题没有太多算法技巧,考的是模拟过程的严谨性,核心是循环不变量。

这三个考点对应三种能力:对输入性质的利用、对窗口边界的判断、对模拟过程规则一致性的把握。这三样东西是面试手撕代码时最容易被追问、也最容易写崩的地方。

1.3 为什么选这三道题作为Day2

我在二刷代码随想录的时候才真正看懂课程设计的用心。如果Day2继续安排同向双指针的题,那Day1的训练成果虽然巩固了,却没有新的突破。而直接上滑动窗口和模拟,跨度又太大。所以Carl把"有序数组的平方"放在最前面——它还是双指针,但方向从同向变成相向,让你在舒适区边缘试探。

等到你对双指针的方向变化有了感觉,再上"长度最小的子数组",让你把一个指针变成两个指针,但是这两个指针的工作方式完全不同了,一个负责扩张、一个负责收缩。最后用"螺旋矩阵II"把二维边界问题抛出来,这时候你已经有了双指针的基础,处理上下左右四个边界就不会完全懵。

这个安排是有梯度的,不是随便拼三道题。如果你正在跟着代码随想录刷题,Day2务必按顺序来,不要跳,跳了会丢掉这个递进感。

2. 有序数组的平方:双指针从同向走向相向

2.1 暴力解法的问题在哪里

题目很简单:给你一个按非递减顺序排序的整数数组nums,返回每个数字的平方组成的新数组,要求也按非递减排序。例如nums = [-4, -1, 0, 3, 10],输出[0, 1, 9, 16, 100]。

绝大多数人第一反应就是:直接平方,然后sort。代码三行搞定。这个思路完全正确,复杂度O(n log n),在小数据量下没有任何问题。但面试官一旦追问"能不能O(n)",很多人的思路就卡住了。

卡住的根源在于,很多人忽略了题目给出的一个关键条件:原数组是非递减的。这个条件意味着,如果数组里全是正数,平方之后顺序不变;如果全是负数,平方之后顺序会完全反过来;如果有正有负,那么平方之后最大的数一定在两端,中间的数平方后反而更小。有序性被平方操作打破了,但打破了之后有一个新的规律:从两端往中间,平方值是从大到小排列的。

这就是相向双指针的切入点。暴力解法的问题就在于,它把"平方后排序"当成一个独立问题,没有利用原数组本身的顺序信息。

2.2 相向双指针的移动逻辑

所以我第一次看Carl的解析时,他画的那个图我印象很深:两个指针left和right分别指向数组的两端,比较平方值,大的那个放到结果数组的从后往前数第一个空位,然后移动对应的指针,直到left和right相遇。

这个逻辑的关键点在于:结果数组从后往前填。为什么不能从前往后填?因为每次比较取的是当前平方值中的最大值,最大值应该放在结果数组的末尾。如果你从前往后填,那每次取到的最大值其实应该放在最后,位置就对不上。所以答案是:从两端取值,从尾部填入。

代码实现如下:

class Solution { public: vector<int> sortedSquares(vector<int>& nums) { int n = nums.size(); vector<int> result(n, 0); int left = 0, right = n - 1; int index = n - 1; while (left <= right) { int leftSquare = nums[left] * nums[left]; int rightSquare = nums[right] * nums[right]; if (leftSquare > rightSquare) { result[index--] = leftSquare; left++; } else { result[index--] = rightSquare; right--; } } return result; } };

边界条件的核心就一个:while (left <= right)。有人会写成left < right,那是错的,因为当left和right指向同一个元素时,这个元素还没有被处理,会漏掉一个值。这种细节在面试里很容易被面试官用一两个测试用例问出来,你自己跑的时候也可能因为结果恰好正确而忽略。

2.3 复杂度与适用场景

  • 时间复杂度:O(n),left和right各自移动,总共遍历n个元素,每个元素处理一次。
  • 空间复杂度:O(n),结果数组是必须的。如果要求原地修改呢?这题不太能原地做,因为结果要从后往前写,而原数组从前往后读,写的位置可能覆盖还没读到的值,除非你再开一个数组。开一个数组其实是最稳妥的解法。

这道题想明白之后,你对双指针的理解会上一个台阶:双指针不一定是同向的快慢指针,也可以是相向的对撞指针。相向双指针在有序数组相关的题目里非常常见,比如两数之和、三数之和、回文串判断等,都是它的应用场景。

有一点我要特别提醒:比较平方值时,很多人喜欢先取绝对值再比较,其实没必要,直接平方比较就行,因为平方的结果是非负的,大小关系等价于绝对值的大小关系。写成nums[left] * nums[left]比abs(nums[left])更直观,也少一次函数调用的开销。

3. 长度最小的子数组:滑动窗口的精髓在"什么时候缩"

3.1 为什么暴力解法过不了

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

暴力做法是两层循环:外层枚举子数组的起点,内层枚举终点,求和并更新最小值。复杂度O(n^2)。数据规模小的时候勉强能跑,一旦n到10^5级别,直接超时。

优化思路要从"减少重复计算"入手。你会发现,在暴力做法里,同一段区间的和会被反复计算。比如当起点从0变成1的时候,[1,2,3]的和又重新加了一遍,这些工作很多是重复的。一个很自然的优化是用前缀和数组,这样内层变成O(1)查和,但依然要两重循环去枚举所有起点终点,复杂度还是O(n^2)。

真正能到O(n)的方案就是滑动窗口。这个思路其实不复杂,但大多数人第一次写会卡在"内层循环到底用if还是while"这个问题上。

3.2 滑动窗口的模板与实现

我来说一下滑动窗口的标准操作方式。right指针负责扩张,它不断向右移动,把新元素加进窗口和sum里。当sum >= target时,说明当前窗口已经满足条件了,此时我们要做的是:记录窗口长度,然后left指针向右收缩,把左边的元素从sum中减去,看收缩之后是否还满足条件,如果还满足,就继续收缩,直到不满足。所以这里必须用while循环,while循环的作用是"把窗口收缩到刚好不满足条件为止"。

代码实现如下:

class Solution { public: int minSubArrayLen(int target, vector<int>& nums) { int left = 0; int sum = 0; int result = INT_MAX; 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; } };

用个具体例子走一遍。nums = [2,3,1,2,4,3],target = 7。

right到3时,sum=2+3+1+2=8,第一次满足条件,窗口长度4,记录result=4。然后left右移,sum变成6,小于7,停止收缩。 right到4,加4,sum=10,满足条件,窗口长度4,不更新。left右移,sum=7,还满足,窗口长度3,result更新为3。left再右移,sum=6,停止。 right到5,加3,sum=9,满足,窗口长度3,不更新。left右移,sum=7,满足,窗口长度2,result更新为2。left右移,sum=3,停止。 最终输出2,对应子数组[4,3]。

这个过程的直观感受是:right像一个贪吃的嘴,不断往窗口里塞元素;一旦吃饱了(sum >= target),left就像理智的胃,开始消化排空,直到重新饿。整个过程中每个元素最多被right加一次、被left减一次,所以总操作数是2n,时间复杂度O(n)。

3.3 滑动窗口的易错点

最容易出错的地方就是内层循环用if代替while。比如第一次sum >= target时,让你记录结果并收缩一次,然后继续移动right。这样有一个问题:收缩一次之后窗口可能仍然满足条件,但你没有记录这个更短的窗口,就错过了最优解。在[1,1,1,1,1], target=3这个例子里,用if只会记录到长度为5的窗口,而正确答案是3。

另一个坑是sum的类型。题目中target和nums都是int,但求和可能溢出int。个别平台上数据范围会卡这一点,我一般直接用long long来累加,稳妥。

第三个易错点:result初始值。如果不存在满足条件的子数组,要返回0。我习惯初始化为INT_MAX,最后判断一下。或者初始化为数组长度+1,最后判断是否等于这个值。两种写法都可以,但要注意别把不存在的场景漏了。

滑动窗口这个模板不仅适用于"和≥target"这类问题,无重复字符的最长子串、字符串排列匹配、最小覆盖子串等经典题目都是同一套框架。Day2把它学会,后面刷字符串专题时会省很大力气。

4. 螺旋矩阵II:模拟题的终极考验是循环不变量

4.1 为什么螺旋矩阵堪称"边界地狱"

题目:给你一个正整数n,生成一个n x n的矩阵,矩阵元素按顺时针螺旋顺序排列,从1到n^2。比如n=3时输出:

1 2 3 8 9 4 7 6 5

这道题在算法上没有任何"巧劲",纯粹是模拟。但它的通过率一直不算高,原因就是:边界条件太多,写着写着就乱了。最常见的错误是某些数字被重复填了,或者某些角落被漏掉了。

我之前见过很多解法,思想都是相同的:一圈一圈地填。最外圈填完填内圈,直到中心。问题在于每一圈的四条边,到底怎么卡边界?有的人最上行填到right-1,最右列填到bottom-1,最下行填到left+1,最左列填到top+1,每条边的区间定义都不一样,写着写着自己就晕了。

这就要用到"循环不变量"的概念了。代码随想录里反复强调的一个词,其实就是:在同一个循环里,你对边界的处理规则必须保持一致。要么每条边都坚持"左闭右闭",要么每条边都坚持"左闭右开"。不要让一条边取到端点,另一条边又不取。

4.2 左右开弓的统一边界策略

我用的是一个比较省心的方法:每一条边都采用"左闭右开"的策略,即每一圈我们只填n-1个元素,最后一个元素交给下一条边去填。

以n=3为例,一轮循环处理四条边:

  • 从(0,0)到(0,1),填1、2;
  • 从(0,2)到(1,2),填3、4;
  • 从(2,2)到(2,1),填5、6;
  • 从(2,0)到(1,0),填7、8; 最后中间剩下(1,1),单独填9。

这样每一圈四条边都遵循同一个规则,循环体内逻辑一致,不用担心漏格子或重复填。

代码实现如下:

class Solution { public: vector<vector<int>> generateMatrix(int n) { vector<vector<int>> matrix(n, vector<int>(n, 0)); int top = 0, bottom = n - 1, left = 0, right = n - 1; int num = 1; while (top <= bottom && left <= right) { for (int j = left; j < right; j++) matrix[top][j] = num++; for (int i = top; i < bottom; i++) matrix[i][right] = num++; for (int j = right; j > left; j--) matrix[bottom][j] = num++; for (int i = bottom; i > top; i--) matrix[i][left] = num++; top++; bottom--; left++; right--; } if (n % 2 == 1) { int mid = n / 2; matrix[mid][mid] = num; } return matrix; } };

注意这个小细节:上面这个实现中,四条边循环结束后我们直接收缩边界,最后单独处理n为奇数时正中心的那个格子。为什么需要单独处理?因为当n是奇数时,比如n=3,最后一轮top=bottom=1、left=right=1,while条件成立,但此时四条边循环里的四个for循环,每个的循环条件都因为left==right或top==bottom而一个元素都不会执行。如果不单独处理中心点,矩阵中心就会留下一个0,而正确答案应该是9。

你也可以在while循环里加入对top==bottom和left==right的判断来填中心值,但这样做会让循环体内的条件分支变多,代码看起来没那么整齐。我更喜欢循环结束后统一处理中心点,逻辑清晰,也符合"最后剩下的一个点单独填"的自然认知。

4.3 边界条件的对照表

我在刷题时整理了一个小表,方便自己对照,也分享给你:

循环阶段topbottomleftright四条边填完后状态
n=3 外层0202填了1到8
n=3 内层1111不填,单独处理中心
n=4 外层0303填了1到12
n=4 内层1212填了13到16

用上面的代码跑n=4时,内层第二轮top=1、bottom=2、left=1、right=2,四个for循环刚好填13、14、15、16四个数。跑完之后top=2、bottom=1,循环退出,不需要单独处理中心。因为n是偶数,没有中心点。

这个表的经验是:不要用n=3一个案例通过就以为万事大吉,一定要用n=4甚至n=5再跑一遍。很多人的代码在n=3时能通过,一换n=4就出问题,原因就是中心条件和偶数边界的处理没有统一。

如果你写成四条边都是"左闭右闭"的策略也可以做到,但loop里的条件就要变成j <= right第二边是i <= bottom,诸如此类。每条边的写法不同,思考负担更大。我推荐"左闭右开"就是因为它的规则一致,循环内不需要任何特殊判断。

5. 高频踩坑实录:这几道题最容易写崩的三个瞬间

5.1 典型错误速查表

Day2的三道题里,我自己以及带过的小伙伴们,最常犯的错误高度集中。整理出来大家可以对照检查:

题目常见错误后果纠正方法
977while(left < right)漏掉中间元素改成left <= right
977结果数组从前往后填顺序错误从后往前填,下标从n-1递减
209内层用if判断sum >= target漏掉更短的窗口必须用while循环连续收缩
209sum用int累加大数组场景可能溢出用long long
209result初始化为0无法判断是否存在解初始化为INT_MAX或n+1
59每条边都取"闭区间"角落元素重复填充统一用左闭右开
59n为奇数时不处理中心中心留下0循环后单独填matrix[n/2][n/2]
59循环条件写while(top < bottom)n=1时直接跳过用top <= bottom && left <= right

这张表的每一条我都真实遇到过,不是纸上谈兵。尤其是977这道题,我早期刷的时候连续三次都是漏了最后一个元素,因为当时不理解为什么要left <= right而不是left < right。后来想明白了:left和right重叠的那个元素,代表区间里只剩最后一个未处理的数字,也必须纳入比较。

5.2 调试技巧:用print大法快速定位边界问题

刷题时如果代码逻辑不复杂但结果不对,我强烈建议先别急着看题解,自己在本地打几个print,看看每一步变量怎么变的。以螺旋矩阵为例,很多人写了半天不知道哪里多加了一个格子,这时候你在每个for循环后面打印一下当前矩阵的状态和边界值,基本一眼就能看到是哪个方向越界了。

我在本地调试螺旋矩阵时的习惯是把num和top/bottom/left/right一起打印出来:

cout << "top=" << top << " bottom=" << bottom << " left=" << left << " right=" << right << " num=" << num << endl;

跑一遍n=5,这个输出会非常直观。你能看到边界收缩的节奏,也能看到四条边一共填了多少个数。

对于209这种和滑动窗口相关的题,打印sum和窗口范围也很有效。特别是当结果不符合预期时,你可以看到sum刚超过target时窗口是什么状态、收缩到什么程度停止的,从而判断while循环写没写对。

5.3 从"刷过"到"会写"的关键一步

根据我自己的经验,这三道题只做一遍是远远不够的。第二遍隔天做,不看任何提示,直接在编辑器里从零写。如果能写出来并且一次通过所有测试用例,才算真正掌握。

另外一个很实用的办法:用不同的语言各写一遍。比如你平时用C++刷题,那就用Python再写一次这三道题。Python写列表操作比C++更直观一些,但语法上的宽松也可能让你忽略一些边界逻辑,双语言交叉验证,能够更清楚地暴露出你对算法本身的理解漏洞。

顺便说一句,这三道题在代码随想录里的定位本身就是"需要在24小时内掌握"的题,所以第二天就复习一遍,是最合理的学习节奏。不要拖到周末统一复习,记忆曲线会帮你记住,但也会让你更早忘掉。

6. 数组题目的方法论沉淀:Day2之后你该带走什么

6.1 看到题目后先问三个问题

Day2结束后,我建议你先别急着开始Day3。花一个小时做一次"元学习"——把三道题放在一起复盘,提炼方法论。我自己的复盘方式是问三个问题:题目里的输入有什么特殊性质?暴力解法慢在哪一步?有没有办法利用输入性质跳过无效计算?

以三道题为例:

  • 977的特殊性质是原数组有序,这决定了平方后的最大值在两端,于是双指针从两端往中间走。
  • 209的特殊性质是所有元素是正数,这保证了sum和窗口长度是单调的,Right右移sum必增,Left右移sum必减,滑动窗口才能成立。如果数组里有负数,这个方案就不成立了。
  • 59的特殊性质是矩阵填充顺序固定,没有任何捷径,唯一的考点是规则的一致性。

你发现没有,这三道题都不是靠"背模板"解决的,而是靠"识别输入性质"解决的。面试官真正想看的就是你能不能识别性质并利用它。

6.2 双指针的三个变体,Day2一手集齐

Day2学完之后,你对双指针的理解应该是一个完整的体系了:

  • 同向双指针(Day1的移除元素):快指针探路,慢指针记录有效位置。
  • 相向双指针(Day2的977):左右夹逼,利用有序性从两端收敛。
  • 滑动窗口(Day2的209):左右指针形成区间,右进左出,维护一个动态窗口。

这三种变体各有各的适用场景,也各有各的边界调试难点。你能把这三类双指针一次性讲清楚、写正确,数组类的双指针题目基本就通透了。

至于螺旋矩阵,它放在Day2的最后一题,其实是个"方法论外的提醒":不是所有题目都有巧妙的算法,有些题就是老老实实的模拟。这类题的价值在于训练代码的严谨性——不依赖灵感、不依赖技巧,只依赖你能否把一个简单的规则从头到尾不犯错地执行完。很多大厂的手撕代码环节特别喜欢考这种题,因为它能直观地看出候选人写代码的时候是不是细心。

6.3 我个人对Day2安排的一点体会

跟着代码随想录刷到Day2的时候,我曾经觉得这个进度有点慢,一天才三道题,和其他动辄一天十道题的计划比确实显得"拖沓"。但后来实际面试时我才意识到,这种慢是对的。数组是整个算法大厦的地基,双指针、滑动窗口是后面字符串、链表、区间问题、双指针类中等难度的题里反复出现的骨架。地基不牢,后面全白搭。

我还记得自己第一次完整把三道题的代码无bug写出来,是在一个周日的下午。写完59题之后,我看着那个螺旋矩阵的打印输出,一种"这套路也不过如此"的踏实感涌上来。后来刷链表反转、刷最长无重复子串、刷合并区间,隐约都有Day2的影子在里面。

如果你今天刚开始Day2,进度太快跟不上没有关系,一天写不完就分两天。真正重要的是,你要在写代码的过程中不断问自己:我为什么让这个指针动?我为什么在这里用while?我为什么把这个边界条件写成这样?当你开始追问这些为什么的时候,刷题就不再是体力活了。

代码随想录这套路线的价值,不在于帮你把题背下来,而在于帮你建立起一套稳定的分析框架。Day2的三道题,就是这套框架里最关键的三块砖。

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

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

立即咨询