鏈表竟然比數組慢了1000多倍?(動圖+性能評測)

這是我的第 215 期分享

作者 | 王磊

來源 | Java中文社羣(ID:javacn666)

轉載請聯繫授權(微信ID:GG_Stone)

數組和鏈表是程序中常用的兩種數據結構,也是面試中常考的面試題之一。然而對於很多人來說,只是模糊的記得二者的區別,可能還記得不一定對,並且每次到了面試的時候,都得把這些的概念拿出來背一遍纔行,未免有些麻煩。而本文則會從執行過程圖以及性能評測等方面入手,讓你更加深入的理解和記憶二者的區別,有了這次深入的學習之後,相信會讓你記憶深刻。

數組

在開始(性能評測)之前我們先來回顧一下,什麼是數組?

數組的定義如下:

數組(Array)是由相同類型的元素(element)的集合所組成的數據結構,分配一塊連續的內存來存儲。利用元素的索引(index)可以計算出該元素對應的存儲地址。

最簡單的數據結構類型是一維數組。例如,索引爲 0 到 9 的 32 位整數數組,可作爲在存儲器地址 2000,2004,2008,...2036 中,存儲 10個 變量,因此索引爲 i 的元素即在存儲器中的 2000+4×i 地址。數組第一個元素的存儲器地址稱爲第一地址或基礎地址。

簡單來說,數組就是由一塊連續的內存組成的數據結構。這個概念中有一個關鍵詞“連續”,它反映了數組的一大特點,就是它必須是由一個連續的內存組成的。

數組的數據結構,如下圖所示:

數組添加的過程,如下圖所示:

數組的優點

數組的“連續”特徵決定了它的訪問速度很快,因爲它是連續存儲的,所以這就決定了它的存儲位置就是固定的,因此它的訪問速度就很快。比如現在有 10 個房間是按照年齡順序入住的,當我們知道第一房子住的是 20 歲的人之後,那麼我們就知道了第二個房子是 21 歲的人,第五個房子是 24 歲的人......等等。

數組的缺點

禍兮福所倚,福兮禍所伏。數組的連續性既有優點又有缺點,優點上面已經說了,而缺點它對內存的要求比較高,必須要找到一塊連續的內存纔行。

數組的另一個缺點就是插入和刪除的效率比較慢,假如我們在數組的非尾部插入或刪除一個數據,那麼就要移動之後的所有數據,這就會帶來一定的性能開銷,刪除的過程如下圖所示:

數組還有一個缺點,它的大小固定,不能動態拓展。

鏈表

鏈表是和數組互補的一種數據結構,它的定義如下:

鏈表(Linked list)是一種常見的基礎數據結構,是一種線性表,但是並不會按線性的順序存儲數據,而是在每一個節點裏存到下一個節點的指針(Pointer)。由於不必須按順序存儲,鏈表在插入的時候可以達到 O(1) 的複雜度,比另一種線性表順序錶快得多,但是查找一個節點或者訪問特定編號的節點則需要 O(n) 的時間,而順序表相應的時間複雜度分別是 O(logn) 和 O(1)。

也就說鏈表是一個無需連續內存存儲的數據結構,鏈表的元素有兩個屬性,一個是元素的值,另一個是指針,此指針標記了下一個元素的地址。

鏈表的數據結構,如下圖所示:

鏈表添加的過程,如下圖所示:

鏈表刪除的過程,如下圖所示:

鏈表分類

鏈表主要分爲以下幾類:

  • 單向鏈表

  • 雙向鏈表

  • 循環鏈表

單向鏈表

單向鏈表中包含兩個域,一個信息域和一個指針域。這個鏈接指向列表中的下一個節點,而最後一個節點則指向一個空值,我們上面所展示的鏈表就是單向鏈表。

雙向鏈表

雙向鏈表也叫雙鏈表,雙向鏈表中不僅有指向後一個節點的指針,還有指向前一個節點的指針,這樣可以從任何一個節點訪問前一個節點,當然也可以訪問後一個節點,以至整個鏈表。

雙向鏈表的結構如下圖所示:

循環鏈表

循環鏈表中第一個節點之前就是最後一個節點,反之亦然。循環鏈表的無邊界使得在這樣的鏈表上設計算法會比普通鏈表更加容易。

循環鏈表的結構如下圖所示:

爲什麼會有單、雙鏈表之分?

有人可能會問,既然已經有單向鏈表了,那爲什麼還要雙向鏈表呢?雙向鏈表有什麼優勢呢?

這個就要從鏈表的刪除說起了,如果單向鏈表要刪除元素的話,不但要找到刪除的節點,還要找到刪除節點的上一個節點(通常稱之爲前驅),因爲需要變更上一個節點中 next 的指針,但又因爲它是單向鏈表,所以在刪除的節點中並沒有存儲上一個節點的相關信息,那麼我們就需要再查詢一遍鏈表以找到上一個節點,這樣就帶來了一定的性能問題,所以就有了雙向鏈表。

鏈表優點

鏈表的優點大致可分爲以下三個:

  1. 鏈表對內存的利用率比較高,無需連續的內存空間,即使有內存碎片,也不影響鏈表的創建;

  2. 鏈表的插入和刪除的速度很快,無需像數組一樣需要移動大量的元素;

  3. 鏈表大小不固定,可以很方便的進行動態擴展。

鏈表缺點

鏈表的主要缺點是不能隨機查找,必須從第一個開始遍歷,查找效率比較低,鏈表查詢的時間複雜度是 O(n)。

性能評測

瞭解了數組和鏈表的基礎知識之後,接下來我們正式進入性能評測環節。

在正式開始之前,我們先來明確一下測試目標,我們需要測試的點其實只有 6 個:

  • 頭部/中間部分/尾部進行添加操作的性能測試;

  • 頭部/中間部分/尾部開始查詢的性能測試。

因爲添加操作和刪除操作在執行時間層面基本是一致的,比如數組添加需要移動後面的元素,刪除也同樣是移動後面的元素;而鏈表也是如此,添加和刪除都是改變自身和相連節點的信息,因此我們就把添加和刪除的測試合二爲一,用添加操作來進行測試。

測試說明

  1. 在 Java 語言中,數組的代表爲 ArrayList,而鏈表的代表爲 LinkedList,因此我們就用這兩個對象來進行測試;

  2. 本文我們將使用 Oracle 官方推薦 JMH 框架來進行測試,點擊查看更多關於 JMH 的內容

  3. 本文測試環境是 JDK 1.8、MacMini、Idea 2020.1。

1.頭部添加性能測試

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {

    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數


    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }

 @Benchmark
    public void addArrayByFirst(Blackhole blackhole) {
        for (int i = 0; i < +operationSize; i++) {
            arrayList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(arrayList);
    }

    @Benchmark
    public void addLinkedByFirst(Blackhole blackhole) {
        for (int i = 0; i < +operationSize; i++) {
            linkedList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(linkedList);
    }
}

從以上代碼可以看出,在測試之前,我們先將 ArrayListLinkedList 進行數據初始化,再從頭部開始添加 100 個元素,執行結果如下:

從以上結果可以看出,LinkedList 的平均執行(完成)時間比 ArrayList 平均執行時間快了約 216 倍。

2.中間添加性能測試

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {
    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }
    
    @Benchmark
    public void addArrayByMiddle(Blackhole blackhole) {
        int startCount = maxSize / 2; // 計算中間位置
        // 中間部分進行插入
        for (int i = startCount; i < (startCount + operationSize); i++) {
            arrayList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(arrayList);
    }

    @Benchmark
    public void addLinkedByMiddle(Blackhole blackhole) {
        int startCount = maxSize / 2; // 計算中間位置
        // 中間部分進行插入
        for (int i = startCount; i < (startCount + operationSize); i++) {
            linkedList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(linkedList);
    }
}

從以上代碼可以看出,在測試之前,我們先將 ArrayList 和 LinkedList 進行數據初始化,再從中間開始添加 100 個元素,執行結果如下:

從上述結果可以看出,LinkedList 的平均執行時間比 ArrayList 平均執行時間快了約 54 倍。

3.尾部添加性能測試

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {
    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }
    
    @Benchmark
    public void addArrayByEnd(Blackhole blackhole) {
        int startCount = maxSize - 1 - operationSize;
        for (int i = startCount; i < (maxSize - 1); i++) {
            arrayList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(arrayList);
    }

    @Benchmark
    public void addLinkedByEnd(Blackhole blackhole) {
        int startCount = maxSize - 1 - operationSize;
        for (int i = startCount; i < (maxSize - 1); i++) {
            linkedList.add(i, i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(linkedList);
    }
}

以上程序的執行結果爲:

從上述結果可以看出,LinkedList 的平均執行時間比 ArrayList 平均執行時間快了約 32 倍。

4.頭部查詢性能評測

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {
    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }

    @Benchmark
    public void findArrayByFirst() {
        for (int i = 0; i < operationSize; i++) {
            arrayList.get(i);
        }
    }

    @Benchmark
    public void findLinkedyByFirst() { 
        for (int i = 0; i < operationSize; i++) {
            linkedList.get(i);
        }
    }
}

以上程序的執行結果爲:

從上述結果可以看出,從頭部查詢 100 個元素時 ArrayList 的平均執行時間比 LinkedList 平均執行時間快了約 1990 倍。

5.中間查詢性能評測

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {
    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }

    @Benchmark
    public void findArrayByMiddle() { 
        int startCount = maxSize / 2;
        int endCount = startCount + operationSize;
        for (int i = startCount; i < endCount; i++) {
            arrayList.get(i);
        }
    }

    @Benchmark
    public void findLinkedyByMiddle() { 
        int startCount = maxSize / 2;
        int endCount = startCount + operationSize;
        for (int i = startCount; i < endCount; i++) {
            linkedList.get(i);
        }
    }
}

以上程序的執行結果爲:

從上述結果可以看出,從中間查詢 100 個元素時 ArrayList 的平均執行時間比 LinkedList 平均執行時間快了約 28089 倍,真是恐怖。

6.尾部查詢性能評測

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {
    private static final int maxSize = 1000; // 測試循環次數
    private static final int operationSize = 100; // 操作次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }

    @Setup
    public void init() {
        // 啓動執行事件
        arrayList = new ArrayList<Integer>();
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }
    }

    @Benchmark
    public void findArrayByEnd() {
        for (int i = (maxSize - operationSize); i < maxSize; i++) {
            arrayList.get(i);
        }
    }

    @Benchmark
    public void findLinkedyByEnd() { 
        for (int i = (maxSize - operationSize); i < maxSize; i++) {
            linkedList.get(i);
        }
    }
}

以上程序的執行結果爲:

從上述結果可以看出,從尾部查詢 100 個元素時 ArrayList 的平均執行時間比 LinkedList 平均執行成時間快了約 1839 倍。

7.擴展添加測試

接下來我們再來測試一下,正常情況下我們從頭開始添加數組和鏈表的性能對比,測試代碼如下:

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 測試完成時間
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱次數和時間
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) // 測試次數和時間
@Fork(1) // fork 1 個線程
@State(Scope.Thread)
public class ArrayOptimizeTest {

    private static final int maxSize = 1000; // 測試循環次數

    private static ArrayList<Integer> arrayList;
    private static LinkedList<Integer> linkedList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(ArrayOptimizeTest.class.getSimpleName()) // 要導入的測試類
                .build();
        new Runner(opt).run(); // 執行測試
    }
    
    @Benchmark
    public void addArray(Blackhole blackhole) { // 中間刪數組表
        arrayList = new ArrayList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            arrayList.add(i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(arrayList);
    }

    @Benchmark
    public void addLinked(Blackhole blackhole) { // 中間刪除鏈表
        linkedList = new LinkedList<Integer>();
        for (int i = 0; i < maxSize; i++) {
            linkedList.add(i);
        }
        // 爲了避免 JIT 忽略未被使用的結果計算
        blackhole.consume(linkedList);
    }
}

以上程序的執行結果爲:

接下來,我們將添加的次數調至 1w,測試結果如下:

最後,我們再將添加次數調至 10w,測試結果如下:

從以上結果可以看出在正常情況下,從頭部依次開始添加元素時,他們性能差別不大。

總結

本文我們介紹了數組的概念以及它的優缺點,同時還介紹了單向鏈表、雙向鏈表及循環鏈表的概念以及鏈表的優缺點。我們在最後的評測中可以看出,當我們正常從頭部依次添加元素時,鏈表和數組的性能差不不大。但當數據初始化完成之後,我們再進行插入操作時,尤其是從頭部插入時,因爲數組要移動之後的所有元素,因此性能要比鏈表低很多;但在查詢時性能剛好相反,因爲鏈表要遍歷查詢,並且 LinkedList 是雙向鏈表,所以在中間查詢時性能要比數組查詢慢了上萬倍(查詢 100 個元素),而兩頭查詢(首部和尾部)時,鏈表也比數組慢了將近 1000 多倍(查詢 100 個元素),因此在查詢比較多的場景中,我們要儘量使用數組,而在添加和刪除操作比較多時,我們應該使用鏈表結構

數組和鏈表的操作時間複雜度,如下表所示:


數組鏈表
查詢O(1)O(n)
插入O(n)O(1)
刪除O(n)O(1)
最後的話原創不易,用心寫好每篇文章,若能看出來,請給我一個「在看」鼓勵吧。

往期推薦

輕鬆學算法的祕密!可視化算法網站彙總!(附動圖)

50種Java編程技巧,越早知道越好!(建議收藏)

關注下方二維碼,每一天都有乾貨!

點亮“在看”,助我寫出更多好文!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章