1. 程式人生 > >有環連結串列及以此為基礎的一些問題

有環連結串列及以此為基礎的一些問題

連結串列有環及其延伸問題

        首先,問題涉及的有環連結串列是指連結串列的尾節點不是null,而是指向連結串列中的其中一個節點,從而使得連結串列的其中一段是迴圈的,如果用圖的話可以得到這樣的資料結構:

那麼基於這樣的資料結構有一系列問題需要處理。最基礎的則是判定是否有環。也就是說判定一個連結串列是否是有環連結串列。判定是否有環之後,又可以要求返回到底是連結串列的哪一個節點開始進入環的,然後環的長度是多少,連結串列的總長度是多少。反向思考,如果連結串列是無環的,那麼給出兩個連結串列的話,如何判定兩個連結串列是否相交,如果相交的話交點是哪個節點。

        接下來就給出解決這些問題的演算法。然後,這篇博文用的語言是C++,然後測試用的連結串列就是上圖,一樣的結構。而關於反向思考判定兩個連結串列相交以及交點的問題,就不再寫了,只給出程式碼。連結串列節點的定義以及建立連結串列、測試正確性的程式碼如下:

struct Node {
    int val;
    struct Node *next;
}head,a,b,c,d,e,f;

void create_list() {
    a.val = 1;
    b.val = 2;
    c.val = 3;
    d.val = 4;
    e.val = 5;
    f.val = 6;
    head.next = &a;
    a.next = &b;
    b.next = &c;
    c.next = &d;
    d.next = &e;
    e.next = &f;
    f.next = &c;
}

void test_link_list() {
    Node *temp = head.next;
    for (int i = 0; i < 10; i++) {
        cout << temp->val << endl;
        temp = temp->next;
    }
}

這個。。。寫的比較省事,不過也比較直白= =。結構體中val的值是指節點的編號,從1開始。

判定連結串列是否有環

        如果是無環,那麼在O(n)複雜度下遍歷到null就說明無環。有環的情況下遍歷是死迴圈的,這個時候我們考慮利用遍歷的速度差。例如高中物理經典問題“追及問題”,A、B兩者的速度不同,那麼在某個時間兩者必定相遇。如果是在操場上賽跑,那麼因為週期性,在n圈以後必定存在快的追及到慢的情況。連結串列同理,如果同時從頭開始用兩個指標開始遍歷,一個遍歷速度慢(一次跳一個結點),一個遍歷速度快(一次跳多個結點),那麼只要保證:快速的能夠整除慢速,也就是滿足週期T的情況,那麼一定能夠在環上追及到。

        那麼,我們設定兩個指標,最簡單的方法慢指標每次跳1,快指標每次跳2,如果碰見null,說明連結串列無環,如果快指標指向結點與慢指標的一樣,那麼說明有環。這樣在O(n)的時間複雜度內就可以得到解。程式碼如下:

int have_ring() {
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) return 1;
        slow = slow->next;
        if (fast->next == NULL) return 0;
        fast = fast->next->next;
    }
    return 0;
}

返回值是1說明有環,返回值是0說明無環。

快指標的速度問題

        我個人認為快指標的速度是無關緊要的,對於一個固定的連結串列能在幾次移動中判定是否有環,主要跟慢指標的速度有關,因為只有以最快的速度進環,才能保證相遇的可能。而在環中,移動幾次能夠相遇又與慢指標的速度、環長有關。增加慢指標的速度,在一次就移動到相遇點的情況下,再快也至少要移動一次。所以速度推薦慢指標移動1快指標移動2。相遇點這個,因為在環上運動的話是週期性的,第一次兩者相遇的話,那麼下一次相遇必定還是在這個點。而如果交換兩者移動的先後順序到達的則是對面的點。

判定入環結點

        既然判定有環,那麼就能引申一系列環上操作,判定入環結點就是比較重要的。不過演算法過程更偏向於數學推導驗證。

        還是上面的連結串列為例,圖可參考最上面的。我們假設連結串列的總長為L,連結串列頭到入環點的距離為A,入環點到快慢指標追及點的長度為X,環長為M。如下圖所示:

如果不論A這一段距離的話,從慢指標到達環上開始,到快慢指標相遇,因為慢指標絕對還沒有繞環一週,也就是說X < M,如果快指標的速度是慢指標的兩倍,那麼慢指標移動長度2*X < M,也就是說快指標一定是沒有超過兩週。而慢指標想要和快指標相遇,至少要繞環一週才可以。所以對於快指標而言,在環上的第二週碰到了慢指標。

        這裡分2種情況,A < M。此時,2*A < M,所以在慢指標進入環的時候,塊指標一週還沒有遍歷完成,那麼一定是在第二週與慢指標相遇,就像上圖的情況,那麼有:

L + X = 2*(A + X)

轉化為:L - A - X = A

這裡可以看到,L - A - X是相遇點到入環點的距離,和A是相同的。

        第二種情況,A >= M,此時快指標至少跑一週了,此時我們考慮一下擷取,即按照週期M將多出來的部分給擷取掉。例如這樣的圖:

我們將其中多出來的P個結點,只要滿足2*P mod M == 0的情況,就可以想象成多出來的部分,慢指標還沒有進環,而塊指標在環上週期運動。步數向抵消。那麼就可以將P個結點擷取,紅線右邊的部分就是上面的圖了,就轉換成了第一種情況。同理,所有滿足A >= M的連結串列都可以轉換成A < M的情況。換而言之,都滿足:L - A -X = A的情況。

        既然滿足這個條件,那麼演算法就非常簡單了,設定兩個指標,一個從head開始移動,另一個從相遇點開始移動,那麼最終相遇的地方就是入節點了。程式碼如下:

Node * get_ring_node() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast){
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
    }
    return p_head;
}

有部分程式碼是重複的,這裡是為了展示= =實際上最好是在判定是否有環的時候,就返回這個相遇點,如果相遇點是null說明無環,否則為有環。當做引數傳遞進函式就可以省略一部分程式碼了。

環長與連結串列總長

        上面得到了這樣的表示式:L - A - X = A。那麼我們求的實際上是L與L - A。首先在上面函式求入環結點的時候,遍歷的數量其實就是A,這樣我們就得到了A值。然後如果我們繼續遍歷,從入環口開始,直到相遇點,統計這個遍歷的次數就可以得到X值。這樣通過表示式的變式:L = 2*A + X就可以得到L了。那麼L - A也就可以直接獲得。

        這裡注意連結串列總長的定義:一般我們指的是結點的數量,我們實際上計算的時候入環結點是計算了2次的,所以答案應該把多出來的給減去。程式碼:

int get_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此時slow指標是指向相遇點沒有變的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return 2*a + x - 1;
}

同上,還是將多餘的寫出來了。

        環上長度就基本跟上面差不多了,將最後的2*a+x-1換成a+x就可以了。程式碼:

int get_ring_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此時slow指標是指向相遇點沒有變的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return a + x;
}

兩個連結串列是否相交及交點

        也是一個據說很經典的題,可以百度搜,原理以及解法可以看博主的另一篇部落格:

https://blog.csdn.net/iwts_24/article/details/83384175

這裡就不多贅述了。

程式碼的重複問題

       可以看到,上面很多程式碼是重複了很多的,因為這一系列的問題有很多重合點。但是如果我們能將入環點、相遇點給記錄下來的話,就可以節省非常多的程式碼。只用將這兩個指標存進全域性變數即可。這也比較簡單,就是加兩句程式碼的事也就不再寫了(主要是比較懶= =)。