Java数组刷题指南:边界、双指针、二分与原地修改 数组这个章节在Java基础编程500题的整个体系里是我花时间最多、也最建议新手反复啃的一块。原因很直接你后面写业务代码、看框架源码、刷算法题绕不开数组。而Java基础编程500题里数组相关的题量之所以能堆到五百道量级不是因为出题人凑数而是数组这一个结构能延伸出的思维模型实在太多——下标控制、边界处理、原地修改、双指针、前缀和、二分、环形结构每一种都能单独拉出一串变式。我见过不少人集合框架用得挺熟一遇到把数组里的元素按规则挪一挪原地删除重复项在有序数组中二分找目标手就僵在那里写出来的代码要么越界要么死循环这就是数组基本功没打扎实的典型表现。这篇东西写给两类人。一类是刚学完 Java 语法、能写 for 循环但一写数组题就心虚的同学我会把Java基础编程500题中数组板块的题型分布、刷题顺序、每类题的解题骨架拆开讲你照着练就行。另一类是工作了一两年、日常 CRUD 写得飞起但算法思维生锈的朋友数组题是最便宜的思维复健素材一道题十分钟比看十篇设计模式文章管用。下面我从题型设计逻辑讲到具体代码实现再讲我自己踩过的坑和排查套路中间夹着参数推演和取舍分析尽量让每一段都能落地。1. 为什么把数组单独拆成 500 题来练1.1 数组是集合框架和一切数据结构的地基很多人学集合的时候觉得 ArrayList 和数组是两回事其实 ArrayList 的底层就是一个Object[] elementData你调用add、remove、get最终都是在对这个数组做下标操作、扩容、移位。你要是没自己手写过一遍数组满了怎么扩、中间删一个元素后面怎么挪那你对 ArrayList 的remove(int index)为什么是 O(n) 就只能靠背。背下来的东西面试完就忘自己写过一遍的东西能记一辈子。这也是Java基础编程500题把数组放在靠前位置的核心考量后面的链表、栈、队列、哈希表全都在数组这个地基上长出来。再往深一层说数组训练的是下标即状态的思维。你写循环的时候脑子里要同时跟踪三个东西当前下标在哪、合法下标区间是什么、循环不变量是什么。这个能力一旦建立你写任何遍历逻辑都会稳。反过来很多人写代码出 bug不是因为不会用 API而是因为脑子里没建立这个不变量的概念边界稍微一动就崩。我的建议很明确数组这 500 题不要当考试题做要当手感训练做。做的时候别急着看答案先逼自己把边界情况列出来——空数组、单元素、全相同、全递增、全递减、含负数这六种跑一遍很多低级错误当场就暴露了。1.2 500 题的量是怎么分布的四象限拆解法如果只是盲目从第一题刷到第五百题效率会非常低因为你会在同一类思维上反复消耗。我在实际整理的时候习惯按两个维度给题打标签一个是思维模型下标/双指针/二分/前缀和/原地修改/二维/环形另一个是难度梯度模板题/变形题/综合题。这样交叉起来就形成一个四象限结构刷的时候按象限推进比按题号推进快得多。下面这张表是我自己用的分类参考你可以拿它给自己的题库做归类也可以直接按这个比例分配时间题型分类占比参考典型特征建议投入时间下标与边界基础约 25%单层/双层遍历、越界判断、空值处理2 天快速过双指针与滑动窗口约 20%左右指针、快慢指针、窗口收缩1 周重点二分查找变体约 15%有序、旋转、找边界值1 周重点前缀和与差分约 10%区间求和、区间更新3 天原地修改与去重约 15%空间 O(1)、元素搬移4 天重点二维数组与矩阵约 10%行列遍历、旋转、螺旋3 天环形与循环数组约 5%取模、循环移位1 天点到为止这个比例不是拍脑袋定的。下标基础类的题量大但思维单一属于热身建立肌肉记忆不该拖太久双指针和二分这两块是真正的思维分水岭投入时间要够原地修改类题目最能训练空间换时间的取舍意识很多人第一次写原地删除会本能地想新建数组这就是思维没转过来。1.3 刷题顺序为什么要打乱而不是从易到难从易到难刷听起来合理实际上有个隐患你会一直在舒适区里获得正反馈等到突然遇见难题前面攒的自信一崩容易直接放弃。更麻烦的是同一类题型连续刷二十道你会形成路径依赖——看到题目就知道是双指针反而失去了判断该用什么方法的训练机会。而真实场景和面试里最难的恰恰是第一步判断。我自己的做法是同类三连 交叉一道一类题型连续做三道把模板固化下来然后插一道完全无关的题型强制自己重新做方法判断。这样刷下来手感更接近真实答题状态。另外每做完一道题花三十秒写一句话这道题的核心是什么方法、关键边界是什么。不写下来的题等于没做。2. 各类题型的解题骨架与关键实现2.1 下标与边界绝大多数数组 bug 都出在这里先说个数据支撑我的观点在我自己记录过的数组类错题里超过七成是边界问题而不是算法错误。典型的三种表现——循环条件写成i arr.length导致越界while (left right)和while (left right)混用导致漏掉最后一个元素或死循环空数组没有提前返回导致arr[0]直接抛异常。处理这类问题的办法不是更小心而是建立固定套路。我自己的习惯是每个方法开头先写三行防御public int findSomething(int[] nums, int target) { if (nums null || nums.length 0) return -1; // 空值防御 int left 0, right nums.length - 1; // 闭区间写法 // ... return -1; }这三行的价值在于它把一个可能到处爆的地方收敛成了一个明确的出口。写完这三行后面你就可以放心假设数组非空。第二种套路是统一区间风格。数组题的区间有两种主流写法左闭右闭[left, right]和左闭右开[left, right)。这两种写法本身没有优劣但你不能在同一个方法里混用混用就是死循环和漏元素的源头。我个人的偏好是全程闭区间因为right nums.length - 1这个初始值直觉上更好理解while (left right)也读起来顺。选定一种然后所有循环条件、所有right的更新right mid - 1还是right mid都跟着这个风格走别中途变卦。提示判断循环该不该带等号有个土办法——问自己区间里只剩一个元素时这个元素还需不需要被处理。需要就用不需要就用。这个问题问清楚比死记硬背快得多。2.2 双指针与滑动窗口先想清楚指针代表什么双指针这类题新手最容易犯的错是知道要用双指针但说不清两个指针各自代表什么语义。指针的语义不清楚移动规则就只能靠猜猜错了就是答案错。所以我做这类题的第一步永远是写一句注释left 指向窗口内第一个元素right 指向窗口外第一个元素。语义定死了移动规则自然就出来了。滑动窗口的骨架其实很固定就四步int left 0, sum 0, ans 0; for (int right 0; right nums.length; right) { sum nums[right]; // 1. 右指针入窗 while (sum target) { // 2. 判断窗口是否违规 sum - nums[left]; // 3. 左指针出窗 left; } ans Math.max(ans, right - left 1); // 4. 更新答案 }这个骨架的关键点在第二步那个while。用while而不是if是因为右指针进来一个元素后可能需要连续收缩很多次才能重新满足条件用if只收缩一次结果就是错的。这个细节在Java基础编程500题的窗口类题目里反复出现我当初就是在这上面栽过两次。另外滑动窗口有一个隐含前提需要提前验证窗口扩大时判据必须是单调的。比如和不超过 target窗口越长和越大单调成立可以用滑动窗口但如果是窗口内最大值与最小值之差不超过 k窗口扩大时差值也可能变小新进来的值落在中间不单调滑动窗口就不能直接用了得换成别的结构。这个前提很多人不验证就直接套模板结果在特定用例上翻车。2.3 前缀和与差分把 O(n²) 压成 O(n) 的两种思路前缀和解决的是区间查询多、单点更新少的场景。定义pre[i]表示前 i 个元素之和那么任意区间[l, r]的和就是pre[r1] - pre[l]单次查询 O(1)。构建前缀和本身是 O(n)所以当查询次数 m 远大于 1 时总复杂度从 O(n·m) 降到 O(n m)收益非常明显。差分是它的镜像解决的是区间更新多、单点查询少。你想对[l, r]区间整体加 v不需要循环加只需要diff[l] v; diff[r1] - v;最后对差分数组做一次前缀和还原即可。单次区间更新的复杂度从 O(n) 降到 O(1)。// 差分对 [l, r] 区间整体加 v int[] diff new int[n 1]; diff[l] v; diff[r 1] - v; // 注意 r 1 可能等于 n所以数组要开 n 1 // 还原 int cur 0; for (int i 0; i n; i) { cur diff[i]; nums[i] cur; }这两者最容易出错的地方不是公式而是下标偏移。前缀和数组长度通常开n 1pre[0] 0这个虚拟位置是为了让pre[r1] - pre[l]在l 0时也能统一处理不用写特判。你要是不开这个位置就得在l 0时单独返回pre[r]代码立刻变丑还容易漏。差分同理右侧边界要开n 1不然r n - 1时diff[r1]直接越界。这两个多开一个位置的习惯我建议从第一天就养成。2.4 二分查找三种边界写法只选一种然后死记二分查找是数组板块里最容易以为自己会了的题型。基本形态人人都会难的是找第一个大于等于目标的位置最后一个小于等于目标的位置这类边界变体。这类变体之所以难是因为每次判断之后指针该怎么动取决于你要找的是哪条边界脑子一乱就写反。我的做法是把二分归成三种固定模板用到的时候直接套目标循环条件命中后动作返回精确查找left rightreturn mid-1找左边界第一个 targetleft rightright midleft找右边界最后一个 targetleft rightleft mid 1left - 1左边界模板里mid的取法必须是left (right - left) / 2也就是向下取整否则在right left 1时会死循环。右边界模板恰相反mid得向上取整left (right - left 1) / 2不然同样卡死。这个取整方向的坑我在Java基础编程500题的二分变体里踩过至少三次每次都是写完跑一遍小样例才发现卡住了。顺带说一句Java 标准库其实提供了Arrays.binarySearch但它有两个限制一是数组必须已排序二是返回值语义特殊——找到返回下标找不到返回-(插入点) - 1。这个负数减一的设计是为了区分没找到和在 0 位置插入但如果你不熟悉直接拿返回值当下标用就会抛异常。我一般的建议是刷题阶段老老实实手写二分把边界感练出来工程里需要快速查一次再考虑用库函数。2.5 原地修改类题目空间复杂度 O(1) 的取舍逻辑原地修改是Java基础编程500题数组板块里我认为最有价值的一类。它的核心思路是用数组自己记录状态——把已经处理好的部分放在数组前段用一个指针指向下一个待写入位置然后遍历后半段符合条件就往前面写。典型的就是原地删除重复项public int removeDuplicates(int[] nums) { if (nums.length 0) return 0; int slow 0; // 指向已处理区间的最后一个位置 for (int fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; // 先挪位置 nums[slow] nums[fast]; // 再写入 } } return slow 1; // 返回新长度 }这段代码里slow和fast的先后顺序很容易写反。我当初就写成过nums[slow] nums[fast]; slow;结果第一次比较时slow还在 0、fast在 1直接把第一个元素覆盖掉了。正确的理解是slow始终指向已确认保留的最后一个元素所以写入前必须先slow把位置让出来。这类题目值不值得花时间我持明确态度值得。因为它训练的是把输入空间当输出空间用的意识这个意识在真实工程里非常有用——比如你需要在一个大数组上做筛选新建一个数组意味着双倍内存如果数据量大到几十兆原地筛就是唯一选择。当然原地修改的代价是会破坏原数组调用方如果再需要原始数据就麻烦了。所以取舍的判据很清楚调用方是否需要原数据。需要就新建不需要就原地。别为了秀操作把不该改的数据改了那是给自己挖坑。3. 核心环节的实操与参数推演3.1 手写一个动态数组扩容因子为什么是 1.5想真正理解数组最有效的一步是自己手写一个简易动态数组内部维护Object[] data和int size写add、get、remove三个方法把扩容和移位都实现一遍。这活儿看着像造轮子但做完你对集合框架的理解会上一个台阶。核心是扩容策略。假设当前容量为cap扩容到newCap一次扩容需要拷贝cap个元素成本是 O(cap)。如果我们按每次加固定数量扩容比如每次加 10那么插入 n 个元素总共要拷贝约 n²/20 次整体退化成 O(n²)慢得离谱。如果按倍数扩容拷贝总量是几何级数均摊到每次插入就是 O(1)。那倍数取多少取 2 和取 1.5 的差别在内存利用率上。直观想一下容量从 8 扩到 16 时那 8 个位置的空闲块被释放但下一次从 16 扩到 32 时需要连续 32 的空间之前释放的 8 太小用不上。如果用 1.5 倍8 → 12 → 18 → 27前几次释放的空间8、12、18累加起来共 38足以容纳下一次需要的 27有机会复用。所以 1.5 倍在长期运行下内存碎片更友好而 2 倍胜在扩容次数少、单次拷贝稍快。业界主流实现选 1.5 倍的居多这个取舍你可以有自己的判断但得知道背后在权衡什么。扩容代码本身很简单关键是用对拷贝方法private void grow(int minCapacity) { int oldCap data.length; int newCap oldCap (oldCap 1); // 1.5 倍 if (newCap minCapacity) newCap minCapacity; Object[] newData new Object[newCap]; System.arraycopy(data, 0, newData, 0, size); data newData; }这里用System.arraycopy而不是 for 循环是因为它是 native 方法底层按内存块批量搬运实测在几千到几十万元素规模下比循环快好几倍。中间删元素时的移位同理也是数组拷贝写成循环纯属自找麻烦。3.2 二维数组的内存布局为什么行列遍历顺序会影响性能写二维数组的时候很多人默认它在内存里是一整块连续的行列矩形这个印象在 Java 里是错的。Java 的int[][]本质是数组的数组外层数组的每个元素是一个引用指向一个独立的一维数组对象。也就是说第 0 行和第 1 行在堆里可能是天南地北两块内存不保证相邻。这带来两个实际影响。第一int[][] a new int[3][];是合法的你可以先定行数后续再给每行分配不同长度就是锯齿数组。第二遍历顺序会影响缓存命中按行优先外层行、内层列遍历时访问的是连续的一维数组缓存友好按列优先遍历时每取一次都要跨到另一个对象缓存命中率明显下降。数据量小的时候感觉不出来等到几百万个元素时差距就明显了。// 行优先缓存友好 for (int i 0; i rows; i) for (int j 0; j cols; j) sum grid[i][j]; // 列优先逻辑没错但缓存命中差 for (int j 0; j cols; j) for (int i 0; i rows; i) sum grid[i][j];还有一个常见坑new int[5][4]建出来是 5 个长度为 4 的数组总共 5 个对象外层 1 个 内层 5 个。如果你只是想用一个 20 元素的一维序列却开了二维那对象头开销和引用开销都是白给的。反过来说如果维度是 10000 × 10000二维数组的对象数量就是 10001 个这时候内存布局的影响就不能忽视了。3.3 排序与去重的组合套路对比数组题里排序 去重 找答案是个高频组合。光去重就有好几种写法各有各的适用场景选错会白白多花时间或者多占空间。下面这张对比表是我自己在整理题型时列的方案时间复杂度额外空间是否保序适用场景先排序后双指针去重O(n log n)O(1)否不要求原顺序、数据量大HashSet 去重O(n)O(n)否只要求唯一性内存够LinkedHashSetO(n)O(n)是要保留首次出现顺序双重循环暴力O(n²)O(1)是仅用于小数据验证选的时候先问自己三个问题原数组顺序重要吗内存紧张吗数据规模多大顺序重要且规模不大用 LinkedHashSet 最省事顺序不重要且规模大排序加双指针最划算。我见过有人对一个百万级数组用双重循环去重跑了几十秒还没出来这就是没做方案判断的代价。另外提一下Arrays.sort的一个细节对基本类型数组int[]它用的是双轴快速排序不稳定对对象数组Integer[]用的是 TimSort稳定。所以如果你需要排序后相同元素的相对顺序不变就必须把数组装箱成对象数组代价是拆装箱开销和内存膨胀。这个取舍在做题时可以不考虑但在业务里遇到按价格排序、同价按上架时间排序这类需求就得知道应该用对象数组配合比较器而不是对基本类型数组排两次。3.4 环形数组取模的正确姿势与负下标陷阱环形数组的题目量不大但坑很集中。核心操作是从当前位置往前走 k 步绕回开头标准写法是(i k) % n。这个式子在i k非负时完全没问题一旦涉及反向移动比如往回退 k 步写成(i - k) % n就会返回负数然后你拿这个负数去当下标直接抛异常。正确的写法是加一个补偿int prev ((i - k) % n n) % n;先取模再加n再取模保证结果落在[0, n-1]。这个加 n 再模 n的模式我建议直接背下来凡是涉及负数取模的下标计算都用它。环形数组还有一个经典用途用固定大小的数组实现队列。维护head、tail两个指针入队时tail (tail 1) % capacity出队时head (head 1) % capacity。判断空和满需要额外手段因为head tail同时表示空和满。两种常见解法一是留一个空位满的条件是(tail 1) % capacity head二是额外维护一个size变量。前者省一个变量但要浪费一个槽位后者逻辑更直白。我在实际写的时候倾向后者因为省那一个槽位意义不大而少一个边界判断能省不少调试时间。4. 常见坑与排查实录4.1 空指针、零长度与看起来不可能的输入越界和空指针这两类问题我在Java基础编程500题的数组部分遇到过太多次总结下来有几个高频触发点。第一个是nums.length - 1当nums长度为 0 时它等于 -1如果后面紧跟着nums[right]那就是ArrayIndexOutOfBoundsException。第二个是方法参数传了null方法内部没判空直接取length抛NullPointerException。第三个是二维数组只判了外层非空没判内层某一行是null。排查这类问题的效率技巧不要靠读代码找直接在方法入口加一行断言打印长度跑几个极端输入空、单元素、两元素问题通常当场现形。我自己的习惯是写完任何一个数组方法立刻用下面这组输入跑一遍// 极端输入自测清单 test(new int[]{}); // 空数组 test(new int[]{1}); // 单元素 test(new int[]{1, 1}); // 全相同 test(new int[]{1, 2}); // 递增两元素 test(new int[]{2, 1}); // 递减两元素 test(new int[]{-1, 0, 1}); // 含负数这六组跑完绝大多数边界 bug 都藏不住。这个习惯我从刷题带到工作里收益极大。4.2 整数溢出那个无声的负数数组题里求和是最容易溢出的操作。int的上限约 21 亿如果一个数组有几亿个元素、或者元素本身接近Integer.MAX_VALUE累加就会悄悄变成负数——不抛异常不报错结果就是错的。这种 bug 最恶心的地方在于它看起来很对你不主动怀疑根本发现不了。防护办法两条一是预判规模数组长度乘以元素最大值接近int上限就换成long累加二是拿不准就一律用long多占 4 字节换正确性非常划算。long sum 0; // 累加器用 long for (int x : nums) sum x; // int 自动提升为 long安全还有一个隐蔽的坑是加法溢出后参与比较。比如判断sum target如果sum已经溢出成负数判断结果就是错的逻辑直接走偏。这类问题在滑动窗口、前缀和的题目里出现过。另外注意mid (left right) / 2这个经典写法当left right超过int上限时会溢出成负数mid变成负下标。标准写法是left (right - left) / 2先减再加中间值不会超过right。这个细节在数组长度上亿时才暴露但既然知道有坑没理由不写对。4.3 那些看起来能用其实不能用的 APIArrays.asList是我见过的坑王。它返回的并不是java.util.ArrayList而是Arrays内部的一个私有静态类长度固定你调add或remove会直接抛UnsupportedOperationException。更坑的是如果你传的是基本类型数组int[] arr {1, 2, 3}; List list Arrays.asList(arr); // 注意不是 ListInteger System.out.println(list.size()); // 输出 1不是 3因为Arrays.asList的形参是T...int[]作为一个整体被当成单个对象塞进了一个长度为 1 的数组所以size()是 1元素是那个int[]本身。要拿到真正的ListInteger必须先把数组装箱成Integer[]。这个坑我在早期项目里真踩过排查了半小时才反应过来。其他几个值得记一下的Arrays.fill(arr, value)只填一维二维数组要用Arrays.fill逐行填或者Arrays.setAllSystem.arraycopy的同源重叠拷贝是安全的内部按方向处理但手写循环做重叠拷贝就会踩数据覆盖Arrays.toString打二维数组会输出一串地址哈希要看内容得用Arrays.deepToString。4.4 时间超限时的定位顺序题目逻辑看着对但一提交就超时这时候别乱改按固定顺序排查效率最高。我的排查顺序是先估复杂度再看有没有隐性 O(n) 操作藏在循环里最后才考虑换算法。第一层估复杂度。看一眼数据规模n 是 10³ 还是 10⁵10³ 允许 O(n²)10⁵ 基本只能 O(n log n) 或 O(n)。很多超限的原因是算法选错了档位比如在 10⁵ 的规模上写了双重循环那是注定过不去的改代码细节没用。第二层找隐性操作。有些操作看着是 O(1)其实是 O(n)。比如在循环里用String拼接每次创建新对象并复制、在循环里调list.contains()线性查找、在循环里调list.remove(int)后面的元素要整体移位。这类问题最容易被忽略因为代码看起来干净漂亮但复杂度已经悄悄翻了几倍。第三层才是换数据结构或换算法。比如把contains换HashSet把remove换双指针原地覆盖把嵌套循环换前缀和。到了这一步基本就是用对方法的层面了前面两层没排除掉之前别急着大改。4.5 常见问题速查表把上面这些坑和我自己记录过的错题整理成一张表做题卡住时可以直接对号入座现象最可能的原因处理办法抛ArrayIndexOutOfBoundsException循环条件带等号但边界算错 / 长度为 0 未拦入口加长度判空检查和抛NullPointerException参数未判空 / 二维内层为 null逐层判空或用防御性拷贝死循环不退出二分 mid 取整方向错 / 指针未移动检查left、right每次是否真的变化结果少了最后一个元素区间风格混用 / 循环条件用了统一闭区间风格求和结果是负数int 溢出累加器换long提交超时循环里有隐性 O(n) 操作按复杂度→隐性开销→换算法三层排查UnsupportedOperationException用了Arrays.asList返回值做增删包一层new ArrayList(...)二维数组打印是乱码用了Arrays.toString换Arrays.deepToString去重后顺序全乱用了排序或HashSet换LinkedHashSet或双指针保序方案5. 训练节奏与复盘方法5.1 错题本只记三个字段刷 500 题不记错题等于白刷一半。但错题本千万别搞复杂我试过记详细解析坚持不到一周就放弃了。后来简化成三个字段反而坚持下来了一句话题目特征、错在哪个环节、下次怎么避免。第一个字段是特征用来建立识别能力。写有序数组 找边界 → 二分左边界模板比抄一遍题干有用得多。第二个字段是错因只写关键那一点比如slow 和 fast 顺序写反覆盖了首元素。第三个字段是对策写成一个可执行的检查动作比如原地修改类题先确认 slow 的语义再动笔。三个字段加起来不超过五十字一道题三十秒能记完。等积累到三十条左右你会发现错因高度重复就那么四五个类型。把这几个类型单独拎出来强化练一轮正确率会有肉眼可见的提升。5.2 从数组到集合、再到算法的过渡路线数组这 500 题刷到什么程度算过关我给的标准是看到一道中等难度的数组题能在三分钟内说清楚用什么方法、关键边界是什么、时间复杂度多少然后二十分钟内写出能通过所有用例的代码。达到这个水平就可以往外扩了。扩展路线我建议这样走数组 → 链表同样是线性结构但指针操作取代了下标训练引用思维→ 栈和队列用数组实现一遍理解它们其实就是受限的数组→ 哈希表理解它底层仍然是数组 冲突处理→ 树和图的遍历训练递归和状态回溯。这条线的每一步都站在上一步的基础上不会断层。最后分享一个我自己一直在用的练习方式每周挑一道数组题用三种不同思路实现一遍然后对比复杂度。比如两数之和可以用双重循环 O(n²)、排序加双指针 O(n log n)、哈希表 O(n) 三种写法。三种都跑通之后你对为什么选这个方案的理解会完全不一样——这种理解看别人题解是看不来的必须自己写一遍才长在身上。数组这块内容说白了就是靠这个笨办法一点点磨出来的没有捷径但磨过之后特别扎实。