ARTICLE DETAIL

资讯详情

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

代码随想录Day2:双指针、滑动窗口与螺旋矩阵的边界控制

代码随想录Day2:双指针、滑动窗口与螺旋矩阵的边界控制 刷LeetCode的人可能都听过“代码随想录”这套刷题路线。我是在数组双指针问题上反复吃亏之后决定老老实实跟着这套节奏一天一天打卡。今天轮到了我的代码随想录打卡Day2核心内容刚好覆盖LeetCode三道题977有序数组的平方、209长度最小的子数组、59螺旋矩阵II。这三道题表面上看起来毫无关联一个在算平方一个在求最短长度一个在螺旋填数但真正做完之后你会发现它们背后串着同一条线索对边界的掌控。这篇博文就记录一下我这次的完整解题过程、踩过的坑以及最终沉淀下来的套路希望能给正在刷数组这一章的你一些参考。1. Day2的三道题到底在考什么1.1 为什么数组章节的第二天会塞进这三道题代码随想录的数组章节第一天是二分查找和移除元素核心练的是“区间定义”和“快慢指针”。第二天突然切换到平方排序、滑动窗口、螺旋矩阵很多第一次刷的人会有点懵。我自己刚开始也很不理解为什么跳转这么大但把题量做完再看这三道题分别对应了数组题目里三种非常高频的思考模型有序数组但经过某种变换后失去“表面有序性”如何利用原有信息重新组织结果。977就是典型数组本身有序平方之后变成两头大中间小连续子数组的最优解问题通常可以用滑动窗口把O(n^2)级别的暴力枚举降到O(n)。209就是这种模型的标准入门题二维数组的遍历与填充顺序本质上考察的是坐标变换和循环不变量。59题就是拿一个二维矩阵练手把“按顺序填数”这件事做到边界不出错。这三种模型在整个刷题生涯里的复用率极高所以卡哥把它们放在Day2集中训练确实是用心设计的。你就算之前觉得自己“会写代码”真到了这三道题面前也会发现很多平时完全不会注意的边界细节。1.2 刷题前的状态评估和时间预估我刷这三道题用的总时长大概三个半小时包括看题、暴力解法试水、写最优解、看错题、重写第二遍。如果你是有一定基础的人我建议你给自己留足两个小时起步不要试图在半小时内硬啃完尤其是59螺旋矩阵它考察的是纯粹的代码组织能力跟智力关系不大但特别考验耐心。我自己的刷题顺序是先写977因为它最接近Day1的双指针手感热身效果很好再写209因为需要理解滑动窗口的收缩逻辑稍微需要抽象一点最后写59因为它代码量最大需要静下心来模拟。这个顺序推荐给你从易到难节奏比较平滑。2. 977有序数组的平方从暴力解到双指针的演进过程2.1 暴力做法的问题在哪拿到这题第一反应特别朴素把数组里的每个元素平方然后对整个数组排序。这个思路完全没错代码也就几行class Solution { public int[] sortedSquares(int[] nums) { int n nums.length; for (int i 0; i n; i) { nums[i] nums[i] * nums[i]; } Arrays.sort(nums); return nums; } }但是这里有一个很关键的复杂度问题如果数组长度为n平方过程是O(n)排序却是O(n log n)。题目给的是有序数组你明明知道它有序还去调用一个“从头排序”的算法等于完全浪费了题目给你的前提条件。这就是典型的“能过但没吃到题目的红利”。LeetCode上这题的数据范围是n最大到10^4暴力解法其实能过但如果你只是想AC那代码随想录的Day2就白刷了。这个阶段的意义在于建立一种条件反射看到有序数组优先想能不能利用这个有序性做文章。2.2 双指针解法完整推导有序数组平方之后有个很有趣的性质最大值一定出现在数组两端不是最左边就是最右边。因为负数平方之后可能变得很大而正数平方之后也大只有中间的数平方后偏小。我举个例子你就明白了。假设数组是[-4, -1, 0, 3, 10]平方后变成[16, 1, 0, 9, 100]最大值100来自最右边的10第二大值16来自最左边的-4。所以如果你想要得到一个从小到大排列的结果数组按常规思路从左往右填是填不出来的因为你不知道下一个应该填左边还是右边的平方结果。正确做法是用两个指针一个指向头部记为left一个指向尾部记为right每次比较nums[left]^2和nums[right]^2谁更大谁大就放在结果数组的末尾然后把对应指针往中间挪一步。这样从后往前倒着填就能保证每次填进去的都是当前剩余元素中最大的那个最终结果天然升序。class Solution { public int[] sortedSquares(int[] nums) { int n nums.length; int[] result new int[n]; 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--; } index--; } return result; } }2.3 指针相遇时的归属问题这里有一个最容易写错的细节while循环的条件到底是left right还是left right我第一遍写的就是left right结果数组少了一个元素。原因很简单当left和right指向同一个位置时这个元素还没有被处理但循环已经退出了。这个元素是正数也好、负数也好、零也好它平方后的值必须是结果数组中的一个位置绝对不能丢。所以循环条件一定要写成left right把最后一次相遇的元素也处理掉。如果你偏爱left right的写法那循环结束后还得单独补一个赋值语句把nums[left]的平方塞到index的位置。两种写法都可以但你必须清楚地知道你选择的写法在最后一步会发生什么。我给一个更直观的类比这就像两个人从两端往中间收一条绳子每一步都取走当前能看到的最大宝石。如果两个人走到了同一个格子还要决定这个格子里的宝石归谁不能两个人看了一眼就走开把宝石留在原地。3. 209长度最小的子数组滑动窗口的边界与收缩逻辑3.1 为什么暴力双循环不可取这题描述起来很直白找最短的连续子数组使它的和大于等于target。我第一反应当然是枚举所有可能的子数组起点i终点j累加判断。时间复杂度O(n^2)代码大概长这样class Solution { public int minSubArrayLen(int target, int[] nums) { int n nums.length; int minLen Integer.MAX_VALUE; for (int i 0; i n; i) { int sum 0; for (int j i; j n; j) { sum nums[j]; if (sum target) { minLen Math.min(minLen, j - i 1); break; } } } return minLen Integer.MAX_VALUE ? 0 : minLen; } }如果你只追求能跑通这个写法在小数据集上完全没问题。但LeetCode这题后面的大数据量测试用例n能到10^5O(n^2)是妥妥的超时。你可能会说“我可以提前break啊找到就跳出”但在极端情况下比如target特别大几乎每个起点都要走到数组尽头才能凑够和那该超时还是会超时。所以核心问题转化成怎么避免无意义的重复累加比如你从下标0加到下标5和总算够了。接下来你让起点变成1重新从1加到5这段总和其实你已经算过了。滑动窗口解决的就是这个多余计算的问题。3.2 滑动窗口的完整实现滑动窗口的思路可以这样理解你维护一段区间[start, end]保证这段区间的和小于target。当end往右扩展和逐渐增大一旦和大于等于target就说明我们找到了一个可行解记录一下当前窗口长度。然后尝试把start往右移动缩小窗口看看能不能在总和仍然满足条件的情况下得到更短的子数组。换句话说窗口的右边界负责“找可行解”窗口的左边界负责“找更优解”。两个指针都只往一个方向移动不会回退所以整体复杂度是O(n)。class Solution { public int minSubArrayLen(int target, int[] nums) { int left 0; int sum 0; int minLen Integer.MAX_VALUE; for (int 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 Integer.MAX_VALUE ? 0 : minLen; } }这段代码最核心的就是while循环。每次右指针扩展一位后进入循环判断当前总和是否达标。如果达标就记录长度然后收缩左边界。收缩完之后如果总和仍然达标就继续收缩直到总和小于target为止。这个“持续收缩”的过程保证你记录到的每一个长度都是“以当前right为右边界时可能达到的最短长度”。3.3 为什么收缩要用while而不是if这是我第一次刷的时候掉进去的坑。我写的是if (sum target) { minLen Math.min(minLen, right - left 1); sum - nums[left]; left; }结果很多用例跑不出来正确结果。原因特别简单当右边界扩展一步之后和可能一下子比target大出很多此时你只移除left上的一个元素可能总和依然大于等于target窗口依然可行但你已经跳出了判断错过了更短的窗口长度。举个例子数组是[1, 2, 3, 4, 5]target是9。right跑到下标3时sum123410已经达标长度是4。如果你只用if收缩一次left变成1sum变成9区间是[2,3,4]长度3其实依然达标但你已经不再继续判断了。正确答案应该是继续把left移到2sum变成7区间变成[3,4]长度2不达标才停下来。所以必须用while反复收缩左边界直到窗口不再满足条件为止。这就有点像拧湿毛巾你不满足于“拧一下水就停”你得拧到拧不出水了才算处理干净。窗口收缩也一样收缩到总和小于target才算完成本轮优化。3.4 返回值的一个隐藏细节题目要求如果不存在符合条件的子数组返回0。所以minLen必须初始化为一个极大值例如Integer.MAX_VALUE最后判断它有没有被更新过。如果你直接把minLen初始化为nums.length恰好整数组和都不达标你就会错误地返回一个长度而不是0。return minLen Integer.MAX_VALUE ? 0 : minLen;这个细节我是在自己写测试用例的时候发现的数组[1, 1, 1, 1, 1]target10整段和才5根本不可能找到子数组但因为我一开始把minLen设成了数组长度5结果代码返回5直接报错。别小看这种地方面试的时候这种低级失误比算法想不出来更减分。4. 59螺旋矩阵II循环不变量是如何救命的4.1 模拟螺旋填数的核心难点59题要求你生成一个n x n的矩阵按顺时针螺旋顺序填入1到n^2。这题看着不像算法题更像一个模拟题。写起来代码量不小但真正的难点只有一个你如何在每一圈的遍历中保证四条边的边界逻辑完全一致。我第一次写这片代码时脑子里的想法是“每条边最后留一个格子交给下一条边处理”。也就是说上边从左到右填到倒数第二个点右边从上到下填到倒数第二个点下边从右到左填到倒数第二个点左边从下到上填到倒数第二个点。这样一整圈下来正好让四条边配合着把外围一圈全部填满且每条边处理的格子个数相同。这种做法在代码随想录里叫“循环不变量”——整个螺旋过程中每一条边都坚持同一种开闭原则不一会左闭右开一会左闭右闭。只要这个原则统一你会发现代码逻辑非常顺滑。4.2 左闭右开区间代码实现下面是我最终提交通过的版本配合注释看会比较清晰class Solution { public int[][] generateMatrix(int n) { int[][] matrix new int[n][n]; int startX 0, startY 0; // 每一圈的起点 int offset 1; // 每一圈四条边的收缩量 int count 1; // 要填入的值 int loop n / 2; // 需要转的圈数 while (loop 0) { int i startX; int 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[n / 2][n / 2] count; } return matrix; } }你仔细看会发现每一条for循环的终止条件都不是直接小于n或大于0而是受offset控制的。offset每一圈加1意味着每条边向内收缩一格。这就是左闭右开原则的具体体现每一圈的“终点”比真实边界少一个格子这个格子留给转弯后的下一条边去填。比如n4时第一圈上边只填(0,0)、(0,1)、(0,2)(0,3)留给了右边去填。右边只填(0,3)、(1,3)、(2,3)(3,3)留给下边去填。下边从(3,3)到(3,1)(3,0)留给左边。左边从(3,0)到(1,0)回到起点附近。这样一整圈正好填满12个格子。4.3 n为奇数的中心格子处理螺旋矩阵转完所有圈之后如果n是偶数所有格子都会被填满如果n是奇数中心会剩下一个独立的格子。比如n3时外层有一圈中心还剩一个格子。n5时外层两圈中心剩一个格子。这个格子不属于任何一圈的循环必须在循环结束后单独赋值。我第一次没加最后的if判断n3时结果矩阵中心是0愣是找了好几分钟才意识到问题。原因就是循环次数loop n / 2n为3时只转一圈只填了外围8个格子中心格子必须手动补上。这也是为什么代码里要用if (n % 2 1)做单独的兜底处理。在面试或者笔试现场这种“最后剩一个小尾巴”的逻辑特别容易被漏掉。我自己的习惯是凡是涉及多层循环嵌套、边界收缩的题目写完主体代码之后先拿n1、n2、n3、n4这四个小尺寸手动走一遍把边界情况单独确认比直接提交等错误反馈高效得多。4.4 什么时候不该用while循环模拟还有一点可以顺带提一下这道题其实也有人用方向向量来写定义右、下、左、上四个方向遇到边界或已访问格子就换方向。这种写法代码更短但理解成本略高。我推荐大家第一次刷的时候用左闭右开逐层模拟因为它的每一步都看得见、摸得着完全符合“循环不变量”的训练目标。等你刷熟了再尝试方向向量写法也不迟。5. 三次提交后的复盘与实用建议5.1 常见错误清单与根因这三道题我前前后后提交了七八次踩过的错误完全可以汇总成一张避坑表。题目错误表现根本原因解决办法977结果数组出现0或缺失元素while循环写成left right中间元素没处理改为left right或循环后补赋值977结果数组部分位置错乱指针移动方向搞反结果从前往后填明确指针从两端向中间走结果从后往前填209返回数组长度而非0minLen初始化成数组长度初始化为Integer.MAX_VALUE最后判空209结果比正确答案大窗口收缩用了if而非while改为while持续收缩到不满足条件59矩阵中心剩0忘记n为奇数时中心单独赋值循环结束后用if (n % 2 1)补中心格59填数越界或互相覆盖四条边开闭区间不一致统一采用左闭右开offset每圈加1这张表建议截图保存或者直接抄到你的错题本里。我在刷题群看到过很多人在这些错误的同一个位置反复跌倒。尤其209和59简直是高频重灾区。5.2 配合代码随想录Day2的正确刷题姿势一来我建议你先别看题解给自己每道题15到20分钟的思考窗口。这个窗口里哪怕只能写出暴力解法后面看题解时的收获也会成倍增长。没有经过思考直接看答案代码你是看懂了但遇到新题你依然不会迁移。二来代码随想录的文章里给的图解和动图非常有用但不要只盯着图看一定要亲手在本地IDE或LeetCode线上编辑器里敲一遍。复制粘贴和手敲代码的记忆深度完全不同。三来这三道题AC之后我习惯用随机点的方式再考自己一次。比如合上代码只凭记忆把滑动窗口的精髓“while循环收缩左边界”这一句话复述出来然后默写代码。如果能默写出来说明这个知识点真的变成你自己的了如果默写不出来那就是还没消化透需要再看一遍。5.3 延伸思考这三道题背后的通用套路做完这三道题我最大的收获不是记住了某个特定解法而是发现它们有一个共同的底层动作用两个指针维护一个动态区间并精确控制区间的边界。977的双指针虽然指的是“从数组两端往中间逼近”但实际上也是在一个不断缩小的区间上做选择。209的滑动窗口是标准的区间维护右指针负责扩张左指针负责收缩。59的螺旋遍历每一圈的四条边构成了一个闭合的矩形区间区别只是你在这个区间上按顺序游走而已。这个“区间思维”在后续刷题里还会反复出现比如链表里的快慢指针、字符串里的最长无重复子串、二叉树里的一些区间查询题目。所以Day2值得你投进去的时间绝不只是这三道题本身而是对你处理边界问题的能力做一次集中加固。我个人的体验是刷完这一天的题目再回头去看Day1的二分查找突然就有了一种“看山还是山”的感觉——二分查找维护的其实也是一个左右收缩的区间只是判断条件和这里不太一样。当你意识到这一层的时候你会发现自己已经开始脱离“背题解”的阶段进入“根据区间性质推导解法”的层次了。这一步迈过去刷题这条路的效率会明显不一样。
返回列表