連結串列面試題總結 C/C++
資料結構和演算法,是我們程式設計最重要的兩大元素,可以說,我們的程式設計,都是在選擇和設計合適的資料結構來存放資料,然後再用合適的演算法來處理這些資料。
在面試中,最經常被提及的就是連結串列,因為它簡單,但又因為需要對指標進行操作,凡是涉及到指標的,都需要我們具有良好的程式設計基礎才能確保程式碼沒有任何錯誤。
連結串列是一種動態的資料結構,因為在建立連結串列時,我們不需要知道連結串列的長度,當插入一個結點時,只需要為該結點分配記憶體,然後調整指標的指向來確保新結點被連線到連結串列中。所以,它不像陣列,記憶體是一次性分配完畢的,而是每新增一個結點分配一次記憶體。正是因為這點,所以它沒有閒置的記憶體,比起陣列,空間效率更高。
像是單向連結串列的結點定義如下:
struct ListNode { int m_nValue; ListNode* m_pNext; };
那麼我們往該連結串列的末尾新增一個結點的程式碼如:
void AddToTail(ListNode** pHead, int value) { ListNode* pNew = new ListNode(); pNew->m_nValue = value; pNew->m_pNext = NULL; if(*pHead == NULL) {*pHead = pNew; } else { ListNode* pNode = *pHead; while(pNode->m_pNext != NULL) { pNode = pNode->m_pNext; } pNode->m_pNext = pNew; } }
我們傳遞一個連結串列時,通常是傳遞它的頭指標的指標。當我們往一個空連結串列插入一個結點時,新插入的結點就是連結串列的頭指標,那麼此時就會修改頭指標,因此必須把pHead引數設定為指向指標的指標,否則出了這個函式,pHead指向的依然是空,因為我們傳遞的會是引數的一個副本。但這裡又有一個問題,為什麼我們必須將一個指向ListNode的指標賦值給一個指標呢?我們完全可以直接在函式中直接宣告一個ListNode而不是它的指標?注意,ListNode的結構中已經非常清楚了,它的組成中包括一個指向下一個結點的指標,如果我們直接宣告一個ListNode,那麼我們是無法將它作為頭指標的下一個結點的,而且這樣也能防止棧溢位,因為我們無法知道ListNode中儲存了多大的資料,像是這樣的資料結構,最好的方式就是傳遞指標,這樣函式棧就不會溢位。
對於java程式設計師來說,指標已經是遙遠的記憶了,因為java完全放棄了指標,但並不意味著我們不需要學習指標的一些基礎知識,畢竟這個世界上的程式碼並不全部是由java所編寫,像是C/C++的程式依然執行在世界上大部分的機器上,像是一些系統的原始碼,就是用它們編寫的,加上如果我們想要和底層打交道的話,學習C/C++是必要的,而指標就是其中一個必修的內容。
就因為連結串列的記憶體不是一次性分配的,所以它並不像陣列一樣,記憶體是連續的,所以如果我們想要在連結串列中查詢某個元素,我們就只能從頭結點開始,而不能像陣列那樣根據索引來,所以時間效率為O(N)。
像是這樣:
void RemoveNode(ListNode** pHead, int value) { if(pHead == NULL || *pHead == NULL) { return; } ListNode* pToBeDeleted = NULL; if((*pHead)->m_nValue == value) { pToBeDeleted = *pHead; *pHead = (*pHead)->m_pNext; } else { ListNode* pNode = *pHead; while(pNode->m_pNext != NULL && pNode->m_pNext->m_nValue != value) { pNode = pNode->m_pNext; } if(pNode->m_pNext != NULL && pNode->m_pNext->m_nValue == value) { pToBeDeleted = pNode->m_pNext; pNode->m_pNext = pNode->m_pNext->m_pNext; } } if(pToBeDeleted != NULL) { delete pToBeDeleted; pToBeDeleted = NULL; } }
上面的程式碼用來在連結串列中找到第一個含有某值的結點並刪除該結點.
常見的連結串列面試題目並不僅僅要求這麼簡單的功能,像是下面這道題目:
題目一:輸入一個連結串列的頭結點,從尾到頭反過來打印出每個結點的值。
首先我們必須明確的一點,就是我們無法像是陣列那樣直接的逆序遍歷,因為連結串列並不是一次性分配記憶體,我們無法使用索引來獲取連結串列中的值,所以我們只能是從頭到尾的遍歷連結串列,然後我們的輸出是從尾到頭,也就是說,對於連結串列中的元素,是"先進後出",如果明白到這點,我們自然就能想到棧。
事實上,連結串列確實是實現棧的基礎,所以這道題目的要求其實就是要求我們使用一個棧。
程式碼如下:
void PrintListReversingly(ListNode* pHead) { std :: stack<ListNode*> nodes; ListNode* pNode = pHead; while(pNode != NULL) { nodes.push(pNode); pNode = pNode->m_pNext; } while(!nodes.empty()) { pNode = nodes.top(); printf("%d\t", pNode->m_nValue); nodes.pop(); } }
既然都已經想到了用棧來實現這個函式,而遞迴在本質上就是一個棧,所以我們完全可以用遞迴來實現:
void PrintListReversingly(ListNode* pHead) { if(pHead != NULL) { if(pHead->m_pNext != NULL) { PrintListReversingly(pHead->m_pNext); } printf("%d\t", pHead->m_nValue); } }
但使用遞迴就意味著可能發生棧溢位的風險,尤其是連結串列非常長的時候。所以,基於迴圈實現的棧的魯棒性要好一些。
利用棧來解決連結串列問題是非常常見的,因為單鏈表的特點是隻能從頭開始遍歷,如果題目要求或者思路要求從尾結點開始遍歷,那麼我們就可以考慮使用棧,因為它符合棧元素的特點:先進後出。
連結串列的逆序是經常考察到的,因為要解決這個問題,必須要反過來思考,從而能夠考察到面試者是否具有逆思維的能力。
題目二:定義一個函式,輸入一個連結串列的頭結點,然後反轉該連結串列並輸出反轉後連結串列的頭結點。
和上面一樣,我們都要對連結串列進行逆序,但不同的是這次我們要改變連結串列的結構。
最直觀的的做法就是:遍歷該連結串列,將每個結點指向前面的結點。但這種做法會有個問題,舉個例子:我們一開始將頭指標指向NULL,也就是說,pHead->next = NULL,但是獲取後面結點的方法是:pHead->next->next,這時會是什麼呢?pHead->next已經是NULL,NULL->next就是個錯誤!所以,我們自然就想到,要在遍歷的時候保留pHead->next。
綜合上面的討論,程式碼如:ListNode* ReverseList(ListNode* pHead) { ListNode* pReversedHead = NULL; ListNode* pNode = pHead; ListNode* pPrev = NULL; while(pNode != NULL) { ListNode* pNext = pNode->m_pNext; if(pNext == NULL) { pReversedHead = pNode; } pNode->m_pNext = pPrev; pPrev = pNode; pNode = pNext; } return pReversedHead; }
從最直觀的的做法開始,一步一步優化,並不是每個人都能第一時間想到最優解,要讓程式碼在第一時間內正確的執行才是首要的,然後在不影響程式碼的外觀行為下改進程式碼。
最優解往往來自於兩個方面:足夠的測試用例和輸出正確的執行程式碼。 還有一種形式的逆序題目: 題目三:輸入一個連結串列,然後輸出它的倒數第K個結點的值,計數從1開始,也就是說,連結串列結尾的元素就是倒計數第1個元素。 像是這樣的題目,我們的第一個想法就是要獲取連結串列的兩個元素:連結串列的總長度N和倒計數的K值。 要獲取連結串列的總長度,我們需要遍歷該連結串列,然後再遍歷N- K + 1來獲取倒數第K個元素的值。這樣子需要遍歷連結串列兩次,雖然可行,但一般遍歷的次數應該下降到1次。 既然是下降到1次,那麼該下降的是哪一次呢?獲取元素需要遍歷是無可厚非的,因為連結串列不能逆序遍歷,只能從頭指標開始遍歷,而我們要獲取倒數第1個元素,就勢必要遍歷到末尾,所以,遍歷N次是無可厚非的。 這種問題的考察是非常常見的,它的解決方法並不神祕,像是上面一開始的解決過程就是自然而然的思路,而更好的思路也往往是基於這樣基礎的認識上,只不過採用的方法不一樣而已。首先,要抓住基本思路的本質:遍歷兩次連結串列,其實就是兩次指標的移動,但它們並不是同時的,所以我們可以想想是否可以讓兩個指標的遍歷動作同時進行呢? 我們的指標還是要從連結串列的頭指標開始,之所以要遍歷到最後,是為了獲取N,而N的作用就是N - K + 1,既然我們決定取消這個N的獲取,那麼我們得想辦法得到N - K + 1。 我們可以先讓一個指標從頭指標開始行動,等到行動到第K - 1步的時候,我們再讓第二個指標開始行動,這時它們之間的差距就是K - 1,等到第一個指標行動到末尾,也就是第N步的時候,第二個指標的位置剛好就是N - (K - 1) = N - K + 1。 在編寫程式碼前,我們最想知道的是,如何根據這樣的條件得出這樣的答案?知道答案是很簡單的一件事,但如何得出答案卻是很難的一件事。 在推出答案前,我們先要知道我們的條件:N和K,然後要得到N - K + 1,然後是兩個指標同時行動,其中一個指標會達到N,所以另一個指標此時的位置就是N - K + 1,也就是說,它和這個指標的位置應該相差K,然後再加1。對於計算機而言,所謂的減法其實就是加法,所以我們可以將N - K + 1改寫為N - (K - 1),這樣我們的思路就變成另一個指標和第一個指標的位置相差K - 1。 基於這樣的思路,我們可以讓第一個指標先行動到第K - 1個位置,然後第二個指標開始行動,接著它們兩個同時行動,這樣就能始終保持兩個指標相差K - 1了。 能想到這樣的思路已經算是思維敏捷了,但我們必須充分考慮各種情況,像是N不一定大於K,連結串列可能是空指標,還有K可能是無效輸入,像是0或者負數。 結合上面的考慮,我們的程式碼如下:ListNode* FindKthToTail(ListNode* pListHead, unsigned int k) { if(pListHead == NULL || k == 0) { return NULL; } ListNode* pAhead = pListHead; ListNode* pBehind = NULL; for(unsigned int i = 0; i < k - 1; ++i) { if(pAhead->m_pNext != NULL) { pAhead = pAhead->m_pNext; } else { return NULL; } } pBehind = pListHead; while(pAhead->m_pNext != NULL) { pAhead = pAhead->m_pNext; pBehind = pBehind->m_pNext; } return pBehind; }
魯棒性是非常重要的,所以在考慮一個問題的時候必須充分考慮各種情況,不要一開始想到思路就開始寫程式碼,最好是先想好測試用例,然後再讓自己的程式碼通過所有的測試用例。
使用棧最大的問題就是空間複雜度,像是下面這道題目:
題目四:輸入兩個連結串列,找出它們的第一個公共結點。
拿到這道題目,我們的第一個想法就是在每遍歷一個連結串列的結點時,再遍歷另一個連結串列。這樣大概的時間複雜度將會是O(M * N)。如果是陣列,或許我們可以考慮一下使用二分查詢來提高查詢的效率,但是連結串列完全不能這樣。
想想我們判斷一個結點是否是公共結點,不僅要比較值,還要比較它下一個結點是否是一樣,也就是說,就算找到該結點,判斷的依據還是要放在後面的結點是否相同,所以,可以倒過來思考:如果從尾結點開始,找到兩個結點的值完全相同,則可以認為前面的結點是公共結點。 但連結串列是單鏈表,我們只能從頭開始遍歷,但是尾結點卻要先比較,這種做法就是所謂的"後進先出",也就是所謂的棧。但使用棧需要空間複雜度,現在我們可以將時間複雜度控制在O(M + N),但是空間複雜度卻是O(M + N)。要想辦法將空間複雜度降到最低,也就是減少兩個棧的比較次數。 注意到一個事情:兩個連結串列的長度不一定相同,我們可以先遍歷兩個連結串列,得到它們的長度M和N,其中M < N,讓較長的連結串列先行N - M,然後再同時遍歷,這樣時間複雜度就是O(M + N),但根本就不需要棧,節省了空間。 程式碼如:ListNode* FindFirstCommonNode(ListNode* pHead1, ListNode* pHead2) { unsigned int len1 = GetListLength(pHead1); unsigned int len2 = GetListLength(pHead2); int lengthDif = len1 - len2; ListNode* pListHeadLong = pHead1; ListNode* pListHeadShort = pHead2; if(len2 > len1) { pListHeadLong = pHead2; pListHeadShort = pHead1; lengthDif = len2 - len1; } for(int i = 0; i < lengthDif; ++i) { pListHeadLong = pListHeadLong->m_pNext; } while((pListHeadLong != NULL) && (pListHeadShort != NULL) && (pListHeadLong != pListHeadShort)) { pListHeadLong = pListHeadLong->m_pNext; pListHeadShort = pListHeadShort->m_pNext; } ListNode* pFirstCommonNode = pListHeadLong; return pFirstCommonNode; } unsigned int GetListLength(ListNode* pHead) { unsigned int length = 0; ListNode* pNode = pHead; while(pNode != NULL) { ++length; pNode = pNode->m_pNext; } return length; }
就算是連結串列的基本操作,也會作為面試題目出現,這時就要求我們能夠寫出更快效率的程式碼出來,像是下面這道題目:
題目五:給定單向連結串列的頭指標和一個結點指標,定義一個函式在O(1)時間刪除該結點。
這個題目的要求是讓我們能夠像陣列操作一樣,實現O(1),而根據一般連結串列的特點,是無法做到這點的,這就要求我們想辦法改進一般的刪除結點的做法。
一般我們刪除結點,就像上面的做法,是從頭指標開始,然後遍歷整個連結串列,之所以要這樣做,是因為我們需要得到將被刪除的結點的前面一個結點,在單向連結串列中,結點中並沒有指向前一個結點的指標,所以我們才從連結串列的頭結點開始按順序查詢。
知道這點後,我們就可以想想其中的一個疑問:為什麼我們一定要得到將被刪除結點前面的結點呢?事實上,比起得到前面的結點,我們更加容易得到後面的結點,因為一般的結點中就已經包含了指向後面結點的指標。我們可以把下一個結點的內容複製到需要刪除的結點上覆蓋原有的內容,再把下一個結點刪除,那其實也就是相當於將當前的結點刪除。
根據這樣的思路,我們可以寫:
void DeleteNode(LisNode** pListHead, ListNode* pToDeleted) { if(!pListHead || !pToBeDeleted) { return; } if(pToBeDeleted->m_pNext != NULL) { ListNode* pNext = pToBeDeleted->m_pNext; pToBeDeleted->m_nValue = pNext->m_nValue; pToBeDeleted->m_pNext = pNext->m_pNext; delete pNext; pNext = NULL; } else if(*pListHead == pToBeDeleted) { delete pToBeDeleted; pToBeDeleted = NULL; *pListHead = NULL; } else { ListNode* pNode = *pListHead; while(pNode->m_pNext != pToBeDeleted) { pNode = pNode->m_pNext; } pNode->m_pNext = NULL; delete pToBeDeleted; pToBeDeleted = NULL; } }
首先我們需要注意幾個特殊情況:如果要刪除的結點位於連結串列的尾部,那麼它就沒有下一個結點,這時我們就必須從連結串列的頭結點開始,順序遍歷得到該結點的前序結點,並完成刪除操作。還有,如果連結串列中只有一個結點,而我們又要刪除;;連結串列的頭結點,也就是尾結點,這時我們在刪除結點後,還需要把連結串列的頭結點設定為NULL,這種做法重要,因為頭指標是一個指標,當我們刪除一個指標後,如果沒有將它設定為NULL,就不能算是真正的刪除該指標。
我們接著分析一下為什麼該演算法的時間複雜度為O(1)。
對於n- 1個非尾結點而言,我們可以在O(1)時把下一個結點的記憶體複製覆蓋要刪除的結點,並刪除下一個結點,但對於尾結點而言,由於仍然需要順序查詢,時間複雜度為O(N),因此總的時間複雜度為O[((N - 1) * O(1) + O(N)) / N] = O(1),這個也是需要我們會計算的,不然我們無法向面試官解釋,為什麼這段程式碼的時間複雜度就是O(1)。
上面的程式碼還是有缺點,就是基於要刪除的結點一定在連結串列中,事實上,不一定,但這份責任是交給函式的呼叫者。
題目六:輸入兩個遞增連結串列,合併為一個遞增連結串列。
這種題目最直觀的做法就是將一個連結串列的值與其他連結串列的值一一比較。考察連結串列的題目不會要求我們時間複雜度,因為連結串列並不像是陣列那樣,可以方便的使用各種排序演算法和查詢演算法。因為連結串列涉及到大量的指標操作,所以連結串列的題目考察的主要是兩個方面:程式碼的魯棒性和簡潔性。
魯棒性就要求我們事先想好足夠的測試用例,事實上,程式碼的設計時間並不比編寫時間短,而且設計時間越長,編寫的時間可以越短,只要設計是有效的。我們來想想空指標的情況。如果其中一個連結串列的頭指標是一個空指標,也就是說,該連結串列是一個空連結串列,那麼合併後的連結串列應該是另一個連結串列。如果兩個連結串列都是空連結串列,那麼合併後的連結串列應該是空連結串列。 接著就是程式碼的簡潔性。事實上,連結串列非常適合遞迴,因為我們在使用連結串列的時候都是使用指標,而不像陣列那樣直接使用一個記憶體塊,因為遞迴的風險,也就是棧溢位可以避免,並且因為連結串列涉及到大量的指標操作,使用遞迴可以讓我們的程式碼更加簡潔,而且簡潔的程式碼更不容易犯錯,畢竟程式碼量越大,可能犯錯的概率也就越大,尤其是操作指標。ListNode* Merge(ListNode* pHead1, ListNode* pHead2) { if(pHead1 == NULL) { return pHead2; } else if(pHead == NULL) { return pHead1; } ListNode* pMergedHead = NULL; if(pHead->m_nValue < pHead->m_nValue) { pMergedHead = pHead1; pMergedHead->m_pNext = Merge(pHead->m_pNext, pHead2); } else { pMergedHead = pHead2; pMergedHead->m_pNext = Merge(pHead1, pHead2->m_pNext); } return pMergedHead; }
到現在為止,我們的連結串列都是單鏈表,並且結點的定義都是一般連結串列的定義,但如果面對的是自定義結點組成的連結串列呢?
像是這樣的連結串列定義:struct ComplexListNode { int m_nValue; ComplexListNode* m_pNext; ComplexListNode* m_pSibling; };
題目七:請實現一個函式實現該連結串列的複製,其中m_pSibling指向的是連結串列中任意一個結點或者NULL。
這種題目就要求我們具有發現規律的能力了。
複製連結串列並不難,但是我們會想到效率的問題。
第一步肯定是要複製每個結點並按照m_pNext連線起來,第二步就是設定每個結點的m_pSibling。我們可以在第一步遍歷的時候就儲存每個節點的m_pSibling,這樣就可以節省第二步的遍歷,將時間複雜度控制在O(N),但是這樣子的空間複雜度就是O(N),事實上,連結串列的問題求解和陣列不一樣,陣列更多考慮的是時間複雜度能否足夠低,而連結串列則考慮空間複雜度能否足夠低。 一個連結串列的求解如果不能將空間複雜度控制在O(1),完全不能通過面試。 我們完全可以不用專門用輔助空間來存放m_pSibling,直接就是將複製後的結點連線在原本結點後面,然後將這個連結串列按照奇數和偶數位置拆成兩個子連結串列,其中,偶數位置就是我們要的複製後的連結串列。ComplexListNode* Clone(ComplexListNode* pHead) { CloneNodes(pHead); ConnectSiblingNodes(pHead); return ReconnectNodes(pHead); } void CloneNodes(ComplexListNode* pHead) { ComplexListNode* pNode = pHead; while(pNode != NULL) { ComplexListNode* pCloned = new ComplexListNode(); pCloned->m_nValue = pNode->m_nValue; pCloned->m_pNext = pNode->m_pNext; pCloned->m_pSibling = NULL; pNode->m_pNext = pCloned; pNode = pCloned->m_pNext; } } void ConnectSiblingNode(ComplexListNode* pHead) { ComplexListNode* pNode = pHead; while(pNode != NULL) { ComplexListNode* pCloned = pNode->m_pNext; if(pNode->m_pSibling != NULL) { pCloned->m_pSibling = pNode->m_pSibling->m_pNext; } pNode = pCloned->m_pNext; } } ComplexListNode* ReconnectNode(ComplexListNode* pHead) { ComplexListNode* pNode = pHead; ComplexListNode* pClonedHead = NULL; ComplexListNode* pClonedNode = NULL; if(pNode != NULL) { pClonedHead = pClonedNode = pNode->m_pNext; pNode->m_pNext = pClonedNode->m_pNext; pNode = pNode->m_pNext; } while(pNode != NULL) { pClonedNode->m_pNext = pNode->m_pNext; pClonedNode = pClonedNode->m_pNext; pNode->m_pNext = pClonedNode->m_pNext; pNode = pNode->m_pNext; } return pClonedHead; }有些題目並不會直接提到連結串列,但它的解法卻需要我們用連結串列來解決。 題目八:0,1,3...,n - 1這n個數字排成一個圓圈,從數字0開始每次從這個圓圈裡刪除第m個數字。求出這個圓圈裡剩下的最後一個數字。 從題目要求中我們無法直觀的感知該問題,得從一個測試用例開始。 假設0,1,2,3,4這5個數字組成一個圓圈,如果我們從數字0開始每次刪除第3個數字,則刪除的前四個數字是2,0,4,1,3。 這就是有名的約瑟夫環問題,它有一個簡潔的數學公式,但除非我們有很深的數學素養和數學靈敏性,否則是很難一下子想出來的。 程式設計師最普遍的方法就是想盡一切辦法讓我們的程式碼通過測試用例。 既然是一個圓圈,我們自然就會聯想到環形連結串列:
int LastRemaining(unsigned int n, unsigned int m) { if(n < 1 || m < 1) { return -1; } unsigned int i = 0; lisg<int> numbers; for(i = 0; i < n; ++i) { numbers.push_back(i); } list<int> :: iterator current = numbers.begin(); while(numbers.size() > 1) { for(int i = 1l i < m; ++i) { current++; if(current == numbers.end()){ current = number.begin(); } } list<int> :: iterator next = ++current; if(next == numbers.end()){ next = numbers.begin(); } --current; numbers.erase(current); current = next; } return *(current); }
int LastRemaining(unsigned int n, unsigned int m) { if(n < 1 || m < 1) { return -1; } unsigned int i = 0; lisg<int> numbers; for(i = 0; i < n; ++i) { numbers.push_back(i); } list<int> :: iterator current = numbers.begin(); while(numbers.size() > 1) { for(int i = 1l i < m; ++i) { current++; if(current == numbers.end()){ current = number.begin(); } } list<int> :: iterator next = ++current; if(next == numbers.end()){ next = numbers.begin(); } --current; numbers.erase(current); current = next; } return *(current); }
我們可以用std :: list來模擬一個環形連結串列,但因為std :: list本身並不是一個環形結構,所以我們還要在迭代器掃描到連結串列末尾的時候,把迭代器移到連結串列的頭部。
如果是使用數學公式的話,程式碼就會非常簡單:
int LastRemaining(unsigend int n, unsigned int m) { if(n < 1 || m < 1) { return -1; } int last = 0; for(int i = 2; i <= n; ++i) { last = (last + m) % i; } return last; }
這就是數學的魅力,並且它的時間複雜度是O(N),空間複雜度是O(1)。