數據結構和算法,是咱們程序設計最重要的兩大元素,能夠說,咱們的編程,都是在選擇和設計合適的數據結構來存放數據,而後再用合適的算法來處理這些數據。java
在面試中,最常常被說起的就是鏈表,由於它簡單,但又由於須要對指針進行操做,凡是涉及到指針的,都須要咱們具備良好的編程基礎才能確保代碼沒有任何錯誤。node
鏈表是一種動態的數據結構,由於在建立鏈表時,咱們不須要知道鏈表的長度,當插入一個結點時,只須要爲該結點分配內存,而後調整指針的指向來確保新結點被鏈接到鏈表中。因此,它不像數組,內存是一次性分配完畢的,而是每添加一個結點分配一次內存。正是由於這點,因此它沒有閒置的內存,比起數組,空間效率更高。程序員
像是單向鏈表的結點定義以下:web
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)。