我见过不少人刚开始刷力扣时,总喜欢挑那些听起来高级的题目来做,结果被虐了两天又默默回到数组题补基础。数组在力扣里确实属于最入门的结构,但“入门”和“简单”其实是两回事,它坑起来一点不比树和图少,只是坑的类型不一样。这篇我结合自己刷数组练习的经历,聊聊力扣数组题到底在考什么、有哪些高频模板、怎么避开那些看着不起眼却能让你连续交好几次WA的边界条件,以及一套可以直接抄作业的刷题节奏。
如果你正准备系统刷算法,或者刷了一段时间却总在数组题上卡壳,这篇应该能给你一些参考。我会尽量少讲虚的,多给我实际用过、验证过的思路和代码骨架。
1. 别小看数组题:它在力扣题库里的真实地位与考察套路
1.1 数组几乎是一切算法的底层载体
很多人有个错觉,觉得数组题太基础,刷它不如刷链表、二叉树、动态规划显得有水平。但事实是,链表可以用数组模拟,字符串可以转换成字符数组处理,矩阵本身就是二维数组,树的前中后序遍历虽然用的是递归,但层序遍历经常要借助数组/队列来存节点。可以说,数组是算法的“最小公倍数”,几乎所有题目最后都要落到某种线性结构上。
力扣的题库里,数组题也是数量最多的类别之一,从简单到困难都有覆盖。简单题考验你编码是否严谨,中等题开始叠加双指针、滑动窗口、前缀和、哈希映射、二分查找这些常见算法,困难题则把数组和动态规划、贪心、线段树这些内容揉在一起。所以我一直觉得,刷数组题不是“练基本功”这么简单,它是在练你对“数据组织方式”和“算法复杂度”的敏感度。
1.2 面试官在数组题里真正想看的三件事
我陪过不少人模拟面试,发现同样一道数组题,不同人的解法差异极大,而这种差异正好暴露了面试官最在意的东西。
第一是代码基本功。循环边界会不会写错,索引会不会越界,空数组能不能处理,这些细节比算法本身更能筛人。
第二是复杂度意识。题目给了10万级的数据规模,你还写三重循环,那不管思路对不对,面试官心里已经给你打了折扣。能不能根据数据范围推断出该用O(n)还是O(n log n)的算法,是很重要的能力。
第三是思维升级能力。给你一道“找出数组中出现次数超过一半的元素”,有人上来就排序取中位数,有人用哈希表计数,有人能想到摩尔投票法。三种解法没有绝对的对错,但展示出的思维层次完全不同。数组题恰恰是这种“解法阶梯”体现得最明显的地方,因为它的约束少,发挥空间大。
1.3 数据规模是藏在题目里的提示
我在刷题时养成了一个习惯:拿到题先看数据范围。力扣几乎每道题都会给你数组长度n或者元素值域,这其实是一个非常有价值的提示。
比如n在100以内,那三重循环大概率能过,你不需要绞尽脑汁优化。n在10万量级,O(n^2)基本会超时,你要想的是O(n log n)或者O(n)的做法。n在十亿这种级别,连O(n)都可能不行,得考虑二分或者数学公式。元素值域如果很小,可以考虑计数数组、位标记这类空间换时间的手段。
数据规模不是题目随便写的,它是出题人给你划定的一条“复杂度红线”。熟练之后,看数据范围大概就能猜出该用什么思路,这能省掉很多无意义的尝试。
2. 数组题四大高频考点的解题模板与代码骨架
2.1 双指针:对撞与快慢两种模式
双指针是我在数组题里用得最多的技巧,没有之一。它核心思想很简单:用两个指针代替一个指针在数组中反复遍历,把时间复杂度从O(n^2)降到O(n)。具体至少有两种常见形态,千万别搞混。
第一种是对撞指针。最常见的是有序数组里找两数之和:左指针在最左,右指针在最右,两数之和等于目标值就返回,小于目标值就把左指针往右动,大于目标值就把右指针往左动。它的前提是数组有序,否则对撞没有意义。
// 有序数组中找两数之和,返回下标 function twoSum(nums, target) { let left = 0, right = nums.length - 1; while (left < right) { const sum = nums[left] + nums[right]; if (sum === target) { return [left, right]; } else if (sum < target) { left++; } else { right--; } } return []; }这里有一个细节我要强调一下:循环条件是left < right而不是left <= right。因为数组里不能同一个元素用两次,如果left === right还继续判断,就相当于把同一个元素重复计算了一遍。这个坑我在刚开始刷题时踩过,后来每次写双指针都会条件反射地检查一下。
第二种是快慢指针。典型场景是“移除数组中的某个元素”,一次遍历里,快指针负责扫描原数组,慢指针负责指向新数组的有效位置。快指针遇到不需要删除的元素,就把它复制到慢指针位置,然后慢指针前进。这个做法在原数组上就地操作,空间复杂度O(1),面试中很加分。
// 原地移除所有等于 val 的元素,返回新长度 function removeElement(nums, val) { let slow = 0; for (let fast = 0; fast < nums.length; fast++) { if (nums[fast] !== val) { nums[slow] = nums[fast]; slow++; } } return slow; }很多人会问,为什么不直接倒序遍历删除?因为JavaScript的数组删除会引起元素移动,倒序删除虽然可以避免索引错乱,但如果有大量删除操作,性能依然不好。快慢指针在思路上更干净,也更容易延伸到“去重”“移动零”这类题目上。
2.2 滑动窗口:从暴力嵌套到线性维护
滑动窗口是数组题里另一个高频考点,它解决的问题有一个共同特征:需要在一个连续的子数组上做判断,而这个子数组是动态伸缩的。
我把它理解成一把“可以自动伸缩的尺子”。右指针负责伸长,左指针负责收缩,窗口内部永远维护当前符合某个条件的连续区间。最典型的问题是“和大于等于目标值的最短子数组”。
// 长度最小的子数组,使其和 >= target function minSubArrayLen(target, nums) { let left = 0, sum = 0, ans = Infinity; for (let right = 0; right < nums.length; right++) { sum += nums[right]; while (sum >= target) { ans = Math.min(ans, right - left + 1); sum -= nums[left]; left++; } } return ans === Infinity ? 0 : ans; }注意这里内层用的是while而不是if。为什么?因为当你把左指针移动一次之后,窗口内的和可能依然大于等于目标值,需要继续移动左指针,直到条件不再满足为止。我第一次写这个题的时候用了if,结果在[2, 3, 1, 2, 4, 3]这类测试用例上直接算错,后来对照调试才反应过来。
还有一个滑动窗口的变种是“固定长度窗口”,比如求数组里每k个连续元素的最大和。这种更简单,窗口长度不变,右指针每前进一格,左指针也跟着前进一格。我习惯先用一个left和right都初始化为0的思路,但固定窗口用for循环从k到n遍历会更直观,代码也更难写错。
2.3 前缀和与差分:区间问题的降维武器
前缀和是一个看起来很简单、但应用极广的技巧。它解决的核心问题是“频繁查询区间和”。如果不做任何预处理,每次求区间[l, r]的和都要遍历一遍,最坏O(n),多次查询就变成O(mn)。用前缀和数组预处理之后,每次区间查询变成O(1)。
构建方式不复杂:新开一个长度n+1的数组pre,pre[i]表示原数组前i个元素的和。那么区间[l, r)的和就是pre[r] - pre[l]。我建议用左闭右开区间来写,能省掉很多下标偏移的思考。
// 构建前缀和 const n = nums.length; const pre = new Array(n + 1).fill(0); for (let i = 0; i < n; i++) { pre[i + 1] = pre[i] + nums[i]; } // 查询区间 [left, right) 的和 function rangeSum(left, right) { return pre[right] - pre[left]; }为什么pre的长度是n+1而不是n?因为pre[0]需要表示“前0个元素的和”,也就是0,这样才能处理从下标0开始的区间。这个细节如果没想清楚,后面很容易出现越界或者结果偏一位的问题。
差分数组则是前缀和的逆向操作,更适合“对某个区间统一加同一个值”的场景。比如你要对数组的[l, r]区间整体加v,直接循环可能要O(n),用差分数组只需要在diff[l] += v,再到diff[r + 1] -= v,最后累加一遍恢复原数组。它常用于多次区间更新的场景,如果你刷题时遇到类似“执行多次区间加操作”的题目,可以往这个方向想。
2.4 原地操作与状态压缩:空间复杂度的取舍
数组题里还有一种很“秀”的技巧,就是在原数组上做标记,避免额外开哈希表。它通常要求数组元素的值域和数组下标之间能建立某种映射关系。比如数组长度是n,所有元素值都在[0, n-1]范围内,你就可以把“数值”本身当作“下标”来用。
最常见的做法是“负号标记法”:遍历数组时,遇到值x,就把下标|x|对应的元素变成它的负数。这样不额外开空间,遍历一遍就能找出重复出现过的数。不过这里有个隐患,如果同一个位置被多次负号标记,你还需要判断一下它是不是已经是负数。另一个常见技巧是“加上偏移量n”,但容易在数据范围很大时溢出,我一般不太推荐除非你有十足把握。
这种原地技巧的最大风险是修改了原数组,之后你再遍历时看到的不再是原始值,必须用绝对值等方式还原。写这类题我会先在纸上模拟一遍,确认“修改后的数据会不会影响后续判断”,再动手写代码。面试时用这种技巧确实很加分,因为它展示出你对空间复杂度的敏感度,但如果你在高压下容易乱,我还是建议老老实实开一个额外数组,先保证正确性。
我简单整理了一下这四种考点的适用场景,方便你快速对照:
| 考点 | 典型问题特征 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 双指针 | 有序性、去重、就地删除 | O(n) | O(1) |
| 滑动窗口 | 连续子数组、满足条件的最短/最长 | O(n) | O(1) |
| 前缀和 | 多次区间和查询 | 预处理O(n),查询O(1) | O(n) |
| 原地标记 | 值域小且与下标可映射、要求O(1)空间 | O(n) | O(1) |
3. 数组题的边界条件:一次AC的隐藏障碍
3.1 空数组与单元素数组:最简单的输入最容易翻车
我复盘过自己Submit失败的记录,发现至少有三分之一是挂在边界输入上,其中空数组和单元素数组占了大头。很多人写主逻辑时想的是“数组至少有两三个元素”,结果一判断空数组就直接越界。
比如你写一个求数组最大值的函数,可能很自然地写出let max = nums[0],然后从1开始遍历,但如果数组是空的,nums[0]直接就是undefined,比较结果全乱套。我在刷题时习惯性在函数开头加一段边界保护:
if (!nums || nums.length === 0) return -1; if (nums.length === 1) return nums[0];这两行看似多余,却能帮你挡住大量莫名其妙的问题。尤其是很多题目要求你返回下标的时候,空数组和单元素数组的处理往往和正常逻辑不同,必须单独考虑。
还有一个建议是:当你写完一个解法之后,先不要提交,而是自己构造几个特殊用例跑一遍。空数组、一个元素、两个相同元素、全部相同元素、升序数组、倒序数组,这些用例10秒钟就能跑完,但能省下你提交后反复WA的煎熬。我自己就在“两个相同元素”这种用例上翻过车,因为有些去重逻辑会错误地把相同元素当作有效结果。
3.2 索引越界:循环边界计算的常见翻车点
数组越界是新手最容易犯的错误,但它又不是那种一运行就崩溃的报错,很多时候JavaScript不会直接报错,而是给你一个undefined,让你对着结果一脸懵。我总结过几个高频越界场景。
第一种是循环里同时访问nums[i]和nums[i+1],比如比较相邻元素。如果你循环条件是i < nums.length,最后一次循环访问nums[i+1]就会越界。这时候要么把条件改成i < nums.length - 1,要么从i = 1开始遍历并访问nums[i-1]。我倾向于用后一种,因为思考“当前元素和前一个元素比较”比“当前元素和后一个元素比较”更符合直觉。
第二种是二分查找里的mid计算。老手都知道用left + (right - left) / 2而不是(left + right) / 2,因为后者在语言里可能会有整数溢出问题,虽然JavaScript里整数溢出风险比C++小,但养成好习惯没坏处。
第三种是双指针移动时没有检查指针是否越过数组末尾。特别是滑动窗口里右指针已经到nums.length - 1之后,你还要访问nums[right + 1]。我的建议是:所有从数组读取值的代码,先确认下标是否在合法范围内,哪怕感觉冗余。刷题时多写一行判断,比调试半小时值太多。
3.3 整数溢出与语言特性差异
数组题里经常遇到求和、求乘积、比较大小这类运算,不同语言有完全不同的表现。用C++或Java刷题时,int类型很容易溢出,两个十亿级别的数相加直接变成负数,导致比较逻辑出错。JavaScript虽然所有数字都是浮点数表示,但超过安全整数范围同样会有精度问题。
一个经典坑是“求数组中所有元素的和再判断”。如果数组长度很大,元素值又很大,结果可能超出安全范围。这时候要么提前判断是否有单个元素就超过目标值,要么用更小范围的累加策略,避免最后才做比较。
还有一个容易被忽略的是“负数的绝对值”。在C++里,abs(INT_MIN)的结果可能还是负数,因为正数范围装不下这么大的绝对值。数组题里你如果要用“取绝对值”来还原被负号标记的元素,一定要意识到这个边界。我一般遇到这种场景,会先判断元素是否大于0,再决定怎么处理,而不是直接取绝对值。
4. 从TLE到AC:数组题的时间复杂度优化实战
4.1 先跑通暴力,再决定要不要优化
我一直不太建议大家拿到数组题就直接想最优解,尤其是刚开始刷题的时候。合理节奏是:先用最直白、甚至最暴力的方式把题目解出来,确保思路正确,再在此基础上考虑怎么优化。
为什么?因为如果一上来就想最优解,你很可能卡在“怎么优化”上,连题目本身的逻辑都没理清楚。暴力解法虽然效率差,但它是很好的“参考答案”。比如“三数之和”,暴力解法就是三重循环,先把框架写对,能过样例,再去想排序+双指针的优化版。这个过程能帮你理解暴力解到底慢在哪,优化方向自然就清晰了。
我刷题时经常先写一版O(n^2)或O(n^3)的解法,拿来和最优解对比。对比之后我才会真正理解为什么需要哈希表、为什么需要排序。这种“先暴力再优化”的路径,比直接看题解记忆深刻得多。
4.2 根据数据规模反推目标复杂度
前面说过数据范围是提示,这里展开讲一下怎么用好这个提示。我自己的经验是:拿到题先看n的范围,心里快速定一个“必须达到”的复杂度目标。
下面这个表是我刷题时经常对照的经验值,不一定绝对准确,但能帮你快速判断方向:
| 数据规模 | 可接受的复杂度 | 常用算法方向 |
|---|---|---|
| n <= 100 | O(n^3) | 暴力枚举、三重循环 |
| n <= 1000 | O(n^2) | 双重循环、两两组合、DP |
| n <= 10^5 | O(n log n) 或 O(n) | 排序、二分、双指针、哈希表 |
| n <= 10^9 | O(log n) 或 O(1) | 二分答案、数学推导 |
如果题目给的是n <= 10^5,你却写出了双重循环,不用跑超时也知道大概率会TLE,这时候就应该停下来想想双指针或者哈希表能不能降一重循环。反过来,如果n只有100,你却写了一个复杂的O(n log n)解法,虽然也能过,但可能多花了不必要的时间。先确认复杂度目标,再选择算法,这是高效的刷题习惯。
4.3 空间换时间:哈希表是数组题的最佳外挂
数组题里最经典的优化手段就是“用哈希表记录已经遍历过的信息”。拿最经典的“两数之和”来说,暴力做法是两层循环,时间复杂度O(n^2)。用哈希表之后,只需要一次遍历:每遇到一个元素nums[i],就查一下target - nums[i]在不在哈希表里,如果在,说明找到了另一半个组合;如果不在,就把当前元素作为键、下标作为值放进哈希表。
// 两数之和:返回满足条件的下标 function twoSum(nums, target) { const map = new Map(); for (let i = 0; i < nums.length; i++) { const need = target - nums[i]; if (map.has(need)) { return [map.get(need), i]; } map.set(nums[i], i); } return []; }这个思路的本质是空间换时间:用O(n)的空间把“查找”的复杂度从O(n)降到了O(1)。后面很多题目,比如“最长连续序列”“和为k的子数组”,其实都是这个思想的变体。哈希表在数组题里的作用,类似你在一排储物柜前面放了一个索引目录,每次找东西不用一个个翻,直接查目录定位到对应柜子,效率提升是肉眼可见的。
4.4 排序预处理:把无序变有序,思路瞬间开阔
数组题里还有一个很实用的预处理手段:排序。很多问题在无序数组上很难做,但一旦有序,解法就变得很直观。
比如前面提到的对撞双指针例子,前提就是数组有序。如果题目给的是无序数组,你又需要比较元素大小关系,不妨先花O(n log n)的时间排序,再考虑用双指针或者二分。排序看起来增加了一步操作,但它往往能把一个难以下手的题目变成一个套模板就能解决的题目。
我常用的判断标准是:题目是否关心元素的“相对顺序”或“原始位置”。如果只关心“值”本身,不关心下标,排序通常是安全的;如果必须返回原数组的下标,排序前就要先记录原始下标,排序后依然可以通过下标还原位置。比如“找出两个数,使它们的和等于目标值”这类题,如果要求返回原下标,我会先创一个包含原始下标和值的对象数组,排序后在双指针,最后再根据对象里的原始下标转换结果。
5. 我把数组题刷了三遍之后的复盘与节奏建议
5.1 一套可复制的三阶段刷题节奏
带过几个朋友刷力扣之后,我总结出一套比较适合普通人的三阶段节奏,尤其适合数组题专项训练。第一阶段是“手感期”,大概花一周时间,每天3到5道简单数组题,目标不是刷量,而是熟悉数组的基本操作、边界处理和常见报错。第二阶段是“题型期”,用两到三周时间,按我前面列的双指针、滑动窗口、前缀和、哈希表、二分查找这些高频考点,分类刷中等题,每天两到三道。第三阶段是“实战期”,每周至少做两场45分钟限时训练,题目直接从中等难度起步,同时开始复刷错题。
这个节奏的理念是:先建立基本盘,再模型化提高,最后在时间压力下检验。如果你一上来就追求每天10道题,前期可能会很爽,但很快就会陷入“刷过就忘”的状态。数组题不是一个靠数量就能堆出效果的题型,它是靠“重复和复盘”才能内化成直觉的题型。
5.2 错题复盘的正确姿势
很多人刷题就是提交通过之后立刻看下一题,这样刷一年可能也没什么进步。我自己比较有效的做法是建立一个极简错题模板,每道没一次AC的题都会记录以下几项:题目考察的算法模型是什么、我第一遍的思路卡在哪个环节、最优解和我的解法差在哪里、这道题对应哪一类通法。
这个模板看着麻烦,但每道题只需要三四分钟就能写完。它最大的价值在于:当你刷到第50道数组题时,回看前30道,会发现很多“卡点”其实是重复的,比如“没考虑空数组”“忘记处理下标偏移”“不知道用哈希表降低查找复杂度”。把这些高频卡点整理出来之后,新题再碰到类似场景会形成条件反射,AC率提升非常明显。
我还习惯每周末花15分钟做一次“模型打卡”,把本周遇到的数组题按双指针、滑窗、前缀和等分类汇总,看看哪类题型题量最多、哪类正确率最低。数组中如果某个模型的正确率特别低,下一周就重点补这一类,而不是漫无目的地做新题。
5.3 别把“刷题量”当成核心目标
我见过有人刷了300道题还是怕面试手写算法,也见过只刷了一百道左右、面试时各种问都能接得上的人。差距不在数量,在于有没有把题目抽象成模型。数组题尤其适合模型化,因为它的题型变体相对集中,很多中等难度的题都是几种基础模板的组合。
比如“滑动窗口 + 哈希表”用于不重复字符的最长子串,“排序 + 双指针”用于大多数和有关的数对问题,“前缀和 + 哈希表”用于统计满足条件的子数组个数。你如果能在看到题目的时候迅速判断出“这个题是滑窗还是前缀和”,就已经赢了一大半。这种判断力不是靠做新题练出来的,而是靠反复总结旧题练出来的。
所以我更推荐“二刷三刷”而不是“一味追新”。把做过的数组题换个问法再写一遍,或者把已经AC的题改成限时重写,这类“带约束的重复”比连续刷新题更能锻炼实战能力。
最后说一个我自己的小习惯吧。每次拿到数组题,我不急着写代码,而是先盯着数据范围看十秒钟,心里大概估算“暴力能不能过,需不需要上哈希表、双指针、前缀和”。这个习惯让我少走了很多弯路,也让我在面试时思路比大多数人快半拍。希望正在刷数组的朋友也能尝试一下,说不定你的AC率也会悄悄提上去。