LeetCode-連結串列

ifrank98發表於2020-11-26

摘要

​ 這篇文章主要是記錄一下在LeetCode刷題過程中,與“連結串列“相關的題目。實際上刷的題數肯定不止,但要慢慢整理,寫好思路跟程式碼。所以,慢慢來吧。

一. 前言

​ 連結串列是一種常用資料結構,其題型也是很有辨識度。對於連結串列的基礎知識在這裡也不必多說,直接整理一下LeetCode上比較經典的連結串列題目吧。很多連結串列的題目,看起來很簡單,但自己動手就會發現很多問題。所以一定要親自去實現一次,不能僅僅停留在讀懂解題思路的階段。

​ 連結串列問題通常都需要迴圈遍歷,有時候需要判斷是否為null。那麼到底什麼情況是判斷head != null還是head.next != null呢?這種實際上也是程式設計的基本功。當你在呼叫一個物件的方法,你需要確保它不為空,否則就會丟擲那每一個Java程式設計師都會碰到的NullPointerException。如果你的程式碼裡用到了,比如current = current.next,你需要確保的是current不為空,這樣就不會導致空指標錯誤。如果你的程式碼裡用到了current = current.next.next,這時候首先你要確保current不為空,其次current.next也不為空。

​ 對於頭結點的處理,通常都比較特殊,因為當我們改變了頭結點時候,後續return就很麻煩。這時候可以新建一個我們自定義的“偽頭結點"dummy,並且使得Dummy.next = head。這樣不管後面head是否發生改變,只需要最後return的是dummy.next即可,即將頭節點普通化,不再需要認為頭節點是特殊的節點。但是dummy的值要巧妙設定,不能影響後續的處理。

​ 而且,切記不要直接操作head,雖然有一些題目無關緊要,但通常我們只想遍歷,如果直接head = head.next,那麼整個連結串列都會隨著遍歷而改變了。正確的做法是建立一個變數,比如current,指向head,然後改變這個current。

​ 對於連結串列的問題,如果不能直接想出來,那麼就多畫圖吧,畫圖就很好理解了。

二. LeetCode 面試題02.01. 移除重複節點

題目描述: 編寫程式碼,移除未排序連結串列中的重複節點。保留最開始出現的節點。題目連結

分析:如果使用O(n)的空間複雜度,那麼很簡單,只需要建立一個雜湊表。如果已經存在該節點的值,便跳過,刪除。如果不能使用額外的空間,也就是O(1)的空間複雜度,那麼就需要用雙指標進行兩層遍歷,此時時間複雜度為O(n ^ 2)。

程式碼①: (O(n)時間複雜度,O(n)空間複雜度)

class Solution {
    public ListNode removeDuplicateNodes(ListNode head) {
        Set<Integer> set = new HashSet<>();
        ListNode current = head, prev = head;
        while (current != null) {
            if (set.contains(current.val))
                prev.next = current.next;
            else {
                set.add(current.val);
                prev = current;         // 如果contains,就不需要更改prev
            }
            current = current.next;
        }
        return head;
    }
}

程式碼②: (O(n ^ 2)時間複雜度,O(1)空間複雜度)

class Solution {
    public ListNode removeDuplicateNodes(ListNode head) {
        ListNode dummy = new ListNode(-1);
        dummy.next = head;      // 創造一個dummy頭結點
        ListNode current = head, prev = dummy, helper;
        while (current != null) {
            helper = head;
            boolean flag = false;
            while (helper != current) { // 檢查current前面是否有重複
                if (helper.val == current.val) {
                    flag = true;
                    break;
                }
                helper = helper.next;
            }
            if (flag)
                prev.next = current.next;
            else 
                prev = current;         // 如果contains,就不需要更改prev
            current = current.next;
        }
        return dummy.next;
    }
}

三. LeetCode 206. 反轉連結串列

描述:反轉一個單連結串列。題目連結

分析:直接定義一個prev變數,用於記錄當前值的前一個值。至於什麼時候設定prev,當然是在上一輪迴圈的時候。在當前迴圈要進行的操作就是:current.next = prev①,這樣就使得當前的current指向了上一個值。可是我們需要遍歷,這時候我們遍歷所用的current = current.next已經不可以了,因為current.next已經改變。所以我們還要增加一個next變數,在執行①之前先記錄current.next的值,然後執行完①,就可以更改prev的值,並且將current指向原本的下一個值。(同樣地,修改prev的操作需要在修改current前進行)。只要清晰整個了邏輯,程式碼就水到渠成。

迭代程式碼

class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode current = head;
        ListNode prev = null;       // head的前一個是null
        while (current != null) {
            ListNode next = current.next;   // 先記錄current.next
            current.next = prev;
            prev = current;         // prev改為指向當前的current
            current = next;         // current指向原本的current.next
        }
        return prev;    //最後current為null,prev就是最後的元素
    }
}

這是迭代的版本,如果使用遞迴,程式碼更簡潔,但邏輯要更加清晰。遞迴的關鍵在於反向工作。假設列表的其餘部分已經被反轉,現在我該如何反轉它前面的部分?

假設列表為:

n(1)→…→n(k−1)→(nk)→n(k+1)→…→n(m)→∅

若從節點 n(k+1) 到 n(m) 已經被反轉,而我們正處於 n(k)。

n(1)→…→n(k−1)→(nk)→n(k+1)←…←n(m)

我們希望n(k+1)的下一個節點指向n(k),所以: n(k).next.next = nk

除此之外,n1的下一個必須指向null。否則當連結串列長度為2時,會形成迴圈,導致出錯。

遞迴程式碼

class Solution {
    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null)
            return head;
        ListNode rest = reverseList(head.next);	// 把rest想成一個整體,rest實際上是原本的tail
        head.next.next = head;	// 注意此時head.next是rest的尾結點
        head.next = null;		// 避免連結串列成環
        return rest;
    }
}

四. LeetCode 160. 相交連結串列

題目描述:編寫一個程式,找到兩個單連結串列相交的起始節點。題目連結

分析: 尋找交點,如果我們要使用兩個相同速度的指標,那麼就是要確保在某一個時候,二者走的距離相同,並且會在交點相遇。假設二者存在交點,那麼這兩個連結串列的差別主要在於交點前的長度不同(相交之後是公共的連結串列部分)。那麼很容易想到,只要讓兩個指標都同時遍歷了一次這兩段交點前部分即可。所以思路:讓指標到達終點時,將指標指向另一段單連結串列的起點,然後繼續前行。這樣保證兩個指標會在交點相遇。同時,這種“指向起點”的操作只需要進行1次,如果無法相遇,說明兩個單連結串列沒有交點。

程式碼

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        ListNode currentA = headA, currentB = headB;
        boolean flag1 = false, flag2 = false;   // 記錄二者是否都到達了一次end
        while (currentA != null && currentB != null) {
            if (currentA == currentB)
                return currentA;    // 相交的結點
            currentA = currentA.next;
            currentB = currentB.next;
            if (currentA == null && !flag1) {
                currentA = headB;   // A跳轉到headB的開頭
                flag1 = true;
            }
            if (currentB == null && !flag2) {
                currentB = headA;
                flag2 = true;       // 確保只跳轉一次
            }
        }
        return null;
    }
}

這段程式碼比較直接,完全就是按照解題思路寫的。但其實還有更簡便的方法,實際上只要兩個指標指向同一個位置的時候,此處就是交點,當然到達終點依然會進行一次跳轉操作。所以此時的迴圈結束條件是curentA == currentB。如果沒有交點該如何?這時候兩個指標必定會同時到達尾部,此時二者都為null,所以同樣是返回currentA或者currentB即可。

簡潔版程式碼

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if(headA == null || headB == null) return null;
        ListNode pA = headA, pB = headB;
        while(pA != pB) {
            pA = pA == null ? headB : pA.next;
            pB = pB == null ? headA : pB.next;
        }
        return pA;
    }
}

同時這道題有點像,如果一個連結串列有環,返回環的入口(雙指標文章裡有提到)。那麼這裡我們同樣可以構造出一個環:遍歷連結串列B,直到終點last,然後將last.next == headA。這時候對於單連結串列A來說,如果有交點,那麼就存在環。如果無交點,就會構成一個環,而環入口就是二者的交點。如果fast最後會到達null,或者fast.next為null,說明不成環,無交點。如果存在環,二者一定在某一時刻相遇,此時只要將fast或者slow置於起始點(headA),然後以同樣的速度繼續前行,二者會在交點處/環入口相遇(證明過程見“雙指標”一文)。同時,題目不允許對原連結串列進行修改,所以在返回之前,還得把last.next改回去,即last.next = null

構造環

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if (headA == null || headB == null)
            return null;
        ListNode last = headB;
        while (last.next != null)
            last = last.next;
        last.next = headB;      // 此時如果有交點,那麼A存在環
        ListNode fast = headA, slow = headA;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            if (slow == fast) {
                slow = headA;
                break;
            }
        }
        if (fast == null || fast.next == null) {
            last.next = null;       // reset
            return null;            // 無交點
        }
        while (slow != fast) {
            slow = slow.next;
            fast = fast.next;
        }
        last.next = null;   // reset
        return slow;        // or fast
    }
}

五. LeetCode 21. 合併兩個有序連結串列

題目描述:將兩個升序連結串列合併為一個新的升序連結串列並返回。新連結串列是通過拼接給定的兩個連結串列的所有節點組成的。 題目連結

分析:直接比較兩個連結串列,選擇更小的值新增到新連結串列中。直到其中一條為null,直接把另一條直接新增即可。同時可以建立一個dummy頭結點,便於操作。

程式碼

class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(1);
        ListNode res = new ListNode(100);
        dummy.next = res;
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                res.next = l1;
                l1 = l1.next;
            }
            else {
                res.next = l2;
                l2 = l2.next;
            }
            res = res.next;
        }
        if (l1 == null)
            res.next = l2;
        else
            res.next = l1;
        return dummy.next.next;
    }
}

六. LeetCode 83. 刪除排序連結串列中的重複元素

題目:給定一個排序連結串列,刪除所有重複的元素,使得每個元素只出現一次。題目連結

分析:最簡單的方法當然就是HashSet了,但那樣就浪費了題中的條件“排序連結串列”。因為是排序,所以我們只要與前一個元素相比,只要相同,那麼就跳過當前的元素。值得注意的是,使用pre來代表前一個元素,但pre並不是每一次都要改變。比如測試用例[1, 1, 1],當current與pre相同的時候,這時候不需要改變pre。本題的唯一難點。

程式碼

class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        if (head == null)
            return head;
        ListNode prev = head;
        ListNode current = head.next;   // 從第二個開始
        while (current != null) {
            if (prev.val == current.val) 
                prev.next = current.next;
            else
                prev = current;         // 相同的情況下,無須改變prev
            current = current.next;
        }
        return head;
    }
}

七. LeetCode 19. 刪除連結串列的倒數第N個結點

描述: RT(保證給定的n的有效的)題目連結

分析:如果是進行兩趟掃描,那麼思路很簡單,第一遍先獲取連結串列的長度,然後就可以知道倒數第n個是順序的第幾個。這道題的難點是如何順序第一遍就知道正確的位置。可以使用兩個指標,先讓其中一個走n步,那麼當這個先行的指標走到結尾,後行的指標就正處於倒數第n個的位置。

程式碼① (兩趟掃描):

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode dummy = new ListNode(-1);
        dummy.next = head;
        int length = 0;
        ListNode current = head;
        while (current != null) {
            length++;
            current = current.next;
        }
        current = dummy;    // 從dummy開始,不然刪除head就比較麻煩
        int tmp = 0;
        while (tmp <= length - n - 1) {
            current = current.next;
            tmp++;
        }
        current.next = current.next.next;
        return dummy.next;
    }
}

程式碼② (一趟掃描)

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode dummy = new ListNode(-1);
        dummy.next = head;
        ListNode fast = dummy;
        for (int i = 0; i <= n; i++)	// n從1開始,要多走一步
            fast = fast.next;
        ListNode slow = dummy;
        while (fast != null) {
            fast = fast.next;
            slow = slow.next;
        }
        slow.next = slow.next.next;
        return dummy.next;
    }
}

八. LeetCode 24. 兩兩較換連結串列中的結點

描述:給定一個連結串列,兩兩交換其中相鄰的節點,並返回交換後的連結串列。題目連結

你不能只是單純的改變節點內部的值,而是需要實際的進行節點交換。

分析:直接定義三個變數,pre,first,second。我們需要設定:second.next = firstfirst.next = second.next,可以提前保留second.next這個值。操作完成之後,更換pre,first,second的值。這裡要注意是否會為null的情況。因為first = nextsecond = next.next,顯然要判斷next是否為null。如果為null,說明後面沒有元素,無須繼續操作,可以結束迴圈。直覺上很容易想,如果還剩下一個元素應該怎麼辦?此時next不為null,但next.null為null。這時候仍然可以結束迴圈,所以如果你願意,你可以把這個判斷條件更改為:

if (next != null || next.next != null)。但實際上,只剩下1個元素的情況下,實際上就是second為null,這時候再迴圈一次其實就在while迴圈裡結束了,所以是一樣的。(對於while跟if的結束條件,有很多種寫法,理解就好)

程式碼

class Solution {
    public ListNode swapPairs(ListNode head) {
        if (head == null || head.next == null)
            return head;
        ListNode dummy = new ListNode(-1);
        dummy.next = head;
        ListNode pre = dummy;
        ListNode first = head;
        ListNode second = head.next;
        while (first != null && second != null) {
            ListNode next = second.next;
            pre.next = second;
            second.next = first;
            first.next = next;
            pre = first;
            first = next;
            if (next == null)
                break;
            second = next.next;
        }
        return dummy.next;
    }
}

以上是迭代演算法,同樣地,也寫出遞迴演算法,就跟206題一樣。遞迴要考慮的是3個部分:

①終止條件 ②返回值 ③本級遞回應該做什麼 (此處參考“網戀教父”的的文章

主要是把整個連結串列看作3個部分,head,next,已經處理完的部分。然後經過操作變成:

next,head,已經處理完的部分

遞迴程式碼

class Solution {
    public ListNode swapPairs(ListNode head) {
        if (head == null || head.next == null)
            return head;
        ListNode next = head.next;
        head.next = swapPairs(next.next);   // 一個整體
        next.next = head;
        return next;
    }
}

九. LeetCode 445. 兩數相加 Ⅱ

題目描述: 給定兩個非空連結串列來代表兩個非負整數。數字最高位位於連結串列開始位置。它們的每個節點只儲存單個數字。將這兩數相加會返回一個新的連結串列。題目連結

你可以假設除了數字 0 之外,這兩個數字都不會以零開頭。

如果輸入連結串列不能修改該如何處理?換句話說,你不能對列表中的節點進行翻轉。

分析:對連結串列進行倒序讀取是很麻煩的操作,所以一下子就想到了用兩個陣列/棧來重新維護資料。實現的過程也遇到了一點小麻煩,效率也還可以,排前面的程式碼也是這種思路,所以看來這就是最終的方法了。這裡顯然是用棧來維護最好(LinkedList),因為我們只需要對首尾的元素進行操作。值得注意的是,當兩個棧都為空,如果此時還有進位,那麼還需要額外new一個值為1的頭結點。

程式碼

class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        LinkedList<Integer> list1 = new LinkedList<>();
        LinkedList<Integer> list2 = new LinkedList<>();
        ListNode h1 = l1, h2 = l2;
        while (h1 != null) {
            list1.addLast(h1.val);
            h1 = h1.next;
        }
        while (h2 != null) {
            list2.addLast(h2.val);
            h2 = h2.next;			// 資料存放到list1和list2中
        }
        ListNode next = null;
        int increment = 0;
        while (true) {
            int val1 = list1.size() == 0 ? 0 : list1.removeLast();
            int val2 = list2.size() == 0 ? 0 : list2.removeLast();
            int sum = val1 + val2 + increment;
            if (sum >= 10) {
                sum %= 10;
                increment = 1;
            }
            else
                increment = 0;
            ListNode current = new ListNode(sum);
            current.next = next;
            next = current;
            if (list1.size() == 0 && list2.size() == 0) {
                if (increment == 0)
                    return current;
                ListNode head = new ListNode(1);	// 迴圈結束,但還有進位的情況
                head.next = current;
                return head;
            }
        }
    }
}

十. LeetCode 234. 迴文連結串列

描述: 判斷一個連結串列是否為迴文連結串列,要求時間複雜度為O(n),空間複雜度為O(1)。題目連結

分析: 空間複雜度為O(1),所以不能使用List等額外的資料結構。對於迴文連結串列,可以理解為中點左右的值都相等。所以我們可以先找到連結串列的中點,然後將後半段反轉,這時候逐個比較後半段與連結串列開頭的值。其實LeetCode裡就有尋找中點的題,也是雙指標。但該題題目要求寫道,如果有兩個中間結點,那麼返回第二個結點。這裡是一個細節。我們獲得了中間結點之後,是不需要把中間結點與head比較的,所以我們會是從mid.next與head開始相比,這就導致了奇偶數情況不同:如果是奇數,可以直接mid.next即可。如果是偶數,因為那樣的解法是獲得第二個中點,顯然我們應該獲得“第一個“作為中點。這時候需要在尋找中點的做法裡做一些改動。

程式碼

class Solution {
    public boolean isPalindrome(ListNode head) {
        if (head == null || head.next == null)
            return true;
        ListNode fast = head, slow = head;
        while (fast.next != null && fast.next.next != null) {   
        // 這會使得偶數連結串列返回第一個中間結點
        // 如果想返回第二個中間結點, 應該用 fast != null && fast.next != null
            fast = fast.next.next;
            slow = slow.next;
        }
        slow = reverse(slow.next);
        while (slow != null) {
            if (slow.val != head.val)
                return false;
            slow = slow.next;
            head = head.next;
        }
        return true;
    }
    public ListNode reverse(ListNode head) {
        if (head == null || head.next == null)
            return head;
        ListNode prev = null;
        ListNode current = head;
        while (current != null) {
            ListNode next = current.next;
            current.next = prev;
            prev = current;
            current = next;
        }
        return prev;
    }
}

相關文章