先说结论:代码随想录算法训练营DAY3的“第一章 数组part02”,核心内容是“双指针法、滑动窗口、模拟行为”三大技巧,对应题目是977.有序数组的平方、209.长度最小的子数组、59.螺旋矩阵II。
这一天的内容我过了两遍,第一遍跟着视频敲,第二遍关掉视频纯手写。第一遍写出来的是“能过的代码”,第二遍写出来的是“能讲清楚为什么的代码”。算法训练营的魅力就在这儿——题目本身不难,难的是把一个技巧用到“条件反射”的程度。这篇文章把DAY3的核心考点和我在实际刷题中踩过的坑都整理一遍,争取让准备开始part02的同学少走弯路。
1. 从递进关系看part02考点:为什么这三道题要放在同一天
1.1 数组part01和part02的分界线
代码随想录的数组章节分两部分,part01是理论基础、二分查找、移除元素,part02是平方、子数组、螺旋矩阵。我当时第一次看到这个划分有点迷惑,觉得part02的题目类型跨度挺大——有的在排序,有的在滑窗,有的在模拟。真正刷完之后才意识到,这三天是一个连续的技巧递进:part01练的是“指针怎么移动”,part02练的是“指针怎么配合、怎么控制边界”。
part01的移除元素是快慢指针,一个指针遍历,一个指针维护新数组。part02的有序数组平方直接把这个思想倒过来用——两个指针从两端往中间走。如果说part01教你认识指针,part02就是教你信任指针:你敢不敢让两个指针互相配合着把一个有序结构处理出来,而不是先排序再老老实实遍历。
1.2 三道题背后的三个方法论
- 977.有序数组的平方,考的是相向双指针,核心逻辑是“平方后最大的数一定在两端”。
- 209.长度最小的子数组,考的是滑动窗口,核心逻辑是“窗口右边界不断扩展,左边界按需收缩”。
- 59.螺旋矩阵II,考的是模拟行为,核心逻辑是“循环不变量,每条边的边界规则必须一致”。
这三道题的共同点是:都有“暴力解法”,也都存在“优雅解法”。暴力解法不是不能AC,而是面试官看你代码时,想看的不是你调了个sort,而是你有没有识别出问题背后的结构。
1.3 一句话总结part02的验收标准
我当时给自己定的标准是:不看任何参考,15分钟内写出代码,并且能解释清楚每一行边界判断为什么这么写。如果你对977的while (left <= right)为什么带等号、209的while为什么不是if、螺旋矩阵的偏移量为什么是n - offset - 1都能脱口而出,part02就算真正过了。
2. 有序数组的平方:相向双指针的“第一次正面交锋”
2.1 为什么第一反应是排序,但最优解是双指针
题目给你一个按非递减顺序排序的整数数组nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
最直观的做法,每个数平方一下,然后sort。这解法没有任何问题,时间复杂度O(n log n),空间O(1)。但注意题目的输入条件:原数组是有序的。有序数组一定存在比全量排序更聪明的解法,这就是训练营想让你锻炼的“信息敏感度”。
为什么平方后最大值一定在两端?因为负数的平方可能很大。比如[-5, -3, 0, 1, 2],平方后最大的是25(在左端),其次是9(也在左端),但你不能简单地直接从左往右排,因为中间还有0和1的平方。
那怎么处理?两个指针从两端往中间比较平方值,谁大谁放到结果数组的末尾。结果数组从后往前填,这样一次遍历就能得到非递减序列。
2.2 代码实现:从后往前填是灵魂
vector<int> sortedSquares(vector<int>& nums) { int n = nums.size(); vector<int> result(n); int left = 0; int right = n - 1; int pos = n - 1; while (left <= right) { int leftSquare = nums[left] * nums[left]; int rightSquare = nums[right] * nums[right]; if (leftSquare > rightSquare) { result[pos--] = leftSquare; left++; } else { result[pos--] = rightSquare; right--; } } return result; }关键点就两个:结果数组必须从后往前填,因为两端的平方值是大的一方;循环条件是left <= right,因为最终两个指针会相遇在同一个元素上,这个元素也要被处理,不能漏掉。
我第一次写的时候把result[pos--] = leftSquare和result[pos--] = rightSquare的方向搞反了,结果整个序列的顺序错乱。调试的时候在纸上画了一下才发现,pos的移动方向必须和结果数组的填充顺序一致,这里的“从后往前”是这道题最容易出错的细节。
2.3 边界条件:等号要不要带,用一组极端案例验证
边界条件是这类题的重灾区。我当时用一个最简单的case验证:nums = [-1]。此时left = 0, right = 0,如果循环条件是left < right,直接不进入循环,result就全0了,错误。所以必须是left <= right。
再看nums = [-1, 0]。left = 0, right = 1,进入循环,leftSquare = 1, rightSquare = 0,1大,填到末尾,left变1;再次进入循环,left = right = 1,处理nums[1] = 0,填到result[0],结束。一切正常。
所以记住:“相遇”的场景必须覆盖,循环结束时机就是指针相遇且处理后停止。
2.4 从这道题延伸出去:双指针的“家族谱”
刷完977之后可以把双指针做个系统总结,不再是一道题一个解法,而是一类题一个套路:
- 快慢指针:一个遍历,一个维护新位置。代表题:27.移除元素、26.删除有序数组中的重复项
- 相向指针(左右夹逼):从两端向中间逼近。代表题:977.有序数组的平方、167.两数之和II(输入有序数组)
- 滑动窗口:快慢指针的进阶版,两个指针共同围成一个“窗口”。代表题:209.长度最小的子数组、76.最小覆盖子串
- 链表双指针:快指针先走n步,慢指针再走。代表题:19.删除链表的倒数第N个结点
当你把双指针从“一种写法”上升到“一类思维”,后面再做字符串章节、链表章节会轻松很多。
3. 长度最小的子数组:滑动窗口的优雅与陷阱
3.1 暴力法的复杂度瓶颈
题目:给定一个正整数数组nums和一个正整数target,找出该数组中满足其和≥target的长度最小的连续子数组,并返回其长度。
暴力解法是双重循环:外层枚举起点,内层枚举终点,维护一个sum。这个方案的正确性毋庸置疑,时间复杂度O(n²)。对于数组长度10⁵这样的规模,直接超时。
滑动窗口为什么能把O(n²)降到O(n)?核心思想是:右指针负责“扩”,左指针负责“缩”,每个元素最多被访问两次,一次被右指针扩展时加进来,一次被左指针收缩时减出去。
3.2 窗口收缩逻辑:for循环右边界 + while收缩左边界
int minSubArrayLen(int target, vector<int>& nums) { int result = INT32_MAX; int sum = 0; int left = 0; int subLength = 0; for (int right = 0; right < nums.size(); right++) { sum += nums[right]; while (sum >= target) { // 注意是while,不是if subLength = right - left + 1; result = min(result, subLength); sum -= nums[left]; left++; } } return result == INT32_MAX ? 0 : result; }为什么收缩逻辑必须用while而不是if?因为当右指针加进来一个很大的数时,窗口内的和可能远超target,此时光把左指针移动一位还不够,可能还需要移动多位才能让sum再次小于target。用if意味着只收缩一次,窗口可能仍然满足sum >= target,但你已经跳过了继续收缩的机会,结果就不是最小长度了。
这个过程可以这样理解:右指针每到一个新位置,就相当于“给窗口喂了一口饭”,左指针则负责“消化”——只要食物还够,就一直吃,吃到不够为止。你在每个右指针位置都尝试收缩到极限,才能保证不遗漏任何一个最小窗口。
3.3 三个最容易写错的细节
这个题我看了不少同学的代码,错误集中在三个位置:
第一个,result的初始值。我见过有人写int result = 0,然后后面min(result, subLength)永远是0。应该用INT32_MAX或一个比数组长度更大的数,最后再判断result == INT32_MAX说明没有满足条件的子数组,返回0。
第二个,sum的更新顺序。正确顺序是在while循环内部,先计算subLength、更新result,再减去nums[left]并移动left。如果你先减sum再计算子数组长度,计算出来的长度就多减了一个元素,长度偏小。
第三个,while循环里的left移动。我曾经写成sum -= nums[left++];这样代码没问题,但初学者容易犯的错是把left++放到while外面了,导致窗口左边界永远不动,死循环。调试时如果发现程序跑不完,优先检查左指针有没有真的在动。
3.4 通用滑动窗口模板,建议背下来
我面试前会把滑动窗口模板整理成固定的四步:
- 右指针遍历数组,把元素加入窗口(更新窗口数据)
- 判断窗口是否满足条件(这里可以是和、子串覆盖、去重等)
- 满足条件时,更新答案(通常是求最小/最大窗口)
- 收缩左指针,从窗口中移除元素(更新窗口数据)
在这个模板里,步骤1和4是对称的,step2的“条件”决定你是求最长还是最短。求最短一般是“满足条件时尝试收缩并更新答案”,求最长一般是“不满足条件时收缩,每次都更新答案”。
后面做76.最小覆盖子串、904.水果成篮、3.无重复字符的最长子串时,都可以套这个四步模板。滑动窗口不是玄学,它是一个被反复验证过的结构。
4. 螺旋矩阵II:循环不变量,一次把逻辑理清楚
4.1 为什么“转圈”的题这么容易错
59.螺旋矩阵II要求给你一个正整数n,生成一个包含1到n²所有元素、且元素按顺时针顺序螺旋排列的n x n正方形矩阵。
这道题没有算法,纯粹是模拟。但模拟题的难点恰恰在于:你需要精确控制每一圈的每一条边,任何一条边的边界条件不一致,整个矩阵就会错位。
我见过一个同学写螺旋矩阵,四条边的循环条件分别是< n、< m、> 0、> 0,看起来没什么问题,跑n=3的时候也对。但一测n=4就乱了。原因就是每条边处理的位置范围不一致,导致转角处的元素被覆盖或被跳过。
4.2 左闭右开:让每条边都遵守同一个规则
这里要引入一个贯穿整个训练营的概念:循环不变量。循环不变量是指在循环过程中保持不变的性质。在螺旋矩阵里,我们要保证每条边的处理规则一致。
我推荐按照Carl视频里的方式,每条边都用“左闭右开”的原则——即每条边处理该方向上除最后一个元素以外的所有元素,把最后一个元素留给下一条边处理。
以n=3为例:
- 第1圈第1条边:从(0,0)到(0,1),不包含(0,2)
- 第1圈第2条边:从(0,2)到(1,2),不包含(2,2)
- 第1圈第3条边:从(2,2)到(2,1),不包含(2,0)
- 第1圈第4条边:从(2,0)到(1,0),不包含(0,0)
这样每条边都只处理n-1个元素,最后一个元素通过内圈继续处理。
vector<vector<int>> generateMatrix(int n) { vector<vector<int>> res(n, vector<int>(n, 0)); int startx = 0, starty = 0; int offset = 1; int count = 1; int loop = n / 2; int mid = n / 2; while (loop--) { int i = startx; int j = starty; for (; j < n - offset; j++) { res[i][j] = count++; } for (; i < n - offset; i++) { res[i][j] = count++; } for (; j > starty; j--) { res[i][j] = count++; } for (; i > startx; i--) { res[i][j] = count++; } startx++; starty++; offset++; } if (n % 2 == 1) { res[mid][mid] = count; } return res; }这里的offset非常关键。每走完一圈,可用的边界就向内缩一格,所以offset每次加1。n - offset就是当前圈在最外层的终止位置。如果offset不递增,第二圈就会越界把已经填好的元素覆盖掉。
4.3 奇数n的中间元素怎么处理
当n为奇数时,矩阵最中间会剩下一个格子,比如n=3时中间是(1,1),n=5时中间是(2,2)。这个格子没有被任何一圈的循环覆盖到,因为每一条边都留了一个元素给下一条边,所有圈走完之后,中心位置是空的。
处理方式很简单:if (n % 2 == 1)直接把最后一个数字填到res[n/2][n/2]。当时我第一遍写的时候完全忘了处理奇数情况,跑n=3时矩阵中间是0,调试了好久才发现。
用n=3走一遍:
- loop = 1(3/2的整数部分),进入一次循环
- 第1条边填充:res[0][0]=1, res[0][1]=2
- 第2条边填充:res[0][2]=3, res[1][2]=4
- 第3条边填充:res[2][2]=5, res[2][1]=6
- 第4条边填充:res[2][0]=7, res[1][0]=8
- 然后
n % 2 == 1,填充res[1][1]=9
完美铺满。如果你自己去实现,建议先拿n=4这种偶数case走两圈,再拿n=5走两圈加中心,验证思路是否完整。
4.4 从螺旋矩阵II到螺旋矩阵I:从“填”到“读”的思维切换
训练营后我自己做了54.螺旋矩阵(按顺时针顺序返回矩阵中的所有元素),发现思路一模一样,只不过II是从外向内“填数字”,I是从外向内“读数字”。II需要控制n*n个数字,I需要控制行数和列数的boundary。
如果你把II的代码吃透了,I的核心逻辑就是把“填充数字”改成“push到结果数组”,同时注意不再存在n为奇数的中心点特殊处理,因为行和列可能不相等,最后一圈可能只剩一行或一列。这时候四条边的循环条件需要加top <= bottom && left <= right的判断,否则会重复读取。
这就是模拟题的价值——它逼你把边界条件想清楚,而不只是“大概知道”怎么写。很多同学说数组题简单,但真正到模拟题就卡壳,问题就出在边界控制不严谨。
5. 把part02的“手艺”移植到真实开发:数组高频操作的几个实例
5.1 从“双指针思维”看JS数组合并、去重
算法训练营的题目看起来跟业务开发有点远,但数组相关的方法论在工作里其实能直接落地。
比如前端经常要合并两个数组,很多人直接arr1.concat(arr2)或者arr1.push(...arr2),这两个写法在数据量小的时候没毛病。但如果arr2非常大,push(...arr2)会因为展开操作符的参数数量限制导致栈溢出。这是我在生产环境真实踩过的坑,后来改成循环push或者用arr1.length = arr1.length + arr2.length配合copyWithin。算法课教会我的是:当数据规模上来时,“看起来简单”的方案不一定可靠,你要对操作本身的复杂度敏感。
数组去重也是一个被问烂了的问题:
// 基础版:Set去重 const unique = [...new Set(arr)]; // 进阶版:对象数组按某个字段去重 const uniqueById = [...new Map(arr.map(item => [item.id, item])).values()];这两个方案背后对应的是“哈希表”的思维,这和训练营DAY1、DAY2里用哈希表解决数组问题的思路一模一样。业务开发写多了你会发现,很多“巧妙的代码”不是灵光一现,而是你脑子里有足够的算法模型,能瞬间映射到正确的数据结构上。
5.2 为什么Vue的watch监听数组第一项,新旧值是一样的
这个问题在热搜词里出现了,而且很多人踩坑。Vue2的响应式系统里,直接arr[0] = 1这种索引赋值无法被拦截,因为Object.defineProperty只能拦截已有的属性。即使你在watch里写了deep: true,拿到的新值和旧值也指向同一个数组引用,数组内容变了,但引用没变,所以watch的oldVal和newVal看起来相同。
我在项目里处理这个问题时的做法:
- 监听一个计算属性,返回
[...arr]这样的浅拷贝,让新数组在引用层面发生变化 - 或者用
this.$set(arr, 0, 1)触发更新 - 或者干脆用computed做一层筛选,watch筛选结果
这个问题的本质是“引用类型数据的不可变性”。你在算法题里写result.push(nums[i])而不注意拷贝,到业务代码里就会遇到这种“看起来一样”的坑。
5.3 KMP的next数组与树状数组上二分:数组不只是“一块连续内存”
热搜词里出现了KMP算法的next数组、树状数组、二维数组、逆序对等话题。有人可能觉得part02没讲这些,为什么我要在DAY3的笔记里提?因为从DAY3开始,你已经掌握了基本的数组遍历、双指针、边界控制,接下来所有更复杂的结构都是在这个基础之上叠加的。
KMP模式串p="abacaba"的next数组怎么求?按“前缀和后缀最长相等长度”的定义:
- next[0]="a",前缀后缀空集,长度为0
- next[1]="ab",前缀集跟后缀集没有相同子串,长度0
- next[2]="aba",前缀"a"与后缀"a"相同,长度为1
- next[3]="abac",后缀"c"、"ac"、"bac"跟前缀完全没有交集,长度0
- next[4]="abaca",前缀"a"与后缀"a"相同,长度为1
- next[5]="abacab",前缀"ab"与后缀"ab"相同,长度为2
- next[6]="abacaba",前缀"aba"与后缀"aba"相同,长度为3
最终next数组是[0, 0, 1, 0, 1, 2, 3]。注意不同的实现可能定义为“最长相同前后缀长度减1”,那个版本是[-1, -1, 0, -1, 0, 1, 2]。你只要在代码里认清自己用的哪种定义就行。
树状数组上二分则更像二分查找的“数组版”:树状数组存储前缀和,你想找“前缀和第一次达到某个值的位置”,可以用类似二分的方法在树状数组的二进制结构上跳跃,达到O(log n)的复杂度。这个技巧在求逆序对时配合离散化很常用。
这些话题看起来离part02很远,但它们的共同根基都是数组。
5.4 二维数组、动态数组、清零操作:开发中绕不开的数组细节
热搜词里还有一批基础但经常出错的点,我简单列一下:
- 二维数组初始化:C++里
vector<vector<int>> matrix(m, vector<int>(n, 0))才真正创建了m行n列的二维结构,int arr[m][n] = {0}这种写法在m、n不是常量时会编译报错。 - 数组清零:C++里
memset(arr, 0, sizeof(arr))是按字节填充,对int数组清零没问题;fill(arr, arr+n, 0)更推荐。QCustomPlot接数组数据时,buffer清空也是类似逻辑,别在循环里当个元素赋值,能用库函数就用库函数。 - 动态数组:C++的vector扩容时是倍增策略,每次容量不够时扩大到约1.5倍或2倍,所以频繁
push_back的均摊复杂度是O(1)。这解释了很多“循环往vector里加元素会卡”的问题——不是因为push_back慢,而是因为你在不该用动态数组的场景用了动态数组。 - 数组转字符串:不同语言差异很大。C++里
std::to_string只能转单个数值,要转数组得配合循环拼接;JS里arr.join(',')一步搞定。你在训练营里写代码的时候可能不关心这种语言特性,但到实际项目里,这是每天都要做的事。
数组的知识体系非常庞大,但所有的复杂操作都建立在“下标、边界、指针移动”这三个基础之上。part02练的就是这三个东西。
6. 三天刷题复盘:那些代码之外的经验
6.1 边界条件错误的三个典型模式
复盘part02的错题,发现边界错误基本是三种:
- 循环条件等号丢失:
left <= right写成left < right,导致最后一个元素漏处理。977、二分查找、反转链表都可能出现。 - 区间端点不统一:一只用闭区间、一只用开区间,导致边界元素重复或遗漏。螺旋矩阵尤其明显。
- 初始值不当:求最小值把result初始为0,求最大值把result初始为INT_MAX,导致答案永远不对。
这三种错误有个共同点:在纸上推演一遍样例就能发现,但很多人就是懒得推演。我在训练营期间定的规矩是:任何一道题,AC之前必须在本地IDE用至少两个不同规模的测试用例跑一遍,其中一个覆盖临界情况(空数组、单个元素、最大值、负数等)。
提示:调试数组题时,最有效的工具不是debugger,而是在关键循环里打印下标、当前值和更新后的结果。尤其是双指针和滑动窗口,打印left、right、sum、result这四件套,一眼就能看出逻辑哪里断了。
6.2 为什么“看懂”和“会写”之间差了这么多
训练营DAY3刚好是很多同学开始“感觉吃力”的时候。我观察到一个现象:进度快的同学并不是智商碾压,而是他们在看懂题解之后会马上合上代码,自己重新写一遍。
看懂题解时你用的是“识别-匹配”模式,你能看出这个代码是对的,因为你见过类似结构。但合上代码自己写时,你用的是“回忆-生成”模式,需要自己把逻辑从零构建出来。这两种模式之间的差距就是你真正的知识盲区。
我在DAY3之后给自己定的规矩是:每题至少独立写两遍,第一遍在看完视频后24小时内,第二遍在3天后。第二遍写之前什么都不看,直接打开编辑器,从题目描述开始写。
6.3 给训练营新人的三条建议
不要“收藏即学会”。训练营的题单已经帮你排好了优先级,按顺序刷就行。不要今天刷数组、明天刷链表、后天刷二叉树,稳住踏实地把数组的每一个技巧吃透。
建议用C++或Java刷题。如果你是为了面试,C++和Java在代码随想录的题解生态里最完善,报错信息也更严格。JavaScript刷题也可以,但有些题(比如二维数组模拟)用JS写起来不太直观。
记录错题的时候不要只写答案,把那个让你卡住的“错误念头”也写下来。比如“我以为while判断条件变了之后sum就不用更新,结果死循环”。几天后再看这些错题,你会发现自己当时踩坑的点其实特别基础——但基础的东西不刻意练习,就会一直在原地踩坑。
DAY3结束之后,数组part02的整体脉络就收尾了。紧接着part03会进入链表,到时候你会发现,链表里的很多操作——比如反转、找中点、删除倒数第N个节点——本质上还是双指针的变体。所以把数组这三天的基础打牢,后面会轻松很多。
如果你也正在这期训练营里,想说的是:看到别人一天刷完三天的量不用焦虑,按自己的节奏来。算法能力不是拼速度,是拼“每个知识点是否能随时调用”。DAY3的这三大技巧,值得你多花时间反复打磨。