原文連接: https://subetter.com/algorith...
單鏈表常常爲公司面試所說起,先不貶其過於簡單,由於單鏈表確實是數據結構中最簡單的一部分,但每每最簡單的,人們越沒法把握其細節。本文一共總結了單鏈表常被說起的各類操做,以下:html
本文中的全部操做均針對帶有頭結點的單鏈表。請注意:頭結點和第一結點是兩個結點,百度百科解釋爲:爲方便操做,在單鏈表的第一個結點以前附設一個結點,稱之爲頭結點。本文中用header代替頭結點。node
在繼續下文以前先約定下結點結構:c++
/* 定義結點結構 */ struct Node { int data; Node * next; Node() { data = 0; next = nullptr; } }; /* 定義頭結點 */ Node * header = new Node;
例如:輸入數據: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; // 頭結點指向第一結點 }
例如:假設現有鏈表: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; // 頭結點指向反轉後的第一結點 }
咱們但願用最小的時間複雜度來完成這個排序任務。歸併排序是個不錯的選擇,平均時間複雜度$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);
爲簡化問題,如下代碼爲合併兩個升序鏈表。指針
/* 合併兩個有序鏈表 */ 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; }
例如,給定鏈表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; }
有環是什麼意思?一個單鏈表最後一個結點的位置的next應該是nullptr,標誌着鏈表的結尾,可是若是如今這個next指向了鏈表裏的某一個結點(能夠是自身),那麼這個鏈表就存在環。以下圖:
所以咱們只要找到兩個結點,其地址相同(由於兩個結點的data可能相同),便可判定有環。
咱們的思路就是:設置兩個快慢指針(快慢指針即兩個指針起點相同,慢指針每次走一步,快指針走兩步),讓它們一直往下走,直到它們相等,說明有環;遇到nullptr,說明無環。下面簡單證實:如上圖,A爲鏈表第一個結點,B爲環與鏈表的交叉點,C爲slow_pointer
與fast_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.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的問題,因此在這裏不做詳述了。
題意規定,給定要刪除的結點和頭結點,現要你刪除這個結點,要求平均時間複雜度爲$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; } }
題意要求,給定鏈表頭結點,在最小複雜度下輸出該鏈表的中間結點。
若是隻知鏈表的頭結點,咱們通常的思路就是先遍歷鏈表獲得鏈表長度,而後再遍歷一遍獲得中間結點,如此時間複雜度爲$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; }