很多人开始刷力扣,第一反应是挑着Hard题硬刚,结果没几天就卡在动态规划上,心态直接崩了。我自己早期刷题反而没这么激进,就是老老实实从“数组”这个标签下的题开始,一题一题啃过来,后面再碰字符串、链表、矩阵甚至图论的题,思路基本都能续上。数组练习在力扣里看着最不起眼,但它是整个算法刷题的基本盘。它不依赖复杂数据结构,核心就是数组本身,考的是你对索引、边界、复杂度、状态维护这些基本功的掌控。这篇文章把我这些年刷数组题的心得整理了一遍,包括题目怎么分类、典型题的思考路径、常见的边界坑,以及日常训练节奏怎么安排。适合刚开始刷力扣的新手,也适合准备面试想快速过一遍数组考点的人。
1. 数组练习为什么是刷题的基本盘
1.1 数组是所有算法题的“最小公倍数”
数组的本质很简单:一块连续内存,通过下标随机访问,时间复杂度O(1)。但插入和删除是O(n),因为要搬移元素。就是因为这个特性,数组题允许的优化空间非常大,你没法靠某个高级容器救场,只能在“怎么减少遍历次数”“怎么用最少的额外信息记录状态”上做文章。
字符串可以看成字符数组,矩阵就是二维数组,哈希表的底层也离不开数组。甚至很多二叉树的题,把它中序遍历出来,最后要处理的还是一个有序数组。所以你刷数组题,表面上学的是某一题的解法,实际上是在给所有算法题建底层模型。数组练熟了,后面学二分、双指针、滑动窗口、前缀和,会发现这些思想在数组上都有最纯粹的版本,理解起来会快很多。
1.2 数组考点在面试里占比极高
我不止一次说过,算法面试准备阶段,数组这个标签值得你专门留出时间刷。一次标准的技术面试,两三道题里大概率有一道是数组变形题,尤其后端、客户端、数据相关岗位,数组几乎是必考区。面试官喜欢用数组题,是因为它能在很短时间里看出候选人的边界意识、复杂度评估能力和代码整洁度,而这些恰恰是实际工程里最需要的素质。
还有一个容易被忽略的点:很多看起来和数组无关的难题,最后收敛到核心步骤时,往往是“排序后做双指针”或者“维护一个前缀和”。也就是说,数组题不只是基础题,它还是很多中等题、困难题的最终落点。把数组吃透,等于给二叉树、动态规划、贪心这些专题都打了底。
1.3 数组练习真正练的是四种底层能力
我刷了这么多题,发现数组题反复锻炼的能力其实就四样:
- 边界感:循环里是
left < right还是left <= right,right初始值是nums.length还是nums.length - 1,这些细节直接决定代码会不会越界、会不会死循环。 - 复杂度意识:暴力解O(n^2)和双指针O(n)之间的差距,很多题就是围绕这个差距设计的,你得一眼判断出当前解法能不能过。
- 状态维护能力:用一个变量、一个哈希表、一个前缀和数组去维护某种状态,多数数组题的核心考点都落在这里。
- 思路迁移能力:数组解法练顺了,遇到字符数组(字符串)、链表甚至矩阵,你会有一种“这题我见过”的熟悉感。
这四样能力靠背题背不出来,只能靠大量数组题喂出来。所以别再小看“简单题”,简单题里藏的东西一点都不少。
2. 数组题目的四类核心考法
2.1 遍历统计类:先学会用一个变量维护状态
这一类是数组题里最基础的,典型代表有求多数元素、找只出现一次的数字、统计某个值出现的次数。它一般不考复杂数据结构,考的是你怎么在遍历过程中用一个变量或一个哈希表维护目标状态。
拿“多数元素”这道题举例,常规想法是哈希表统计每个数字出现次数,时间O(n)、空间O(n)。但最优解是摩尔投票法,空间降到O(1)。它的逻辑也很巧妙:维护一个候选值,遇到相同值计数加一,遇到不同值计数减一,计数归零就换下一个候选值。因为多数元素出现的次数超过一半,抵消完所有其他元素后,最后剩下来的那个候选值必然是它。
def majorityElement(nums): candidate = None count = 0 for x in nums: if count == 0: candidate = x count += 1 if x == candidate else -1 return candidate这个解法的核心不是“记录所有值”,而是“用一个计数器抵消成对的不同值”。这种思想一旦建立起来,你会发现很多需要统计的题都能用类似方式优化空间。
2.2 双指针:排序后指针移动的艺术
双指针是数组题里出现频率最高的一类技巧,主要分两种:对撞指针和快慢指针。
对撞指针的经典场景是“有序数组里找两个数满足某种关系”,比如两数之和、三数之和、盛最多水的容器。核心思路是:先排序,用左右两个指针从两端往中间走,根据当前和与目标值的大小关系决定移动哪一边,每移动一次就排除掉一批不可能的组合,整体复杂度从O(n^2)降到O(n)。
快慢指针的典型场景是原地去重、移动零、判断是否有重复值。快指针负责扫描,慢指针负责记录有效位置,相当于在同一个数组里打出一套“读指针”和“写指针”的组合拳。
这里提醒一个细节:做对撞指针题,尤其是三数之和这种需要去重的题,移动指针时要跳过重复值,否则会出现重复组合。很多人一开始写三数之和,边界判断漏了去重,结果答案里有大量重复三元组,被判错的时候还不知道问题出在哪。
2.3 滑动窗口:处理连续子数组问题的利器
滑动窗口本质上也是双指针,只是它维护的是一个区间。两个指针left和right分别代表窗口的左右边界,right不断向右扩展,把新元素纳入窗口;当窗口条件被破坏时,left向右收缩,把不符合条件的元素移出窗口。
典型题目包括:找和大于等于某个值的最短连续子数组、不含重复字符的最长子串、最小覆盖子串。这类题最核心的点是“窗口内维护什么信息”。维护窗口和、维护字符频次、维护当前窗口是否满足某种性质,都会影响代码结构。
以“无重复字符的最长子串”为例:
def lengthOfLongestSubstring(s): window = set() left = 0 ans = 0 for right, ch in enumerate(s): while ch in window: window.remove(s[left]) left += 1 window.add(ch) ans = max(ans, right - left + 1) return ans每移动一次右指针,就去检查左指针需不需要收缩,窗口内始终维护一个“无重复字符”的状态。这个模式几乎是滑动窗口题的通用骨架,建议把它练成肌肉记忆。
2.4 前缀和与差分:区间问题的高效预处理
前缀和解决的是“频繁求区间和”的问题。预处理一个pre数组,pre[i]表示前i个元素的和,那么区间[l, r]的和就是pre[r+1] - pre[l],一次减法搞定,不用每次重新遍历。
更进阶的用法是配合哈希表,比如“和为k的子数组”这个经典问题。遍历一次数组,不断累加前缀和,同时用哈希表记录每个前缀和出现的次数,每次判断“当前前缀和减去k”是否在哈希表里。如果在,说明存在某个位置到当前下标的区间和等于k。这样时间复杂度和空间复杂度都是O(n)。
差分数组则是区间增量问题的好帮手。当你在很多区间上反复做“区间内每个值加一个数”的操作时,朴素做法是每操作一次就遍历一遍区间,复杂度很高。差分数组只需要在区间的起点加一个数,终点后面减一个数,最后做一次前缀和就能还原出整个数组的最终状态。这种技巧在面试里出现频率不低,但知道的人不多,提前掌握会有惊喜。
遍历统计、双指针、滑动窗口、前缀和这四类并不互斥,实际题目经常混着用。比如滑动窗口的窗口和可以用前缀和来计算,去重型双指针也可以用哈希表辅助判断。先分类型练,是为了把每种思想从“听过”变成“用过”,等它们在你脑子里变成工具,再遇到综合题你就知道该从工具箱里抽哪把。
3. 从暴力到最优:两道经典题的完整思考链
3.1 两数之和:从双重循环到哈希表
“两数之和”是数组题里最有代表性的一道,也是很多人刷力扣的第一道题。题目很简单:给一个整数数组和一个目标值,找出数组中和等于目标值的两个数,返回它们下标。
第一反应是暴力,两层循环枚举所有数对:
def twoSum_bruteforce(nums, target): n = len(nums) for i in range(n): for j in range(i + 1, n): if nums[i] + nums[j] == target: return [i, j] return []这个解法时间复杂度O(n^2),对于大数组基本没法用。那怎么优化?关键问题是:对于遍历到的每一个数,我们真正想找的是它的“补数”,也就是target - x。如果你能在O(1)时间内查到一个补数在之前的遍历中是否出现过,就能省掉内层循环。哈希表正好干这个事。
def twoSum(nums, target): seen = {} for i, x in enumerate(nums): if target - x in seen: return [seen[target - x], i] seen[x] = i return []一次遍历,边查边存,时间O(n),空间O(n)。面试的时候,你可以先给出暴力解,再说哈希表优化,这个思考过程本身就很加分。顺带一提,如果题目改成“数组有序”且要求返回两个数而不是下标,那还有一个更省空间的双指针写法,时间O(n)、空间O(1),原地解决。
3.2 长度最小的子数组:从两层循环到滑动窗口
再看一道中等题:“长度最小的子数组”。给一个正整数数组和一个正整数target,找出满足“连续子数组和大于等于target”的最短子数组长度。如果没有,返回0。
暴力思路很简单:枚举左端点,向右累加,一旦累加和超过target就记录长度,然后换下一个左端点。每个左端点都可能重新累加一遍,时间复杂度O(n^2),在数组长度十万级别的情况下直接超时。
滑动窗口的思路就巧妙很多。用一个右指针扩展窗口,不断累加窗口和;当窗口和满足条件时,记录当前长度,然后移动左指针收缩窗口,同时把移出窗口的值从累加和里减掉。窗口像一条贪吃蛇,永远保持一个“当前区间的和”,每次只移动两个指针,不会回头重复计算。
def minSubArrayLen(target, nums): left = 0 total = 0 ans = len(nums) + 1 for right, x in enumerate(nums): total += x while total >= target: ans = min(ans, right - left + 1) total -= nums[left] left += 1 return 0 if ans == len(nums) + 1 else ans为什么这个解法的复杂度是O(n),而不是看起来像两层的O(n^2)?因为每个元素最多被右指针加入一次、被左指针移除一次,两边拿到w总移动次数不会超过2 * n,所以均摊下来是线性时间。这个“均摊”的概念在滑动窗口题里特别关键,你能在面试时把这个点说清楚,面试官基本就放心了。
从这两道题能看出来,数组题的进阶路径几乎都是同一个套路:暴力解先打底,找冗余遍历在哪里,然后用哈希表、双指针、滑动窗口或预处理把重复操作消掉。把这道思考链练熟了,遇到新题就不会慌。
4. 实战中容易踩的边界与细节坑
4.1 循环边界的经典翻车现场
数组题里最常见的报错就是“索引越界”。比如在循环里访问nums[i + 1],当i跑到最后一个位置时就越界了。正确做法是看清循环边界,要么把循环条件改成i < nums.length - 1,要么在循环体内先判断i + 1是否合法。
另一个典型场景是二分查找和双指针的边界判断。while left <= right的时候,更新边界要用right = mid - 1和left = mid + 1;但如果写的是while left < right,更新方式往往就不一样。很多人刷二分题卡死循环,老实说不是算法不懂,就是边界条件没统一。我的建议是:固定一套自己顺手的写法,别每次临场随意改,这样能把这类错误概率降到最低。
还有一种情况是索引写反,比如把nums[left]写成nums[right],或者把left和right的初始值搞错。这类错误用眼睛看很难发现,多靠调试和跑用例暴露。
4.2 排序导致“索引失效”
“两数之和”那类题要求返回原数组下标,但很多人为了用双指针,直接对数组排序,排序完之后下标全乱了,最后返回的下标是错的。这是刷题新手特别容易踩的坑。
解决办法无非两种:要么用哈希表记录“值 -> 原下标”的映射,排序前先备份一份带下标的对组;要么换一种不需要排序的解法。做任何题目之前,先想清楚题目要求的是“值”还是“下标”,以及排序会不会破坏这个信息。很多题允许修改原数组,但允许修改不代表可以忽略索引引用关系,这两件事要分清楚。
4.3 空数组、单元素、重复值这些边界用例
写数组题,最忌讳一上来就写主逻辑,写完就跑。我自己的习惯是:动手之前把几个特殊用例列出来,比如空数组、只有一个元素、所有元素相同、目标值比所有元素都大、窗口永远不满足条件。
这里整理一个常见的边界问题速查表:
| 场景 | 错误示例 | 正确做法 |
|---|---|---|
| 空数组 | 直接访问 nums[0] 报越界 | 先判断长度,为空直接返回默认值 |
| 单元素数组 | 双指针初始化 left=0, right=1,直接越界 | 先处理长度小于2的情况 |
| 所有元素相同 | 去重逻辑漏掉最后一个重复值 | 循环内处理“相邻两值相等”的情况 |
| 目标值比所有元素都大 | 返回了0当作合法长度 | 把“未找到”和“长度为0”区分开返回 |
| 累加和溢出 | 用int存前缀和或窗口和 | 根据数据范围换long,或提前判断 |
花五分钟列边界用例,能帮你省下被评测判错再调代码的半小时。
4.4 原地修改的“信息编码”技巧
有些数组题要求不能用额外空间,必须在原数组上操作。这时候最常见的思路就是“把数组本身当作哈希表用”。比如“缺失的第一个正数”这类题,可以把每个数字放到它值减1对应的下标位置,把“某个数是否出现过”这个信息编码到数组自身里。
“移动零”是更简单的原地操作题:用双指针,快指针扫描,慢指针指向下一个非零元素应该放的位置,一趟走完再把末尾补零。这类原地题的共同点是:你要先弄清楚允许修改哪些位置,以及修改顺序会不会影响后续判断。原地算法在工程上也很有用,比如处理超大数据集时,能避免额外内存的开销,这个习惯值得养成。
5. 数组题的训练方法与面试避坑
5.1 按专题模块刷,别按题号顺次刷
很多人打开力扣,从第一题往后一道一道刷,结果刷到一堆冷门题,既没有针对性,也容易挫伤信心。更好的做法是打开“数组”这个标签,把题目按照考察点拆成几组:遍历统计组、双指针组、滑动窗口组、前缀和组、模拟组、矩阵组。每一组挑五到十道题,按难度从简单到中等、再到困难梯度推进。
这样做的好处是,你在短时间内反复使用同一个技巧,肌肉记忆形成得很快。比如连续刷十道双指针题,后面看到“有序数组”“寻找两个元素”这类关键词,你脑子里会不自觉地弹出双指针的框架。组间再穿插着做,综合题也难不倒你。
5.2 一题多解是提升效率最快的路径
同一道题,暴力解、哈希表解、双指针解、排序后处理,复杂度可能完全不一样。每次做完题,别急着看下一道,先打开题解区确认一下这题有没有更优的解法。如果有,自己把别人的思路复述一遍,再手写一遍代码,最好能跑通所有测试用例。
比如“三数之和”,你可以先写暴力三重循环,再优化成排序加双指针,两种写法的复杂度在表格里一对比,差别非常直观:
| 解法 | 时间复杂度 | 空间复杂度 | 核心思路 |
|---|---|---|---|
| 三重循环 | O(n^3) | O(1) | 枚举所有三元组 |
| 哈希表辅助 | O(n^2) | O(n) | 两数之和思路套用 |
| 排序+双指针 | O(n^2) | O(1) | 排序后去重枚举 |
理解一道题的多种解法,比刷十道题但每题只会一种写法更有价值。面试时你就算没能立刻写出最优解,能先把暴力解写对,再逐步优化,整个过程也是加分的。
5.3 整理一套自己的“可复用模板”
数组题有不少模板可以沉淀。双指针、滑动窗口、前缀和这三大件尤其值得总结成自己的代码片段。
双指针模板的骨架大概是:
def twoPointers(nums): left, right = 0, len(nums) - 1 while left < right: # 根据条件移动 left 或 right if condition(nums[left], nums[right]): left += 1 else: right -= 1滑动窗口模板的骨架是:
def slidingWindow(nums): left = 0 state = 初始状态 for right, x in enumerate(nums): state = 更新状态(x) while 窗口不满足条件: state = 撤销状态(nums[left]) left += 1 记录答案模板不是用来背的,是用来参考的。我建议你刷完一定数量的题之后,用自己的语言整理一遍这几个模板,整理的过程就是内化的过程。
5.4 面试现场的执行节奏
数组题在面试里的建议节奏是这样的:先确认输入范围,包括数组是否有序、是否可能为空、数值范围多大、能不能修改原数组。然后告诉面试官你的初步思路,先给一个暴力解,再分析复杂度,说明哪里是瓶颈,最后提出优化方案。不要一上来就闷头写最优解,万一思路错了,容易在沟通过程中越走越偏。
写完代码一定要自查一遍边界用例,比如空数组、单元素、窗口从未满足过条件这些情况。很多候选人代码逻辑没错,就栽在最基础的边界检查上,非常可惜。
5.5 复盘周期怎么安排
算法刷题不追求“题量很大”,追求的是“见过的题型都能快速反应”。当天做完的题,晚上花几分钟在脑子里想一遍思路和复杂度。三天后重新做一遍,不看题解,只看自己能不能独立写出解法。一周之后再回过头来看,如果还能保持最优解的思路,这道题基本就融进你的知识体系了。
我个人经验是,人的短期记忆衰减非常快,很多题刚做完当天觉得“稳了”,一周后就只剩模糊印象。用间隔复习的方式把印象加深,比一天刷二十题管用得多。
说在最后
带过不少人刷题,我观察到一个很明显的规律:凡是数组这一块能沉下心来踏踏实实练完的人,后面学二叉树、动态规划、图论都会更快。原因也不复杂,数组练习就像一面镜子,直接照出你对边界、复杂度、状态维护这些基本功掌握得多扎实。如果现在你正准备开始刷力扣,别管别人晒了几百题几千题,先把数组这一组练扎实,后面你会感谢这段打地基的日子。