在实际的业务开发中,我们经常需要同时遍历两个长度相同的列表,并把对应位置的元素组合起来处理。比如一个列表存的是学生姓名,另一个列表存的是对应的成绩,我们需要把它们配对生成一份报表;或者一个列表存的是键,另一个列表存的是值,需要合成一个 Map。这类需求在 Python 中可以用内置的 zip 函数一行搞定,但 Java 标准库中并没有直接提供这样的工具,因此掌握几种高效的实现方式就显得尤为重要。
传统 for 循环遍历:最直接的索引方案
最朴素的做法是使用传统的 for 循环,通过索引同时访问两个列表。这种方式的好处是逻辑清晰,任何 Java 版本都支持,而且在遍历过程中可以随时修改循环变量、提前 break 或 continue,灵活性很高。
List<String> names = Arrays.asList("张三", "李四", "王五");
List<Integer> scores = Arrays.asList(90, 85, 78);
for (int i = 0; i < names.size(); i++) {
System.out.println(names.get(i) + " 的成绩是 " + scores.get(i));
}但这种方式存在几个隐患。首先是索引越界问题:如果两个列表长度不一致,scores.get(i) 就会抛出 IndexOutOfBoundsException,因此严谨的写法应该先取两个长度的最小值作为循环上界。其次是代码冗余,每次都要写一遍索引控制逻辑,当这种需求在项目中频繁出现时,重复代码会明显增多。另外,如果列表实现是 LinkedList,通过 get(i) 按索引访问的时间复杂度是 O(n),整体遍历就会退化成 O(n 平方),性能非常糟糕,这一点需要特别注意。
使用 IntStream.range 实现 Stream 风格的并行遍历
Java 8 之后,可以用 IntStream.range(0, size) 生成一个索引流,再在 lambda 中按索引取出两个列表的元素,这种写法更符合函数式风格,代码也更紧凑。由于索引本身就是流中的元素,不存在索引管理的问题,配合 mapToObj 可以直接把索引转换成组合后的对象。
List<String> names = Arrays.asList("张三", "李四", "王五");
List<Integer> scores = Arrays.asList(90, 85, 78);
// 先确定安全边界,避免越界
int size = Math.min(names.size(), scores.size());
List<String> result = IntStream.range(0, size)
.mapToObj(i -> names.get(i) + " 的成绩是 " + scores.get(i))
.collect(Collectors.toList());
result.forEach(System.out::println);如果希望组合成对象列表,只需要把字符串拼接换成对象构造即可。例如定义一个 StudentScore 记录类(Java 16+ 可用 record),然后在 mapToObj 中构造它。这种方式的优势在于可以无缝衔接 Stream 的其他能力,比如 filter 过滤掉不及格的成绩、map 做进一步转换、collect 分组统计等,链式调用一气呵成。需要提醒的是,lambda 内部访问的局部变量必须是最终或等效最终的,如果需要在遍历中修改外部计数器之类的状态,Stream 方案就不太适合,应退回传统循环。
record StudentScore(String name, int score) {}
List<StudentScore> pairs = IntStream.range(0, size)
.mapToObj(i -> new StudentScore(names.get(i), scores.get(i)))
.filter(p -> p.score() >= 80)
.collect(Collectors.toList());借助 Guava 的 Streams.zip 和自写通用工具方法
Google 的 Guava 库在 21.0 版本之后提供了 Streams.zip 方法,它的行为和 Python 的 zip 非常相似:接收两个流和一个 BiFunction,返回按位置组合后的新流。当两个流长度不一致时,它会在较短的那个流耗尽后自动停止,天然避免了越界问题,这是它相对手写索引方案的最大优势。
import com.google.common.collect.Streams;
List<String> names = Arrays.asList("张三", "李四", "王五");
List<Integer> scores = Arrays.asList(90, 85, 78);
List<String> result = Streams.zip(names.stream(), scores.stream(),
(name, score) -> name + " 的成绩是 " + score)
.collect(Collectors.toList());如果项目不想引入额外的依赖,也可以自己封装一个通用的 zip 工具方法,把长度不一致时的截断策略、组合逻辑都收敛到一处。下面给出一个基于迭代器的实现,它不依赖索引访问,因此即使传入的是 LinkedList 也只有 O(n) 的开销。
public static <A, B, R> List<R> zip(List<A> listA, List<B> listB,
BiFunction<A, B, R> combiner) {
List<R> result = new ArrayList<>();
Iterator<A> itA = listA.iterator();
Iterator<B> itB = listB.iterator();
while (itA.hasNext() && itB.hasNext()) {
result.add(combiner.apply(itA.next(), itB.next()));
}
return result;
}
// 使用方式
List<String> combined = zip(names, scores,
(name, score) -> name + " 的成绩是 " + score);这个工具方法封装好之后,项目中所有双列表遍历的需求都可以复用它,既保证了代码一致性,也把越界风险锁死在工具内部。组合的结果也可以是 AbstractMap.SimpleEntry 或者一个 Pair 类,方便后续直接转成 Map:zip(keys, values, SimpleEntry::new).stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))。
边界处理与方案选择建议
无论采用哪种方案,都必须明确两个列表长度不一致时的策略。常见的策略有三种:一是按较短的长度截断,适合容忍数据缺失的场景;二是直接抛出 IllegalArgumentException,适合数据不一致即为异常的强一致场景;三是用默认值填充较短的列表,适合展示类需求。建议在工具方法中把策略参数化,由调用方决定。
综合来看,方案的选择可以这样考虑:如果只是偶尔一两处使用,传统 for 循环加上 Math.min 边界控制就足够了,简单直接;如果已经在大量使用 Stream 链式处理,IntStream.range 能与现有代码风格保持一致;如果项目中已经引入了 Guava,Streams.zip 是语义最清晰的方案;如果这种需求高频出现,强烈建议封装自研的 zip 工具类,把边界策略统一管理。此外在性能方面,对于 ArrayList 这类随机访问列表,各方案的差距很小,而对于 LinkedList,一定要避免索引式的 get(i) 访问,改用迭代器或 Streams.zip 才能保持线性复杂度。