数据结构和算法,是咱们程序设计最重要的两大元素,能够说,咱们的编程,都是在选择和设计合适的数据结构来存放数据,而后再用合适的算法来处理这些数据。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)。