數據結構:手撕鏈表

1. 簡介

鏈表也是最基礎的數據結構,屬於線性表。鏈表就像火車一樣,每一個車廂互相連接,這些車廂就是一個個結點(Node)。鏈表就是通過這些結點的連接形成的。

對比於數組,鏈表不支持隨機訪問,所以數組的訪問速度非常快,而鏈表就慢了。但是鏈表的長度是動態的,這一點比數組好,不會浪費空間。

2. 創建鏈表

把結點Node封裝在類中,因爲用戶是不需要知道有Node結點這些概念。

public class LinkedList<E> {
    // 結點
    private class Node{
        public E data;
        public Node next;

        public Node(E data, Node next) {
            this.data = data;
            this.next = next;
        }

        public Node(E data) {
            this(data, null);
        }

        public Node() {
            this(null, null);
        }
        
        @Override
        public String toString() {
            return data.toString();
        }
    }
}

3. 鏈表的添加

在添加之前,我們需要去訪問,因爲鏈表中沒有索引這種概念,那訪問就需要一個頭結點head,從頭結點開始訪問。

public class LinkedList<E> {
    // 結點
    private class Node{
        ...
    }
    // 新增
    private Node head;
    // 鏈表長度
    private int size;
    
    public LinkedList() {
        head = null;
        size = 0;
    }
    
    /**
     * 獲取鏈表中的元素個數
     * @return
     */
    public int getSize() {
        return size;
    }

    /**
     * 判斷鏈表是否爲空
     * @return
     */
    public boolean isEmpty() {
        return size == 0;
    }
}


如圖:主要有三步:

  • 第一步先創建一個node結點並把值傳進去;
  • 第二步把新創建的結點的next指針指向head;
  • 第三步把head指向node結點。

在這裏插入圖片描述

    /**
     * 添加操作
     * @param data
     */
    public void addFirst(E data){
        // Node node = new Node(data);
        // node.next = head;
        // head = node;
        // 前面三條語句可以結合成一條
        head = new Node(data, head);
        size++;
    }

有些書上的頭結點不存儲值。其實頭結點可以存儲值也可不存儲,無論如何就是一個標記,根據該標記方便我們可以操作鏈表。當然頭結點不存值的情況代碼需要修改,下面會說。

4. 鏈表的插入操作

現在假設鏈表從頭結點到尾,可用索引0,1,2…表示,那麼假如要把一個結點node插入到索引2,則需要怎麼操作。

注意:鏈表中沒有索引的,這裏只是爲了演示插入操作,因爲該操作是一個非常重要的思維。

首先能想到的是,先去查詢該位置,查詢是利用頭結點head,但必須創建一個頭結點head的副本來查詢,因爲頭結點head只能一直標記頭結點。我們需要查詢出該索引的前一個位置的結點,記爲prev。

然後將node的next指向prev:

在這裏插入圖片描述

最後將prev的next指向node,就成功插入了:
在這裏插入圖片描述
這兩條順序的順序不可換,可以試試換了兩條語句順序後的結果,就是錯誤的:
在這裏插入圖片描述

需要注意,當如果要從索引0插入時,要怎麼辦,頭結點可沒有前一個結點。這可以調用addFirst方法。

    /**
     * 插入操作
     * index 範圍爲0到size
     * 鏈表中是沒有索引的概念,該操作只能理解思維
     * @param index
     * @param data
     */
    public void insert(int index, E data){
        if(index < 0 || index > size){
            throw new IllegalArgumentException("Insert failed. Illegal index.");
        }

        if(index == 0){
            addFirst(data);
        } else {
            Node prev = head;
            for(int i = 0; i < index - 1; i++){
                prev = prev.next;
            }

            // Node node = new Node(data);
            // node.next = prev.next;
            // prev.next = node;
            // 另一種寫法,就是上面三句的結合
            prev.next = new Node(data, prev.next);

            size++;
        }
    }

上面的代碼還可以修改,比如如果超出size,那麼可以把插入的結點添加到鏈表尾。

現在寫個末尾添加結點的方法:

    /**
     * 添加到尾部
     * @param data
     */
    public void addLast(E data){
        insert(size, data);
    }

這些東西都是涉及到引用的知識,比如查詢:

    // 創建一個head副本
    Node prev = head;
    for(int i = 0; i < index - 1; i++){
        // 此時改變引用指向,並不會影響到head
        prev = prev.next;
    }

如果這樣寫:

    // 不創建head副本
    for(int i = 0; i < index - 1; i++){
        // 此時改變引用指向,那就影響到了head的指向,即前面的結點會丟失,永遠找不回來。
        head = head.next;
    }

5. 鏈表改爲使用虛擬頭結點

有些書用的頭結點不放任何東西時,每次添加結點是添加到頭節點後面的,該頭結點稱爲虛擬頭結點,記爲dummyHead,比如:
在這裏插入圖片描述

思路都是一樣的,其實這是一個插入操作。因爲每次都知道要插入的位置的前一個結點,所以完全不需要索引的概念,現在可以來寫下,另一種添加操作:(把剛剛的head改成dummyHead

    // 把 head名稱改爲 dummyHead
    private Node dummyHead;
    // 修改構造函數
    public LinkedList() {
        // 創建虛擬頭結點
        dummyHead = new Node(null);
        size = 0;
    }
    
      /**
     * 插入操作
     * index 範圍爲0到size
     * 鏈表中是沒有索引的概念,該操作只能理解思維
     * 插入操作的關鍵點在於:找到目標位置的前一個位置
     * @param index
     * @param data
     */
    public void insert(int index, E data){
        if(index < 0 || index > size){
            throw new IllegalArgumentException("Insert failed. Illegal index.");
        }
        // 此時head是虛擬頭結點
        Node prev = dummyHead;
        // 注意邊界
        for(int i = 0; i < index; i++){
            prev = prev.next;
        }

        // Node node = new Node(data);
        // node.next = prev.next;
        // prev.next = node;
        // 另一種寫法
        prev.next = new Node(data, prev.next);

        size++;

    }
    
    /**
     * 添加頭結點操作
     * @param data
     */
    public void addFirst(E data){
        insert(0, data);
    }

跟前面的添加操作對比看看:

  • 前面的添加操作,每次添加的結點都當成頭結點head。
  • 這裏的添加操作,每次添加的結點都放在頭結點head後面。

兩種方法都可以使用任意一種。現在我們把前面的代碼換成使用虛擬頭結點來做

5. 鏈表的查詢

爲了練習還是引入索引。

注意查詢是要查詢哪個結點,像前面的插入操作是爲了查詢某位置的前一個結點。下面的查詢是爲了查詢某位置的結點。

查詢有兩種方式,一種使用while,一種使用for。

    /**
     * 獲取鏈表第index個元素(0~size)
     * @param index
     * @return
     */
    public E get(int index) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed. Illegal index.");
        }
        // 查找第index位置的結點
        Node cur = dummyHead.next;
        for(int i = 0; i < index; i++) {
            cur = cur.next;
        }

        return cur.data;
    }

    /**
     * 獲取首結點的值
     * @return
     */
    public E getFirst() {
        return get(0);
    }

    /**
     * 獲取尾結點的值
     * @return
     */
    public E getLast() {
        return get(size - 1);
    }
    
    /**
     * 查詢鏈表中是否包含元素data
     * @param data
     * @return
     */
    public boolean contains(E data) {
        Node cur = dummyHead.next;
        while(cur != null) {
            if(cur.data.equals(data)) {
                return true;
            }
            cur = cur.next;
        }
        
        return false;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        
        // 第一種,使用while
        // Node cur = dummyHead.next;
        // while(cur != null) {
        //     sb.append(cur + "->");
        //     cur = cur.next;
        // }

        // 第二種,使用for
        for(Node cur = dummyHead.next; cur != null; cur = cur.next) {
            sb.append(cur + "->");
        }

        sb.append("NULL");
        
        return sb.toString();
    }

最好測試一下:

    public static void main(String[] args) {
        LinkedList<Integer> linkedList = new LinkedList<>();
        
          for(int i = 0; i < 5; i++) {
            linkedList.addFirst(i+1);
            System.out.println(linkedList);
        }
        System.out.println("新添加:");
        linkedList.insert(2, 5);
        linkedList.addLast(6);

        System.out.println(linkedList);
        System.out.println("首結點元素:" + linkedList.getFirst());
        System.out.println("尾結點元素:" + linkedList.getLast());
        System.out.println("是否包含元素1:" + linkedList.contains(1));

    }

6. 鏈表的修改

無非還是先查詢,在修改。

    /**
     * 爲了練習還是引入索引。
     * 表示修改index位置的結點的元素
     * @param index 索引
     * @param data 要修改的值
     */
    public  void set(int index, E data) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("Set failed. Index is illegal");
        }
        // 查詢
        Node cur = dummyHead.next;
        for(int i = 0; i < index; i++) {
            cur = cur.next;
        }
        
        cur.data = data;
    }

測試:

    public static void main(String[] args) {
        LinkedList<Integer> linkedList = new LinkedList<>();

        for(int i = 0; i < 5; i++) {
            linkedList.addFirst(i+1);
            System.out.println(linkedList);
        }


        System.out.print("修改索引爲2的元素,改爲10:");
        linkedList.set(2, 10);
        System.out.println(linkedList);
    }

8. 鏈表的刪除

還是引入索引方便演示,假設要刪除索引爲2的結點。

每個結點都有一個next,在查詢時是根據這個next來找到下一個結點。所以通過索引1結點的next就可以找到索引2的結點,以此爲前提,那麼只要讓索引1結點的next不指向索引2的結點,不就找不到索引2的結點了嗎,這不就是刪除了嗎。所以我們得先找到要刪除的結點的前一個結點,記爲prev

但是我們還得保證索引2的結點後面的結點不丟失,所以可以把索引1結點的next指向索引2結點的next。這就刪除了。

看看圖:
第一步:初始狀態
在這裏插入圖片描述

第二步:查詢,找到delNode的前一個結點
在這裏插入圖片描述

第三步:刪除
在這裏插入圖片描述

小優化:可以看到delNode雖然是刪了,但是還沒有被垃圾揮手器回收,因爲delNode還是有引用。所以我們主動把delNode指向null。
在這裏插入圖片描述

代碼:

    /**
     * 刪除
     * @param index
     * @return 返回刪除元素
     */
    public E remove(int index) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("delete failed. Index is illegal");
        }
        // 待刪除的結點之前的結點
        Node prev = dummyHead;
        for(int i = 0; i < index; i++) {
            prev = prev.next;
        }
        // 要刪除的結點
        Node delNode = prev.next;
        // 刪除
        prev.next = delNode.next;
        delNode.next = null;

        size--;

        return delNode.data;
    }

    /**
     * 刪除首結點
     * @return 返回刪除元素
     */
    public E removeFirst() {
        return remove(0);
    }

    /**
     * 刪除尾結點
     * @return 返回刪除元素
     */
    public E removeLast() {
        return remove(size - 1);
    }
    
    /**
     * 從鏈表中刪除元素e
     * @param data
     */
    public void removeElement(E data){

        Node prev = dummyHead;
        while(prev.next != null){
            if(prev.next.data.equals(data))
                break;
            prev = prev.next;
        }

        if(prev.next != null){
            Node delNode = prev.next;
            prev.next = delNode.next;
            delNode.next = null;
        }
    }

測試:

   public static void main(String[] args) {

        LinkedList<Integer> linkedList = new LinkedList<>();

        for(int i = 0; i < 5; i++) {
            linkedList.addFirst(i+1);
            System.out.println(linkedList);
        }
        System.out.println("新添加:");
        linkedList.insert(2, 5);
        linkedList.addLast(6);

        System.out.println(linkedList);
        System.out.println("首結點元素:" + linkedList.getFirst());
        System.out.println("尾結點元素:" + linkedList.getLast());
        System.out.println("是否包含元素1:" + linkedList.contains(1));

        System.out.print("修改索引爲2的元素,改爲10:");
        linkedList.set(2, 10);
        System.out.println(linkedList);

        // 刪除
        System.out.println("刪除索引爲2的元素" + linkedList.remove(2));
        System.out.println("刪除首結點" + linkedList.removeFirst());
        System.out.println("刪除尾結點" + linkedList.removeLast());

        System.out.println(linkedList);
    }

9. 複雜度分析

  • 添加操作:總體爲O(n)

    • addFirst():O(1)
    • addLast():O(n)
    • insert(index):平均情況下index可能在前半部分也可能在後半部分,所以均攤起來爲:O(n/2)=O(n)
  • 刪除操作:總體爲O(n)

    • removeFirst():O(1)
    • removeLast():O(n)
    • remove(index):平均情況下index可能在前半部分也可能在後半部分,所以均攤起來爲:O(n/2)=O(n)
  • 修改操作:總體爲O(n)

    • set(idnex, data):O(n)
  • 查找操作:總體爲O(n)

    • getFirst(index):O(1)
    • getLast():O(n)
    • get(index):O(n)
    • contains():O(n)

相對於數組來說,鏈表的總體時間複雜度確實是比數組差,因爲在知道索引的情況下,數組支持隨機訪問。

但是,我們可以發現,如果鏈表只在頭結點操作,那麼對於增,刪,查的操作都是O(1),而且可以知道鏈表其實不去修改好

所以現在如果只對頭結點操作,那麼鏈表的總體複雜度跟數組差不多,而且比數組好的就是,鏈表是動態的,不會浪費空間

10. 鏈表的全部代碼

public class LinkedList<E> {
    // 結點
    private class Node{
        public E data;
        public Node next;

        public Node(E data, Node next) {
            this.data = data;
            this.next = next;
        }

        public Node(E data) {
            this(data, null);
        }

        public Node() {
            this(null, null);
        }

        @Override
        public String toString() {
            return data.toString();
        }
    }

    private Node dummyHead;
    private int size;

    public LinkedList() {
        // 創建虛擬頭結點
        dummyHead = new Node(null);
        size = 0;
    }

    /**
     * 獲取鏈表中的元素個數
     * @return
     */
    public int getSize() {
        return size;
    }

    /**
     * 判斷鏈表是否爲空
     * @return
     */
    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 插入操作
     * index 範圍爲0到size
     * 鏈表中是沒有索引的概念,該操作只能理解思維
     * 插入操作的關鍵點在於:找到目標位置的前一個位置
     * @param index
     * @param data
     */
    public void insert(int index, E data) {
        if(index < 0 || index > size){
            throw new IllegalArgumentException("Insert failed. Illegal index.");
        }
        // 此時head是虛擬頭結點,查找index位置的前一個結點
        Node prev = dummyHead;
        // 注意邊界
        for(int i = 0; i < index; i++){
            prev = prev.next;
        }

        // Node node = new Node(data);
        // node.next = prev.next;
        // prev.next = node;
        // 另一種寫法
        prev.next = new Node(data, prev.next);

        size++;

    }

    /**
     * 添加到虛擬頭結點的下一個位置
     * @param data
     */
    public void addFirst(E data) {
        // Node node = new Node(data);
        // node.next = head;
        // head = node;
        // 前面三條語句可以結合成一條
        // head = new Node(data, head);
        //
        // size++;
        insert(0, data);
    }

    /**
     * 添加到尾部
     * @param data
     */
    public void addLast(E data){
        insert(size, data);
    }



    /**
     *
     * @param index 索引
     * @return 獲取鏈表第index個元素(0~size)
     */
    public E get(int index) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed. Illegal index.");
        }
        // 查找第index位置的結點
        Node cur = dummyHead.next;
        for(int i = 0; i < index; i++) {
            cur = cur.next;
        }

        return cur.data;
    }

    /**
     * @return 獲取首結點的值
     */
    public E getFirst() {
        return get(0);
    }

    /**
     * @return 獲取尾結點的值
     */
    public E getLast() {
        return get(size - 1);
    }

    /**
     * 查詢鏈表中是否包含元素data
     * @param data
     * @return
     */
    public boolean contains(E data) {
        Node cur = dummyHead.next;
        while(cur != null) {
            if(cur.data.equals(data)) {
                return true;
            }
            cur = cur.next;
        }

        return false;
    }

    /**
     * 爲了練習還是引入索引。
     * 表示修改index位置的結點的元素
     * @param index 索引
     * @param data 要修改的值
     */
    public  void set(int index, E data) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("Set failed. Index is illegal");
        }
        // 查詢
        Node cur = dummyHead.next;
        for(int i = 0; i < index; i++) {
            cur = cur.next;
        }

        cur.data = data;
    }

    /**
     * 刪除
     * @param index
     * @return 返回刪除元素
     */
    public E remove(int index) {
        if(index < 0 || index >= size) {
            throw new IllegalArgumentException("delete failed. Index is illegal");
        }
        // 待刪除的結點之前的結點
        Node prev = dummyHead;
        for(int i = 0; i < index; i++) {
            prev = prev.next;
        }
        // 要刪除的結點
        Node delNode = prev.next;
        // 刪除
        prev.next = delNode.next;
        delNode.next = null;

        size--;

        return delNode.data;
    }

    /**
     * 刪除首結點
     * @return 返回刪除元素
     */
    public E removeFirst() {
        return remove(0);
    }

    /**
     * 刪除尾結點
     * @return 返回刪除元素
     */
    public E removeLast() {
        return remove(size - 1);
    }
    
    /**
     * 從鏈表中刪除元素e
     * @param data
     */
    public void removeElement(E data){

        Node prev = dummyHead;
        while(prev.next != null){
            if(prev.next.data.equals(data))
                break;
            prev = prev.next;
        }

        if(prev.next != null){
            Node delNode = prev.next;
            prev.next = delNode.next;
            delNode.next = null;
        }
    }
    
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();

        // 第一種,使用while
        // Node cur = dummyHead.next;
        // while(cur != null) {
        //     sb.append(cur + "->");
        //     cur = cur.next;
        // }

        // 第二種,使用for
        for(Node cur = dummyHead.next; cur != null; cur = cur.next) {
            sb.append(cur + "->");
        }

        sb.append("NULL");

        return sb.toString();
    }

    public static void main(String[] args) {

        LinkedList<Integer> linkedList = new LinkedList<>();

        for(int i = 0; i < 5; i++) {
            linkedList.addFirst(i+1);
            System.out.println(linkedList);
        }
        System.out.println("新添加:");
        linkedList.insert(2, 5);
        linkedList.addLast(6);

        System.out.println(linkedList);
        System.out.println("首結點元素:" + linkedList.getFirst());
        System.out.println("尾結點元素:" + linkedList.getLast());
        System.out.println("是否包含元素1:" + linkedList.contains(1));

        System.out.print("修改索引爲2的元素,改爲10:");
        linkedList.set(2, 10);
        System.out.println(linkedList);

        // 刪除
        System.out.println("刪除索引爲2的元素" + linkedList.remove(2));
        System.out.println("刪除首結點" + linkedList.removeFirst());
        System.out.println("刪除尾結點" + linkedList.removeLast());

        System.out.println(linkedList);
    }
}

11. 使用鏈表來實現棧

Stack接口:

public interface Stack<E> {

    int getSize();
    boolean isEmpty();
    void push(E e);
    E pop();
    E peek();
    
}

使用鏈表實現:

public class LinkedListStack<E> implements Stack<E> {

    private LinkedList<E> linkList;

    public LinkedListStack() {
        linkList = new LinkedList<>();
    }

    @Override
    public int getSize() {
        return linkList.getSize();
    }

    @Override
    public boolean isEmpty() {
        return linkList.isEmpty();
    }

    @Override
    public void push(E e) {
        // 從鏈表頭添加
        linkList.addFirst(e);
    }

    @Override
    public E pop() {
        // 從鏈表頭刪除
        return linkList.removeFirst();
    }

    @Override
    public E peek() {
        // 從鏈表頭查看
        return linkList.getFirst();
    }

    @Override
    public String toString() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("Stack top :").append(linkList.toString());
        return stringBuilder.toString();
    }

    public static void main(String[] args) {
        LinkedListStack<Integer> stack = new LinkedListStack<>();
        for (int i = 0; i < 5; i++) {
            stack.push(i);
            System.out.println(stack);
        }

        stack.pop();
        System.out.println(stack);

    }
}

雖然基於鏈表實現的棧在頭結點入棧和出棧的時間複雜度都是O(1)。但是鏈表是需要創建節點的,如果這兩個操作的次數很大很大,比如入棧和出棧各1000000次,那麼是需要很久的。

12. 使用鏈表實現隊列

我們知道鏈表如果操作尾結點,那麼時間複雜度爲O(n)。如果在尾結點加個標記,那麼每次操作就不用去找尾節點。該標記跟head一樣,我們記爲tail。

但是如果要刪除尾結點,必須遍歷一次鏈表,因爲要找到刪除尾結點的前一個結點,即使有tail也無法改變。

所以我們可以在鏈表首做隊列頭,而在鏈表尾做隊列尾。這樣,我們在入隊和出隊的兩個操作的時間複雜度都是O(1)。

比如添加操作:

  • 先讓head和tail初始化爲null。
  • 只有一個結點是特殊情況:此時tail和head共同指向同一個結點。
  • 當添加第二個結點時,規定是在tail後面添加的,並且此時就要改變tail的指向。
    在這裏插入圖片描述

代碼:

public interface Queue<E> {

    int getSize();
    boolean isEmpty();
    void enqueue(E data);
    E dequeue();
    E getFront();
}
public class LinkedListQueue<E> implements Queue<E> {
    // 結點
    private class Node{
        public E data;
        public Node next;

        public Node(E data, Node next) {
            this.data = data;
            this.next = next;
        }

        public Node(E data) {
            this(data, null);
        }

        public Node() {
            this(null, null);
        }

        @Override
        public String toString() {
            return data.toString();
        }
    }

    private Node head, tail;
    private int size;

    public LinkedListQueue() {
        this.head = null;
        this.tail = null;
        this.size = 0;
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public void enqueue(E data) {
        if(tail == null) {
            tail = new Node(data);
            head = tail;
        } else {
            tail.next = new Node(data);
            tail = tail.next;
        }
        size++;
    }

    @Override
    public E dequeue() {
        if(isEmpty()) {
            throw new IllegalArgumentException("Cannot dequeue from an empty queue.");
        }

        Node ret = head;
        head = head.next;
        // 如果隊列只有一個元素,刪除後就沒了,要修改tail
        if(head == null) {
            tail = null;
        }
        size--;
        return ret.data;
    }

    @Override
    public E getFront() {
        if(isEmpty()) {
            throw new  IllegalArgumentException("Queue is Empty");
        }

        return head.data;
    }


    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();

        Node cur = head;
        sb.append("Queue top: ");
        while(cur != null) {
            sb.append(cur + "->");
            cur = cur.next;
        }
        sb.append("NULL tail");

        return sb.toString();
    }

    public static void main(String[] args) {
        LinkedListQueue<Integer> queue = new LinkedListQueue<>();
        for(int i = 0; i < 5; i++){
            queue.enqueue(i);
            System.out.println(queue);

            if(i % 3 == 2){
                queue.dequeue();
                System.out.println(queue);
            }
        }
    }
}

使用鏈表實現的隊列比使用數組實現的隊列性能更好。因爲數組一定有一頭要O(n)。當然數組可以修改成循環數組來解決此問題。

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