數據結構和算法,是我們程序設計最重要的兩大元素,可以說,我們的編程,都是在選擇和設計合適的數據結構來存放數據,然后再用合適的算法來處理這些數據。
在面試中,最經常被提及的就是鏈表,因為它簡單,但又因為需要對指針進行操作,凡是涉及到指針的,都需要我們具有良好的編程基礎才能確保代碼沒有任何錯誤。
鏈表是一種動態的數據結構,因為在創建鏈表時,我們不需要知道鏈表的長度,當插入一個結點時,只需要為該結點分配內存,然后調整指針的指向來確保新結點被連接到鏈表中。所以,它不像數組,內存是一次性分配完畢的,而是每添加一個結點分配一次內存。正是因為這點,所以它沒有閑置的內存,比起數組,空間效率更高。
像是單向鏈表的結點定義如下:
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; }
從最直觀的的做法開始,一步一步優化,並不是每個人都能第一時間想到最優解,要讓代碼在第一時間內正確的運行才是首要的,然后在不影響代碼的外觀行為下改進代碼。
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)。如果是數組,或許我們可以考慮一下使用二分查找來提高查找的效率,但是鏈表完全不能這樣。
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。
這種題目就要求我們具有發現規律的能力了。
復制鏈表並不難,但是我們會想到效率的問題。
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; }
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)。