数组这块内容,我这些年带人刷题时反复验证过一个规律:能一次把数组题写对的人,后面学集合、学字符串、学动态规划,上手速度都会快一截;数组题写得磕磕绊绊的,往往不是语法不熟,而是脑子里没有"索引"这根弦。Java基础编程500题里,数组部分看着最朴素,实际上是最值钱的一段训练量——它逼着你用最原始的工具(下标、长度、循环)去处理数据,没有任何集合框架帮你兜底。这套题适合刚学完语法、想把手感练扎实的人,也适合写了几年业务代码、但一遇到手写算法就发怵的人回头补课。接下来我按题库的实际结构、典型题的完整手写过程、边界条件的清单化排查、以及做完之后怎么验证这几条线,把数组这一段的练习方法拆开讲。
1. 数组题在整套题库里承担的角色
1.1 它训练的是"索引思维"而不是API记忆
很多人对数组题有个误解,觉得无非就是length、for循环、Arrays.sort这几个东西翻来覆去用,练不出什么新花样。这个判断只对了一半。数组题的真正训练目标,是让你在头脑里建立一个"下标与数据一一对应"的模型:第i个位置放什么、走到第几位该停、两个指针相向而行什么时候会交叉、边界元素单独处理还是统一处理。这套思维一旦成型,后面写字符串反转、写链表快慢指针、写滑动窗口,全是从同一个根上长出来的。
我见过不少开发者,业务代码写得飞快,但让他手写一个"原地删除重复元素",第一反应是新建一个ArrayList往里塞,然后再转回数组。代码能跑,但这说明他没有建立起"在原数组上用两个下标协作"的直觉。500题里那些明确要求"原地""不使用额外空间""O(1)额外空间"的题目,就是为了逼出这种直觉而设计的。所以刷这部分题的时候,别急着用集合工具绕过去,先老老实实拿两个int变量当指针走一遍,哪怕写得丑。
还有一点值得说:数组的下标从0开始,这个看似微不足道的约定,是数组题里错误率最高的来源之一。人的直觉习惯从1数起,而代码从0数起,中间那一次偏移如果在脑子里没有形成条件反射,就会出现"最后一个元素永远处理不到"或者"循环多跑一轮读越界"这类问题。这类错误不是靠看一遍就能避免的,只能靠大量重复把它压进肌肉记忆里,这也是500题这个量级存在的意义。
1.2 题量怎么分配才算没白刷
一套500题的题库,数组部分通常不会平均撒胡椒面。按我自己的经验和带人的实际效果,比较合理的分配比例大致是这样:基础遍历与统计类占三成左右,双指针与原地变换占两成半,二维数组与坐标映射占一成半,排序查找结合类占两成,剩下的一成留给综合应用题。这个比例的逻辑是——遍历统计是地基,必须练到闭着眼睛都不会错;双指针是分水岭,决定你后面能不能顺利过渡到更复杂的算法;二维数组是空间感的训练;排序查找是让你习惯"先整理数据再处理"的思路。
| 题型板块 | 建议占比 | 核心训练目标 | 常见失分点 |
|---|---|---|---|
| 遍历、统计、极值 | 30% | 循环边界与累加器初始化 | 初值设错、末位漏处理 |
| 双指针、原地变换 | 25% | 下标协作与不变量维护 | 指针交叉条件写错 |
| 二维数组、坐标映射 | 15% | 行列关系与内存布局理解 | 行列下标写反 |
| 排序后处理、二分查找 | 20% | 有序性利用与区间收缩 | 区间开闭不一致 |
| 综合应用 | 10% | 多步骤组合与拆解能力 | 中间状态被污染 |
刷的时候有个很实用的做法:不要按题库顺序从头到尾平推,而是按板块集中打。同一个板块连着做二三十道,手感是叠加的;如果今天做一道遍历、明天做一道双指针,每次都要重新热身,效率低一半。我自己习惯是把一个板块的题先在纸上过一遍思路,只写关键循环,不写完整代码,筛出那些"想不清楚"的,再上机把它们完整敲一遍。这样一轮下来,真正需要动手的题目数量能压缩到原来的三分之一,但覆盖的知识点一个不少。
2. 三类必须练到条件反射的题型
2.1 遍历统计类:把累加器的初始化当回事
这类题目包括求和、求平均、找最大值最小值、统计满足条件的元素个数、判断是否存在某个值等等。看起来最简单,但恰恰是失分最集中的地方,原因几乎全部出在两个细节上:累加器初值,和循环边界。
求最大值的初值不能随手写0。如果数组全是负数,写0就会得到一个根本不存在的"最大值0"。正确做法是拿数组第一个元素当下标0的初值,然后从下标1开始比较。求和则相反,初值必须是0,而且要注意累加过程中可能溢出——如果题目给了元素范围到Integer.MAX_VALUE,那就得用long来接。这两个写法我在新人代码里见过太多次错误版本,每次都得强调一遍。
public static int maxOf(int[] nums) { if (nums == null || nums.length == 0) { throw new IllegalArgumentException("数组不能为空"); } int max = nums[0]; for (int i = 1; i < nums.length; i++) { if (nums[i] > max) { max = nums[i]; } } return max; } public static long sumOf(int[] nums) { long sum = 0L; for (int value : nums) { sum += value; } return sum; }注意max从nums[0]起步、循环从i = 1开始,这两处是配套的。如果循环从0开始也不会错,但多了一次无意义的自比较,更重要的是会让读者误以为初值可以是任意值。代码的可读性有一部分就藏在这种"看起来多余的严谨"里。
统计类题目还有一个高频变体:统计出现次数、找第一个满足条件的下标、找最后一个满足条件的下标。找第一个的时候,一旦命中直接return;找最后一个的时候,不能提前返回,得用一个变量记下来继续往后走。这两种写法在题库里会反复出现,练到不用想就能写出来的程度,后面做题会省很多脑力。
2.2 双指针与原地变换:先想清楚不变量再动手
双指针是数组部分真正的分水岭。它的核心不是"用两个变量",而是"这两个变量各自代表什么含义,并且在循环的每一步都必须保持不变"。这个"含义"就是所谓的不变量,想清楚了,代码几乎是自动流出来的;没想清楚,就会陷入反复调试下标的泥潭。
以最常见的"有序数组原地去重"为例,慢指针slow的含义是:区间[0, slow]内是已经去重完成的元素。快指针fast的职责是扫描剩余部分。每一步的逻辑就是:如果nums[fast]和nums[slow]不同,说明发现了一个新值,把slow前移一位并写入;如果相同,跳过。循环结束时,结果长度就是slow + 1。
public static int[] dedupSorted(int[] nums) { if (nums == null || nums.length == 0) { return new int[0]; } int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return Arrays.copyOf(nums, slow + 1); }这段代码里有三处值得琢磨。第一,fast从1开始而不是0,因为nums[0]天然属于去重后的第一个元素,不需要和自己比较。第二,slow++在赋值之前,因为slow当前指向的是最后一个已确认的位置,新元素要放在它的下一位。第三,返回值用Arrays.copyOf截断,而不是直接返回原数组,因为原数组尾部还残留着旧数据,直接返回会让调用方误以为整段都有效。
另一个经典是"移除指定值的所有元素",思路几乎一样,只是判断条件从"不同"换成"不等于目标值"。还有"移动零",本质是把非零元素往前压缩,再把剩余位置填零。这三道题其实共享同一套骨架,刷的时候可以刻意放在一起做,做完了自己总结一下它们的不变量分别是什么。这种"一题多变"的归纳,比多做二十道不相干的题有用得多。
2.3 二维数组:行列下标是最容易写反的地方
二维数组题在500题里占比不高,但翻车率很高,原因很直接——大部分人脑子里对"行"和"列"的直觉,和代码里array[i][j]的书写顺序是冲突的。日常说"第3行第5列",写代码时如果一顺手写成array[col][row],在方阵上跑起来可能不报错,只是结果全错,排查起来极其痛苦。
我的建议是固定一套命名习惯,从头到尾不改:外层循环变量永远叫row,内层永远叫col,访问永远是grid[row][col]。绝对不要用i、j这种没有语义的名字写二维数组,哪怕只写五六行。等你在调试时盯着grid[j][i]发懵的时候,就会明白多打几个字母是值得的。
二维数组的初始化也有坑。Java里int[][] grid = new int[3][4]会一次性把内层数组也分配好,每个元素默认0;但int[][] grid = new int[3][]只分配了外层,内层全是null,这时候直接访问grid[0][0]会抛空指针。题库里那种"按行输入、每行长度不同"的题目,就必须用第二种写法,先读长度再逐个new。这个区别在集合框架里对应的是new ArrayList<>()和new ArrayList<>(capacity),但二维数组的坑更隐蔽,因为编译期完全不报错。
3. 三道典型题的完整手写过程
3.1 轮转数组:三种解法的取舍逻辑
题目大意是把数组整体向右移动k位,超出末尾的元素绕回开头。这道题在500题里通常会被标注为中等难度,它的价值在于同一道题有三种差异很大的写法,正好用来训练"方案对比"的能力。
最直观的写法是开一个等长临时数组,按(i + k) % n的映射把每个元素搬到新位置,再复制回去。时间复杂度O(n),空间O(n),写起来几乎不会错,适合第一次接触时建立正确的心智模型。
第二种是三次翻转:先把整个数组翻转,再把前k个翻转,最后把后n-k个翻转。这个方法我第一次看到的时候觉得像变魔术,后来自己推了一遍才明白——翻转的本质是反转顺序,整体翻转把"后k个"送到了前面但顺序反了,再局部翻转两次刚好把两段的顺序各自修正回来。空间O(1),时间O(n)。
public static void rotate(int[] nums, int k) { int n = nums.length; if (n == 0) { return; } k %= n; if (k == 0) { return; } reverse(nums, 0, n - 1); reverse(nums, 0, k - 1); reverse(nums, k, n - 1); } private static void reverse(int[] arr, int left, int right) { while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }这段代码里k %= n这一行是必须的。如果k比n大,比如长度5、k等于7,实际效果等价于移动2位,不取模就会越界。很多人写测试用例时只测k小于n的情况,一提交就挂在这种地方。k == 0的提前返回则是为了处理k恰好是n倍数的情况,虽然不返回也能得到正确结果,但省掉三次无意义的翻转。
第三种是环状替换,从某个位置出发不断把元素送到目标位置,走一圈回到起点后换个起点继续。空间O(1),时间O(n),但写法最绕,需要额外的计数变量控制总替换次数。我一般建议先把前两种写熟,环状替换作为扩展了解就行,实际手写场景里用它的机会不多。
| 解法 | 时间 | 额外空间 | 代码复杂度 | 推荐场景 |
|---|---|---|---|---|
| 临时数组 | O(n) | O(n) | 低 | 首次理解映射关系 |
| 三次翻转 | O(n) | O(1) | 中 | 有空间约束时首选 |
| 环状替换 | O(n) | O(1) | 高 | 追求极致,需谨慎验证 |
3.2 杨辉三角:二维结构的增量构建
杨辉三角这道题看着是数学题,实际考的是"如何一边扩容一边读取已有数据"。核心规律是每行的首尾都是1,中间每个位置等于上一行相邻两数之和。写的时候最容易犯的错,是先算好整行再放进结果集,结果读取上一行时下标算错。
public static List<List<Integer>> generate(int numRows) { List<List<Integer>> result = new ArrayList<>(); for (int row = 0; row < numRows; row++) { List<Integer> current = new ArrayList<>(row + 1); for (int col = 0; col <= row; col++) { if (col == 0 || col == row) { current.add(1); } else { List<Integer> prev = result.get(row - 1); current.add(prev.get(col - 1) + prev.get(col)); } } result.add(current); } return result; }这里有个细节:result.get(row - 1)只在col不等于0和row时才会执行,所以第0行不会去取result.get(-1),逻辑上是安全的。但如果把取上一行的操作提到内层循环外面,虽然性能更好一点,代码反而会因为row == 0时的空指针需要额外判断。我倾向于在循环里取,用清晰换一点性能,因为这道题的规模本身很小。
如果题目要求用int[][]二维数组来返回,那就要先new int[numRows][],再逐行new int[row + 1],绝对不能一步new int[numRows][numRows]——那样得到的是方阵,每行长度都一样,不仅浪费空间,输出格式也不符合题目要求。这个坑我在批改别人代码时见过不止一次。
3.3 数组交集:从暴力到排序双指针的演进
求两个数组的交集,题目通常会附加"结果中每个元素唯一"或者"按出现次数取最小"的要求。前者对应普通的集合运算,后者对应计数映射。这道题的价值在于它清晰地展示了"用不用额外空间"带来的解法差异。
最暴力的写法是双层循环,外层遍历第一个数组,内层在第二个数组里找,找到就记录下来。时间复杂度O(m×n),数据量一大就超时,但作为对拍的基准解非常合适。
用HashSet的写法是先把一个数组全部塞进集合,再遍历另一个数组去查,时间O(m+n),空间O(m)。注意如果要处理重复元素,就得换成Map记次数,每命中一次把计数减一。
排序加双指针的写法空间O(1)(不考虑排序本身的开销),思路是两个指针分别扫两个有序数组,谁小谁往前,相等就记录并同时前移。这个思路在后面处理有序链表的合并、归并排序的合并阶段会反复用到,值得多写几遍。
public static int[] intersection(int[] a, int[] b) { Arrays.sort(a); Arrays.sort(b); List<Integer> temp = new ArrayList<>(); int i = 0, j = 0; while (i < a.length && j < b.length) { if (a[i] < b[j]) { i++; } else if (a[i] > b[j]) { j++; } else { if (temp.isEmpty() || temp.get(temp.size() - 1) != a[i]) { temp.add(a[i]); } i++; j++; } } int[] res = new int[temp.size()]; for (int k = 0; k < res.length; k++) { res[k] = temp.get(k); } return res; }temp.isEmpty() || temp.get(...) != a[i]这一句是去重的关键。因为两个数组都已经排好序,相等的元素一定连续出现,只要和上一次记录的值比一下就能过滤掉重复。如果不加这个判断,两个数组里都有多个相同值时,结果里就会出现重复项。
4. 边界条件清单:扣分集中在这一块
4.1 空数组、单元素和全相同元素
任何数组题在动手写之前,先把这三个用例在脑子里过一遍:长度为0、长度为1、所有元素相同。这三个用例覆盖了绝大多数边界逻辑漏洞。
长度为0时,nums[0]直接越界,所以任何访问首元素的地方都必须有前置判断。长度为1时,很多双指针的循环体一次都不会执行,返回值是否正确需要单独确认——比如去重题返回slow + 1,此时slow是0,返回长度1,正确;但如果写成返回slow,就变成0了。全相同元素则专门用来检验去重逻辑和指针移动条件,如果代码在某些分支下没有推进指针,就会死循环。
我习惯在写完一道题之后,立刻在纸上列这五行测试数据:[]、[7]、[1,1,1,1]、[1,2,3,4]、[4,3,2,1]。前三个测边界,后两个测正序和逆序。这五行能覆盖掉八成以上的逻辑错误,成本几乎为零。题库里那些标注"通过率低"的题目,很多时候不是算法难,而是测试用例里藏了这些边角情况。
4.2 索引越界的四种典型写法
索引越界是数组题的头号报错。我把它归纳成四种最常见的写法,每种都对应一个具体的思维误区。
第一种,循环条件写成i <= nums.length。这是把"长度"和"最大下标"混淆了,长度是n,最大下标是n-1。这个错误太常见,几乎每个初学者都会犯一次,之后就会形成条件反射。
第二种,在循环体内访问nums[i + 1]或nums[i - 1]却没有约束i的范围。比如比较相邻元素时写if (nums[i] > nums[i + 1]),循环条件却是i < nums.length,最后一轮必然越界。正确写法是循环到i < nums.length - 1。
第三种,双指针的移动顺序写反。比如快排分区里,先移动左指针再判断,可能导致左指针越过右指针后还在访问。凡是双指针,循环条件必须是left < right或left <= right,并且每次移动后都要重新检查。
第四种,取模运算前没处理负数或零。index = (i + k) % n在n为0时会抛除零异常,在k为负数时结果可能是负数,直接用这个负数当下标就炸了。凡是涉及取模的下标计算,前面都要加一句长度判空。
4.3 数值溢出:数组求和最容易忽略的雷
int的范围大约是正负21亿。如果数组长度是十万,每个元素接近一亿,求和结果就会溢出,而且不会报错,只会得到一个莫名其妙的负数。这种错误最恶心的地方在于——小数据量测试全过,一到真实数据就出错。
判断要不要用long的方法很简单:看题目给的元素范围。如果单个元素上限是10的9次方,数组长度上限是10的5次方,那乘积就是10的14次方,远超int上限,必须用long。这个估算过程在读完题之后花十秒钟做一下,能省掉后面半小时的排查。
还有一类隐蔽的溢出发生在"两数之和"这类题目的比较里。如果用a + b == target来判断,当a和b都很大时,加法本身就可能溢出变成负数,恰好等于一个负的target,产生误判。稳妥写法是移项成a == target - b,把加法改成减法,虽然减法也可能溢出,但配合范围约束通常更安全。更彻底的办法是全程用long计算再比较。
| 边界场景 | 典型表现 | 排查方法 |
|---|---|---|
| 空数组 | 访问首元素抛异常 | 方法入口统一判空 |
| 单元素 | 循环不执行,返回值为空或0 | 单独跑一次长度为1的用例 |
| 全相同元素 | 指针不推进导致死循环 | 加打印观察指针变化 |
| 下标偏移 | 循环条件用<= | 统一改成<并复查末位 |
| 数值溢出 | 结果为负或异常小 | 估算最大值,必要时换long |
5. 做完题之后怎么验证才算真的做对
5.1 用暴力解给优化解当裁判
刷题最怕的情况是"样例过了就以为对了"。真正的验证方法是写一个暴力解作为基准,然后随机生成大量测试数据,让两个解跑同样的输入,比对输出。这个方法在竞赛圈叫对拍,在日常练习里同样好用。
具体操作是:把暴力解和你的解各写成一个静态方法,主方法里循环生成随机数组,长度从0到20随机,元素值从负10到10随机,两个方法各跑一遍,结果不一致就把这组输入打印出来。跑上一千组,能揪出绝大多数边界问题。这个流程写一次可以用很久,后面的题目只需要换掉两个方法的实现就行。
public static void main(String[] args) { Random random = new Random(42); for (int round = 0; round < 1000; round++) { int len = random.nextInt(21); int[] arr = new int[len]; for (int i = 0; i < len; i++) { arr[i] = random.nextInt(21) - 10; } int[] copy = Arrays.copyOf(arr, arr.length); int expected = bruteForce(copy); int actual = fastSolution(arr); if (expected != actual) { System.out.println("不一致: " + Arrays.toString(arr)); System.out.println("期望 " + expected + " 实际 " + actual); return; } } System.out.println("全部通过"); }注意随机种子固定成42,这样每次跑的结果可复现,排查问题时不会因为随机数据变化而失去线索。另外传给两个方法的数组要各自复制一份,因为原地修改的解法会污染输入,导致第二个方法拿到的已经不是原始数据了——这个细节不注意,对拍结果会完全失真,而且很难想到原因。
5.2 错题本的记录维度比记录数量重要
光把错题抄一遍没什么用,真正有价值的是记录四个维度:错误类型、错误位置、当时的思路、正确思路的差异点。错误类型可以粗分为边界、逻辑、类型、复杂度四类,统计一段时间之后你会发现自己的薄弱环节高度集中在某一两类上,接下来就可以有针对性地补。
我自己记错题时还会多写一栏"下次怎么提前发现"。比如某道题是因为i和j写反导致的,那下次写二维数组前就先在注释里写一行// grid[row][col],把约定固定下来。这种"预防措施"比单纯记住答案有用得多,因为它改变的是你的书写习惯,而不是某一道题的解法。
错题复盘的时间间隔也有讲究。当天做完当天记,隔一天再看一遍,隔一周再重做一遍。三次之后还错的,说明不是粗心而是理解有偏差,得回去重新想清楚算法原理。这个节奏我在带人时试过,比重头刷一遍效率高不少,尤其适合500题这种大题库——你不可能每道题都刷三遍,但错题值得。
5.3 复杂度估算要在写代码之前做
数组题里大量存在"能用但超时"的写法。比如在一个循环里调用contains去查另一个集合,表面上是两行代码,实际复杂度可能已经到O(n²)了。题库通常会给数据规模提示,看到长度上限是10的5次方,就该立刻反应过来O(n²)会超时,必须往O(n log n)或者O(n)上想。
一个实用的判断口诀:n到10的5次方,允许O(n log n),尽量O(n);n到10的3次方,O(n²)可以接受;n到20以内,指数级和全排列都可以考虑。看到题目先扫一眼数据范围,再决定往哪个方向设计,比写完再优化省事得多。
另外,Java里有些操作的复杂度容易被低估。Arrays.sort对基本类型是双轴快排,平均O(n log n);String的+拼接在循环里是O(n²);ArrayList的contains是O(n)。把这些记清楚,写代码时就能下意识避开。
最后分享一个我自己用的小习惯:每道题写完之后,在方法上方加一行注释,写清楚时间复杂度和额外空间。这个动作只花几秒钟,但积攒到几百道题之后,你对自己的解法处于什么水平会有一个非常清晰的认知,复习时也不用重新推导一遍。数组这一段的题,说到底是拿最基础的工具练最核心的思维,题目本身不难,难的是把每一道都写扎实、把每一个边界都想全。