面试常备题---链表总结篇

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

      在面试中,最经常被提及的就是链表,因为它简单,但又因为需要对指针进行操作,凡是涉及到指针的,都需要我们具有良好的编程基础才能确保代码没有任何错误。

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

      像是单向链表的结点定义如下:

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

 

 

 

 

     

     

     


    

 

你可能感兴趣的:(面试常备题---链表总结篇)