1. 程式人生 > >連結串列相關演算法

連結串列相關演算法

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

示例:

輸入:1->2->4, 1->3->4
輸出:1->1->2->3->4->4
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        if (l1 == null && l2 == null) {
            return null;
        }
        ListNode node = new ListNode(0);
        ListNode dummy = node;
        while (l1 != null || l2 != null) {
            if (l1 == null) {
                node.next = l2;
                break;
            } else if (l2 == null) {
                node.next = l1;
                break;
            } else {
                if (l1.val < l2.val) {
                    node.next = l1;
                    l1 = l1.next;
                } else {
                    node.next = l2;
                    l2 = l2.next;
                }
                node = node.next;
            }
        }//end while
        return dummy.next;
    }
}

給定一個連結串列,刪除連結串列的倒數第 個節點,並且返回連結串列的頭結點。

示例:

給定一個連結串列: 1->2->3->4->5, 和 n = 2.

當刪除了倒數第二個節點後,連結串列變為 1->2->3->5.

說明:

給定的 n 保證是有效的。

進階:

你能嘗試使用一趟掃描實現嗎?

方法一:兩次遍歷演算法

思路

我們注意到這個問題可以容易地簡化成另一個問題:刪除從列表開頭數起的第 (L−n+1)(L - n + 1)(L−n+1) 個結點,其中 LLL 是列表的長度。只要我們找到列表的長度 LLL,這個問題就很容易解決。

演算法

首先我們將新增一個啞結點作為輔助,該結點位於列表頭部。啞結點用來簡化某些極端情況,例如列表中只含有一個結點,或需要刪除列表的頭部。在第一次遍歷中,我們找出列表的長度 LLL。然後設定一個指向啞結點的指標,並移動它遍歷列表,直至它到達第 (L−n)(L - n)(L−n) 個結點那裡。我們把第 (L−n)(L - n)(L−n) 個結點的 next 指標重新連結至第 (L−n+2)(L - n + 2)(L−n+2) 個結點,完成這個演算法。

Remove the nth element from a list

圖 1. 刪除列表中的第 L - n + 1 個元素

public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    int length  = 0;
    ListNode first = head;
    while (first != null) {
        length++;
        first = first.next;
    }
    length -= n;
    first = dummy;
    while (length > 0) {
        length--;
        first = first.next;
    }
    first.next = first.next.next;
    return dummy.next;
}

複雜度分析

  • 時間複雜度:O(L)O(L)O(L),

    該演算法對列表進行了兩次遍歷,首先計算了列表的長度 LLL 其次找到第 (L−n)(L - n)(L−n) 個結點。 操作執行了 2L−n2L-n2L−n 步,時間複雜度為 O(L)O(L)O(L)。

  • 空間複雜度:O(1)O(1)O(1),

    我們只用了常量級的額外空間。
     


方法二:一次遍歷演算法

演算法

上述演算法可以優化為只使用一次遍歷。我們可以使用兩個指標而不是一個指標。第一個指標從列表的開頭向前移動 n+1n+1n+1 步,而第二個指標將從列表的開頭出發。現在,這兩個指標被 nnn 個結點分開。我們通過同時移動兩個指標向前來保持這個恆定的間隔,直到第一個指標到達最後一個結點。此時第二個指標將指向從最後一個結點數起的第 nnn 個結點。我們重新連結第二個指標所引用的結點的 next 指標指向該結點的下下個結點。

Remove the nth element from a list

圖 2. 刪除連結串列的倒數第 N 個元素

public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode first = dummy;
    ListNode second = dummy;
    // Advances first pointer so that the gap between first and second is n nodes apart
    for (int i = 1; i <= n + 1; i++) {
        first = first.next;
    }
    // Move first to the end, maintaining the gap
    while (first != null) {
        first = first.next;
        second = second.next;
    }
    second.next = second.next.next;
    return dummy.next;
}

複雜度分析

  • 時間複雜度:O(L)O(L)O(L),

    該演算法對含有 LLL 個結點的列表進行了一次遍歷。因此時間複雜度為 O(L)O(L)O(L)。

  • 空間複雜度:O(1)O(1)O(1),

    我們只用了常量級的額外空間。

給定一個連結串列,判斷連結串列中是否有環。

進階:
你能否不使用額外空間解決此題?

方法一:雜湊表

思路

我們可以通過檢查一個結點此前是否被訪問過來判斷連結串列是否為環形連結串列。常用的方法是使用雜湊表。

演算法

我們遍歷所有結點並在雜湊表中儲存每個結點的引用(或記憶體地址)。如果當前結點為空結點 null(即已檢測到連結串列尾部的下一個結點),那麼我們已經遍歷完整個連結串列,並且該連結串列不是環形連結串列。如果當前結點的引用已經存在於雜湊表中,那麼返回 true(即該連結串列為環形連結串列)。

public boolean hasCycle(ListNode head) {
    Set<ListNode> nodesSeen = new HashSet<>();
    while (head != null) {
        if (nodesSeen.contains(head)) {
            return true;
        } else {
            nodesSeen.add(head);
        }
        head = head.next;
    }
    return false;
}

複雜度分析

  • 時間複雜度:O(n)O(n)O(n), 對於含有 nnn 個元素的連結串列,我們訪問每個元素最多一次。新增一個結點到雜湊表中只需要花費 O(1)O(1)O(1) 的時間。

  • 空間複雜度:O(n)O(n)O(n), 空間取決於新增到雜湊表中的元素數目,最多可以新增 nnn 個元素。


方法二:雙指標

思路

想象一下,兩名運動員以不同的速度在環形賽道上跑步會發生什麼?

演算法

通過使用具有 不同速度 的快、慢兩個指標遍歷連結串列,空間複雜度可以被降低至 O(1)O(1)O(1)。慢指標每次移動一步,而快指標每次移動兩步。

如果列表中不存在環,最終快指標將會最先到達尾部,此時我們可以返回 false

現在考慮一個環形連結串列,把慢指標和快指標想象成兩個在環形賽道上跑步的運動員(分別稱之為慢跑者與快跑者)。而快跑者最終一定會追上慢跑者。這是為什麼呢?考慮下面這種情況(記作情況 A) - 假如快跑者只落後慢跑者一步,在下一次迭代中,它們就會分別跑了一步或兩步並相遇。

其他情況又會怎樣呢?例如,我們沒有考慮快跑者在慢跑者之後兩步或三步的情況。但其實不難想到,因為在下一次或者下下次迭代後,又會變成上面提到的情況 A。

public boolean hasCycle(ListNode head) {
    if (head == null || head.next == null) {
        return false;
    }
    ListNode slow = head;
    ListNode fast = head.next;
    while (slow != fast) {
        if (fast == null || fast.next == null) {
            return false;
        }
        slow = slow.next;
        fast = fast.next.next;
    }
    return true;
}

複雜度分析

  • 時間複雜度:O(n)O(n)O(n), 讓我們將 nnn 設為連結串列中結點的總數。為了分析時間複雜度,我們分別考慮下面兩種情況。

    • 連結串列中不存在環:
      快指標將會首先到達尾部,其時間取決於列表的長度,也就是 O(n)O(n)O(n)。

    • 連結串列中存在環:
      我們將慢指標的移動過程劃分為兩個階段:非環部分與環形部分:

      1. 慢指標在走完非環部分階段後將進入環形部分:此時,快指標已經進入環中 迭代次數=非環部分長度=N\text{迭代次數} = \text{非環部分長度} = N迭代次數=非環部分長度=N

      2. 兩個指標都在環形區域中:考慮兩個在環形賽道上的運動員 - 快跑者每次移動兩步而慢跑者每次只移動一步。其速度的差值為1,因此需要經過 二者之間距離速度差值\dfrac{\text{二者之間距離}}{\text{速度差值}}速度差值二者之間距離​ 次迴圈後,快跑者可以追上慢跑者。這個距離幾乎就是 "環形部分長度 K\text{環形部分長度 K}環形部分長度 K" 且速度差值為 1,我們得出這樣的結論 迭代次數=近似於\text{迭代次數} = \text{近似於}迭代次數=近似於 "環形部分長度 K\text{環形部分長度 K}環形部分長度 K".

    因此,在最糟糕的情形下,時間複雜度為 O(N+K)O(N+K)O(N+K),也就是 O(n)O(n)O(n)。

  • 空間複雜度:O(1)O(1)O(1), 我們只使用了慢指標和快指標兩個結點,所以空間複雜度為 O(1)O(1)O(1)。

反轉一個單鏈表。

示例:

輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1->NULL
/**
 * 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;
        }
        // dummy 保持不動, 不斷把新的node insert到 dummy.next
        ListNode dummy = new ListNode(-1);
        while (head != null) {
            ListNode temp = head.next;
            head.next = dummy.next;
            dummy.next = head;
            head = temp;
        }
        return dummy.next;
    }
}