Java基礎系列:瞭解LinkedList

來,進來的小夥伴們,咱們認識一下。html

我是俗世遊子,在外流浪多年的Java程序猿java

這兩天看到太多小夥伴在秀公司「10.24程序員節」的福利,我認可我酸了o(╥﹏╥)onode

上一節咱們聊過了ArrayList,對其底層結構和源碼實現進行了瞭解,那這節咱們來聊一聊關於它的「兄弟集合」:LinkedList程序員

集合之LinkedList

特性

一樣屬於List的子類,那麼也就一樣擁有了其特色:api

  • 有序
  • 不重複

LinkedList結構

能夠看到,LinkedList除了實現List接口外,還實現了Queue接口,在Java中,該接口定義的是隊列的,向咱們以後要聊到的數組

等等的都是屬於該類的實現安全

基於這種方式,那麼咱們的LinkedList也適合作隊列的處理場景,好比:數據結構

  • FIFO
  • 堆,棧等

鏈表的介紹

LinkedList底層是基於雙向鏈表的方式來存儲的,那確定有人在想,什麼是鏈表呢?咱們這就來聊一聊oracle

什麼是鏈表

鏈表是一種在邏輯上連續,可是物理存儲上非連續的存儲結構,其保證邏輯連續是經過指針指向來肯定順序的。ide

鏈表結構

上面看到的是單向鏈表,能夠看到:

  • 在鏈表中,每一個節點稱爲Node,其中包含兩個部分
    • data:具體存儲數據
    • next:是指針的指向,指向下一個Node

還有一種雙向鏈表的形式

雙向鏈表

看上圖:

  • 節點中,額外多了個一個prev的指向,雙向鏈表的節點是兩兩互相指向

LinkedList就是採用的雙向鏈表的形式,下面咱們來看具體的代碼

鏈表的操做

背景:這裏已雙向鏈表爲例

對鏈表操做,實際上就是修改指針的指向,好比

  • 插入元素

頭尾的插入很是簡單,直接指向 nextprev 就能夠了,這裏咱們看插入到中間

雙向鏈表元素插入到中間

  • 移除元素

移除元素和插入元素很相似,無非就是將指定元素刪除掉,而後將指針指向下一個節點

移除元素

前面的鏈表介紹都是爲以後作鋪墊,咱們繼續來看今天的主角:LinkedList

LinkedList詳細介紹

LinkedList底層是採用雙向鏈表的結構來進行數據存儲的,能夠說,LinkedList全部的操做都是針對引用指向來進行操做的。下面來看具體的方法

咱們是這麼操做LinkedList

LinkedList<String> linkedList = new LinkedList<>();
// Arrays.asList("item1", "item2"):爲了方便演示
LinkedList<String> linkedList2 = new LinkedList<>(Arrays.asList("item1", "item2"));

一樣,咱們仍是經過構造方法來看:

public LinkedList() {
}

public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

ArrayList不一樣,這裏只有兩個構造方法。

  • LinkedList中,初始長度是不須要設置的,並且也不須要擴容操做。

  • 理論狀況下,只要內存足夠大,那麼LinkedList就能夠一直存儲下去

不須要設置初始長度和底層存儲結構有關,若是想不明白能夠先去上一節看一看數組的介紹

不過也有說,LinkedList是存在最大容量的:不能超過Integer.MAX_VALUE

你們能夠查找下資料,好好驗證下該說法(本人沒有在源碼中發現對應的驗證)

同時,咱們來看一看Node的屬性值:

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

雙向鏈表節點:在代碼中對應的具體實現。

這裏我須要讓你們考慮一個問題:

ArrayList和LinkedList一樣存儲了100W的數據,哪一種集合佔用的空間更大?

具體的操做方法

插入元素的方法

前面也說到了,LinkedList除了實現List接口外,還實現了Queue接口,天然也重寫其對應的方法,下面咱們一一來看看:

linkedList.add("item2");
linkedList.add(1, "item3");
linkedList.addFirst("item0");
linkedList.addLast("item9");
咱們對比add(e)addLast(e)的源代碼
public boolean add(E e) {
    linkLast(e);
    return true;
}

public void addLast(E e) {
    linkLast(e);
}

void linkLast(E e) {
    // 獲得臨時變量尾結點
    final Node<E> l = last;
    // 新節點的上一個節點是以前的尾結點,下一個節點是null
    final Node<E> newNode = new Node<>(l, e, null);
    // 新的節點成爲尾結點,
    last = newNode;
    // 若是尾結點是null,這個鏈表是空的,那麼頭結點也是新增的節點
    if (l == null)
        first = newNode;
    else
        // 以前尾結點的next是新節點
        l.next = newNode;
    size++;
    modCount++;
}
  • 二者實現方式都是linkLast(e)方法,從字面和具體的實現方法上來看,默認的add(e)方法是將元素添加到了鏈表的尾部,這裏專業名詞叫:尾插法
  • addLast(e)就更不用說了,直接將元素添加到尾部

  • 關於linkLast(e)方法註釋都已經有了,其實就是在調整指針的指向,總體描述以下圖:

尾部插入

addFirst(e)
public void addFirst(E e) {
    linkFirst(e);
}

private void linkFirst(E e) {
    // 獲得臨時變量頭結點
    final Node<E> f = first;
    // 新節點 插入到頭部,因此新節點的next指向f
    final Node<E> newNode = new Node<>(null, e, f);
    // 一樣,新節點稱爲新的頭
    first = newNode;
    // 若是頭結點是null,這個鏈表是空的,那麼尾結點也是新增的節點
    if (f == null)
        last = newNode;
    else
        // 不然的話,新節點的prev指向新節點
        f.prev = newNode;
    size++;
    modCount++;
}
  • 其實,addFirst(e)addLast(e)方法正好相反

這裏就不給圖了,就是上面插入尾部改爲插入頭部

add(index, e)

這裏咱們要看一下指定位置的插入:

public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

linkBefore()方法就不貼出來了,也就是改變prev和next的指向,下面咱們看一下node(index)方法

  • 咱們能夠看到,指定位置插入節點,須要先遍歷將當前索引上的節點找到,而後在改變指向
  • 一樣,這裏在遍歷的時候採用簡單二分法,判斷當前索引是在鏈表的前半段仍是在後半段,減小循環遍歷的次數,提升了性能

這裏咱們舉個例子

好比:LinkedList中存儲了500條,咱們須要往250個位置上添加,那麼node(index)對應的遍歷就是在後半段:

Node<E> x = last;
for (int i = 500 - 1; i > 250; i--)
    x = x.prev;
return x;

// 好比這裏是 400
Node<E> x = last;
for (int i = 500 - 1; i > 400; i--)
    x = x.prev;
return x;
  • 這樣的狀況下,須要循環最少(499-250)次才能找到對應的節點,而若是索引越大,那麼循環次數就越小:
  • 基於這種方式咱們得出結論:

雖然遍歷採用簡單二分法提高了總體遍歷的性能,可是若是遍歷的節點越靠近中間位置,檢索的效率也就越低

這裏給你們留一個試驗:ArrayList的插入和LinkedList的插入,性能相好比何?須要考慮一下幾個方面:

  • ArrayList的擴容問題
  • 插入到頭部,中間,尾部的性能

獲取元素

直接get(index)的方式
linkedList.get(0);
// 在LinkedList中已經記錄了頭節點和尾結點,這裏就是獲得當前的數據就好了
linkedList.getFirst();
linkedList.getLast();
  • 其實這裏的get(index)咱們上面已經介紹到了,就是經過node(index)來獲得指定索引的數據的
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
Iterator的方式
Iterator<String> iterator = linkedList.iterator();

這種模式咱們就不介紹了,iterator()實現實際上是採用這種方式來作的:

public ListIterator<E> listIterator() {
    return listIterator(0);
}

public ListIterator<E> listIterator(final int index) {
    rangeCheckForAdd(index);

    return new ListItr(index);
}

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;
    private int expectedModCount = modCount;

    ListItr(int index) {
        // assert isPositionIndex(index);
        next = (index == size) ? null : node(index);
        nextIndex = index;
    }
}

這裏默認傳入的參數是:0,LinkedList爲咱們開放了該方法:

ListIterator<String> listIterator = linkedList.listIterator(0); // ==  linkedList.iterator();

ListIterator和Iterator二者的對比咱們上節也介紹過了

那麼,問題來了:在迭代LinkedList的時候咱們該採用那種方式?

咱們來作個實驗進行驗證:1W的數據,咱們來進行驗證

int len = 1_0000;
LinkedList<String> linkedList = new LinkedList<String>() {{
    for (int i = 0; i < len; i++) {
        add("item" + i);
    }
}};

long start = System.currentTimeMillis();
for (int i = 0; i < len; i++) {
    linkedList.get(i);
}
System.out.println("for i 耗時:" + (System.currentTimeMillis() - start));

start = System.currentTimeMillis();
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
    iterator.next();
}
System.out.println("iterator 耗時:" + (System.currentTimeMillis() - start));

猜一猜最終的結果如何?

迭代對比

咱們來想想爲何:

  • 上面介紹過,get(index)底層實現是:node(index),最外層每循環一次,node(index)每次都會進行一次二分查找,而後循環迭代索引取出對應的值
  • 若是採用iterator()的話,只會在構造方法中進行一次node(0)的迭代取出第一個節點,由於雙向鏈表的形式,因此經過item.next來就能夠取出對應的元素
public E next() {
    checkForComodification();
    if (!hasNext())
        throw new NoSuchElementException();

    lastReturned = next;
    next = next.next;
    nextIndex++;
    return lastReturned.item;
}

因此咱們在LinkedList的迭代的時候,最好採用iterator()的方式

移除元素

咱們都是經過remove()來移除元素,那麼對應其中的實現:

E unlink(Node<E> x) {
    // assert x != null;
    // 獲得當前元素,當前next和prev的指向對象
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    // 若是prev是null,說明是第一個節點
    if (prev == null) {
        first = next;
    } else {
        // 不然就將當前節點的prev的下一個節點指向當前節點的next
        prev.next = next;
        x.prev = null;
    }

    // 若是next是null,說明是最後一個節點
    if (next == null) {
        last = prev;
    } else {
        // 不然就將當前節點的prev的下一個節點指向當前節點的prev
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}

這裏其實就是修改引用的過程,這裏就不展現圖了,你們感興趣的話我在後面給出一個站點,你們能夠在那個站點上查看對應具體的過程

這裏咱們就過了,下面基於LinkedList簡單聊一點隊列的東西

隊列

隊列也是咱們經常使用的一種數據結構:並且只容許在一端進行插入操做,另外一端進行刪除操做。

這不就是排排站,先插入進來的先被處理掉。

這種結構能夠稱爲先進先出的方式,也就是咱們所說的:FIFO,具體以下:

image-20201024165735272

那麼,LinkedList又是怎麼作的呢?

簡單來兩個方法來實現一下:

  • push(e)

將元素推送到由此列表表示的堆棧上

底層實現也很是簡單,就是調用以前的addFirst(e)來操做的,都是上面介紹過的

public void push(E e) {
    addFirst(e);
}
  • pollLast(e)

檢索並刪除此列表的最後一個元素

public E pollLast() {
    final Node<E> l = last;
    return (l == null) ? null : unlinkLast(l);
}

private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}

根據LinkedList提供的方法就能夠有不少中實現方式,只要知足先進先出的方式

線程安全性

ArrayList是同樣的,若是隻是當作局部變量來使用的話,是不存在線程問題的;可是若是當作共享資源來使用,那麼必然是線程不安全的,針對解決方式:

  • 本身加鎖
  • 使用Collections#synchronizedList方法的返回值來進行數據操做

文檔

更多關於LinkedList使用方法推薦查看其文檔:

LinkedListAPI文檔

數據結構必備站點

相關文章
相關標籤/搜索