19

LinkedList-和有什么区别ArrayList?什么时候最好使用 a LinkedList

我认为每个 Java 开发人员在面试时都至少听说过一次这个问题。

- 如果您希望能够在列表中间插入项目,则链接列表更可取。

这是这个问题的常见答案。每个人都知道。每次您询问有关 List 实现之间差异的问题时,您都会得到以下答案:

什么时候应该使用 LinkedList?您何时需要在元素之间或开始时有效地移除?

从这里

忘了提及插入费用。在 LinkedList 中,一旦你有正确的位置,插入成本O(1),而在 ArrayList 中它上升到O(n)- 必须移动超过插入点的所有元素。

从这里

当您希望能够在列表中间插入项目(例如优先级队列)时,链接列表优于数组。

从这里

ArrayList 速度较慢,因为它需要复制数组的一部分才能删除已空闲的插槽。LinkedList 只需要操作几个引用。

从这里

和更多...

但是你有没有尝试过自己复制它?我昨天试过,得到了这些结果:

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class Test {
    public static void main(String... args) {
        final int MAX_VAL = 10000;
        List<Integer> linkedList = new LinkedList<Integer>();
        List<Integer> arrayList = new ArrayList<Integer>();
        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
            arrayList.add(i);
        }
        long time = System.nanoTime();
        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(MAX_VAL/2, i);
        }
        System.out.println("LL time: " + (System.nanoTime() - time));
        time = System.nanoTime();
        for(int i = 0; i < MAX_VAL; i++) {
            arrayList.add(MAX_VAL/2, i);
        }
        System.out.println("AL time: " + (System.nanoTime() - time));
    }
}

输出:

LL时间:114098106

阿尔时间:24121889

那是什么?为什么 LinkedList 这么烂?也许我们应该尝试删除操作而不是添加?好的,让我们试试:

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class Test {
    public static void main(String... args) {
        final int MAX_VAL = 10000;
        List<Integer> linkedList = new LinkedList<Integer>();
        List<Integer> arrayList = new ArrayList<Integer>();
        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
            arrayList.add(i);
        }
        long time = System.nanoTime();
        for(int i = 0; i < MAX_VAL/2; i++) {
            linkedList.remove(MAX_VAL/2);
        }
        System.out.println("LL time: " + (System.nanoTime() - time));
        time = System.nanoTime();
        for(int i = 0; i < MAX_VAL/2; i++) {
            arrayList.remove(MAX_VAL/2);
        }
        System.out.println("AL time: " + (System.nanoTime() - time));
    }
}

输出:

LL时间:27581163

阿尔时间:3103051

哦,ArrayList 还是比 LinkedList 快。是什么原因?这个神话破灭了吗?或者也许我错了?

在此处输入图像描述

4

8 回答 8

28

破获

并不真地。这里

for(int i = 0; i < MAX_VAL; i++) {
    linkedList.add(MAX_VAL/2, i);
}

您不只是插入项目;您支付从开始到i每次迭代的成本。自然是这样O(i)

另一方面,在您真正见证中间列表插入的性能优势之前,列表必须非常大。System.arraycopy是一个极快的操作,另一方面,每次插入到 a 中都LinkedList需要分配一个节点实例。

总之,ArrayList对于 99% 或更多的实际案例来说,a 是一个更好的选择,并且利用 a 的狭窄优势LinkedList需要非常小心。

JVM 微基准测试的一般说明

我还应该警告您,您的基准测试代码严重不足。在 JVM 上进行微基准测试时,有很多需要注意的事项清单,例如:

  • 总是预热代码,让 JIT 编译器得到它;
  • nanoTime由于准确性/精度问题,在解释结果时要非常小心。使读数至少增长到毫秒(百万纳秒)以确保可靠性;
  • 控制垃圾收集器的虚假副作用;
  • 等等

因此建议使用现成的微基准测试框架,例如OpenJDK 的 jmh

于 2013-05-29T08:21:18.190 回答
9

为了演示 add() 操作的(无效)效果,最好使用ListIterator对象而不是列表对象。如果直接在链表上使用 add() 方法,它会从链表头开始,并且必须迭代到要插入项目的位置。这部分需要 O( n )。如果您使用 ListIterator,它将保持我们添加元素的位置,并且算法不必每次都迭代到列表的中间。

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;

public class Test {
    public static void main(String... args) {
        final int MAX_VAL = 10000;
        List<Integer> linkedList = new LinkedList<Integer>();
        List<Integer> arrayList = new ArrayList<Integer>();
        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
            arrayList.add(i);
        }
        long time = System.nanoTime();


        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(MAX_VAL/2, i);
        }
        System.out.println("LL time:\t" + (System.nanoTime() - time));

        time = System.nanoTime();
        for(int i = 0; i < MAX_VAL; i++) {
            arrayList.add(MAX_VAL/2, i);
        }
        System.out.println("AL time:\t" + (System.nanoTime() - time));


        //Reset the lists
        linkedList = new LinkedList<Integer>();
        arrayList = new ArrayList<Integer>();
        for(int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
            arrayList.add(i);
        }

        time = System.nanoTime();
        ListIterator<Integer> li = linkedList.listIterator(MAX_VAL/2);
        for(int i = 0; i < MAX_VAL; i++) {
            li.add(i);
        }
        System.out.println("LL iterator:\t" + (System.nanoTime() - time));

        time = System.nanoTime();
        ListIterator<Integer> ali = arrayList.listIterator(MAX_VAL/2);
        for(int i = 0; i < MAX_VAL; i++) {
            ali.add(i);
        }
        System.out.println("AL iterator:\t" + (System.nanoTime() - time));
    }
}

我的结果表明,在 LinkedList 上使用 ListIterator 为在“中间”插入元素提供了最佳性能:

LL time:     237819474
AL time:      31410507
LL iterator:   5423172
AL iterator:  23975798
于 2013-05-29T08:38:01.177 回答
4

您的测试有偏差 - 它不能衡量通常的性能差异。

关于 LinkedList 结构的一般观察(与 ArrayList 相比,对于大列表):

  1. 在头部或尾部添加/删除节点非常快
  2. 从中间获取元素非常慢
  3. 当您接近列表的任一端时,获取元素变得更快(线性)
  4. 从头部或尾部获取元素的速度接近 ArrayList
  5. 在中间某处添加/删除元素是两个操作:get加节点插入
  6. 如果您使用 ListIterator,您可以在中间某处添加/删除节点并避免获取 - 一个非常快速的操作

您的测试旨在测试 (5)。

但它总是执行最坏的情况——正好在中间添加/删除一个元素。

你的微基准给出了系统误差。您需要统一或随机分布添加/删除位置。或者使用现实生活中复杂且具有挑战性的应用程序进行宏观基准测试。

关于创建准确微基准的挑战的有趣读物:Java 理论与实践:有缺陷的微基准的剖析

于 2013-05-29T09:28:00.257 回答
1

我重写了 Matej 的程序以随机​​选择一种方法并为每种方法运行 50 次试验。如果您在每个类别中平均进行一半的试验,那么结果如下:

LL:570
AL:120
LL 迭代器:1
AL 迭代器:60

LL 迭代器确实需要很多排序时间。在最坏的情况下,由于预热(第一个周期)和 gc(未排序数据的随机峰值),它的性能下降了 15 倍。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Random;

public class TestList {

    public static void main(String... args) {
        final int MAX_VAL = 10000;
        int[] currentIndex = {0, 0, 0, 0};
        int[] remaining = {50, 50, 50, 50};
        int[][] sequence = new int[4][50];

        while (keepWorking(remaining)) { //run 50 tests for each case at random

            int currentMethod = chooseMethod(remaining); //choose case. Probability is higher for tests with less trials

            switch (currentMethod) { //run a test based on the choice
                case 0:
                    sequence[currentMethod][currentIndex[currentMethod]] = getLL(MAX_VAL);
                    break;
                case 1:
                    sequence[currentMethod][currentIndex[currentMethod]] = getAL(MAX_VAL);
                    break;
                case 2:
                    sequence[currentMethod][currentIndex[currentMethod]] = getLLIt(MAX_VAL);
                    break;
                default:
                    sequence[currentMethod][currentIndex[currentMethod]] = getALIt(MAX_VAL);
                    break;
            }

            remaining[currentMethod]--;
            currentIndex[currentMethod]++;
        }

        for (int[] ar : sequence) {
            Arrays.sort(ar);
        }

        System.out.println("Time (us\nLL    \tAL\tLL incr\t AL incr");
        for (int i = 0; i < sequence[0].length; i++) {
            System.out.println(sequence[0][i] + "\t" + sequence[1][i] + "\t" + sequence[2][i] + "\t" + sequence[3][i]);
        }
        System.out.println("\nTime normalized to fastest run of a method\nLL\tAL\tLL incr\t AL incr");
        for (int i = 0; i < sequence[0].length; i++) {
            System.out.print(i);
            for (int j = 0; j < sequence.length; j++) {  //to 4
                int a = sequence[j][i] / (sequence[j][0]/100); //to keep result within the scope of int
                System.out.print("\t" + a);
            }
            System.out.println();
        }
    }

    public static boolean keepWorking(int[] remaining) {

        for (int i = 0; i < remaining.length; i++) {
            if (remaining[i] > 0) {
                return true;
            }
        }
        return false;
    }

    public static int chooseMethod(int[] rem) {
        int[] bins = new int[rem.length];
        for (int i = 0; i < rem.length; i++) {
            for (int j = i; j < rem.length; j++) {
                bins[j] += rem[i];
            }
        }
        int randomNum = new Random().nextInt(bins[rem.length - 1]);
        for (int i = 0; i < bins.length; i++) {
            if (randomNum < bins[i]) {
                return i;
            }
        }
        return -1;
    }

    public static int getLL(int MAX_VAL) {

        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
        }
        long time = System.nanoTime();

        for (int i = 0; i < MAX_VAL; i++) {
            linkedList.add(MAX_VAL / 2, i);
        }
        return (int) (System.nanoTime() - time)/1000;
    }

    public static int getAL(int MAX_VAL) {

        List<Integer> arrayList = new ArrayList<>(MAX_VAL);
        for (int i = 0; i < MAX_VAL; i++) {
            arrayList.add(i);
        }
        long time = System.nanoTime();
        for (int i = 0; i < MAX_VAL; i++) {
            arrayList.add(MAX_VAL / 2, i);
        }
        return (int) (System.nanoTime() - time)/1000;
    }

    public static int getLLIt(int MAX_VAL) {

        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < MAX_VAL; i++) {
            linkedList.add(i);
        }

        long time = System.nanoTime();

        ListIterator<Integer> li = linkedList.listIterator(MAX_VAL / 2);
        for (int i = 0; i < MAX_VAL; i++) {
            li.add(i);
        }
        return (int) (System.nanoTime() - time)/1000;
    }

    public static int getALIt(int MAX_VAL) {

        List<Integer> arrayList = new ArrayList<>(MAX_VAL);
        for (int i = 0; i < MAX_VAL; i++) {
            arrayList.add(i);
        }

        long time = System.nanoTime();
        ListIterator<Integer> ali = arrayList.listIterator(MAX_VAL / 2);
        for (int i = 0; i < MAX_VAL; i++) {
            ali.add(i);
        }
        return (int) (System.nanoTime() - time)/1000;
    }
}
于 2016-08-09T16:47:22.700 回答
0

Some care must be taken with simple profiling like this:

  • Garbage collection may occur at unpredictable time slowing down the unpredictable part.
  • JRE is slower when it first starts and later "warms up".

To work around this, do profiling in the loop, repeating both cases many times best in random order, and take the typical values rather than extremities. This sometimes deliver different results.

于 2013-05-29T08:33:53.183 回答
0

因为 ArrayList 按顺序存储值,因此 1:添加值更快(只需在最后一个索引中添加值) 2:更新或删除更慢(在到达节点之前必须遍历整个列表)

因为数组列表适用于 LinkedList 概念 1:插入速度较慢(需要找到对上一个或下一个值的引用) 2:更新速度更快(因为只能通过引用到达确切的节点)

这个链接可以参考

于 2013-05-29T09:04:35.397 回答
0

在理想情况下,您总是插入排序列表。首先,您使用二进制搜索机制找到插入索引,然后在该索引处插入。此外,在执行此操作时,您不能一直使用相同的 listiterator。您将每次都将迭代器设置为新索引位置。所以在这个现实生活场景中,哪个插入更快。

于 2016-10-12T02:11:33.917 回答
0

哎呀... 教条的人怎么可以...

在 YouTube 上查找“Bjarne Stroustrup:为什么应该避免链接列表”以获得所有答案。答案不是来自一些随机的 StackOverflow 海报,而是来自一位计算机科学教授——哦,顺便说一下——开发了 C++。

为了简短起见:

  • 链表最大限度地提高缓存未命中率,因为节点是在堆上随机分配的,还可以防止硬件和软件缓存预取
  • 将一个节点读入缓存往往是缓存线宽的浪费,这也最大化了内存带宽的浪费,这就是冯-诺依曼-瓶颈
  • 链表存储更多的数据(指针),最大化内存带宽浪费,这就是 Von-Neumann-Bottleneck
  • 尤其是使用 SIMD,流式数组非常快,这将使memcpy任意大小的数组比遍历相同大小的链表更快,并具有上面列出的所有负面影响。在现代(1980+ ????)机器上插入/删除数组总是更快。

链表非常慢。这是一种易于理解的“高级”数据结构,即使面对确凿的证据,前计算机科学专业的学生也坚持使用它们,正如答案中所见。我个人觉得很尴尬。

当 CPU 没有缓存时,链表会更快 - 当内存与 CPU 一样快/快时。那可能是教授之前提到的学生的教授停止学习的时候。

于 2021-01-09T11:44:23.423 回答