面试常备题---链表

面试常备题---链表总结篇http://www.cnblogs.com/wenjiang/p/3310233.html

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

      在面试中,最常常被说起的就是链表,由于它简单,但又由于须要对指针进行操做,凡是涉及到指针的,都须要咱们具备良好的编程基础才能确保代码没有任何错误。java

      链表是一种动态的数据结构,由于在建立链表时,咱们不须要知道链表的长度,当插入一个结点时,只须要为该结点分配内存,而后调整指针的指向来确保新结点被链接到链表中。因此,它不像数组,内存是一次性分配完毕的,而是每添加一个结点分配一次内存。正是由于这点,因此它没有闲置的内存,比起数组,空间效率更高。node

      像是单向链表的结点定义以下:程序员

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)。