連結串列演算法經典十題總結
前言
由於前面寫了一些資料結構的相關的文章,但是都是偏基本的資料結構知識,並沒有實際的演算法題加以實踐,故整理十道題目,都是比較常見的連結串列類的演算法題,也參考了優秀的部落格。
預備的資料結構知識點:
1.連結串列的倒數第K個結點
問題描述:
輸入一個連結串列,輸出該連結串列中倒數第k個結點。為了符合大多數人的習慣,本題從1開始計數,即連結串列的尾結點是倒數第1個結點。例如一個連結串列有6個結點,從頭結點開始它們的值依次是1、2、3、4、5、6。這個連結串列的倒數第3個結點是值為4的結點,需要保證時間複雜度。
演算法思路:
設定兩個指標p1,p2,從頭到尾開始出發,一個指標先出發k個節點,然後第二個指標再進行出發,當第一個指標到達連結串列的節點的時候,則第二個指標表示的位置就是連結串列的倒數第k個節點的位置。

程式碼如下:
//倒數第k個結點 ListNode findKth(ListNode head,int k){ ListNode cur=head; ListNode now=head; int i=0; while(cur!=null&i++<k){ cur=cur->next; } while(cur!=null){ now=now->next; cur=cur->next; } }
總結:當我們用一個指標遍歷連結串列不能解決問題的時候,可以嘗試用兩個指標來遍歷連結串列。可以讓其中一個指標遍歷的速度快一些(比如一次在連結串列上走兩步),或者讓它先在連結串列上走若干步。
2.從尾到頭列印連結串列(遞迴和非遞迴)
問題描述:
輸入一個單鏈錶鏈表,從尾到頭列印連結串列每個節點的值。輸入描述:輸入為連結串列的表頭;輸出描述:輸出為需要列印的“新連結串列”的表頭。
演算法思路:
首先我們想到從尾到頭打印出來,由於單鏈表的查詢只能從頭到尾,所以可以想出棧的特性,先進後出。所以非遞迴可以把連結串列的點全部放入一個棧當中,然後依次取出棧頂的位置即可。
程式碼如下:
//非遞迴 void PrintReversing(ListNode * head){ //利用一個棧 Stack stack; ListNode *node=head->next; //將連結串列的結點壓入 while(node!=null){ stack.push(node); node=node->next; } ListNode *popNode; while(stack.isEmpty()){ //獲得最上面的元素 popNode=stack.top(); //列印 printf("%d\t",popNode->value); //彈出元素 stack.pop(); }
/遞迴 void printRevese(ListNode *head){ if(head!=null){ if(head->next!=null){ printRevese(head->next); } print("%d\t",head->value); } }
非遞迴的描述當中,經常會用棧或者佇列這些資料結構來改寫一些遞迴的演算法。其實遞迴的演算法的時間複雜度是遞迴樹的高度,所以遞迴的層數越高,時間複雜度也就會越高的。
3.如何判斷一個連結串列有環
問題描述:
有一個單向連結串列,連結串列當中有可能出現“環”,如何用程式判斷出這個連結串列是有環連結串列?
不允許修改連結串列結構。時間複雜度O(n),空間複雜度O(1)。
演算法思路:
方法一、窮舉遍歷
首先從頭節點開始,依次遍歷單鏈表的每一個節點。每遍歷到一個新節點,就從頭節點重新遍歷新節點之前的所有節點,用新節點ID和此節點之前所有節點ID依次作比較。如果發現新節點之前的所有節點當中存在相同節點ID,則說明該節點被遍歷過兩次,連結串列有環;如果之前的所有節點當中不存在相同的節點,就繼續遍歷下一個新節點,繼續重複剛才的操作。
假設從連結串列頭節點到入環點的距離是D,連結串列的環長是S。那麼演算法的時間複雜度是0+1+2+3+….+(D+S-1) = (D+S-1) (D+S)/2 , 可以簡單地理解成 O(N N)。而此演算法沒有建立額外儲存空間,空間複雜度可以簡單地理解成為O(1)。
這種方法是暴力破解的方式,時間複雜度太高。
方法二、快慢指標
首先建立兩個指標1和2,同時指向這個連結串列的頭節點。然後開始一個大迴圈,在迴圈體中,讓指標1每次向下移動一個節點,讓指標2每次向下移動兩個節點,然後比較兩個指標指向的節點是否相同。如果相同,則判斷出連結串列有環,如果不同,則繼續下一次迴圈。
說明 :在迴圈的環裡面,跑的快的指標一定會反覆遇到跑的慢的指標 ,比如:在一個環形跑道上,兩個運動員在同一地點起跑,一個運動員速度快,一個運動員速度慢。當兩人跑了一段時間,速度快的運動員必然會從速度慢的運動員身後再次追上並超過,原因很簡單,因為跑道是環形的。
程式碼如下:
/** * 判斷單鏈表是否存在環 * @param head * @return */ public static <T> boolean isLoopList(ListNode<T> head){ ListNode<T> slowPointer, fastPointer; //使用快慢指標,慢指標每次向前一步,快指標每次兩步 slowPointer = fastPointer = head; while(fastPointer != null && fastPointer.next != null){ slowPointer = slowPointer.next; fastPointer = fastPointer.next.next; //兩指標相遇則有環 if(slowPointer == fastPointer){ return true; } } return false; }
4.連結串列中環的大小
問題描述
有一個單向連結串列,連結串列當中有可能出現“環”,那麼如何知道連結串列中環的長度呢?
演算法思路
由3.如何判斷一個連結串列有環可以知道,快慢指標可以找到連結串列是否有環存在,如果兩個指標第一次相遇後,第二次相遇是什麼時候呢?第二次相遇是不是可以認為快的指標比慢的指標多跑了一個環的長度。所以找到第二次相遇的時候就找到了環的大小。

程式碼如下
//求環中相遇結點 public Node cycleNode(Node head){ //連結串列為空則返回null if(head == null) return null; Node first = head; Node second = head; while(first != null && first.next != null){ first = first.next.next; second = second.next; //兩指標相遇,則返回相遇的結點 if(first == second) return first; } //連結串列無環,則返回null return null; } public int getCycleLength(Node head){ Node node = cycleNode(head); //node為空則代表連結串列無環 if(node == null) return 0; int length=1; Node current = node.next; //再次相遇則迴圈結束 while(current != node){ length++; current = current.next; } return length; }
5.連結串列中環的入口結點
問題描述
給一個連結串列,若其中包含環,請找出該連結串列的環的入口結點,否則,輸出null。
演算法思路
如果連結串列存在環,那麼計算出環的長度n,然後準備兩個指標pSlow,pFast,pFast先走n步,然後pSlow和pFase一塊走,當兩者相遇時,即為環的入口處;所以解決三個問題:如何判斷一個連結串列有環;如何判斷連結串列中環的大小;連結串列中環的入口結點。實際上最後的判斷就如同連結串列的倒數第k個節點。
程式碼如下
public class Solution { public ListNode EntryNodeOfLoop(ListNode pHead) { if(pHead.next == null || pHead.next.next == null) return null; ListNode slow = pHead.next; ListNode fast = pHead.next.next; while(fast != null){ if(fast == slow){ fast = pHead; while(fast != slow){ fast = fast.next; slow = slow.next; } return fast; } slow = slow.next; fast = fast.next.next; } return null; } }
以上5題的套路其實都非常類似,第5題可以說是前面幾道題的一個彙總題目吧,連結串列類的題利用快慢指標,兩個指標確實挺多的,如下面題目7
6.單鏈表在時間複雜度為O(1)刪除連結串列結點
問題描述
給定單鏈表的頭指標和一個結點指標,定一個函式在時間複雜度為O(1)刪除連結串列結點
演算法思路
根據瞭解的條件,如果只有一個單鏈表的頭指標,連結串列的刪除操作其實正常的是O(n)的時間複雜度。因為首先想到的是從頭開始順序遍歷單鏈表,然後找到節點,再進行刪除。但是這樣的方式達到的時間複雜度並不是O(1);實際上純粹的刪除節點操作,連結串列的刪除操作是O(1)。前提是需要找到刪除指定節點的前一個結點就可以。
那麼是不是必須找到刪除指定節點的前一個結點呢?如果我們刪除的節點是A,那麼我們把A下一個節點B和A的data進行交換,然後我們刪除節點B,是不是也可以達到同樣的效果。
答案是肯定的。
既然不能在O(1)得到刪除節點的前一個元素,但我們可以輕鬆得到後一個元素,這樣,我們何不把後一個元素賦值給待刪除節點,這樣也就相當於是刪除了當前元素。可見,該方法可行,但如果待刪除節點為最後一個節點,則不能按照以上思路,沒有辦法,只能按照常規方法遍歷,時間複雜度為O(n),是不是不符合題目要求呢?可能很多人在這就會懷疑自己的思考,從而放棄這種思路,最後可能放棄這道題,這就是這道面試題有意思的地方,雖看簡單,但是考察了大家的分析判斷能力,是否擁有強大的心理,充分自信。其實我們分析一下,仍然是滿足題目要求的,如果刪除節點為前面的n-1個節點,則時間複雜度為O(1),只有刪除節點為最後一個時,時間複雜度才為O(n),所以平均的時間複雜度為:(O(1) * (n-1) + O(n))/n = O(1);仍然為O(1).
程式碼如下
/* Delete a node in a list with O(1) * input:pListHead - the head of list *pToBeDeleted - the node to be deleted */ structListNode { intm_nKey; ListNode*m_pNext; }; void DeleteNode(ListNode *pListHead, ListNode *pToBeDeleted) { if (!pListHead || !pToBeDeleted) return; if (pToBeDeleted->m_pNext != NULL) { ListNode *pNext = pToBeDeleted->m_pNext; pToBeDeleted->m_pNext = pNext->m_pNext; pToBeDeleted->m_nKey = pNext->m_nKey; delete pNext; pNext = NULL; } else { //待刪除節點為尾節點 ListNode *pTemp = pListHead; while(pTemp->m_pNext != pToBeDeleted) pTemp = pTemp->m_pNext; pTemp->m_pNext = NULL; delete pToBeDeleted; pToBeDeleted = NULL; } }
題目的考慮的點,也很特別
7.兩個連結串列的第一個公共結點
問題描述
輸入兩個單鏈表,找出他們的第一個公共結點。
演算法思路
我們瞭解到單鏈表的指標是指向下一個節點的,如果兩個單鏈表的第一個公共節點就說明他們後面的節點都是在一起的。類似下圖,由於兩個連結串列的長度可能是不一致的,所以首先比較兩個連結串列的長度m,n,然後用兩個指標分別指向兩個連結串列的頭節點,讓較長的連結串列的指標先走|m-n|個長度,如果他們下面的節點是一樣的,就說明出現了第一個公共節點。

程式碼如下
/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) { if (pHead1 == null||pHead2 == null) { return null; } int count1 = 0; ListNode p1 = pHead1; while (p1!=null){ p1 = p1.next; count1++; } int count2 = 0; ListNode p2 = pHead2; while (p2!=null){ p2 = p2.next; count2++; } int flag = count1 - count2; if (flag > 0){ while (flag>0){ pHead1 = pHead1.next; flag --; } while (pHead1!=pHead2){ pHead1 = pHead1.next; pHead2 = pHead2.next; } return pHead1; } if (flag <= 0){ while (flag<0){ pHead2 = pHead2.next; flag ++; } while (pHead1 != pHead2){ pHead2 = pHead2.next; pHead1 = pHead1.next; } return pHead1; } return null; } }
8.合併兩個排序的連結串列
問題描述
輸入兩個單調遞增的連結串列,輸出兩個連結串列合成後的連結串列,當然我們需要合成後的連結串列滿足單調不減規則。
演算法思路
這道題比較簡單,合併兩個有序的連結串列,就可以設定兩個指標進行操作即可,同時比較大小,但是也需要注意兩個連結串列的長度進行比較。
程式碼如下
/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode Merge(ListNode list1,ListNode list2) { ListNode head = new ListNode(-1); ListNode cur = head; while (list1 != null && list2 != null) { if (list1.val <= list2.val) { cur.next = list1; list1 = list1.next; } else { cur.next = list2; list2 = list2.next; } cur = cur.next; } if (list1 != null) cur.next = list1; if (list2 != null) cur.next = list2; return head.next; } }
9.複雜的連結串列複製
問題描述
題目:請實現函式ComplexListNode Clone(ComplexListNode head),複製一個複雜連結串列。在複雜連結串列中,每個結點除了有一個Next指標指向下一個結點外,還有一個Sibling指向連結串列中的任意結點或者NULL。
下圖是一個含有5個結點的複雜連結串列。圖中實線箭頭表示m_pNext指標,虛線箭頭表示m_pSibling指標。為簡單起見,指向NULL的指標沒有畫出。

演算法思路
第一種:O(n2)的普通解法
第一步是複製原始連結串列上的每一個結點,並用Next節點連結起來;
第二步是設定每個結點的Sibling節點指標。
第二種 :藉助輔助空間的O(n)解法
第一步仍然是複製原始連結串列上的每個結點N建立N',然後把這些創建出來的結點用Next連結起來。同時我們把<N,N'>的配對資訊放到一個雜湊表中。
第二步還是設定複製連結串列上每個結點的m_pSibling。由於有了雜湊表,我們可以用O(1)的時間根據S找到S'。
第三種:不借助輔助空間的O(n)解法
第一步仍然是根據原始連結串列的每個結點N建立對應的N'。(把N'連結在N的後面)

第二步設定複製出來的結點的Sibling。(把N'的Sibling指向N的Sibling)

第三步把這個長連結串列拆分成兩個連結串列:把奇數位置的結點用Next連結起來就是原始連結串列,偶數數值的則是複製連結串列。
程式碼如下
public class Solution { public RandomListNode Clone(RandomListNode pHead) { if(pHead == null) { return null; } RandomListNode currentNode = pHead; //1、複製每個結點,如複製結點A得到A1,將結點A1插到結點A後面; while(currentNode != null){ RandomListNode cloneNode = new RandomListNode(currentNode.label); RandomListNode nextNode = currentNode.next; currentNode.next = cloneNode; cloneNode.next = nextNode; currentNode = nextNode; } currentNode = pHead; //2、重新遍歷連結串列,複製老結點的隨機指標給新結點,如A1.random = A.random.next; while(currentNode != null) { currentNode.next.random = currentNode.random==null?null:currentNode.random.next; currentNode = currentNode.next.next; } //3、拆分連結串列,將連結串列拆分為原連結串列和複製後的連結串列 currentNode = pHead; RandomListNode pCloneHead = pHead.next; while(currentNode != null) { RandomListNode cloneNode = currentNode.next; currentNode.next = cloneNode.next; cloneNode.next = cloneNode.next==null?null:cloneNode.next.next; currentNode = currentNode.next; } return pCloneHead; } }
10.反轉連結串列
問題描述
題目:定義一個函式,輸入一個連結串列的頭結點,反轉該連結串列並輸出反轉後連結串列的頭結點。如圖:

演算法思路
為了正確地反轉一個連結串列,需要調整連結串列中指標的方向。為了將複雜的過程說清楚,這裡藉助於下面的這張圖片。

上面的圖中所示的連結串列中,h、i和j是3個相鄰的結點。假設經過若干操作,我們已經把h結點之前的指標調整完畢,這個結點的 m_pNext 都指向前面的一個結點。接下來我們把i的 m_pNext 指向h,此時結構如上圖所示。
從上圖注意到,由於結點i的 m_pNext 都指向了它的前一個結點,導致我們無法在連結串列中遍歷到結點j。為了避免連結串列在i處斷裂,我們需要在調整結點i的 m_pNext 之前,把結點j儲存下來。
即在調整結點i的 m_pNext 指標時,除了需要知道結點i本身之外,還需要i的前一個結點h,因為我們需要把結點i的 m_pNext 指向結點h。同時,還需要實現儲存i的一個結點j,以防止連結串列斷開。故我們需要定義3個指標,分別指向當前遍歷到的結點、它的前一個結點及後一個結點。故反轉結束後,新連結串列的頭的結點就是原來連結串列的尾部結點。尾部結點為 m_pNext 為null的結點。
程式碼如下
public class ReverseList_16 { public ListNode ReverseList(ListNode head) { if (head == null || head.nextNode == null) { return head; } ListNode next = head.nextNode; head.nextNode = null; ListNode newHead = ReverseList(next); next.nextNode = head; return newHead; } public ListNode ReverseList1(ListNode head) { ListNode newList = new ListNode(-1); while (head != null) { ListNode next = head.nextNode; head.nextNode = newList.nextNode; newList.nextNode = head; head = next; } return newList.nextNode; } }
歡迎關注公眾號:coder辰砂,一個認認真真寫文章的公眾號
參考:
https://blog.csdn.net/u010983881/article/details/78896293
https://blog.csdn.net/inspiredbh/article/details/54915091
https://www.jianshu.com/p/092d14d13216
https://www.cnblogs.com/bakari/p/4013812.html
http://www.cnblogs.com/edisonchou/p/4790090.html
https://blog.csdn.net/u013132035/article/details/80589657