鏈表的底層原理和實現


一、簡介

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

  后面會持續更新數據結構相關的博文。

  數據結構專欄: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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM