【LeetCode】->連結串列->通向連結串列自由之路

山河罔顧發表於2020-10-03

Ⅰ 前言

在我資料結構與演算法的連結串列講解中,我留下了幾個連結串列必須要掌握的操作,掌握看了它們,就基本可以實現 “連結串列自由” 了。

在這裡插入圖片描述
關於連結串列的詳細講解,可以跳轉到我下面的文章。

【資料結構與演算法】->資料結構->連結串列->LRU快取淘汰演算法的實現

這幾個操作分別對應了 LeetCode 上的幾道題,我們一起來看看。

Ⅱ 刪除連結串列倒數第 n 個結點(#19)

這道題的具體描述如下?

在這裡插入圖片描述
這個題我最開始的想法是用雜湊表先將這些結點按順序儲存起來,這樣可以直接根據鍵值來找到需要刪除的點,並且,也只需要遍歷一次,就可以將所有結點存入雜湊表中,後面直接根據鍵值來取就好了。

想法是好的,最後時間效能上打敗了百分之六十多的人,讓我意識到這個程式寫得是多麼蠢。

這個題其實還可以用一個小技巧來做,就是快慢指標。我們先讓一個快指標走 n 次,然後這時候慢指標和快指標一起移動,這樣當快指標移動到終點,也就是這條鏈的尾結點的時候,慢指標剛好到要刪除的地方。

為了方便對連結串列進行操作,我引入了一個哨兵,這樣如果要刪除的結點是頭結點,也不需要做特殊處理。我們直接來看程式碼。?

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/*
 * @lc app=leetcode.cn id=19 lang=java
 *
 * [19] 刪除連結串列的倒數第N個節點
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode pre = new ListNode(0);
        ListNode fastPoint = pre;
        ListNode slowPoint = pre;
        pre.next = head;

        while (n != 0) {
            fastPoint = fastPoint.next;
            n--;
        }

        while (fastPoint.next != null) {
            fastPoint = fastPoint.next;
            slowPoint = slowPoint.next;
        }

        slowPoint.next = slowPoint.next.next;

        return pre.next;
    }
}
// @lc code=end


可以看到效能提高了非常多。

在這裡插入圖片描述

Ⅲ 兩個有序連結串列的合併 (#21)

我先將這道題的具體描述貼上。

在這裡插入圖片描述
這道題大家很容易想到利用 while 迴圈,然後對兩個有序連結串列進行逐節點的比較,這個思路比較簡單,我一開始也是想不到什麼好方法,只能用這種笨蛋解法。我先將我的笨蛋解法貼出來,思路很簡單,大家看一下就很容易理解了。

/*
 * @lc app=leetcode.cn id=21 lang=java
 *
 * [21] 合併兩個有序連結串列
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        ListNode head = new ListNode();
        ListNode tmp = head;
        ListNode p = l1;
        ListNode q = l2;

        while (p != null && q != null) {
            ListNode cur = new ListNode();
            if (p.val < q.val) {
                cur.val = p.val;
                p = p.next;
            } else {
                cur.val = q.val;
                q = q.next;
            }
            tmp.next = cur;
            tmp = cur;
        }

        while (p != null) {
            ListNode cur = new ListNode(p.val);
            tmp.next = cur;
            tmp = cur;
            p = p.next;
        }
        
        while (q != null) {
            ListNode cur = new ListNode(q.val);
            tmp.next = cur;
            tmp = cur;
            q = q.next;
        }

        return head.next;
    }
}
// @lc code=end


思路就是先對兩個有序連結串列進行比較,直到一個連結串列到盡頭了。那麼如果還有連結串列沒有被遍歷完,因為是順序的,所以直接將它餘下的元素加入到最後的連結串列中即可。

這個笨蛋解法的耗時是多少呢?

在這裡插入圖片描述
可以看到,時間複雜度和空間複雜度都非常高。

我們這個解法每一次比較之後,都要建立一個新的結點,在 while 迴圈中不僅浪費了很多時間,也浪費了大量的空間,其實沒必要,我們再看看一個更聰明的解法,用遞迴來解決這個問題。

題目的部分我不再給出,只貼出作答的部分?

class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        if (l1 == null) {
            return l2;
        }
        if (l2 == null) {
            return l1;
        }
        if (l1.val < l2.val) {
            l1.next = mergeTwoLists(l1.next, l2);
            return l1;
        } else {
            l2.next = mergeTwoLists(l1, l2.next);
            return l2;
        }
    }
}

程式碼比前面的少了非常多,我們再看看效能?

在這裡插入圖片描述
通過這個遞迴,最後返回的就是最小的結點,我們也不需要再建立額外的空間,直接將原先的結點連線起來就好。

Ⅳ 連結串列中環的檢測(#141)

同樣的,我先把題目貼出。

在這裡插入圖片描述

在這裡插入圖片描述

看到這個題我首先想到的就是用一個set集合或者hash table來儲存,遍歷整個連結串列,如果有相同的結點存進去,那就說明有環,返回 true,否則返回 false。但是,同樣的,我首先可以想到的演算法一定不是最好的演算法。

我們注意題目裡的進階要求,空間複雜度為 O(1)。那就肯定不可以用 set 集合了,那樣複雜度就變成 O(n) 了。

這時候,我們就要用到一個非常巧妙的解決方法,也是我們上面提到過的 快慢指標。那這個快慢指標要怎麼用呢?

大家肯定對我們初中學過的追及問題有印象吧?如果你比你追及的物件速度快,那你就一定可以追到它。對應到這道題,我們讓快指標每次往前走兩格,慢指標每次往前走一格,如果連結串列中有環的話,快慢指標最後就會進入環中,相當於兩個人開始在操場跑圈,那快的人一定會追上慢的人,兩個人一定能相見。

放在這個題中,也是同樣的思路。我們最後就檢測快指標有沒有追到慢指標,如果追到了,說明一定有環,如果沒有追到,那連結串列中一定沒有環。根據這個思路,我們來寫程式碼。

/*
 * @lc app=leetcode.cn id=141 lang=java
 *
 * [141] 環形連結串列
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public boolean hasCycle(ListNode head) {
        ListNode fast = head;
        ListNode slow = head;

        while (fast != null && fast.next != null) {
            if (fast.next == slow) {
                return true;
            }
            fast = fast.next.next;
            slow = slow.next;
        }
        return false;
    }
}
// @lc code=end


這裡我還想直接截個圖,因為不僅我的編輯器太漂亮了,這個解法也狠漂亮,非常簡潔。

在這裡插入圖片描述

Ⅴ 單連結串列反轉(#206)

具體題目如下?

在這裡插入圖片描述
進階的要求是用迭代或者遞迴來解決,那我們就不要考慮用一個容器比如 ArrayList 來存放然後反轉這種演算法了,我們直接來看比較複雜的迭代和遞迴。

這兩個演算法在我之前的文章中寫過,大家有興趣可以跳轉過去看。

【程式設計師必修數學課】->基礎思想篇->迭代法

【程式設計師必修數學課】->基礎思想篇->遞迴(上)->泛化數學歸納

【程式設計師必修數學課】->基礎思想篇->遞迴(下)->分而治之&從歸併排序到MapReduce

首先我們先看遞迴。

/*
 * @lc app=leetcode.cn id=206 lang=java
 *
 * [206] 反轉連結串列
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null) {
            return head;
        }
        ListNode cur = reverseList(head.next);
        head.next.next = head;
        head.next = null;

        return cur;
    }
}
// @lc code=end


最複雜的地方是下面這句

在這裡插入圖片描述

這其實就是一個反轉的過程。

在這裡插入圖片描述
拿上圖 4 個這個結點來舉例子。

head.next.next 指的就是 4.next.next 也就是 5.next,它等於 head,也就是指向了自己,這個語句就實現了結點 5 指向了 4,然後我們再把 head.next = null,也就是 4 指向 5 給斷開,這就實現了反轉。

在這裡插入圖片描述
整個遞迴的過程,返回的 cur 就是 5,也就是最後一個結點,這樣所有的反轉完成之後,返回的 5 就相當於是這個反轉連結串列的頭結點了,也就完成了整個的反轉過程。

我們來看看遞迴的效能?

在這裡插入圖片描述
時間是很快,但是空間消耗是很大的,因為每一層遞迴都要消耗系統堆疊空間,所以佔用的記憶體很大。

我們再來看迭代。

public ListNode reverseList(ListNode head) {
        ListNode pre = null;
        ListNode cur = head;
        ListNode tmp = null;

        while (cur != null) {
            tmp = cur.next;
            cur.next = pre;
            pre = cur;
            cur = tmp;
        }

        return pre;
    }

迭代法實現的思想就是每遍歷到一個結點,用 cur 標記,然後 pre 是 cur 前面的結點,使得 cur 指向 pre,這樣遍歷完,到 cur 指向 null 的時候,pre 正好指向尾結點,這樣就實現了反轉。

迭代法的思路不難理解,大家可以用程式碼做個參考。

迭代法實現的效能也是很強大,空間消耗比遞迴法少很多?

在這裡插入圖片描述

Ⅵ 求連結串列的中間節點(#876)

我們先看看這道題的具體描述。

在這裡插入圖片描述
我們要求一個連結串列的中間結點。經過前面幾道題,我們知道了有一個很巧妙的遍歷方法,就是快慢指標,這道題簡直就是為快慢指標而設的。

要求中間結點,那我們令快指標和慢指標都指向頭結點,然後開始遍歷。快指標一次走兩格,慢指標一次走一格,這樣直到快指標遍歷到連結串列末端。如果是奇數個,那快指標剛好指向尾結點,fast.next = null。如果是偶數個,那快指標就指向了尾結點的下一個結點,也就是空,即 fast = null

所以,我們可以根據這兩個邊界條件,來判斷快指標是否遍歷完了整個連結串列。當快指標遍歷完連結串列之後,由於它每次都比慢指標多走一步,所以慢指標最後指向的就是我們要求的中間結點。

我們直接來看程式碼?

/*
 * @lc app=leetcode.cn id=876 lang=java
 *
 * [876] 連結串列的中間結點
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode middleNode(ListNode head) {
        ListNode fast = head;
        ListNode slow = head;

        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
        }

        return slow;
    }
}
// @lc code=end


為方便大家看,我再截個圖?

在這裡插入圖片描述
效能測試如下?

在這裡插入圖片描述
OK,這就是連結串列的幾個重要操作,掌握熟練這些題,我們就在通向連結串列自由之路上前進了一大截。

相關文章