單鏈表操做(面試必看)

原文連接: https://subetter.com/algorith...

一:前言

單鏈表常常爲公司面試所說起,先不貶其過於簡單,由於單鏈表確實是數據結構中最簡單的一部分,但每每最簡單的,人們越沒法把握其細節。本文一共總結了單鏈表常被說起的各類操做,以下:html

  1. 逆序構造單鏈表;
  2. 鏈表反轉;
  3. 鏈表排序;
  4. 合併兩個有序鏈表;
  5. 求出鏈表倒數第k個值;
  6. 判斷鏈表是否有環,有環返回相遇結點;
  7. 在一個有環鏈表中找到環的入口;
  8. 刪除當前結點;
  9. 找出鏈表的中間結點。

本文中的全部操做均針對帶有頭結點的單鏈表。請注意:頭結點和第一結點是兩個結點,百度百科解釋爲:爲方便操做,在單鏈表的第一個結點以前附設一個結點,稱之爲頭結點。本文中用header代替頭結點。node

在繼續下文以前先約定下結點結構:c++

/* 定義結點結構 */
struct Node
{
    int data;
    Node * next;
    Node() { data = 0; next = nullptr; }
};

/* 定義頭結點 */
Node * header = new Node;

二:具體分析與實現代碼

2.1 逆序構造單鏈表

例如:輸入數據:1 2 3 4 5 6,構造單鏈表:6->5->4->3->2->1。面試

/* 逆序構造單鏈表,-1 結束輸入 */
void desc_construct(Node * header)
{
    Node * pre = nullptr;  // 前一個結點
    int x;
  
    while (cin >> x && x != -1)
    {
        Node * cur = new Node;
        cur->data = x;
        cur->next = pre;  // 指向前一個結點
        pre = cur;        // 保存當前結點
    }
  
    header->next = pre;  // 頭結點指向第一結點
}

2.2 鏈表反轉

例如:假設現有鏈表:6->5->4->3->2->1,進行反轉操做後,鏈表變成:1->2->3->4->5->6。算法

/* 反轉鏈表 */
void reverse(Node * header)
{
    if (!header->next || !header->next->next)  // 若是是空鏈表或鏈表只有一個結點
        return;
  
    Node * cur = header->next;  // 指向第一個結點
    Node * pre = nullptr;
  
    while (cur)
    {
        Node * temp = cur->next;  // 保存下一個結點
        cur->next = pre;          // 調整指向
        pre = cur;                // pre 前進一步
        cur = temp;               // cur 前進一步
    }
  
    header->next = pre;  // 頭結點指向反轉後的第一結點
}

2.3 鏈表升序排序

咱們但願用最小的時間複雜度來完成這個排序任務。歸併排序是個不錯的選擇,平均時間複雜度$T(n)=O(nlogn)$,可是還有其餘方法麼?數據結構

咱們想到常常出現的快排,快排是須要一個指針指向頭,一個指針指向尾,而後兩個指針相向運動並按必定規律交換值,最後使得支點左邊小於支點,支點右邊大於支點,可是對於單鏈表而言,指向結尾的指針很好辦,可是這個指針如何往前,咱們只有一個next(這並非一個雙向鏈表)。oop

若是是這樣的話,對於單鏈表咱們沒有前驅指針,怎麼能使得後面的那個指針往前移動呢?因此這種快排思路行不通,若是咱們能使兩個指針都往next方向移動而且也能夠按相同規律交換值那就行了,怎麼作呢?this

接下來咱們使用快排的另外一種思路來解答。咱們只須要兩個指針i和j,這兩個指針均往next方向移動,移動的過程當中始終保持區間[1, i]的data都小於base(位置0是主元),區間[i+1, j)的data都大於等於base,那麼當j走到末尾的時候便完成了一次支點的尋找。若以swap操做即if判斷語句成立做爲基本操做,其操做數和快速排序相同,故該方法的平均時間複雜度亦爲$T(n)=O(nlogn)$。spa

/**
 * 鏈表升序排序
 * 
 * begin 鏈表的第一個結點,即 header->next
 * end   鏈表的最後一個結點的 next
 */
void asc_sort(Node * begin, Node * end)
{
    if (begin == end || begin->next == end)  // 鏈表爲空或只有一個結點
        return;
  
    int base = begin->data;   // 設置主元
    Node * i = begin;         // i 左邊的小於 base
    Node * j = begin->next;   // i 和 j 中間的大於 base
  
    while (j != end)
    {
        if (j->data < base)
        {
            i = i->next;
            swap(i->data, j->data);
        }
        j = j->next;
    }
    swap(i->data, begin->data);  // 交換主元和 i 的值

    asc_sort(begin, i);      // 遞歸左邊
    asc_sort(i->next, end);  // 遞歸右邊
}

// how to use it?
asc_sort(header->next, nullptr);

2.4 合併兩個有序的單鏈表

爲簡化問題,如下代碼爲合併兩個升序鏈表。指針

/* 合併兩個有序鏈表 */
void asc_merge(Node * header, Node * other_header)
{
    asc_sort(header->next, nullptr);  // 保證有序
    asc_sort(other_header->next, nullptr);

    if (!header->next)  // 鏈表爲空
    {
        header->next = other_header->next;  // 合併後兩個 header 指向第一個結點
        return;
    }
    if (!list->header->next)  // 鏈表爲空
    {
        other_header->next = header->next;  // 合併後兩個 header 指向第一個結點
        return;
    }

    Node * p = nullptr;                         // 還需一個指針,指向合併的結點
    Node * this_pointer = header->next;         // 第一個結點
    Node * other_pointer = other_header->next;  // 第一個結點
    
    // 單獨考慮合併的第一個結點
    if (this_pointer->data < other_pointer->data)
    {
        other_header->next = p = this_pointer;  // p 指向新合併的結點
        this_pointer = this_pointer->next;      // 前進一步
    }
    else
    {
        header->next = p = other_pointer;      // p 指向新合併的結點
        other_pointer = other_pointer->next;   // 前進一步
    }

    while (this_pointer && other_pointer)
    {
        if (this_pointer->data < other_pointer->data)
        {
            p->next = this_pointer;  // 合併新結點
            p = this_pointer;        // p 前進一步指向新合併的結點
            this_pointer = this_pointer->next;
        }
        else
        {
            p->next = other_pointer;  // 合併新結點
            p = other_pointer;        // p 前進一步指向新合併的結點
            other_pointer = other_pointer->next;
        }
    }

    // 處理剩下的結點
    if (this_pointer)
        p->next = this_pointer;
    if (other_pointer)
        p->next = other_pointer;
}

2.5 返回鏈表倒數第k個值

例如,給定鏈表1->4->3->5->6->8,返回倒數第3個數,也就是5。要求,只給定鏈表,但並不知道鏈表長度,如何在最短期內找出這個倒數第k個值。

其實思路很簡單,假設k是小於等於鏈表長度,那麼咱們能夠設置兩個指針p和q,這兩個指針在鏈表裏的距離就是k,那麼後面那個指針走到鏈表末尾的nullptr時,另外一個指針確定指向鏈表倒數第k個值。

/* 返回鏈表倒數第k個值 */
int kth_last(Node * header, int k)
{
    Node * p = header->next;
    Node * q = p;

    for (int i = 0; i < k; i++)
    {
        if (!q)
        {
            cout << "鏈表長度小於k\n";
            return -1;
        }
        q = q->next;
    }

    while (q)
    {
        q = q->next;
        p = p->next;
    }
  
    return p->data;
}

2.6 判斷鏈表是否有環,有環返回相遇結點

有環是什麼意思?一個單鏈表最後一個結點的位置的next應該是nullptr,標誌着鏈表的結尾,可是若是如今這個next指向了鏈表裏的某一個結點(能夠是自身),那麼這個鏈表就存在環。以下圖:

所以咱們只要找到兩個結點,其地址相同(由於兩個結點的data可能相同),便可判定有環。

咱們的思路就是:設置兩個快慢指針(快慢指針即兩個指針起點相同,慢指針每次走一步,快指針走兩步),讓它們一直往下走,直到它們相等,說明有環;遇到nullptr,說明無環。下面簡單證實:如上圖,A爲鏈表第一個結點,B爲環與鏈表的交叉點,C爲slow_pointerfast_pointer相遇的位置。假設環的長度爲r,則有

$$ AB+BC+t_1r=\frac {AB+BC+t_2r}{2} \tag{左爲慢指針,右爲快指針} $$

化簡爲:

$$ AB+BC=(t_2-2t_1)r \tag{t1,t2爲整數} $$

在肯定了AB和r後,只需調整BC,使AB+BC能整除r便可。

/* 判斷鏈表是否有環,有環返回相遇結點 */
Node * is_loop(Node * header)
{
    if (!header->next)  // 空鏈表
        return nullptr;
  
    Node * slow_pointer = header;
    Node * fast_pointer = header;
  
    while (fast_pointer->next && fast_pointer->next->next && slow_pointer != fast_pointer)
    {
        slow_pointer = slow_pointer->next;        // 慢指針走一步
        fast_pointer = fast_pointer->next->next;  // 快指針走兩步
    }

    if (slow_pointer == fast_pointer)
        return slow_pointer;

    return nullptr;
}

2.7 在一個有環鏈表中找到環的入口

參考2.6圖,若存在環且找到了相遇點C,此時令一個指針start_node從鏈表第一個結點處開始日後遍歷,再令另外一個指針meet_node從C處日後遍歷,它們的相遇結點就是環的入口點。爲何呢?

2.6公式已經證實了:若快慢指針相遇在C點,則:

$$ AB+BC=tr \tag{t是整數} $$

進一步整理上式爲:

$$ AB=(r-BC)+(t-1)r \tag{其中r-BC的含義請對照2.6圖} $$

好了,至此,證實就已經很顯然了。當start_node走了r-BC距離後,meet_node正好到達入口處B點,此時start_node還剩(t-1)r距離,顯然兩個指針繼續走的話,必定會相遇在入口處B點。

/* 在一個有環鏈表中找到環的入口 */
Node * find_meet_node(Node * header)
{
    Node * meet_node = is_loop(header);
  
    if (meet_node == nullptr)  // 不存在環
        return nullptr;
  
    Node * start_node = header->next;
    while (start_node != meet_node)
    {
        start_node = start_node->next;
        meet_node = meet_node->next;
    }
  
    return start_node;
}

此外,咱們也會遇到「判斷兩個鏈表是否相交」,「求出兩個相交鏈表的交點」這樣的問題,百變不離其宗,咱們只需把鏈表尾接到其中一個鏈表頭就轉化爲2.6和2.7的問題,因此在這裏不做詳述了。

2.8 刪除當前結點

題意規定,給定要刪除的結點和頭結點,現要你刪除這個結點,要求平均時間複雜度爲$T(n)=O(1)$。

例如,現有這樣的鏈表,1->2->3->4->5->6,須要刪除4,咱們的思路確定是先找到4的前一個結點3,和4的後一個結點5,而後把3和5連起來,再把4刪除。可是這樣作的話,咱們須要花費$O(n)$的時間來找到3和5,與題意要求的$O(1)$相距甚遠。

咱們之因此須要從頭結點開始查找要刪除的結點,是由於咱們須要獲得要刪除結點的前一個結點。咱們試着換一種思路。若是咱們要刪除4,能夠把4和5的數據交換下,而後刪除5,再把4和6鏈接起來,如此其時間複雜度爲$O(1)$。

上面的思路還有一個問題:若是刪除的結點位於鏈表的尾部,沒有下一個結點,怎麼辦?咱們仍然從鏈表的頭結點開始,順便遍歷獲得給定結點的前序結點,並完成刪除操做。這個時候時間複雜度是$O(n)$。那題目要求咱們須要在$O(1)$時間完成刪除操做,咱們的算法是否是不符合要求?實際上,假設鏈表總共有n個結點,咱們的算法在n-1個狀況下,時間複雜度是$O(1)$,只有當給定的結點處於鏈表末尾的時候,時間複雜度爲$O(n)$。所以其平均時間複雜度$\frac {(n-1)⋅O(1)+1⋅O(n)}n$,仍然爲$O(1)$。

/* 刪除當前結點 */
void del(Node * header, Node * position)
{
    if (!position->next)  // 要刪除的是最後一個結點
    {
        Node * p = header;
        while (p->next != position)
            p = p->next;  // 找到 position 的前一個結點
        p->next = nullptr;
        delete position;
    }
    else
    {
        Node * p = position->next;
        swap(p->data, position->data);
        position->next = p->next;
        delete p;
    }
}

2.9 找出單鏈表的中間結點

題意要求,給定鏈表頭結點,在最小複雜度下輸出該鏈表的中間結點。
若是隻知鏈表的頭結點,咱們通常的思路就是先遍歷鏈表獲得鏈表長度,而後再遍歷一遍獲得中間結點,如此時間複雜度爲$O(n)+O(\frac n2)$。

上面的思路彷佛不太使人滿意。咱們又想到快慢指針,它有一個很重要的性質:慢指針走的長度等於快慢指針相距的程度。因此利用這個性質,當快指針走到鏈表尾時,慢指針正好在中間結點。

/* 找出單鏈表的中間結點 */
Node * find_middle(Node * header)
{
    Node * slow_pointer = header;
    Node * fast_pointer = header;

    while (fast_pointer->next && fast_pointer->next->next)
    {
        slow_pointer = slow_pointer->next;        // 慢指針走一步
        fast_pointer = fast_pointer->next->next;  // 快指針走兩步
    }

    return slow_pointer;
}
相關文章
相關標籤/搜索