【圖解算法】鏈表(下)

筆試時,鏈表的題能過儘快過,不考慮空間複雜度;面試時,則儘量考慮如何將空間複雜度降到O(1)

問題描述

  1. 將單向鏈表按某值劃分成左邊小、中間相等、右邊大的形式。
  2. 複製含有隨機指針節點的鏈表。
  3. 兩個單鏈表相交的系列問題。

##將單向鏈表按某值劃分成左邊小、中間相等、右邊大的形式

這道題實際上就是荷蘭國旗問題的單鏈表版本,所謂荷蘭國旗問題:(leetcode中的顏色分類問題)

給定一個包含紅色、白色和藍色,一共 n 個元素的數組,原地對它們進行排序,使得相同顏色的元素相鄰,並按照紅色、白色、藍色順序排列。

此題中,我們使用整數 0、 1 和 2 分別表示紅色、白色和藍色。

實際上,這裏是0、1、2或者是隨便給一些數字+一個閾值K,小於K的放左邊,大於K的放右邊,二者是一樣的。

注意:
不能使用代碼庫中的排序函數來解決這道題。且空間複雜度爲O(1)

示例:

輸入: [2,0,2,1,1,0]
輸出: [0,0,1,1,2,2]

這道題如果知道怎麼做,那麼一個直觀的做法就是先將鏈表存儲在一個節點數組中,然後使用數組版本的荷蘭國旗解法解決,然後再重新連接成鏈表,但是這樣需要一個O(N)的空間複雜度,推薦在筆試過程中使用該方法快速過掉這道題;在面試中,我們則應該儘量優化,體現我們的思考深度。一個是使用O(1)的空間複雜度,另一個則是保持鏈表原來的相對順序。

荷蘭國旗問題解法

這裏我們先來說下荷蘭國旗怎麼解,首先要注意,我們使用的是數組;我們只需三個變量來記錄數組中的位置,leftright記錄分割的位置(整個數組需要劃分爲三個部分),使用index記錄掃描到的數組元素位置。

三個變量的起始位置

從上圖中可以看到,left = -1index = 0right = arr.length

接下來我們將對index向右進行遍歷,每次遍歷需要注意一些條件,如果當前的值arr[index]小於K,說明這個值應該放左邊區域,如果arr[index]大於K,說明這個值應該放右邊,我們來看下代碼就很容易明白:

輔助函數:交換數組中的值

void swap(int *arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

排序代碼:

void colorSort(int *arr, int len, int K) { // 荷蘭國旗問題排序
    int left = -1;
    int right = len;
    int index = 0;
    while (index < right) {
        if (arr[index] < K) { // 當前值小於閾值 K
            swap(arr, ++left, index++);
        } else if (arr[index] > K) { // 當前值大於閾值 K
            swap(arr, --right, index);
        } else { // 當前值等於閾值 K
            index++;
        }
    }
}

理解了荷蘭國旗問題,再來理解我們的單鏈表版本就好理解很多了,事實上,除了使用額外的數組空間外,我們可以使用幾個變量來解決這個問題。

單鏈表版本的荷蘭國旗問題

我們可以考慮使用三對HeadEnd來記錄三個區域的鏈表的頭和尾,如下圖所示:

最終分爲三個鏈表

如上圖所示,我們利用上述的六個變量,將一個單鏈表分成三個鏈表,最後將它們串起來:

最終結果-有序地分類

好,我們知道最終結果長啥樣後,接下來就是考慮如何得到這樣的結果,實際上代碼思路很簡單,首先遍歷一次鏈表,用三個head指針分別標記第一個小於K、第一個等於K、和第一個大於K的數的指針,當然,我們要處理邊界問題,比如這裏面沒有等於K的,那麼中間的鏈表就是空的,連接的時候就要十分注意。

代碼:

ListNode *colorSort(ListNode *head, int K) {
    ListNode *leftHead = nullptr, *leftEnd = nullptr;
    ListNode *midHead = nullptr, *midEnd = nullptr;
    ListNode *rightHead = nullptr, *rightEnd = nullptr;
    ListNode *next = nullptr;
    while (head != nullptr) { // 將一個鏈表拆分爲三個鏈表
        next = head->next; // 保存下一個節點
        head->next = nullptr;
        if (head->val < K) {
            if (leftHead == nullptr) {
                leftHead = head;
                leftEnd = head;
            } else {
                leftEnd->next = head;
                leftEnd = leftEnd->next;
            }
        } else if (head->val > K) {
            if (rightHead == nullptr) {
                rightHead = head;
                rightEnd = head;
            } else {
                rightEnd->next = head;
                rightEnd = rightEnd->next;
            }
        } else {
            if (midHead == nullptr) {
                midHead = head;
                midEnd = head;
            } else {
                midEnd->next = head;
                midEnd = midEnd->next;
            }
        }
        head = next;
    }

    // 連接左鏈表和中間的鏈表
    if (leftEnd != nullptr) {
        leftEnd->next = midHead;
      	// 如果中間鏈表爲空,midEnd 應指向 leftEnd
        midEnd = (midEnd == NULL) ? leftEnd : midEnd;
    }
    // 連接中間鏈表和右鏈表
    if (midEnd != nullptr) {
        midEnd->next = rightHead;
    }
    // 返回三個鏈表串起來後的頭節點
    return (leftHead != NULL) ? leftHead : ((midHead != NULL) ? midHead : rightHead);
}

可以看到,雖然思路簡單,但是整個代碼細節非常多,面試過程中一不小心就會出錯,大家寫代碼前一定要捋清後再寫。

複製含有隨機指針節點的鏈表

【題目】 一種特殊的鏈表節點類描述如下:

struct ListNode {
    int val;
    ListNode *next; // 和普通的單鏈表一樣
    ListNode *rand; // 隨機指向某個節點

    ListNode(int x) : val(x), next(nullptr), rand(nullptr) {}
};

rand指針是這種鏈表中新增的指針,這個指針可能指向鏈表中的任意一個節點,也可能指向NULL。 給定一個由含有隨機指針節點的鏈表的頭節點head,請實現一個函數完成這個鏈表中所有結構的複製,並返回複製的新鏈表的頭節點。

進階:不使用額外的數據結構,只用有限幾個變量,且在時間複雜度爲 O(N) 內完成原問題要實現的函數。

【題意解析】

我們首先畫個圖來理解一下這種含有隨機指針節點的鏈表:

有隨機指針節點的鏈表

如上圖所示,黃線即爲rand指針的指向,可以看到指向是完全隨機沒有規律的。

【普通解法】

接下來我們說說簡單的解法,以節點1爲例子,我們知道它的rand指針指向節點2,但是拷貝後的新節點1如何找到新節點2呢?這裏我們就會想到使用哈希表,我們可以將 <節點新節點> 的映射結構存入哈希表中。

我們首先遍歷一遍鏈表,拷貝一份只複製了next指針的鏈表,同時建立哈希表;再對這個新鏈表進行一次遍歷,每遍歷一個節點,將這個節點在哈希表中對應的節點取出,用rand指針去連接即可。

我們先來看看單純的拷貝單鏈表:

ListNode *copyByHash(ListNode *head) {
    ListNode *newHead = new ListNode(head->val);
    ListNode *q = newHead;
    ListNode *p = head;
    while (p->next != nullptr) { // 拷貝單鏈表
        p = p->next;
        q->next = new ListNode(p->val);
        q = q->next;
    }
    return newHead;
}

接下來我們加入哈希表部分:

ListNode *copy(ListNode *head) {
  	if (head == nullptr) { // 空指針判斷
        return nullptr;
    }
    ListNode *newHead = new ListNode(head->val);
    ListNode *q = newHead;
    ListNode *p = head;
    map<ListNode *, ListNode *> nodeMap;
    nodeMap[head] = newHead;

    while (p->next != nullptr) { // 拷貝單鏈表
        p = p->next;
        q->next = new ListNode(p->val);
        q = q->next;
        nodeMap[p] = q; // 第一次遍歷的過程中即可同時進行哈希映射
    }
    p = head;
    q = newHead;
    while (q != nullptr) { // 拷貝 rand 指針
        q->rand = p->rand == nullptr ? nullptr : nodeMap[p->rand];
        q = q->next;
        p = p->next;
    }

    return newHead;
}

使用哈希表的方法固然簡單,但需要O(N)的空間複雜度,有沒有O(1)的解決辦法呢?繼續看我們的優化方法。

【空間優化】

實際上,我們的解題關鍵就是節點新節點之間的聯繫,假如我們可以使用某種方法將它們聯繫起來,那麼也就不需要哈希表了。這種辦法就是將新節點連接在原節點後面,這樣通過節點->next就可以獲取到新節點了。如下圖所示:

將新節點插入原節點後面

我們首先通過一次遍歷達成上述效果:

藍色箭頭處爲cur->next的指向變更

創建並插入新節點1

將圖結合代碼看更加直觀:

ListNode *cur = head;
ListNode *next = nullptr;

while (cur != nullptr) { // 拷貝單鏈表
    next = cur->next;
    cur->next = new ListNode(cur->val);
    cur->next->next = next;
    cur = next;
}

接下來,在拷貝rand指針的時候,我們只需要拷貝節點->rand->next即可:

拷貝rand指針

cur = head;
ListNode *curCopy = nullptr;
while (cur != nullptr) { // 拷貝 rand 指針
    next = cur->next->next;
    curCopy = cur->next;
    curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
    cur = next;
}

最終還需要對它們進行拆分:

拆分後對最終結果

完整代碼:

ListNode *copy(ListNode *head) {
    if (head == nullptr) { // 邊界判斷
        return nullptr;
    }
    ListNode *cur = head;
    ListNode *next = nullptr;

    while (cur != nullptr) { // 拷貝單鏈表
        next = cur->next;
        cur->next = new ListNode(cur->val);
        cur->next->next = next;
        cur = next;
    }
    cur = head;
    ListNode *curCopy = nullptr;
    while (cur != nullptr) { // 拷貝 rand 指針
        next = cur->next->next;
        curCopy = cur->next;
        curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
        cur = next;
    }
    ListNode* res = head->next;
    cur = head;
    while (cur != nullptr) { // 拆分並還原
        next = cur->next->next;
        curCopy = cur->next;
        cur->next = next;
        curCopy->next = next == nullptr ? nullptr : next;
        cur = next;
    }
    return res;
}

兩個單鏈表相交的系列問題

【題目】 在本題中,單鏈表可能有環,也可能無環。給定兩個單鏈表的頭節點 head1head2,這兩個鏈表可能相交,也可能不相交。請實現一個函數, 如果兩個鏈表相交,請返回相交的第一個節點;如果不相交,返回null 即可。

【 要求】如果鏈表1 的長度爲N,鏈表2的長度爲M,時間複雜度請達到 O(N+M),額外空間複雜度請達到O(1)

【題意解析】

注意了,本題實際上將鏈表相交的常見問題都糅合在一起了,是有一定難度的,我們需要先將問題切分開來:

  1. 如何判斷單鏈表有環無環?

    我們寫一個函數來實現返回一個鏈表的環判定,有環返回入環的第一個點,無環則返回null

  2. 如何判斷單鏈表相交與否?

    這個需要分開討論,需要意識到,一個鏈表有環 ,另一個鏈表無環,是不可能相交的。

    • 兩個鏈表都無環
    • 兩個鏈表都有環

相交的三種可能

對於有環相交2,我們在返回環的起點的時候,從兩個起點中任意返回一個即可。

判斷單鏈表有環無環?

這裏也是用到我們前面講過的快慢指針,如果快慢指針有機會相等,那說明這個鏈表有環,兩個指針在環裏面兜圈,否則,任何一個指針到達了NULL,說明這個鏈表無環。這裏理解了後,我們再考慮如何返回入環的第一個節點。

如下方的代碼:

  1. 不要忘了處理邊界,要形成環至少有三個節點;
  2. 第一次循環中,如果快指針遇到了null,說明鏈表無環,直接返回null即可;如果快指針和慢指針相遇,即可確定鏈表有環,接下來就是確定環的入口(或起點);
  3. 在第二次循環中,我們先將快指針重新指向頭節點,然後從一次走兩步變爲一次走一步,當快慢指針再次相遇時,相遇的節點必然是環的入口節點。

關於第三點,我們如何證明呢?

如下圖所示,整個鏈表的長度爲L,其中環部分的長度爲R,非環的部分長度爲x,當快慢指針相遇時,相遇點到入環節點的距離爲y,假設相遇時慢指針走的距離爲s,所以s = x + y;同時,對於快指針來說,快指針的速度是慢指針的2倍,我們假設了慢指針走了s,那麼快指針就走了2s,且快指針走的距離等價於s加上比慢指針在環中多走了n圈,所以2*s = s + n*R,化簡得到s = n * R;另外,整個鏈表的長度等價於非環部分的長度x加上環的長度R

我們根據上述得到的三條式子進行代換,可以得到下圖中x = (n-1)*R + L-x-y,這條式子表明,我們將快指針重新放到起點,慢指針則不變,快指針從非環部分移動的距離x等價於慢指針從第一次相遇點再次到達入環點的距離(n-1)*R + L-x-y。其中的(n-1)*R表明在這個過程中,慢指針可能會在環中轉n-1圈,但不影響快慢指針同時到達入口點;

證明第三點

ListNode *getCircleEntry(ListNode *head) {
    // 處理邊界,至少要有三個點纔能有環
    if (head == nullptr || head->next == nullptr || head->next->next == nullptr) {
        return nullptr;
    }
    ListNode *slow = head->next;
    ListNode *fast = head->next->next;
    while (slow != fast) {
        if (fast == nullptr || fast->next == nullptr) { // 遇到 null,說明無環
            return nullptr;
        }
        fast = fast->next->next;
        slow = slow->next;
    }
    fast = head; // 快指針回到頭節點,慢指針不變
    while (slow != fast) {
        slow = slow->next;
        fast = fast->next; // 快指針每次只走一步
    }
    return fast;
}

如何判斷單鏈表相交與否?

我們根據前面判斷有無環的函數,可以將單鏈表相交問題分爲兩個部分進行討論:

ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
    ListNode *p1 = getCircleEntry(head1);
    ListNode *p2 = getCircleEntry(head2);
    if (p1 == nullptr && p2 == nullptr) {
      // 兩個無環單鏈表相交問題
    } else if (p1 != nullptr && p2 != nullptr) {
      // 兩個有環單鏈表相交問題
    }
}

接下來我們看如何解決無環單鏈表的相交問題:

  1. 先各自遍歷兩個鏈表,記錄長度,同時判斷最後一個節點是否相等,若不等,則一定不相交,返回null,否則,二者相交,到下一步;
  2. 將鏈表1的長度記錄爲len1,將鏈表2的長度記錄爲len2,如果len1大於len2,那麼鏈表1先移動len1-len2步,然後兩個指針一起走,判斷兩個指針是否相等,相等返回當前節點即可。
  3. 在第二點的基礎上,我們可以做一個小優化,使用一個變量count來記錄二者的長度差值,對鏈表1,每次自增,對鏈表2,每次自減,即可根據最終count的正負來判斷鏈表1、2何者更長。
ListNode *getNoLoopNode(ListNode *head1, ListNode *head2) {
    ListNode *p = head1;
    ListNode *q = head2;
    int count = 0;
    while (p->next != nullptr) { // 計算鏈表1長度
        count++;
        p = p->next;
    }
    while (q->next != nullptr) { // 計算鏈表2長度
        count--;
        q = q->next;
    }
    if (p != q) {
        return nullptr;
    }
    p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
    q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q
    
    for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
        p = p->next;
    }

    while (p != q) { // 直到找到第一個 p 和 q 相等的節點
        p = p->next;
        q = q->next;
    }
    return p;
}

接下來,我們看如何解決有環鏈表的相交問題:

我們前面已經得到了獲取一個有環鏈表的入環節點的函數:getCircleEntry(ListNode *head),因此我們可以獲取兩個鏈表的入環節點loop1loop2

如果loop1 == loop2,說明是第一種有環相交:

第一種有環相交,只有一個入口點

我們只需要把前面的判斷無環鏈表的相交節點的函數的邊界從null改爲loop1/loop2即可:

if (loop1 == loop2) {
    ListNode *p = head1;
    ListNode *q = head2;
    int count = 0;
    while (p->next != loop1) { // 計算鏈表1長度
        count++;
        p = p->next;
    }
    while (q->next != loop2) { // 計算鏈表2長度
        count--;
        q = q->next;
    }
    p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
    q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q

    for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
        p = p->next;
    }

    while (p != q) { // 直到找到第一個 p 和 q 相等的節點
        p = p->next;
        q = q->next;
    }
    return p;
}

如果loop1 != loop2,那麼兩個有環鏈表有可能不相交,也可能如第二種有環相交情況所示:

兩個循環入口節點不一樣的情況下

解決辦法:

我們讓鏈表1從loop1的位置開始行動,如果在再次回到loop1的過程中沒有遇到loop2,說明兩個鏈表不相交,直接返回null,否則,返回loop1或者loop2都可以。

else {
    ListNode *p = loop1->next;
    while (p != loop1) {
        if (p == loop2) {
          	return loop2;
        }
        p = p->next;
    }
    return nullptr;
}

整合之後:

ListNode *getLoopNode(ListNode *head1, ListNode *head2) {
    ListNode *loop1 = getCircleEntry(head1);
    ListNode *loop2 = getCircleEntry(head2);
    if (loop1 == loop2) {
        ListNode *p = head1;
        ListNode *q = head2;
        int count = 0;
        while (p->next != loop1) { // 計算鏈表1長度
            count++;
            p = p->next;
        }
        while (q->next != loop2) { // 計算鏈表2長度
            count--;
            q = q->next;
        }
        p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
        q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q

        for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
            p = p->next;
        }

        while (p != q) { // 直到找到第一個 p 和 q 相等的節點
            p = p->next;
            q = q->next;
        }
        return p;
    } else {
        ListNode *p = loop1->next;
        while (p != loop1) {
            if (p == loop2) {
                return loop2;
            }
            p = p->next;
        }
        return nullptr;
    }
}

主函數:

ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
    ListNode *p1 = getCircleEntry(head1);
    ListNode *p2 = getCircleEntry(head2);
    if (p1 == nullptr && p2 == nullptr) { // 兩個無環單鏈表相交問題
        return getNoLoopNode(head1, head2);
    } else if (p1 != nullptr && p2 != nullptr) { // 兩個有環鏈表相交問題
        return getLoopNode(head1, head2);
    }
}

【優化】

我們前面求兩個無環單鏈表的相交節點時,先各遍歷一遍得到長度,再遍歷第二次去獲取位置,實際上我們可以使用更加簡潔的寫法:

ListNode *getNoLoopNode2(ListNode *head1, ListNode *head2) {
    if (head1 == nullptr || head2 == nullptr)
        return nullptr;

    ListNode *p = head1;
    ListNode *q = head2;
    while (p != q) {
        if (p == nullptr)
            p = head2;
        else
            p = p->next;

        if (q == nullptr)
            q = head1;
        else
            q = q->next;
    }
    return p;
}

原理如下圖所示:

p點從帶圓圈的橙線(上面那條)出發,到達NULL之後跳轉到head2繼續,而q從帶圓圈的藍線(下面那條)出發,達到NULL之後跳轉到head1繼續,二者會在相交處相遇,此時p == q,跳出循環,返回p,即當前的相交節點。

這種方法在兩個鏈表長度相同時,只需遍歷相交前的部分即可。

運動軌跡

loop1 == loop2中整合該方法也非常容易,跟之前的方法一樣,將對NULL的判斷改爲對loop1/loop2的判斷即可。

發佈了97 篇原創文章 · 獲贊 80 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章