1. 程式人生 > >連結串列的底層原理和實現

連結串列的底層原理和實現

一、簡介

  本文從連結串列的簡介開始,介紹了連結串列的儲存結構,並根據其儲存結構分析了其儲存結構所帶來的優缺點,進一步我們通過程式碼實現了一個輸入我們的單向連結串列。然後通過對遞迴過程和記憶體分配的詳細講解讓大家對連結串列的引用和連結串列反轉有一個深入的瞭解。單向連結串列實現了兩個版本,分別使用迴圈和遞迴實現了兩個版本的連結串列,相信大家仔細閱讀本文後會對連結串列和遞迴有一個深刻的理解。再也不怕面試官讓手寫連結串列或者反轉連結串列了。

  後面會持續更新資料結構相關的博文。

  資料結構專欄:https://www.cnblogs.com/hello-shf/category/1519192.html

  git傳送門:https://github.com/hello-shf/data-structure.git

二、連結串列

2.1、連結串列簡介

  連結串列是一種物理儲存單元上非連續、非順序的儲存結構,資料元素的邏輯順序是通過連結串列中的指標連結次序實現的。連結串列由一系列結點(連結串列中每一個元素稱為結點)組成,結點可以在執行時動態生成。每個結點包括兩個部分:一個是儲存資料元素的資料域,另一個是儲存下一個結點地址的指標域。

  使用連結串列結構可以克服陣列連結串列需要預先知道資料大小的缺點,連結串列結構可以充分利用計算機記憶體空間,實現靈活的記憶體動態管理。但是連結串列失去了陣列隨機讀取的優點,同時連結串列由於增加了結點的指標域,空間開銷比較大。

  前面文章我們介紹了陣列這種資料結構,其最大的優點就是連續的儲存空間所帶來的隨機訪問的能力,最大的缺點同樣是連續儲存空間所造成的的容量的固定即不具備動態性。對於連結串列剛好相反,其是由物理儲存單元上非連續的儲存結構,這種結構能真正實現資料結構的動態性,但隨之而來的就是喪失了隨機訪問的優點。正如一句古話-魚和熊掌不可兼得。陣列和連結串列正是這種互補的關係。

  由上面可知,連結串列最大的優點就在於--動態。

 

2.2、連結串列的儲存結構

  如下圖所示,單向連結串列正是以這種方式儲存的。單向連結串列包含兩個域,一個是資訊域,一個是指標域。也就是單向連結串列的節點被分成兩部分,一部分是儲存或顯示關於節點的資訊,第二部分儲存下一個節點的地址,而最後一個節點則指向一個空值。

  雙向連結串列相對於單向連結串列,不過就是在指標域中除了指向下一個元素的指標,還存在一個指向上一個元素的指標。

  循壞連結串列相對於單向連結串列,在最後一個元素的指標域存在一個指向頭節點的指標。使之形成一個環。

 

三、實現一個單向連結串列

  首先,為什麼我們要自己實現一個連結串列?大家在找工作面試的時候,一旦被問到資料結構,手寫連結串列應該都是必備的問題。其次,因為連結串列具有天然的遞迴性,連結串列的學習,有助於我們更深層次的理解遞迴。同樣連結串列的學習對於我們理解Java中的“引用”有很好的幫助。

  對於我們要實現的連結串列,我們作如下設計

1 以Node作為連結串列的基礎儲存結構
2 單向連結串列
3 使用泛型-增加靈活性
4 基本操作:增刪改查等

 

3.1、連結串列的底層儲存結構

  對於連結串列我們將資料儲存在一個node節點中,所以我們要設計一個node。

 1  /**
 2  * 描述:單向連結串列實現
 3  * 對應 java 集合類 linkedList
 4  *
 5  * @Author shf
 6  * @Date 2019/7/18 16:45
 7  * @Version V1.0
 8  **/
 9 public class MyLinkedList<E> {
10     /**
11      * 私有的 Node
12      */
13     private class Node{
14         public E e;
15         public Node next;
16 
17         public Node(E e, Node next){
18             this.e = e;
19             this.next = next;
20         }
21         public Node(E e){
22             this(e, null);
23         }
24         public Node(){
25             this(null, null);
26         }
27     }
28     private Node head;
29     private int size;
30 
31     public MyLinkedList(){
32         head = null;
33         size = 0;
34     }
35     public int getSize(){
36         return this.size;
37     }
38     public boolean isEmpty(){
39         return size == 0;
40     }
41 }

   如上程式碼所示,我們通過定義一個私有的Node類,作為我們連結串列的基礎儲存結構。並在MyLinkedList中維護一個 head 屬性,作為整個連結串列的頭結點。

 

3.2、新增元素

   我們設計這麼一個方法,就是在連結串列的 index 位置 新增元素,我們只需要找到index的前一個元素prev,然後讓其next指向我們要新增的節點newNode,然後讓newNode的next指向prev的next節點即可。可能看著這段話有點繞。比如在 索引為2的位置新增一個新元素,看下圖:

  這樣我們就將我們的666元素新增到了索引為2的位置。具體程式碼實現如下所示:

 1     /**
 2      * 在 index 位置 新增元素
 3      * 時間複雜度:O(n)
 4      * @param index
 5      * @param e
 6      */
 7     public void add(int index, E e){
 8 
 9         if(index < 0 || index > size)
10             throw new IllegalArgumentException("Add failed. Illegal index.");
11 
12         if(index == 0)
13             addFirst(e);
14         else{
15             Node prev = head;
16             for(int i = 0 ; i < index - 1 ; i ++)
17                 prev = prev.next;
18 
19             // Node node = new Node(e);
20             // node.next = prev.next;
21             // prev.next = node;
22             // 以上三行程式碼等價於下面這行程式碼 
23 
24             prev.next = new Node(e, prev.next);
25             size ++;
26         }
27     }
28     /**
29      * 在連結串列頭 新增元素
30      * 時間複雜度:O(1)
31      * @param e
32      */
33     public void addFirst(E e){
34         // Node node = new Node(e);
35         // node.next = head;
36         // head = node;
37         // 以上三行程式碼等價於下面這行程式碼 
38 
39         head = new Node(e, head);
40         size ++;
41     }
42 
43     /**
44      * 在連結串列尾 新增元素
45      * 時間複雜度:O(n)
46      * @param e
47      */
48     public void addLast(E e){
49         add(size, e);
50     }

   在上面add方法中我們需要判斷 index == 0 這種特殊情況。我們可以通過將維護的head改為一個虛假的頭節點 dummyHead,來改善我們的程式碼。這也是構造連結串列的一般手段。

  對於 head 這種情況,連結串列的儲存結構如下圖所示:

  如果我們將 MyLinkedList中維護的 head 變成dummyHead,儲存結構如下:

  相應的我們的程式碼將進行簡化:

 1     private Node dummyHead;
 2     private int size;
 3 
 4     public MyLinkedList(){
 5         dummyHead = new Node();
 6         size = 0;
 7     }
 8     public int getSize(){
 9         return this.size;
10     }
11     public boolean isEmpty(){
12         return size == 0;
13     }
14 
15     /**
16      * 在 index 位置 新增元素
17      * @param index
18      * @param e
19      */
20     public void add(int index, E e){
21         if(index < 0 || index > size){
22             throw new IllegalArgumentException("新增失敗,Index 引數不合法");
23         }
24         Node prev = dummyHead;// TODO 不理解這一行就是沒有理解java中引用的含義
25         for(int i=0; i< index; i++){
26             prev = prev.next;
27         }
28         prev.next = new Node(e, prev.next);
29         size ++;
30     }
31 
32     /**
33      * 在連結串列頭 新增元素
34      * 時間複雜度:O(1)
35      * @param e
36      */
37     public void addFirst(E e){
38         this.add(0, e);
39     }
40 
41     /**
42      * 在連結串列尾 新增元素
43      * 時間複雜度:O(n)
44      * @param e
45      */
46     public void addLast(E e){
47         this.add(size, e);
48     }

  我們可以看到,當我們引入了dummyHead,我們的程式碼更加精練了。後面所有的操作,我們都依據有dummyHead的程式碼來實現。

 

3.3、刪除

  刪除和新增其實就差不多了,我們設計一個方法,刪除指定索引位置的元素的方法。如下圖,我們刪除索引為2位置的元素666。

  如圖所示,我們只需要找到 所以為2的前一個元素prev,然後讓其next指向666元素的下一個元素即可。但是別忘了,將666和連結串列斷開連線。

 1     /**
 2      * 刪除連結串列 index 位置的元素
 3      * @param index
 4      * @return
 5      */
 6     public E remove(int index){
 7         if(index < 0 || index >= size){
 8             throw new IllegalArgumentException("操作失敗,Index 引數不合法");
 9         }
10         Node prev = dummyHead;
11         for(int i=0; i< index; i++){
12             prev = prev.next;
13         }
14         Node rem = prev.next;
15         prev.next = rem.next;
16         rem.next = null;// 看不懂這行就是還沒理解連結串列。將rem斷開與連結串列的聯絡。
17         size--;
18         return rem.e;
19     }
20 
21     /**
22      * 刪除 頭元素
23      * @return
24      */
25     public E removeFirst(){
26         return remove(0);
27     }
28 
29     /**
30      * 刪除 尾元素
31      * @return
32      */
33     public E removeLast(){
34         return remove(size - 1);
35     }

 

3.4、連結串列反轉

  首先,我們在巨集觀角度分析,連結串列是有天然遞迴性的,這個大家都明白,我們想要實現連結串列反轉,無非就是讓每個元素的next指向前一個元素即可。看圖(加了水印,大家湊活著看吧,作圖很辛苦):

  程式碼先放到這

 1     /**
 2      * 連結串列反轉
 3      */
 4     public void reverseList(){
 5         dummyHead.next = reverseList(dummyHead.next);
 6     }
 7 
 8     /**
 9      * 連結串列反轉 - 遞迴實現
10      * @param root
11      * @return
12      */
13     private Node reverseList(Node root){
14         if(root.next == null){
15             return root;
16         }
17         // 先記住 root 的next節點
18         Node temp = root.next;
19         // 遞迴 root 的next節點,並返回root的節點
20         Node node = reverseList(root.next);
21         // 將 root 節點與連結串列斷開連線
22         root.next = null;
23         // 讓我們之前快取的 root的下一個節點 指向 root節點,這樣就實現了連結串列的反轉
24         temp.next = root;
25         return node;
26     }

   看到上面程式碼,估計大家會有點頭蒙,並且不知所措,沒問題,繼續往下看,為了方便描述,我們加一個引數,遞迴深度。

 1     /**
 2      * 連結串列反轉
 3      */
 4     public void reverseList(){
 5         dummyHead.next = reverseList(dummyHead.next, 0);
 6     }
 7     /**
 8      * 連結串列反轉 - 遞迴實現
 9      * @param root
10      * @return
11      */
12     private Node reverseList(Node root, int deap){
13         System.out.println("遞迴深度==>" + deap);
14         if(root.next == null){
15             return root;
16         }
17         // 先記住 root 的next節點
18         Node temp = root.next;
19         // 遞迴 root 的next節點,並返回root的節點
20         Node node = reverseList(root.next, (deap + 1));
21         // 將 root 節點與連結串列斷開連線
22         root.next = null;
23         // 讓我們之前快取的 root的下一個節點 指向 root節點,這樣就實現了連結串列的反轉
24         temp.next = root;
25         return node;
26     }

  遞迴深度==>0
  遞迴深度==>1
  遞迴深度==>2
  遞迴深度==>3

  對於上面這幾行程式碼,我們發現,我們對 node 什麼都沒做,為什麼要返回 node 呢?其實呢,node只是一個引用,node始終指向遞迴深度為 3的時候,返回的root,也就是 0 這個節點。明確這一點我們繼續分析。  

  結合遞迴深度,先分析一下遞迴樹,如下表所示:

遞迴深度 遞迴樹(root的指向) 遞迴樹(temp的指向) 遞迴樹(node指向)
0 3 2 0
1 2 1 0
2 1 0 0
3 0    

  如果你看上面的遞迴樹對root,temp,node的指向感覺還有點懵,沒關係,繼續往下看,我們從堆疊的記憶體分佈來說一下各個引用隨遞迴深度的變化。從下圖我們不難發現,其實在堆裡面始終都是3210四個節點,也就是說,root,temp,node僅僅是堆記憶體裡面這四個節點的引用而已。到這裡想必大家應該對引用有了一個直觀的理解。

  接下來,我們結合上圖和壓棧出棧的角度對該遞迴程式碼的執行順序和堆記憶體的變化進行一個詳細的分析。

  結合上面的遞迴樹和堆疊的記憶體分佈圖進行一下分析:

  第1步:遞迴深度0,temp變數指向遞迴深度為0的root.next及節點2(2 ==> 1 ==> 0 ==> null)。並將temp變數壓入棧頂。執行遞迴,也就是步驟1。

  第2步:遞迴深度1,temp變數指向遞迴深度為1的root.next及節點1(1 ==> 0 ==> null)。並將temp變數壓入棧頂。執行遞迴,也就是步驟2。

  第3步:遞迴深度2,temp變數指向遞迴深度為1的root.next及節點0( 0 ==> null)。並將temp變數壓入棧頂。執行遞迴,也就是步驟3。

  第4步:遞迴深度3,直接返回root == 0(0 == > null)也就是出棧。

  第5步:遞迴深度2,當前棧頂元素為第3步的temp(指向0 == null),node指向 0節點(0 ==> null)(我們就不提node壓棧出棧的事情了,因為我們上面分析過node始終是指向0節點的)。

      首先看上面的遞迴樹,當前node = 0;root = 1;temp=0;

      執行程式碼:

      root.next = null;這行程式碼改變了堆記憶體中的1節點的指向,將1節點和0幾點斷開了連線。及1 ==> null。當前堆記憶體如下圖1。

      temp.next = root;這行程式碼將0節點的下一個節點指向root所指向的堆記憶體也就是1節點。及 0 ==> 1 ==> null。當前堆記憶體如下圖2。

  第6步:return node;node,temp變量出棧。

  第7步:遞迴深度1,當前棧頂元素為第2步的temp(指向節點1 == null)。

      首先看上面的遞迴樹,當前node = 0; root = 2;temp = 1;別忘了當前節點1 ==> null,0 == 1 ==> null。

      執行程式碼:

      root.next = null;這行程式碼同樣改變了堆記憶體中2節點的指向,將2節點的和1節點斷開了連線。及2 ==> null。當前堆記憶體如下圖3。

      temp.next = root;這行程式碼將1節點指向root所指向的堆記憶體也就是2節點。及1 ==> 2 ==> null。當前堆記憶體如下圖4所示。

  第8步:return node;node, temp變量出棧。

  第9步:遞迴深度0,當前棧頂元素為0步的temp(指向節點2 == null)

      首先看上面的遞迴樹,當前node = 0; root = 3;temp = 2;別忘了當前節點2 ==> null,0 == 1 ==> 2 ==> null。

      執行程式碼:

      root.next = null;這行程式碼同樣改變了堆記憶體中3節點的指向,將3節點的和2節點斷開了連線。及3 ==> null。當前堆記憶體如下圖5。

      temp.next = root;這行程式碼將2節點指向root所指向的堆記憶體也就是3節點。及2 ==> 3 ==> null。當前堆記憶體如下圖6所示。

      return node;node, temp變量出棧。

  

  OK,終於分析完了,大家應該對遞迴有了一個深刻的理解。

  其實遞迴反轉連結串列的程式碼還可以更簡練一點:

1     private Node reverseList1(Node node){
2         if(node.next == null){
3             return node;
4         }
5         Node cur = reverseList1(node.next);
6         node.next.next = node;
7         node.next = null;
8         return cur;
9     }

 

3.5、查,改等操作

  關於這些操作,如果前面的增和刪操作看明白了,這些操作就很簡單了。直接上程式碼吧。

 1     /**
 2      * 獲取連結串列的第index個位置的元素
 3      * 時間複雜度:O(n)
 4      * @param index
 5      * @return
 6      */
 7     public E get(int index){
 8         if(index < 0 || index >= size){
 9             throw new IllegalArgumentException("獲取失敗,Index 引數非法");
10         }
11         Node cur = dummyHead.next;
12         for(int i=0; i< index; i++){
13             cur = cur.next;
14         }
15         return cur.e;
16     }
17 
18     /**
19      * 獲取頭元素
20      * 時間複雜度:O(1)
21      * @return
22      */
23     public E getFirst(){
24         return get(0);
25     }
26 
27     /**
28      * 獲取尾元素
29      * 時間複雜度:O(n)
30      * @return
31      */
32     public E getLast(){
33         return get(size - 1);
34     }
35 
36     /**
37      * 修改 index 位置的元素 e
38      * 時間複雜度:O(n)
39      * @param index
40      * @param e
41      */
42     public void set(int index, E e){
43         if(index < 0 || index >= size){
44             throw new IllegalArgumentException("操作失敗,Index 引數不合法");
45         }
46         Node cur = this.dummyHead.next;
47         for(int i=0; i< index; i++){
48             cur = cur.next;
49         }
50         cur.e = e;
51     }
52 
53     /**
54      * 查詢連結串列中是否存在元素 e
55      * 時間複雜度:O(n)
56      * @param e
57      * @return
58      */
59     public boolean contains(E e){
60         Node cur = dummyHead.next;
61         for(int i=0; i<size; i++){
62             if(cur.e == e){
63                 return true;
64             }
65             cur = cur.next;
66         }
67         return false;
68     }

 

  關於各個操作的時間複雜度,在每個方法的註釋中都寫明瞭。連結串列的時間複雜度很穩定,沒什麼好分析的。

 

四、單向連結串列相應的遞迴實現

 

/**
 * 描述:遞迴實現版
 *
 * @Author shf
 * @Date 2019/7/26 17:04
 * @Version V1.0
 **/
public class LinkedListR<E> {
    private class Node{
        private Node next;
        private E e;
        public Node(E e, Node next){
            this.e = e;
            this.next = next;
        }
        public Node(E e){
            this(e, null);
        }
        public Node(){
            this(null, null);
        }
        @Override
        public String toString(){
            return e.toString();
        }
    }
    private Node dummyHead;
    private int size;
    public LinkedListR(){
        this.dummyHead = new Node();
        this.size = 0;
    }
    public int size(){
        return size;
    }
    public boolean isEmpty(){
        return size == 0;
    }

    /**
     * 向 index 索引位置 新增元素 e
     * @param index
     * @param e
     */
    public void add(int index, E e){
        add(index, e, dummyHead, 0);
    }

    /**
     * 向 index 索引位置 新增元素 e 遞迴實現
     * @param index 索引位置
     * @param e 要新增的元素 e
     * @param prev index 索引位置的前一個元素
     * @param n
     */
    private void add(int index, E e, Node prev, int n){
        if(index == n){
            size ++;
            prev.next = new Node(e, prev.next);
            return;
        }
        add(index, e, prev.next, n+1);
    }

    /**
     * 向連結串列 頭 新增元素
     * @param e
     */
    public void addFirst(E e){
        this.add(0, e);
    }

    /**
     * 向連結串列 尾 新增元素
     * @param e
     */
    public void addLast(E e){
        this.add(this.size, e);
    }

    /**
     * 獲取索引位置為 index 處的元素
     * @param index
     * @return
     */
    public E get(int index){
        if(index < 0 || index >= size){
            throw new IllegalArgumentException("index 引數非法");
        }
        return get(index, 0, dummyHead.next);
    }
    private E get(int index, int n, Node node){
        if(index == n){
            return node.e;
        }
        return get(index, (n + 1), node.next);
    }
    public E getFirst(){
        return this.get(0);
    }
    public E getLast(){
        return this.get(this.size - 1);
    }
    public boolean contains(E e){
        return contains(e, dummyHead.next);
    }
    private boolean contains(E e, Node node){
        if(node == null){
            return false;
        }
        if(node.e.equals(e)){
            return true;
        }
        return contains(e, node.next);
    }
    public E remove(int index){
        if(index < 0 || index >= size){
            throw new IllegalArgumentException("Index is illegal");
        }
        return remove(dummyHead, index, 0);
    }
    private E remove(Node prev, int index, int n){
        if(n == index){
            Node cur = prev.next;
            prev.next = cur.next;
            cur.next = null;
            return cur.e;
        }
        return remove(prev.next, index, (n + 1));
    }
    public E removeElement(E e){
        return removeElement(e, dummyHead);
    }

    private E removeElement(E e, Node prev){
        if(prev.next != null && e.equals(prev.next.e)){
            Node cur = prev.next;
            prev.next = cur.next;
            cur.next = null;
            return cur.e;
        }
        return removeElement(e, prev.next);
    }
    @Override
    public String toString(){
        StringBuilder res = new StringBuilder();

        Node cur = dummyHead.next;
        while(cur != null){
            res.append(cur + "->");
            cur = cur.next;
        }
        res.append("NULL");

        return res.toString();
    }
}

 

 

 

   為了中華民族的偉大復興,做一個愛國敬業的碼農。

   參考文獻:

  《玩轉資料結構-從入門到進階-劉宇波》

  《資料結構與演算法分析-Java語言描述》

 

  如有錯誤還請留言指正。

  原創不易,轉載請註明原文地址:https://www.cnblogs.com/hello-shf/p/11304615.html