1. 程式人生 > >對單鏈(Single-Linked List)操作的思考

對單鏈(Single-Linked List)操作的思考

前言
對於單鏈(single-linked list)的考察頻繁的出現在各種各樣的考試題和麵試題中。這其中的原因,一方面是因為單鏈的使用相當廣泛,更重要的是單鏈的使用非常靈活,某些單鏈的操作對程式設計師的演算法分析能力和靈活思考能力有很高的要求。本文通過對幾個有代表性的單鏈演算法的分析試圖尋找解決單鏈問題的"萬能鑰匙",最起碼也為提供一個通用的思路。

單鏈的特性
在進入到具體的演算法分析之前,我覺得有必要分析一下單鏈到底具有那些區別於其他資料結構的特徵,更重要的是我們需要知道這些特性對我們的演算法分析會產生那些影響(包括積極的影響和消極的影響)。對於單鏈的結構這裡不在羅嗦,任何一本資料結構的書都不會吝嗇筆墨來講解單鏈的結構。從單鏈的結構出發,我覺得單鏈的遍歷過程具有以下兩個特點:
    1> 預知未來

。對於當前的結點,我們可以"偷窺"一下這個結點以後的一個或者多個結點;
    2> 覆水難收。對於當前的結點,如果不作記錄的話,我們無法再得到曾經經歷的結點。
通過這兩個特點,我們已經可以隱隱約約感覺了一些東西,這些東西和最終解決單鏈問題的演算法有著千絲萬縷的關係。如果你不相信的話,且看下面我的分析。

問題1:反轉一個單鏈(reverse an single-linked list)
對於反轉一個單鏈,最大的問題在於我們需要將當前結點的指向下一個結點的指標指向一個剛剛訪問的一個結點,一個簡單的解決方法就是將單鏈的所有結點全部儲存起來,然後再按照和他們的訪問順序相反的順序再一次訪問他們。對於這個想法我們很自然的就可以想到使用棧這個資料結構,因為棧的"先進後出"的特性正是我們需要的。這樣演算法就是:
  1> 從頭結點開始,遍歷整個單鏈,將遍歷到的每一個結點壓入棧中。
  2> 從棧中彈出第一個結點,並將這個結點作為新的頭節點。
  3> 將棧中的結點依次壓出,並將這些結點按順序作為新鏈的尾結點。
  4> 整個過程直到棧為空。
這個演算法相當於對單鏈進行了兩次遍歷,所以它的時間複雜度為O(n)。由於它另外儲存了所有結點,所以它的空間複雜度也為O(n)。對於這個演算法,我們還有優化的空間麼?仔細想想,其實我們並沒有必要儲存所有曾經訪問過的結點,我們只需要儲存當前結點的前一個結點。但是當我們把當前結點的下一個結點設定為前一個結點後,我們就丟失了當前結點的下一個結點。要解決這樣問題也不難,只要我們在調整之前,儲存原來的下一個結點,這樣再設定完以後,我們仍然保持對原有單鏈的"所有權"。總結下來,我們需要三個額外的變數:

  Node* pActiveNode  = pHead;              //當前結點  Node* pBehindNode  = NULL;               //當前結點的前一個結點  Node* pAdvanceNode = pHead->Next;       //當前結點的後一個結點
在遍歷的過程中,我們需要將當前結點的下一個結點設定為前一個結點:   pActiveNode->Next = pBehindNode;

同時我們要按照以下的順序調整這三個變數:

  pBehindNode = pActiveNode;
  pActiveNode = pAdvanceNode;
  pAdvanceNode = pAdvanceNode->
Next;

整個遍歷過程當pActiveNode == NULL時候結束,此時的pBehindNode就是新的單鏈的頭結點。
和前面的演算法比較,雖然它們的時間複雜度都是O(n),但是這個演算法只遍歷了一次。由於這個演算法中使用的額外記憶體空間的大小是固定的,所以它的空間複雜度是O(1)。相對於前面的演算法,可以說是不小的進步。

問題2:獲得處於單鏈中間位置的結點
這個問題初看起來並沒有什麼難度,我們只要想遍歷一次單鏈就可以獲得這個單鏈的長度,有了這個長度,獲得處於中間位置的結點就不是什麼問題了。這個問題的難點在於如果我們只允許對這個單鏈進行一次遍歷,那我們該如果設計我們的演算法呢?有了前面解決問題1的經驗,我們意識到可能需要用到兩個額外的遊標:   Node* pSlowNode = pHead;                     //標識當前正在訪問的結點  Node* pFastNode = pHead;                     //標識當前訪問結點後面的某個結點
如果我們將前面遊標的步長設為1(每次前進一個結點),將另外一個遊標的步長設為2(每次前進二個結點):   pSlowNode = pSlowNode->Next;              //前進一個結點  pFastNode = pFastNode->Next->Next;        //前進兩個結點
那麼後面遊標的前進速度是前面遊標的前進速度的兩倍。當後面遊標(快的遊標)達到尾結點(或者尾結點的前面一個結點)的時候,前面的遊標(慢的遊標)正好處於單鏈中間的位置。Aha! 這正是我們需要的。

總結
由以上的演算法分析來看,對於單鏈的遍歷操作,我們通常可以抓住以下幾個要點:
1> 在遍歷的過程中,我們可以維護以下三個狀態:
        1> 當前訪問的結點。
        2> 當前訪問的結點的前M個結點。
        3> 當前訪問的結點的後N個結點。
     其中,第一個狀態是所有遍歷過程都需要的,其他的兩個應該根據具體的問題來判斷是否需要維護它們。
2> 在遍歷的過程中,遍歷的步長是遍歷的一個重要特徵,正確步長的選取往往是演算法設計的關鍵。
3> 在遍歷的過程中,儘量避免多次遍歷,可以使用額外記憶體空間來換取遍歷時間的方法將遍歷過程縮減到一次完成。

後記
下面我列出了我收集的一些關於單鏈的問題,大家可以試試用本文所總結的解題思路去思考這些問題,說不定有意想不到的效果:
  1. 判斷一個單鏈是否存在環(Cyclic Single-Linked List);
  2. 獲得倒數第M個結點(Mth-to-Last Element of a Single-Linked List);
  3. 判斷這個單鏈是否存在相交(Intersection Between Two Single-Linked List);
  4.將兩個已經按升序排列的單鏈合併成一個仍然按升序排列的單鏈
  5.單向連結串列的刪除操作,已知 head, p(指向被刪除元素),要求複雜度為 O(1);
我很樂意和大家就這些問題展開深入的討論,如果大家有任何的建議和疑問,記得給我寫郵件喲

歷史記錄
01/09/2007   v1.0
原文的第一版