這篇內容我將介紹幾個關於鏈表的經典問題,很多都是來自互聯網(相信絕大部分來自於經典書籍),特在本篇中收集和整理(持續更新,歡迎提供鏈表相關問題)。
以下問題,簡單的我都會列出解題的大體思路,複雜點的我會詳細的說明。
問題1:給定一個單向鏈表,設計一個算法,找到該鏈表的倒數第m個元素,當m=0時,返回鏈表最後一個元素。
解法一:
要遍歷鏈表兩次,第一次遍歷鏈表取得長度n,然後計算要沿鏈表移動的步數s=n-m(當然這裏假設n>m);第二次遍歷鏈表拿到在s處的鏈表結點元素。
解法二:
要拿到倒數第m個元素,我們可以設計兩個指針p1,p2,它們的在鏈表中的間距爲m(這裏間距是通過p1,p2相對於頭結點的位序計算),同時移動兩個指針,當後一個指針p2到鏈表末尾時,前一個指針p1就是要取的結點。
問題2:判斷一個的單向鏈表是否有環。
解法:
最經典的解法就是快慢指針法,定義指針p,q,在鏈表中,p每次前進一步,q每次前進兩步,如果p能和q重合,那麼具有環。可以這麼理解,在每次移動的過程中,可以看做p相對q是靜止的,q每次移動一步,所以如果有環,那麼q總能趕上p的。
下面我們給出上面問題的證明:
假設假設快慢指針的移動速度分別爲vp,vq,並且環的長度爲l,在某一時刻t,p和q指針距離環的入口結點相距分別爲dp,dq,那麼,假設在經過m次移動後,p和q指針重合,即有環,那麼滿足下面的等式:
dp+vp*m mod(l) = dq+vq*m mod(l);
-> (vp-vq)*m mod(l)=dq-dp
可進一步推導出 (vp-vq)*m - p*l = dq-dp (p爲取模的商)
這裏假設 A=vp-vq, B=-l,C=dq-dp
那麼等式爲 A*m+pB=C ,其中,A,B,C已知。這裏對我們的問題來說,要求取一對數(m,p)使得可以取得m爲一正整數即可,當然對於算法來說越小越好。其實,這裏這個問題就是經典的拓展歐幾裏問題,即ax+by=c,若c mod gcd(a,b)=0,則一定存在解x,y滿足等式。這裏,我們證明的前提是vp=1,vq=2,則A=-1,B=-l,C=vp*t-vp*t,顯然gcd(A,B)等於1,
則一定滿足C mod gcd(a,b) = 0,所以一定存在解m,p使得等式滿足,即當我們用步進分別爲1和2的指針循環時,如果鏈表有環就一定會發生碰撞。關於這個拓展歐幾里得算法的詳細證明,請參考<MAT-歐幾里得及拓展歐幾里得算法>一篇。
這裏我們給出算法的僞代碼:
bool has_loop(head)
begin:
p=head; q=head;
while(q&&q->next){
p=p->next;
q=q->next->next;
if(p==q) reuturn true;
}
return false;
end;
問題3:將一個單向鏈表逆序
解法:
算法開始的條件應該至少有兩個以上的結點數,需要用到三個指針,分別指向前驅結點,當前結點以及後繼結點,操作看下面一組圖:
1初始p=head->next; q=head->next->next ; t=NULL;
2.判斷q是否到鏈表尾,如果是,結束循環,否則執行循環體,將t指向q的下個元素,同時將q的下個元素修改爲前驅結點p,分別向前移動p,q指針,此時q,t指向同一個結點
3.類似步驟2
4.此次循環執行完,p指向末尾結點,q=NULL
5.調整頭結點的下個節點A 指向NULL,指向頭結點指向p.
相信從上面的圖中可以看到逆序的過程,具體算法如下:
list_node* reverse_list(list_node* head)
{
list_node *p,*q,*t;
assert(head);
if(head->next == NULL || head->next->next==null){ //鏈表爲空或者只有一個元素
return head;
}
p = head->next;
q = head->next->next;
t = NULL;
while(q){
t=q->next;
q->next = p ;
p=q;
q=t;
}
head->next->next = NULL;
head->next = p;
return head;
}
問題4.當單向鏈表存在環時,找到環的入口點。
解法:
結論: 分別從鏈表頭和碰撞點,同步地一步一步前進掃描,直到碰撞,此碰撞點,即是環的入口。證明:
假設環外長度爲a,環內長度爲b,鏈表的總長度爲a+b。我們假定經過x步後,發生碰撞。這裏我們主要證明從碰撞點x前進a步即爲入口點,因爲從鏈表頭經過a步到入口點是顯然的。我們可以將具有環的鏈表想象成小寫的字母 'b',定義第i步訪問節點用S(i)表示,則
S(i) = i ; i<a;
S(i) = a+(i-a)%b;i >=a
則有S(x)=S(2X)(這裏速度分別爲1,2),根據環的週期性有2x=tb+x ;( t爲整數)
->x= tb;
又因爲碰撞發生在環內,固有x>=a連接點爲從起點走a步,即S(a)
S(a)=S(tb+a)=S(x+a); 因此得證:從碰撞點x前進a步即爲入口點。
list_node* find_list_loop_entry()
{
list_node *p=head;
list_node *q=head;
while(q&&q->next)
{
p=p->next;
q=q->next->next;
if(p==q) break;
}
if(!fase || !(fase->next))
return NULL;
p = head;
while(p!=q)
{
p=p->next;
q=q->next;
}
return p;
}
問題5:在已知鏈表有環的情況下,如何計算環的長度?
解法一:
我們可以從碰撞點開始,定義指針s ,讓s指針從碰撞點p處單步前進,如果下個指針等於p就停止,否則,長度加1.解法二:
還可以繼續讓p,q分別以步長爲1和2前進,下次碰撞時所經過的操作就是環的長度,因爲q相比p每次前進1,等於說p轉一圈時q已經轉了兩圈,p轉一圈的長度就是環的長度。
問題6.判斷兩個單項無環鏈表是否相交?
解法一:
首先,想象下鏈表鏈表相交時的結構,是不是類似一個"人"字,也就是說,兩個鏈表從相交處到結尾的結點
都是公用的。因此,我們分別遍歷兩條鏈表,判斷其末尾的節點是否是同一個節點即可判斷是否相交。
解法二:
另一種方法是將一條鏈表鏈接到另一個鏈表的末尾構成一個新的鏈表,來判斷這條新的鏈表是否有環,如果有環那麼顯然是
相交的,否則不相交。而這個問題就轉換爲問題1判斷鏈表是否有環。
問題7:去掉單向鏈表中的重複元素
解法一:
遍歷鏈表,對於每個結點,在循環內部遍歷其後續的所有結點,如果遇到與當前結點重複的元素,則刪除該元素。對於長度爲n的鏈表,需要的步n-1+n-2+...+1次操作,所以算法的複雜度爲O(n^2).
解法二:
根據鏈表結點元素,建立一個hash table,然後遍歷該鏈表,對於每個結點如果其對應的hash code存在於hash table,則刪除該結點,否則將hash code加入到hash表中,
典型的以空間換時間的優化算法。
或許你會想到List容器中的unique方法,是的這個方法同該問題類似,只是刪除的容器中相鄰的重複元素,在調用unique方法時首先需要對容器元素進行sort,這樣才能刪除掉容器內的重複元素。
問題8:求取兩個單項鍊表的交、並、差集。
假設有鏈表L1,L2,長度分別爲m,n , 這裏,分別用intersection(L1,L2),union(L1,L2),substract(L1,L2)來表示兩個鏈表的交集、並集、差集,其結果存放在L3鏈表中。這裏差集爲L1-L2(即在L1中沒在L2中的結點集)
解法一:
intersection :首先對L1遍歷,對於L1中的每個結點遍歷L2,如果在L2鏈表中找到該元素,則將其插入到L3中,
並將L2中的該元素刪除掉。
substract : 對於首先將鏈表L1複製給L3,對於L2的每個結點k 遍歷L3,如果k存在於L3,則從L3中刪除k.
解法二:
解法一中的方法足夠直觀,但效率很低,O(mn)的時間複雜度。同上個問題類似,這裏我們同樣可以使用hash table的方法來對算法進行優化。
union: 創建hash表,分別遍歷兩個鏈表L1,L2,將結點對應的hash code插入到hash表,插入的時候檢測是否已經在hash表中,如果不存在,則同時將結點插入到結果鏈表L3,如果存在,則繼續下個結點。
intersection:同樣創建一個hash表,將L1加入到hash表中,對L2鏈表進行遍歷,檢測結點是否在hash表中,如果存在,則將其插入到結果鏈表L3中。
substract:將L2鏈表映射到hash表中,對於L1中的每個結點,判斷結點hash code是否存在於L2中,不存在則將其加入到結果鏈表L3中。否則,跳過。
問題9:約瑟夫環問題
Josephus環問題描述的是:已知n個人(以編號1,2,3...n分別表示)圍坐在一張圓桌周圍。從編號爲1的人開始報數,數到m的那個人出列;他的下一個人又從1開始報數,數到m的那個人又出列;依此規律重複下去,直到圓桌周圍的人全部出列。現在我們需要知道最後一個出列的人的序號或者出列的整個序列。
解法一:
對已知的n個人建立一個循環鏈表,結點結構保存有結點的位序,定義cur,pre指針以及計數器i,分別指向當前結點和前驅結點,首先cur沿鏈表向後移動,並開始計數++i,當i==m時,將當前元素從鏈表中刪除並重置計數器i, 同時輸出移除的位序表示出列,否則,繼續向後移動指針cur,pre,直到pre==cur時結束,這時cur指向的結點就是最後一個出列的。這個算法的時間複雜度爲O(nm).
解法二:
問題相當於已知n個人(以編號0,1,2,3...n-1表示),從0開始報數,報到m-1的出列,剩下的繼續從0開始報,直到最後一個退出。我們知道第一個出列人的編號爲m%n-1,
剩下的n-1個人構成新的約瑟夫環,以m%n的編號開始:K,k+1,k+2,....0,1,2,3....k-2 ,並同時從k開始報0,這時對於新環其編號對應爲 k->0,k+1->1,k+2->2 .... k-2->n-2,變換後
轉換爲n-1個人退出的子問題,假設在這個新環中最後退出的人編號爲x,則x'=(x+k)%n正好就是n個人中最後退出的編號。這樣我們就可以得到一個遞推公式f,令f表示i個人
報數k退出最後勝利者的編號,則
f[1]=0 i =1;
f[i]=(f[i-1]+k)%i i>1;
根據這個遞推公式,我們很容易的能夠求出n個人出圈時的f[n]值,即最後退出時的編號。
問題10:鏈表排序
解法一:
鏈表的排序和一般的數組類似,我們也可以用常見的排序方法來對其排序,比如冒泡,插入,選擇,歸併等等。這裏使用選擇排序對鏈表進行升序排序,對於鏈表L,遍歷其中的每個結點,假設當前遍歷的節點k,則遍歷其後的所有結點找到最小的結點元素 m,然後將結點k,m進行交換。只是這裏的交換操作可以選擇是交換結點指針,還是交換結點值,
當然交換結點值更加容易實現,不過對於結點元素較多的鏈表交換值顯然更加低效,交換結點指針可能要更好,但是,這樣相對來說也更加複雜。至於如何選擇,需要在這兩點上進行折中。
簡單的交換值的鏈表選擇排序:
sort_list(struct list_node* head)
begin:
cur=head->next; //第一個結點
while(cur){
max = get_min_node(cur);//找到cur後最小的結點
swap(cur,max);//交換結點值
cur = cur->next;
};
end;
那麼,如果我們選擇要交換指針,要怎麼做呢?下面我們基於一個帶頭結點的單向鏈表描述我們的思路:
我們知道,對於升序的排序來說,選擇排序每趟都需要找到從某一元素(基準點)開始向後查找最小的元素,然後將其進行交換。那麼在鏈表中,我們要完成這個操作,需要通過兩個指針來記錄基準點和最小結點,同時,由於交換的需要,我們還必須要保存其前驅結點的指針。所以維護好這些信息纔是鏈表排序的關鍵。
//鏈表排序
void sort_list(link_node* L)
{
/*q,n分別指向基準點和最小結點,p,m分別爲其前驅指針*/
list_node *p,*q,*m,*n;
list_node *t,*s; //用於掃描鏈表
p = L;
q = L->next;
while(q){
t=n=q;//最小結點初始爲基準點
s=m=p;
while(t){//查找最小結點
if(t->data < n->data){
m=s;n=t;
}
s=s->next;
t=t->next;
}
if(n!=q){ //最小結點不是基準點,需要交換
p->next = n;
p=n;
m->next = q;
m = q;
q=q->next;
n=n->next;
p->next = q;
m->next = n;
}else{//調整基準點
p=p->next;
q=p->next;
}
}
}
當然,基於類似的邏輯,我們還可以寫出鏈表的插入排序算法。解法二:
在鏈表的排序中,我們可以使用歸併排序來完成,這裏我們可以參考下SGI STL list的sort實現方式,首先看看它是怎麼對鏈表進行排序的。
template <class _Tp, class _Alloc> template <class _StrictWeakOrdering>
void list<_Tp, _Alloc>::sort(_StrictWeakOrdering __comp)
{
// Do nothing if the list has length 0 or 1.
if (_M_node->_M_next != _M_node && _M_node->_M_next->_M_next != _M_node) {
list<_Tp, _Alloc> __carry;
list<_Tp, _Alloc> __counter[64];
int __fill = 0;
while (!empty()) {
__carry.splice(__carry.begin(), *this, begin());
int __i = 0;
while(__i < __fill && !__counter[__i].empty()) {
__counter[__i].merge(__carry, __comp);
__carry.swap(__counter[__i++]);
}
__carry.swap(__counter[__i]);
if (__i == __fill) ++__fill;
}
for (int __i = 1; __i < __fill; ++__i)
__counter[__i].merge(__counter[__i-1], __comp);
swap(__counter[__fill-1]);
}
}
sort算法採用非遞歸的方式自底向上進行歸併,其中__counter[64]用來模擬程序棧來完成排序操作,counter[i]保存2^i個元素的子串。因此可以排序的元素個數受限於2^0+2^1+
....2^63 = 2^64-1。算法每次從原list抽取一個元素放入carry中,隨後檢查counter[i]是否爲空,如果爲空,則將抽取出的元素放到counter[i]中,否則,需要和上層進行歸併,直到遇到空的counter,並將已歸併的元素放在其中。需要注意的是,counter.empty判斷的是counter已經包含了2^i個元素的子串,這也決定了這樣的組織方式,必已2的冪次方的數量進行逐層遞推。也就是說,如果算法已經遞推到第i層,那麼第i層一定是2^i個元素,這爲下次while循環內後面的累加提供了依據。看個例子,對序列<9 5 4 1 7 3 8 10>進行排序。
source list :9 5 4 1 7 3 8 10
******************************
source list:
5 4 1 7 3 8 10
counter list array:
counter[0]: 9
******************************
******************************
source list:
4 1 7 3 8 10
counter list array:
counter[0]:
counter[1]: 5 9
******************************
******************************
source list:
1 7 3 8 10
counter list array:
counter[0]: 4
counter[1]: 5 9
******************************
******************************
source list:
7 3 8 10
counter list array:
counter[0]:
counter[1]:
counter[2]: 1 4 5 9
******************************
******************************
source list:
3 8 10
counter list array:
counter[0]: 7
counter[1]:
counter[2]: 1 4 5 9
******************************
******************************
source list:
8 10
counter list array:
counter[0]:
counter[1]: 3 7
counter[2]: 1 4 5 9
******************************
******************************
source list:
10
counter list array:
counter[0]: 8
counter[1]: 3 7
counter[2]: 1 4 5 9
******************************
******************************
source list:
counter list array:
counter[0]:
counter[1]:
counter[2]:
counter[3]: 1 3 4 5 7 8 9 10
******************************
result: 1 3 4 5 7 8 9 10
修改list sort,打印出遞歸的過程,每次外循環結束,打印出原list以及當前使用的counter,這是通過fill來標記的,這裏需要注意的是splice和merge的方法。splice每次會從原list 頭部抽取一個元素,merge方法將兩個有序列表進行排序(結果也是有序的),merge之後,carray會被清空。