1. 数组遍历这件事,为什么值得单独拎出来讲
我经常在很多技术社区看到有人问"数组遍历不就一个for循环搞定吗?"——说实话,我在刚学Java那会儿也是这么想的,直到某天在某个面试复盘里被问到"遍历数组的几种方式以及它们之间的差异",一时间竟只答上来两三种,这才意识到,数组遍历虽然看起来简单,背后的门道远比想象中多。
数组作为最基础的数据结构,是算法题里出现频率最高的一种载体。无论是排序、查找、去重、滑动窗口,还是双指针法、前缀和、差分数组,这些算法本质上都是在做一件事:按某种规律遍历数组,并在遍历过程中维护或更新状态。所以遍历方式选得好不好,直接影响代码的可读性、性能,甚至直接决定一个算法能不能跑通。
Java里遍历数组的手段非常丰富:基础的for循环、增强for、while和do-while,进阶的Arrays工具类方法、Stream流式操作、递归、双指针、以及针对多维数组的各种遍历模式。很多人只停留在"会用for"的阶段,碰到"删除数组中的某个元素""倒序遍历二维数组""统计数组子数组个数"这类场景时,要么代码写得又臭又长,要么频繁踩边界条件的坑。
这篇文章我准备按"从基础到特殊、从原理到实战、从代码到性能"这条线,把Java数组遍历这件事拆开揉碎讲清楚。适合刚接触Java和算法的同学系统入门,也适合写过一段时间代码、想补齐自身盲区的开发者。文章里的代码我都实测跑过,每个结论背后都有对应的JDK源码或JVM行为逻辑,不是拍脑袋写出来的,放心参考。
2. 先搞清楚数组的底层结构:遍历速度和"访问方式"强相关
2.1 连续内存与O(1)随机访问
Java里的数组在内存中分配的是一段连续地址空间,存储的是同一类型的元素。数组在JVM中有一个对象头,紧接着就是连续的元素存储区域。正因为连续存储,访问arr[i]时JVM可以直接通过"首地址 + i × 每个元素占用的字节数"这种偏移计算来定位元素,时间复杂度是O(1)。这也是为什么遍历数组比遍历链表快得多——链表的每个节点散落在堆的不同位置,访问下一个节点必须依赖指针跳转,缓存命中率差,性能自然上不去。
这段连续内存的特性,直接决定了数组遍历时的最优策略。尽可能按索引从小到大顺序访问,这个遍历顺序正好贴合CPU缓存的预取机制,性能表现最好。如果刻意打乱访问顺序(比如每次访问数组的随机位置),虽然单次访问依然是O(1),但频繁的缓存缺失会让整体耗时呈数量级上升。这一点后面做性能实测时还会再次验证。
2.2 数组长度:属性而非方法
Java里获取数组长度用的是arr.length,注意这不是方法调用,后面没有小括号。这是很多新手容易弄错的地方。String.length()是方法,List.size()是方法,而数组的length是公开属性。因为数组是JVM直接支持的对象形态,length存在对象头里,取值几乎零开销。遍历时把arr.length提取到局部变量,本质上不是为了省那几次属性访问的开销——JIT编译器会自动做这个优化——但对于规范化代码来说,能减少每轮循环的多余操作,建议养成习惯。
2.3 数组的三种创建方式与默认值问题
写遍历代码之前,先确认一下数组是怎么创建的,因为不同方式产生的初始内容差异很大:
int[] arr = new int[5]:创建长度为5的数组,所有元素默认是0。int[] arr = {1, 2, 3, 4, 5}:静态初始化,直接指定元素内容。int[] arr = new int[]{1, 2, 3, 4, 5}:等价于上面的写法,只是更显式。
对于引用类型数组,比如String[] arr = new String[5],此时所有元素默认为null。所以遍历之前最好先判断是否为null,否则直接调用arr[i].length()会抛NullPointerException。这个坑在算法题里很隐蔽,尤其是从外部传入的数组,最好养成"先用Arrays.toString输出看一眼"的习惯。
3. 基础遍历全家桶:for、增强for、while、do-while的适用边界
3.1 传统for循环:精确控制的王道
传统for循环是遍历数组的"默认选项",也是各种算法题中出镜率最高的写法:
int[] arr = {1, 2, 3, 4, 5}; for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); }这种写法的优势在于你同时拿到了三样东西:索引i、元素arr[i]、步长控制。这意味着你可以做很多事情:
- 从中间某个位置开始遍历:
for (int i = start; i < end; i++) - 奇数位或偶数位遍历:
for (int i = 0; i < arr.length; i += 2) - 倒序遍历:
for (int i = arr.length - 1; i >= 0; i--) - 在遍历过程中对元素重新赋值:
arr[i] = arr[i] * 2 - 结合另一个数组做双数组同步遍历:
arr1[i] + arr2[i]
在算法竞赛和面试场景里,常规题解大多采用这种写法,因为它最直白,逻辑也最容易维护。我把这归为"默认选项"——当你没有明确的理由去用其他遍历方式时,直接选传统for循环,基本不会错。
3.2 增强for:简洁但拿不到索引
增强for(也叫foreach)在遍历集合和数组时都非常常用:
for (int num : arr) { System.out.println(num); }语法上简洁得令人舒适,不需要关心索引,也不容易写出越界bug。但有两个限制需要心里有数:
- 拿不到当前元素的下标。如果遍历过程中需要知道当前是第几个元素,就得额外定义一个计数器变量,这反而失去了简洁优势。
- 遍历过程中的赋值操作,无法修改原数组的元素值。因为
num只是从数组里复制出来的一个副本,你对它赋值不会写回数组。如果试图修改原数组元素,还是得回到传统for或使用Arrays.setAll这类方法。
从字节码层面来说,数组的增强for在编译后其实会被还原成类似传统for的索引遍历逻辑,性能上几乎可以画等号,这一点和集合类的增强for(依赖Iterator迭代器)有本质区别。这个差异在后面的性能实测部分会重点展开。
3.3 while与do-while:看起来冷门但有时很香
大部分开发者遍历数组很少用while,但在两类场景里,while比for更自然。
第一类是事先不确定结束位置的遍历,典型如二分查找:
int left = 0, right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } }这里循环的每一轮都会根据条件缩小搜索区间,而不是固定步长地移动索引,用for循环写起来反而别扭。while循环更关注"区间"的变化,而不是"计数器"的累加。
第二类是需要至少执行一次的场景。比如循环滚动播放的模拟逻辑,或者从某个起点反复跳转直到命中条件,用do-while可以避免先判断再执行的空转:
int[] arr = {3, 1, 4, 1, 5, 9}; do { System.out.println(arr[i]); i = (i + step) % arr.length; } while (i != startIndex);do-while保证循环体至少执行一次,这在模拟"下一步跳到哪"的算法里(比如约瑟夫环、环形数组)非常实用。现实中使用频率不高,但面试时能脱口而出这个细节,会显得功底扎实。
3.4 遍历方式的选择建议
我根据自己的项目经验和阅读源码的习惯,整理了一张选型表,方便在不同场景快速决定用哪种遍历:
| 场景 | 推荐遍历方式 | 理由 |
|---|---|---|
| 常规遍历,需要修改元素 | for + 索引 | 直接通过索引赋值 |
| 只需要读元素,不关心下标 | foreach | 简洁清晰,不易出错 |
| 二分查找/区间收缩 | while + 左右指针 | 关注区间而非固定步长 |
| 环形结构模拟/至少执行一次 | do-while | 保证首次进入逻辑 |
| 多数组同步操作 | for + 统一索引 | 保证每个数组位置对应 |
| 海量数据且可并行 | 后续会提到Stream并行流 | 利用多核CPU |
4. 特殊遍历方法:Arrays工具类、Stream流式、递归与双指针
4.1 Arrays工具类的一行流遍历
很多人写了很久Java,却不知道java.util.Arrays里藏着很多"半遍历"方法。这些方法不需要你手动写循环,但内部确实对数组做了遍历。
第一个是打印数组内容:Arrays.toString(arr)能直接用一行代码输出数组内容,返回的是带中括号和逗号的字符串。而二维数组对应的是Arrays.deepToString(arr),可以递归输出所有嵌套层。这个在刷题调试时极其好用。
第二个是批量填充:Arrays.fill(arr, 100)会把数组所有元素设成100。二维数组也是类似的思路:先逐行Arrays.fill(row, 0),或者双层循环手动赋值。fill内部就是一段线性遍历,源码非常直白。
第三个是排序:Arrays.sort(arr)底层用了Dual-Pivot Quicksort(针对基本类型)或TimSort(针对引用类型)。它内部包含对整个数组的遍历、比较、交换,如果你要在数组中找最大/最小元素,先排序再取首尾值,代码最简洁:
Arrays.sort(arr); int min = arr[0]; // 最小值 int max = arr[arr.length - 1]; // 最大值但注意排序是O(n log n),如果只需要找一次最大最小值,直接遍历数组的O(n)写法其实更快。只有当你需要反复查询第K大、中位数或区间统计时,排序才划算。
第四个是二分查找:Arrays.binarySearch(arr, target)要求数组已排序。找到返回索引,找不到返回负数(具体是-(插入点) - 1)。这个负数的语义很多人记不清,实际使用中建议判断结果>= 0即可,不需要深究具体负值。
4.2 Stream流式遍历:函数式风格的进阶玩法
Java 8引入Stream API后,数组遍历多了一种完全不同的选择:
int[] arr = {1, 2, 3, 4, 5}; // 求和 int sum = Arrays.stream(arr).sum(); // 最大值 int max = Arrays.stream(arr).max().getAsInt(); // 过滤并输出 Arrays.stream(arr) .filter(n -> n % 2 == 0) .forEach(System.out::println); // 并行流 int sumPar = Arrays.stream(arr).parallel().sum();这里最关键的一个选择是:对于基本类型数组,不要让Arrays.stream(arr)泛化成Stream<Integer>,因为那会触发自动装箱,每个元素都要包装成Integer对象,带来极大的性能损耗,甚至可能让程序慢一个数量级。幸好Arrays.stream对基本类型数组有专门的重载:int[]返回IntStream,long[]返回LongStream,double[]返回DoubleStream,这三个都是基本类型特化流,内部直接基于原始数组操作,没有装箱开销。
Stream还提供了很多花式操作,比如map转换元素、distinct去重、sorted排序、limit截断、reduce归约。组合起来可以取代大量手写循环,例如求数组中所有偶数的平方和:
int result = Arrays.stream(arr) .filter(n -> n % 2 == 0) .map(n -> n * n) .sum();对于处理二维数组,还可以通过flatMap把多维"摊平"成一维再遍历。不过用Stream写算法题有时反而绕,中间操作链很长时,调试困难,而且stream的中间操作(filter/map)需要一个元素一个元素地经过整个管道,某些场景下性能不如手写循环里的单遍处理。我的建议是:数据处理、日志统计、简单的转换和聚合,优先用Stream;算法题追求逻辑直白和性能可控,还是手写循环更稳妥。
4.3 递归遍历:循环之外的优雅解
数组遍历不一定要用循环,递归也能做到,只是在大多数场景下递归的开销更大——每次递归调用都会压栈。但有些问题用循环写很麻烦,用递归反而清晰,典型的是求子集、排列组合、全排列这类回溯算法。
举一个最基础的例子,递归遍历一维数组并输出:
public static void traverse(int[] arr, int i) { if (i >= arr.length) { return; } System.out.println(arr[i]); traverse(arr, i + 1); }虽然这是"用递归做循环",实际工程中没有任何性能优势,但它说明了递归处理数组时的基本范式:先检查出口条件,再处理当前元素,最后递归处理剩余部分。这个范式延伸出去,就是二叉树遍历、全排列、子集生成等算法的内核。
在算法题中,递归遍历数组更常见的形态是基于索引切分。比如快速排序,每次partition后递归处理左右两段子数组;归并排序则是递归地将数组二分后再合并。这些算法本质上依然是在"遍历"数组,只是遍历的粒度变成了子区间,递归是天然契合这种分治思想的写法。
需要注意递归深度的问题。一维数组长度上万时,递归遍历就可能触发StackOverflowError。所以大规模数据还是要回到迭代遍历或考虑尾递归优化(JVM并不天然支持尾递归优化,所以别指望这个)。
4.4 双指针遍历:算法题里出场率最高的"特殊方式"
如果说上面几种都属于"数组自己的遍历方法",那双指针更像是"一种基于数组特性的遍历策略"。之所以把它放进"特殊遍历方法"里讲,是因为数组连续存储和O(1)随机访问这两个特性,让双指针在数组中能发挥出惊人的效率。
最基本的双指针是"两端向中间逼近",典型场景是判断回文:
boolean isPalindrome(int[] arr) { int left = 0, right = arr.length - 1; while (left < right) { if (arr[left] != arr[right]) { return false; } left++; right--; } return true; }这种写法每一个元素只访问一次,时间复杂度O(n),空间复杂度O(1),比"新建反转数组再比较"的方案省了大半内存。
另一个高频场景是快慢指针——一个指针按步长1移动,另一个按步长2移动。在数组里可以用来探测环的存在(比如链表相关的题),在数组中则常用来实现"原地去重"和"移动零":
// 移动零:把所有0移到末尾,非0元素保持原顺序 void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { nums[slow++] = nums[fast]; } } while (slow < nums.length) { nums[slow++] = 0; } }这里fast负责"遍历整个数组寻找非零元素",slow负责"维护非零元素应该放的位置",整个过程只遍历一遍,不需要额外申请数组。这就是双指针遍历的精髓:两个指针各司其职,分工协作,把原本可能需要两层循环的O(n²)问题压缩成O(n)。类似的题目还有"有序数组两数之和""最长连续不重复子串""接雨水"里用的左右指针,它们在LeetCode高频题里到处都是。
4.5 特殊遍历的其他变体
除了前面几种,还有几个冷门但偶尔会派上用场的遍历形态。
索引跳步遍历——根据条件动态决定下一步的位置,而不是固定i++。比如"跳跃游戏"类的算法题里,就是遍历过程中维护一个"当前能跳到的最远位置",不断更新:
int maxJump = 0; for (int i = 0; i <= maxJump && i < nums.length; i++) { maxJump = Math.max(maxJump, i + nums[i]); }倒序遍历配合状态更新——很多问题从前往后推需要多层循环,倒过来遍历反而能用一层循环解决。比如"打家劫舍"系列问题里,从数组尾端开始维护"偷或不偷"两个状态,代码比正向推导简洁得多。
区间同向双指针(滑动窗口)——这本质上是双指针的进阶版,两个指针一前一后形成一个"窗口",根据窗口内元素的状态调整右边界或左边界。它解决了大量"连续子数组"类问题,比如"最长无重复子串""长度最小的子数组"。
这些遍历变体看起来五花八门,本质都是同一个底层能力:通过控制索引的变化方式,在一个线性结构上表达出复杂的搜索和更新逻辑。所以先把基础遍历吃透,再学这些变体,会感觉非常丝滑。
5. 多维数组与不规则数组的遍历
5.1 二维数组的存储本质:数组的数组
Java中的二维数组是一个"数组的数组",并不是C语言那种连续的一块矩形内存。int[][] matrix = new int[3][4]创建出来的结构是:matrix[0]、matrix[1]、matrix[2]各自引用一个长度为4的一维数组,这三个一维数组在堆内存中并不保证连续。
这个结构带来的直接后果,就是遍历二维数组时,"先行后列"和"先列后行"的性能差异非常明显。因为"先行后列"意味着每次访问的是一维数组内部相邻的元素,JVM的缓存预取能发挥最大效率;"先列后行"则每访问一个元素就要跳跃到另一个一维数组的堆地址上,缓存命中率骤降,在千万数据量的矩阵操作中,差距可能达到数倍。
标准写法是这样:
int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; // 先行后列 for (int i = 0; i < matrix.length; i++) { for (int j = 0; j < matrix[i].length; j++) { System.out.print(matrix[i][j] + " "); } }每次内层循环的边界用matrix[i].length而不是matrix[0].length,这是为了兼容不规则数组,后面会细说。
5.2 不规则数组(锯齿数组)的遍历细节
Java的二维数组并不要求每行长度一致。你可以这样创建:
int[][] ans = new int[5][]; ans[0] = new int[]{1}; ans[1] = new int[]{2, 3}; ans[2] = new int[]{4, 5, 6};这种数组常出现在杨辉三角、按行存储动态结果的算法输出里。遍历它的正确写法是外层遍历行、内层每行独立判断长度:
for (int i = 0; i < ans.length; i++) { for (int j = 0; j < ans[i].length; j++) { // 处理 ans[i][j] } }如果偷懒把内层边界写死成matrix[0].length,遍历不规则数组时会直接ArrayIndexOutOfBoundsException。这是我实际写代码时踩过的坑——某次用固定列宽去遍历按需生成的结果集,数组长度参差不齐,直接跑崩了。后来养成了习惯:二维数组的内层循环,边界一律写xx[i].length,宁可多敲几个字符也不投机。
5.3 多维数组的高阶遍历模式
多维数组最常见的遍历场景是矩阵相关的算法题,这里列出几个典型的模式。
螺旋遍历——从外圈到内圈绕圈访问矩阵:
int top = 0, bottom = matrix.length - 1; int left = 0, right = matrix[0].length - 1; while (top <= bottom && left <= right) { // 访问上边界 for (int j = left; j <= right; j++) { // 处理 matrix[top][j] } top++; // 访问右边界... }核心思路是把"一圈"当成一次遍历的单位,通过不断压缩四个边界完成全部访问。在面试中很能体现二维数组遍历的理解深度。
对角线遍历——线性代数里的矩阵对角线,映射到Java里就是matrix[i][i](主对角线)和matrix[i][n-1-i](副对角线)。一些动态规划题(比如"最小路径和""不同路径")中会按对角线方向更新状态,这种遍历模式的通用实现通常需要控制行、列两个索引的变化规律。
用Stream遍历二维数组。先通过Arrays.stream(matrix)把行作为流元素,再配合flatMapToInt(row -> Arrays.stream(row))把每一行摊平成一个一维流,之后就能直接对全矩阵做聚合操作:
int sum = Arrays.stream(matrix) .flatMapToInt(Arrays::stream) .sum();这种写法的优点是代码极简,适合做矩阵合并统计;缺点是一旦需要处理行与行之间的联系(比如相邻元素判断),强行用Stream反而不直观。所以它适合"无脑聚合"的场景,不适合逻辑复杂的矩阵算法。
6. 性能实测:不同遍历方式的真实差距
6.1 实测数据:五百万级数组遍历耗时对比
为了让"遍历方式的性能差异"这个说法不至于停留在感觉上,我自己在本地做了一组对比测试。测试环境是OpenJDK 17,数组大小为500万,分别用四种方式求和并记录耗时。每轮测试前做了热身循环,避免JIT冷启动对结果的影响。
| 遍历方式 | 平均耗时(ms) | 说明 |
|---|---|---|
| for + 索引(每次访问arr.length) | 约18 | 基准写法 |
| for + 局部变量len | 约17 | JIT会自动优化,差距极小 |
| foreach | 约20 | 比索引for略慢,但可忽略 |
| IntStream的sum() | 约38 | 有流框架开销 |
| IntStream并行流sum() | 约8 | 在8核环境下明显占优 |
从数据可以看出几个现象:
- 在单线程场景下,普通for最快,foreach几乎不落后。正如前面所说,数组的foreach编译后本质还是索引访问,所以差距很小。
- Stream顺序流的开销比较明显,大约是普通for的两倍,根源在于Stream框架本身的函数调用链和迭代器抽象。
- 并行流在元素数量足够大时,优势立竿见影。500万的sum已经能让并行流甩开普通for。但数据量小的时候,新建Thread的调度开销会反超。
这个测试告诉我一个最核心的原则:性能优化的前提是数据规模。小数组用哪种都无所谓,大数组才需要认真评估。真实项目里,绝大多数遍历的数据量都在百量级到万量级之间,这种量级下Stream的可读性优势远大于性能劣势,用Stream完全没问题。真正到百万千万级的热点循环,再考虑换成手写for或者并行流。
6.2 性能差距的底层原因
普通for循环之所以快,关键在JIT编译器的优化。循环体内只做简单的数值累加,JIT很容易把这段代码编译成SIMD指令或者进行循环展开,几个元素一轮并行处理。而foreach在数组场景下经过JIT处理也能达到接近效果,但多了一层语法糖转换,优化路径稍微复杂一点。
Stream顺序流慢的根源,在于函数式操作的调用链。IntPipeline内部有一堆抽象类层级,每个元素都要经过accept方法的回调,JIT虽然能内联部分调用,但开销仍然存在。并行流之所以快,是因为sum这种操作是天然的"无状态聚合",分块后各算各的再合并,非常契合Stream的分支框架。但这也意味着,如果你在并行流里共享了可变状态(比如往同一个ArrayList里add),不仅性能会崩,还会产生线程安全问题。这是个隐性大坑,刚上手Stream并行流时很容易踩到。
6.3 什么时候选哪种遍历:我的建议
既然性能数据已经摆出来了,给出一套落地建议:
- 刷算法题、写核心逻辑:一律用普通for或while,逻辑直白,边界好控制,性能也最稳。
- 集合或数组的数据处理、日志过滤、转换聚合:优先用Stream,代码简短,阅读体验远超一大段for+if。
- 超大数组且操作是"无状态聚合":考虑
parallel()并行流,但务必确认元素之间没有依赖,操作也是线程安全的。 - 需要在遍历过程中修改数组元素:for + 索引是唯一正确选择;foreach和Stream都做不到。
- 需要跳出遍历、提前终止:for和while都能用break,但Stream的forEach里无法直接break(stream的
allMatch/anyMatch可以变相实现提前终止,但这又是另一个话题了)。
7. 实战中的高频踩坑点与个人经验
7.1 空数组和null,是两回事
遍历数组前,先想清楚两个场景:数组本身是null,还是数组元素为null?
数组为null意味着arr这个引用没有指向任何对象,这时调用arr.length直接NullPointerException。所以如果数组可能来自外部参数,先判空:
if (arr == null) { // 返回默认值或者直接处理 }数组本身不为null但元素为null,多见于引用类型数组。比如String[] arr = new String[3],此时arr[0]是null。遍历时直接调用arr[i].length()就会抛异常。处理方式通常是先判断元素是否为null再继续。
7.2 char[]的toString陷阱
这是我在帮别人review代码时经常看到的问题:直接打印一个char数组,得到的结果是一串[C@4554617c这样的乱码。原因是char[]继承了Object的toString(),而Object的默认实现只返回"类型名@hashCode"。要正确输出char数组的内容,必须使用Arrays.toString(charArray),或者依赖String类的构造函数:new String(charArray)。
如果说String和char[]之间的转换也是高频操作,那么在算法题里遇到"反转字符串"时,注意String是不可变的,不能原地修改,必须转成char[]操作,最后再转回String。很多人在这一步绕来绕去,其实记住toCharArray()和new String(chars)这两个对称API就够了。
7.3 Arrays.asList的"假"List
用Arrays.asList(arr)包装数组时,得到的List有几个重要特性:
- 是定长List,调用
add或remove会抛UnsupportedOperationException,因为底层还是那个固定大小的数组。 - 修改原数组,会同步反映到List里(这是因为它只是数组的一个视图)。
- 如果传参是基本类型数组,比如
int[],Arrays.asList(arr)返回的List里只有一个元素,类型是int[]而不是Integer。
第三点在代码里非常隐蔽:
int[] arr = {1, 2, 3}; List<int[]> list = Arrays.asList(arr); // list.size() == 1,而不是3所以想快速把数组转成List来遍历,要用Arrays.stream(arr).boxed().collect(Collectors.toList()),或者直接用List.of(1, 2, 3)创建不可变List。不过说到底,数组就是数组,很多算法题要求原地操作,别总想着转List,转来转去反而引入一堆奇怪的行为。
7.4 遍历中"删除"元素的问题
数组长度固定,没有"删除"概念,只有"覆盖"。但很多算法的需求是"移除数组中的某个值",比如移除所有等于target的元素。常见做法是在遍历过程中维护一个"写入位置"(slow指针),遇到不等于target的才写入,遍历结束后原数组前几位的值就是结果:
int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != target) { nums[slow++] = nums[fast]; } } // 此时slow就是新数组的逻辑长度这样避免了新建数组,属于原地操作的标准思路。注意这时候数组的实际长度并没有改变,只是"有效长度"变成了slow。如果你期待的是物理删除,Java数组做不到,要么用List,要么直接新建一个数组并拷贝。
7.5 算法题中数组遍历的经典组合
最后总结几类我刷题和面试中反复用到遍历组合,这些组合算是"遍历思想"在算法题里的直接映射。
前缀和——遍历一次数组,维护从开头到当前位置的累加和,用于快速求解子数组区间和:
int[] prefix = new int[nums.length + 1]; for (int i = 0; i < nums.length; i++) { prefix[i + 1] = prefix[i] + nums[i]; } // 区间[left, right)的和 = prefix[right] - prefix[left]这种"遍历一次,记录历史状态"的思路还可以延伸成差分数组、哈希表计数等,本质上是利用遍历过程中积累的信息来避免重复循环。
摩尔投票——遍历数组统计候选众数,思想的本质也是"同一个遍历过程中维护计数状态",只是状态转移规则不同。这类题目最大的共同点是:几乎所有数组类的算法题,最优解都是O(n)时间 + O(1)空间的单次遍历(或双指针遍历)。能在一次遍历中解决的问题,绝不要写成两层嵌套循环。
滑动窗口——维护左右两个指针,右指针不断右移扩大窗口,左指针根据条件收缩窗口。遍历过程中窗口始终覆盖一个"合法"区间,所求的答案就藏在每个合法区间的属性里。
这些组合都不是新概念,它们比普通for循环更进了一步:不仅"访问每个元素",还在每个元素上执行某种状态更新逻辑。这正是数组遍历在算法中的真正应用场景——遍历只是载体,真正的核心是你在遍历过程中维护的"状态"。
我个人至今在写题时的一个体会是:拿到一道数组题,先想清楚"我需要一次遍历还是两次遍历""遍历顺序是从左到右还是从右到左""需不需要额外维护一个状态容器(指针、哈希表、栈)"。这三个问题想明白,代码基本就成型了,剩下的都是体力活。数组遍历的方法本身并不复杂,但把它组合进各种算法场景,才是功力深浅的真正分水岭。