vlambda博客
学习文章列表

Java 面试题:反转不可变列表

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华



最近的Java面试中,我准备了一个反转不可变列表的问题。结果发现,对于大多数候选人而言,问题并不像想象中那么简单。因此决定在这里分享一下。


问题


实现下面的接口实现反转输入的列表:


public interface Reverser {
List<Integer> reverseList(List<Integer> list);
}


期望结果:


输入: 1, 2, 3
输出: 3, 2, 1


唯一的要求是输入的list是不可变的


实现如下:


public List<Integer> reverseList(List<Integer> list) {
final int size = list.size();
List<Integer> reversedList = new ArrayList<>();

for (int i = 0; i < size; i++) {
// 把元素i添加到位置0
reversedList.add(0, list.get(i));
}

return reversedList;
}


这个解决方案能达到预期结果。但是从性能角度来看还有问题,你能找出来吗?


能不能用其他方式实现?


通常认为,访问输入列表中指定索引的元素耗费的时间是常数。


作为基准测试,在Intel Core i7-7700(3.6 GHz)上使用Oracle JDK 9,输入100万个元素列表执行的响应时间如下:

problem avgt 403.000 us/op


可能的解决方案


迭代模式


首先看一下输入列表迭代的几种方式。众所周知,有许多经典方式可供选择。

从Java 5开始,可以使用for-each循环:


for (int i : list) {

reversedList.add(0, i);

}


这么做真的更快吗?在Effective Java中,Joshua Block谈到:


在某些情况下,[for-each循环]与普通的for循环相比可能会具有一些性能上的优势,因为前者只计算一次数组索引的极限。尽管可以手动计算,但程序员并不总是这样做。


换句话说,如果已经预先计算了列表大小(就像在最初的实现中所做的那样),那么与经典版本相比不会有任何差别。


Java 8提供了另一个选项,使用Stream API:


list
.stream()
.forEach(e -> reversedList.add(0, e));


同样,这么做没有带来任何明显的性能提升。使用for-each和Stream运行基准测试得到的结果几乎相同:


problem          avgt     403.000 us/op
for-each avgt 403.000 us/op
stream avgt 403.000 us/op


分配ArrayList


ArrayList数组大小可以调整。背后的实现包含capacity变量,用来存储数组大小。


每种ArrayList实现都有自己的扩容策略,根据JDK的版本不同扩容策略也不同(例如,数组填满时容量递增50%)。


要取消这种动态增长,可以指定初始容量初始化ArrayList。由于我们已经知道输出列表的大小,因此可以做到这一点。


实现如下:


ArrayList<Integer> reversedList = new ArrayList<>(size);


运行结果:


problem          avgt     403.000 us/op
initial-capacity avgt 403.000 us/op


结果并没有发生变化。如何解释呢?


JIT编译器提供的运行时优化让人们相信,设置初始容量并不重要。如果禁用预热时间,就能看到两个测试响应时间之间的差异。


元素移位


让我们仔细看看生成结果ArrayList插入元素方式:


reversedList.add(0, elem);


这个简单的表达式时间复杂度是线性的,不是恒定的常量。实际上,在ArrayList位置0插入元素需要把所有元素右移。


这是上面实现中存在的主要问题。那么还有什么其他选择吗?


第一种,使用操作时间为常量的数据结构在位0插入元素。在Java中,可以使用LinkedList来管理指向第一个元素的指针。


调用addFirst()在第一个位置插入元素:


public List<Integer> reverseList(List<Integer> list) {
final int size = list.size();
LinkedList<Integer> reversedList = new LinkedList<>();

for (int i = 0; i < size; i++) {
// Add element i to the first position
reversedList.addFirst(list.get(i));
}

return reversedList;
}


效果有没有明显提升?


problem          avgt     403.000 us/op
linked-list avgt 541 us/op


结果好多了! 


第二种方法,保留ArrayList结构,在末尾插入元素(这样操作的时间复杂度是常数)。


必须对列表倒序迭代,如下所示:


public List<Integer> reverseList(List<Integer> list) {
final int size = list.size();
List<Integer> reversedList = new ArrayList<>(size);

for (int i = size - 1; i >= 0; i--) {
// Add element i to the last position
reversedList.add(list.get(i));
}

return reversedList;
}

problem avgt 403.000 us/op
linked-list avgt 541 us/op
insert-last-pos avgt 281 us/op


响应时间甚至比LinkedList方案还要短。


实际上,由于LinkedList会动态分配内存(每个元素都包装为一个节点对象),使用时会增加少量内存开销。因此,如果已经知道结构大小,那么使用像ArrayList这样的数据结构显然会更快。


内部数组拷贝


ArrayList的底层实现是数组。如果我们试着复制内部数组并对副本直接进行反转会怎么样?


public List<Integer> reverseList(List<Integer> list) {
final int size = list.size();
final Integer[] array = list.toArray(new Integer[0]); // Copy array

for (int i = 0; i < size / 2; i++) {
swap(array, i, size - i - 1); // Swap elements
}

return Arrays.asList(array);
}


swap()函数只交换两个索引对应的数组元素。


这样能不能带来性能提升?


problem          avgt     403.000 us/op
linked-list avgt 541 us/op
insert-last-pos avgt 281 us/op
copy-swap-array avgt 254 us/op


有趣的是,这种方案甚至比之前的解决方案更快。一些可能的解释:


  • 数组副本由System.arraycopy()管理,这是一个优化过的本地方法。

  • 这种代码对CPU缓存更友好。


视图


最后一种解决方案借助了输入列表的不可变性。因此,可以继承AbstractList创建一个新列表:


public List<Integer> reverseList(List<Integer> list) {
return new AbstractList<Integer>() {
@Override
public Integer get(int index) {
return list.get(list.size() - 1 - index);
}

@Override
public int size()
{
return list.size();
}
};
}


显然,在这种情况下与基准测试比较reverseList()性能是不合适的,因为输出结果在调用时才生成:


problem          avgt     403.000 us/op
linked-list avgt 541 us/op
insert-last-pos avgt 281 us/op
copy-swap-array avgt 254 us/op
view avgt 0,002 us/op


需要处理list.size()-1-index时,调用get()产生开销很小。实际编程中,如何使用列表值得仔细考虑。如果访问列表非常频繁,则最好使用副本。


最后,有些人过于强调函数式编程和不变性对性能始终带来负面影响。实际上不能一概而论。


以本文讨论的面试题为例,最好的解决方案往往取决于问题本身的约束条件。


推荐阅读

(点击标题可跳转阅读)





看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️