news 2026/9/30 4:42:04

Java数组筛选偶数并变换:循环与Stream实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java数组筛选偶数并变换:循环与Stream实践指南

1. 先从“筛选偶数”这个需求说起

1.1 一个很典型的数组处理场景

我相信每个写过 Java 的人都会遇到这种情况:手里有一个数组,里面一堆数,要从中把偶数挑出来,再对挑出来的数做点加工。比如统计一批成绩里及格的人数、从传感器读数里提取有效值、把一组订单金额中的“整数部分”挑出来重新计算等等。这类需求看起来特别简单,简单到大家通常随手写一个 for 循环就完事了。但恰恰是这种“简单”的需求,在真实开发里暴露出不少问题:数组越界、空指针、筛选完数组里多了几个 0、莫名其妙改了原数组的数据……我在代码评审里见过太多次了。

这个题目其实包含了两层操作:一层是“筛选”,按条件(这里就是偶数)挑出元素;另一层是“变换”,把挑出来的元素做数值加工。这两件事分开看都简单,合在一起就牵扯到一个问题——你到底是在原数组上操作,还是生成一个新数组?这个选择直接决定了代码的安全性、可读性和性能表现。所以这篇文章不会只给一个“标准答案”,我打算把常见的几种写法全部拆开,讲清楚各自的思路、适用场景和踩坑点,最后给你一套能直接抄的完整示例。

1.2 先想想这个需求真正用来做什么

“筛选偶数 + 数值变换”这类逻辑,本质上就是数据处理里的 filter + map,只不过数据源从集合换成了数组。你把这个模式想明白了,以后不管筛的是奇数、质数、还是成绩大于 60 分的学生,套路都是同一个。在 Java 里,数组是固定长度的连续内存结构,它和 ArrayList 最大的区别就是:数组没有现成的“删除元素”机制。这就导致不少人第一次写筛选逻辑时,选择用中间数组 + 计数器来模拟“动态扩容”,结果写出来的代码又长又容易错。

所以这里有个核心思路要先建立起来:数组筛选不应该试图“删元素”,而应该“挑选并复制”。你只负责遍历原数组、把符合条件的元素拷贝进新区间,最后通过缩容得到一个刚好装下筛选结果的新数组。这个思路贯穿全篇,后面所有写法,不管是传统循环、Stream 流式操作,还是集合辅助,本质上都是这个思路的变体。

2. 传统循环实现:最直观也最容易出错的地方

2.1 基础写法与“临时数组”的坑

先给一个最常规的写法,很多教材里都是这么教的:

public static int[] filterEvens(int[] numbers) { int[] temp = new int[numbers.length]; int index = 0; for (int value : numbers) { if (value % 2 == 0) { temp[index++] = value; } } return Arrays.copyOf(temp, index); }

这里有两个关键点。第一,临时数组为什么长度必须是numbers.length?因为你要为“最坏情况”做准备——万一把把数组里全是偶数,临时数组必须能装下全部元素。第二,Arrays.copyOf(temp, index)是必须的,它的作用是把临时数组截断到实际元素个数。如果不做这一步,返回的数组末尾会多出一堆默认值 0,这在逻辑上等于筛选出了“假数据”。

注意:temp[index++] = value这里 index 既是下标,也是已存元素的数量。筛选完成后,index 的值恰好就是偶数个数。这个“下标即数量”的用法,是数组筛选里的核心技巧,我建议形成肌肉记忆。

不过这个写法的隐患也不少。第一个隐患是原数组为空时,temp长度为零,copyOf空数组没问题,逻辑上是对的,但如果你在前面加上了“先对 numbers 做非空判断”的逻辑,忘掉 else 分支就会导致返回 null,后面调用方直接崩。第二个隐患是,如果以后把筛选条件从“偶数”改成“能被 3 整除”,你得小心翼翼地改if里的条件,一旦改错或者漏调,排查起来很费劲。所以这种写法适用于“以后不会再变”的一次性逻辑,比如临时脚本、小型工具类。

2.2 遍历方式怎么选:普通 for、增强 for 还是 while

这三种遍历方式在底层其实差别不大,但在特定场景下各有优劣。普通for (int i = 0; i < numbers.length; i++)的优势是可以拿到下标,如果后续需要把“偶数在原数组中的位置”也记录下来,你就必须用这种写法。增强 for 写起来最简洁,代码上看不到下标,不容易出现“索引越界”的手误,适合只读数据的场景。while遍历数组很少用,除非你写的是链表结构或者需要手动控制迭代状态。

我自己的习惯是:筛选逻辑单独抽方法时,一律用增强 for;需要记录“原下标 + 值”时,改成普通 for。下面这段代码就是把“索引位置”一起挑出来的写法:

public static Map<Integer, Integer> filterEvensWithIndex(int[] numbers) { Map<Integer, Integer> result = new LinkedHashMap<>(); for (int i = 0; i < numbers.length; i++) { if (numbers[i] % 2 == 0) { result.put(i, numbers[i]); } } return result; }

这里多出来的LinkedHashMap会保留插入顺序,方便你后续按原数组顺序重建结果。如果你非要用数组来存下标和值的对应关系,可以用一个二维数组,但说实话实际项目里这种需求我用 Map 更多,因为语义更清楚。

2.3 手写循环的性能到底好在哪里

很多人面试被问“数组和 ArrayList 谁快”,答案其实是“看场景”。数组遍历本身就比 ArrayList 快那么一点点,因为 ArrayList 的底层也是数组,但它多了一层方法调用和扩容逻辑(虽然现代 JVM 会做逃逸分析和内联优化,差距越来越小)。手写 for 循环筛选时,你只申请了一次内存(临时数组),只遍历了一次原数组,没有任何额外对象创建,所以它在性能上非常能打。

我在一个批量处理 100 万个整数的场景下做过粗略测试,普通 for 循环筛选 + 拷贝,耗时大约 8~12 毫秒;用 Stream 写,耗时大约 15~20 毫秒。差一倍左右,但说实话,日常业务中如果数据量只有几千几万,这点差距根本感知不到。所以我不主张“为了性能非要手写循环”,我更建议你根据代码的可维护性来做取舍。性能真正敏感的场景,往往是循环体里面做了大量计算,而不是循环本身。

3. Stream 流式处理:Java 8+ 的正确打开方式

3.1 一次从命令式到声明式的转变

如果你还在用 Java 7,那 Stream 这部分可以直接跳过;但只要项目是 Java 8 及以上,我强烈建议优先考虑流式写法。同样是筛选偶数,用 Stream 是这样写的:

int[] evens = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .toArray();

就一行半。不需要临时数组,不需要计数器,不需要Arrays.copyOf。你只管告诉它“我要偶数”,剩下的步骤框架包了。这个思维转变很关键:传统循环是“怎么做”的细节导向,Stream 是“做什么”的目标导向。前者就像你一步步告诉朋友怎么走到你家,后者是直接分享定位。

这里有个细节要注意:Arrays.stream(numbers)对int[]生成的是一个IntStream,而不是Stream<Integer>。区别在哪?IntStream里所有中间操作都是基于原始类型int的,不涉及装箱拆箱,性能和普通 for 循环差距很小。如果你写的是Stream<Integer>,那每个数都会被包装成Integer对象,内存开销瞬间上来,性能也会下降。所以只要是处理基本数字数组,优先用IntStream、LongStream、DoubleStream。

3.2 filter、map、collect 的组合套路

筛选出来了,接下来就是数值变换。“取出偶数并求平方”,放在 Stream 里就是一个链式组合:

int[] squaredEvens = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .map(n -> n * n) .toArray();

这个写法的可读性比循环高了不止一个档次。筛选条件是 filter 里的lambda,变换规则是 map 里的lambda,两个方法的名字本身就是自解释的。在代码评审的时候,看到filter(n -> n % 2 == 0)一眼就能明白意图,但看到一堆循环加 if 就需要多花几秒钟去“翻译”。

filter的作用是保留那些让断言为true的元素,断言就是n -> n % 2 == 0这个Predicate。map的作用是把每个元素通过指定函数映射成新值,这里的n -> n * n就是IntUnaryOperator。如果你后续还想做更多变换,比如“先加 5 再乘 10”,只要在链上再加一个.map(n -> n + 5).map(n -> n * 10)即可。当然你也可以写成一个复合函数.map(n -> (n + 5) * 10),这取决于你对“单一职责”和“可读性”的偏好。

3.3 把结果收集成 List 而不是数组

有时候你筛选完,下游要的不是数组而是List。这时候有两个选择:boxed()之后再collect(Collectors.toList()),或者直接循环收集。Stream 的经典写法如下:

List<Integer> evens = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .boxed() .collect(Collectors.toList());

boxed()的作用是把IntStream转换成Stream<Integer>,这一步是为了让元素能被放进List<Integer>。注意,只要调用了boxed(),元素就会被装箱成Integer对象,所以这里会有少量性能损耗。如果你最终还是要int[]数组,那没必要走boxed(),直接用toArray()就好。

提示:如果你想直接拿int[]数组交给其他模块处理,那就用toArray();如果你想进一步做去重、排序、分组等操作,建议收集成List后用集合的 API 继续操作,或者直接在 Stream 链上继续加distinct()、sorted()。

3.4 组合场景:筛选偶数后再排序和去重

既然聊到了 Stream,我就顺手多给一个组合场景,很多人都问过“筛选完之后还要排序怎么做”。其实不需要把结果拿回来再排序,全部在链上完成就行:

int[] sortedDistinctEvens = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .distinct() .sorted() .toArray();

distinct()去重,sorted()升序排序,顺序可以按需调整。但有一点值得注意:先distinct()还是先sorted()性能会有差异。一般来说,如果你数据量很大且重复率也高,先distinct()再sorted()可以减少排序的数据量。如果数据量只有几十几百,你完全不用纠结顺序,怎么写着顺眼怎么写。

4. 数值变换的几种实现方式

4.1 原数组原地修改与生成新数组的对比

筛选完偶数后,数值变换通常有两种需求场景。一种是“只取偶数,并且把偶数对应的位置做变换”,比如把原数组里所有偶数改成它们的平方,不影响奇数位置。另一种是“把筛选出来的偶数做变换后,放回一个全新的数组”。前者是原地修改,后者是生成新数组。

原地修改的典型写法:

public static void transformEvensInPlace(int[] numbers) { for (int i = 0; i < numbers.length; i++) { if (numbers[i] % 2 == 0) { numbers[i] = numbers[i] * numbers[i]; // 举个例子:平方 } } }

这种写法省内存、速度快,但副作用很大——原数组被改了。如果你调用方还指着原数组做其他计算,这就容易出问题。生成新数组的写法,就是前面 Stream 里那套,或者用循环版本。它的好处是原数组数据不变,符合函数式编程的“不可变”理念,更安全,但代价是额外创建了一个数组。

我实际开发里的决策原则很简单:明确告诉调用方我这个方法会“修改原数组”还是“返回新数组”,并且从命名上就区分开。比如transformInPlace和buildTransformedCopy,看一眼方法名就知道自己该不该传引用。最怕的就是那种改完原数组、又偷偷返回新数组的写法,调用方稍不留神就把两个数据搞混,排查起来极其痛苦。

4.2 数值变换的常见公式:平方、绝对值、求和改写

“数值变换”这个概念听着抽象,落到实际就是把某个数通过一个函数变成另一个数。除了平方,最常见的还有取绝对值、加固定偏移量、求倒数、归一化等等。放在代码里都是换一个map的 lambda 而已:

// 取绝对值 .map(n -> Math.abs(n)) // 向后偏移 10 .map(n -> n + 10) // 保留个位数 .map(n -> n % 10) // 范围限制在 0~255 .map(n -> Math.max(0, Math.min(255, n)))

这里我多说一句,lambda 里可以写复杂逻辑,但如果逻辑超过一两行,建议抽成私有方法,再写成map(MyUtils::transform)的方法引用形式。这样 Stream 链上的代码保持简洁,变换逻辑本身也方便单测。比如:

public static int clampToRange(int value) { return Math.max(0, Math.min(255, value)); } // 使用 .map(TransformUtils::clampToRange)

4.3 变换之后想累加或统计怎么办

有时候你一眼命中了要做的事情并不是“把每个偶数变换一下”,而是“把偶数变换后的总和算出来”。比如棋盘路线上所有偶数坐标点的距离总和。这种场景不用toArray(),直接sum()就行:

int sumOfSquaredEvens = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .map(n -> n * n) .sum();

如果你想要平均值、最大值、最小值、计数这些统计量,IntStream里也有现成方法,或者用summaryStatistics()一次性拿到所有统计值:

IntSummaryStatistics stats = Arrays.stream(numbers) .filter(n -> n % 2 == 0) .map(n -> n * n) .summaryStatistics(); // stats.getSum(), stats.getAverage(), stats.getMax(), stats.getMin(), stats.getCount()

这个在面试里也比较常见,很多候选人知道sum(),但说不出IntSummaryStatistics这个类。说实话你不需要背类名,只要记得“流式统计有现成类”就够了,用的时候 IDE 会提示。

5. 完整示例:从成绩数组里筛及格线并做分数变换

5.1 场景建模与代码落地

光讲碎片逻辑不过瘾,我放一个完整的实操场景。假设一个班级有一组成绩,类型是int[],要做的操作是:把所有及格的成绩(>= 60)挑出来,并给这批成绩每人加 5 分作为平时分补偿,然后返回新的数组。数据如下:

int[] scores = {58, 92, 75, 44, 88, 63, 90, 37, 72};

我先写 Stream 的版本,这是我最推荐的:

public static int[] addBonusToPassedScores(int[] scores, int bonus) { if (scores == null || scores.length == 0) { return new int[0]; } return Arrays.stream(scores) .filter(score -> score >= 60) .map(score -> score + bonus) .toArray(); }

执行结果是:92? 没算错的话 92 + 5 = 97, 75 + 5 = 80, 88 + 5 = 93, 63 + 5 = 68, 90 + 5 = 95, 72 + 5 = 77,最终数组就是[97, 80, 93, 68, 95, 77]。原数组没有被动过,后续如果还要用scores做其他统计,数据还是原始状态。

再放一个传统循环版本,方便你对照着理解:

public static int[] addBonusToPassedScoresLoop(int[] scores, int bonus) { if (scores == null) { return new int[0]; } int[] temp = new int[scores.length]; int index = 0; for (int score : scores) { if (score >= 60) { temp[index++] = score + bonus; } } return Arrays.copyOf(temp, index); }

我之所以说 Stream 更合适,是因为这个场景里“筛选条件”和“变换规则”都非常清晰,Stream 让代码意图外显。而传统循环版本里,你要看完for、if、copyOf才能拼凑出整体逻辑。新手出错率也更高,比如忘了copyOf导致返回数组带一串 0,比如忘了index++导致同一个位置被覆盖。如果你正在批量处理这种逻辑,用 Stream 能省掉很多低级 bug。

5.2 扩展到找出最大值、成绩分段统计

同样的思路可以轻松扩展到更多需求。还是这批成绩,如果我要统计“及格人数、最高分、平均分”,用循环你得写好几个变量,用 Stream 就可以这样:

long passedCount = Arrays.stream(scores) .filter(score -> score >= 60) .count(); OptionalInt maxPassed = Arrays.stream(scores) .filter(score -> score >= 60) .max();

注意max()返回的是OptionalInt,原因是当数组里没有任何及格成绩时,max()并不存在“合理的默认值”,所以用 Optional 来包装。不少初学者直接调getAsInt()然后收到NoSuchElementException,就是因为在调用前没检查isPresent()。更好的做法是给一个默认值:

int maxPassedScore = Arrays.stream(scores) .filter(score -> score >= 60) .max() .orElse(-1);

这样没有及格成绩时返回 -1,后续逻辑可以根据 -1 做分叉,避免了异常。这个orElse的习惯在流式编程里非常常用,包括findFirst、findAny也都有类似的Optional返回。

5.3 从数组到集合、再从集合回到数组

第三种场景是把筛选结果交给其他模块,而这些模块的接口签名只接受List<Integer>。这时就需要把数组转成集合。这里有个经典写法差异,我用代码对比一下:

// 写法 A:IntStream boxed + collect List<Integer> evens = Arrays.stream(scores) .filter(score -> score % 2 == 0) .boxed() .collect(Collectors.toList()); // 写法 B:先用循环加到 ArrayList,再转回 int[] List<Integer> evenList = new ArrayList<>(); for (int score : scores) { if (score % 2 == 0) { evenList.add(score); } } int[] evenArray = evenList.stream().mapToInt(Integer::intValue).toArray();

两种思路适用于不同场景。写法 A 简洁、无副作用;写法 B 灵活,可以在循环里顺便做点别的操作(比如同时累加总和)。但写法 B 有一个隐患:evenList.add(score)里的 score 是基本类型int,Java 会自动装箱成Integer,这一步对超大数据量有轻微损耗。所以如果你确定性能是瓶颈,又需要集合操作,可以考虑用IntArrayList之类的第三方集合类,或者干脆用数组。

提示:数组转集合最容易踩的坑是Arrays.asList(numbers),这里numbers是int[],Arrays.asList会把整个int[]当作一个元素,结果得到一个List<int[]>而不是List<Integer>。如果要用asList,得先把int[]装箱为Integer[],但这个装箱过程本身也有额外开销,我一般直接推荐用boxed().collect()。

6. 常见问题与排查技巧实录

6.1 返回结果里多出一串 0

这是我见过最多的新手错误。临时数组长度是原数组长度,筛选完之后不截断,直接返回,导致结果数组末尾出现一堆 0。0 本身是有效偶数这件事很容易混淆判断。排查方法是打印返回数组的length和内容,如果发现length大于筛选出的实际数量,就说明没做缩容。解决办法就是Arrays.copyOf(temp, index),其中index是计数器最终值。

6.2 NullPointerException 到底在哪触发的

数组为 null 时,直接在for (int value : numbers)处就会抛异常,很多人一脸懵。另一个隐蔽场景是:筛选后的数组传给方法时,方法内部直接用Arrays.stream(arr),如果 arr 是 null,也会 NPE。我的习惯是统一在方法入口做一次防御性检查:

if (numbers == null || numbers.length == 0) { return new int[0]; }

这样保证调用方拿到的永远是有效数组,而非 null。这也符合“快速失败”的反例——其实这里更像是“宽容失败”,因为空数组在很多下游逻辑里是可以安全消费的。

6.3Arrays.asList的陷阱

前面写过了,再提醒一次:Arrays.asList(numbers)对int[]不会拆箱。它返回的List底层数组是引用数组,里面的元素是int[]类型。如果把这个 List 当普通 List 遍历,你拿到的元素类型不是 Integer 而是 int 数组,编译期可能不报错,运行期逻辑就完全乱掉。排查时看一眼变量类型就知道:List<int[]>和List<Integer>在 IDE 的调试面板里展示结构不一样。

6.4 筛选条件写反或者模运算误用

value % 2 == 0是偶数,value % 2 == 1是奇数,这个没争议。但负数呢?-3 % 2在 Java 里的结果是 -1,不是 1。所以如果你写value % 2 == 1来筛奇数,负数奇数会被漏掉。正确写法是value % 2 != 0判断“不是偶数”,这样正负奇数都能被正确识别。这个细节在面试题里经常出现,我见过不少候选人卡在这里。

6.5 超大数组的扩容与性能损耗

如果原数组特别大,比如 100 万以上,且你无法预估结果数量,传统循环里“临时数组 + copyOf”只做一次扩容拷贝,性能是可控的。但如果你图方便把筛选结果放进ArrayList,再stream().mapToInt(...)转回数组,中间会产生包装对象和多次扩容拷贝,性能损耗会成倍放大。应对方案有两种:一是用IntStream一条链走到底;二是用ArrayList<Integer>收集完后,如果你确定只需要求和或计数,直接用原始数组再遍历一遍统计,避免转换开销。

7. 最后的实操心得

最后分享几个我做代码审查时特别看重的点。第一个是:你写的筛选逻辑到底在哪个层级复用。如果只是脚本里跑一遍,循环就够了;如果是一个会被多处调用的公共工具方法,建议用 Stream 并把条件抽成Predicate参数。第二个是:能不装箱就不要装箱。处理int[]数据时,IntStream永远比Stream<Integer>稳,尤其涉及大数据量时。第三个是:记得返回空数组而不是 null。这虽然是个小习惯,但能省掉下游一堆空指针判断。

根据我个人经验,“筛选 + 变换”这类需求在真实项目里出现的频率远比想象中高,而把它写清楚的关键,不在于炫技,而在于读代码的人能不能一眼看懂你的意图。先想清楚是原地改还是新数组,再决定用循环还是 Stream,最后别忘了边界处理。这套思路捋下来,不管面试还是实战,你都能稳得住。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:41:44

声呐阵列信号处理:波数域、空间FFT与波束形成的本质

1. 先搞懂“波数”&#xff1a;声呐里的空间频率1.1 我为什么想专门聊聊这个名词早几年调试一部多波束声呐的时候&#xff0c;我最怕听到三个字&#xff1a;波数域。那会儿日常工作已经习惯了画波束图&#xff0c;在角度域里调阵列&#xff0c;总觉得所谓“波数域处理”是另一套…

作者头像 李华
网站建设 2026/9/30 4:41:43

电磁仿真底层逻辑:6大定理在HFSS/CST中的工程映射

简介&#xff1a;本资源是一份面向电磁场与微波技术专业高年级本科生及研究生的理论强化学习材料&#xff0c;聚焦高等电磁理论中核心定理与原理的系统梳理与数学推导&#xff0c;助力读者深入理解场论基础、夯实求解思路、突破边界条件与唯一性分析等难点。PPT共72页&#xff…

作者头像 李华
网站建设 2026/9/30 4:41:14

TensorFlow 真实定位:工业级AI系统工程栈与SavedModel可执行合同

1. 这不是“又一个深度学习框架”——TensorFlow 的真实定位与误用陷阱 很多人第一次听说 TensorFlow&#xff0c;是在某篇“AI入门指南”里看到它和 PyTorch 并列排在“主流框架”那一栏&#xff1b;也有人是在公司技术选型会上&#xff0c;听到架构师说“我们后端模型服务统…

作者头像 李华
网站建设 2026/9/30 4:40:58

CR3转JPG全指南:佳能RAW格式转换方法与参数设置

第一次拿到CR3文件的人&#xff0c;十个里有九个会愣一下&#xff1a;明明相机里看着好好的&#xff0c;拷到电脑上却显示成一个打不开的图标&#xff0c;双击时要么报错&#xff0c;要么只有缩略图能凑合看一眼。我拍佳能R系列这几年&#xff0c;几乎每周都要帮人处理这类问题…

作者头像 李华
网站建设 2026/9/30 4:40:55

模型优化器实战:从计算图到INT8量化的推理加速全流程

1. 模型优化器到底在解决什么问题第一次接触 Model-Optimizer 这个概念&#xff0c;是在一个推荐系统的排序模型上。当时线上推理延迟死活压不下去&#xff0c;单次请求要跑 180ms&#xff0c;业务方要求必须降到 80ms 以内。我试过换更小的模型、砍特征、加机器&#xff0c;效…

作者头像 李华
网站建设 2026/9/30 4:40:25

概率论与数理统计期末复习:二十种必考题型全拆解

又到期末了&#xff0c;每年这时候后台问得最多的就是概率论怎么复习。这门课和高等数学不太一样&#xff0c;高数你不会写&#xff0c;起码还能凭直觉凑个极限、求个导数&#xff0c;概率统计要是概念没串起来&#xff0c;拿到卷子是真的一个字都写不出来。我自己备考的时候&a…

作者头像 李华