对单链(Single-Linked List)操作的思考

前言
对于单链(single-linked list)的考察频繁的出现在各种各样的考试题和面试题中。这其中的原因,一方面是因为单链的使用相当广泛,更重要的是单链的使用非常灵活,某些单链的操作对程序员的算法分析能力和灵活思考能力有很高的要求。本文通过对几个有代表性的单链算法的分析试图寻找解决单链问题的"万能钥匙",最起码也为提供一个通用的思路。

单链的特性
在进入到具体的算法分析之前,我觉得有必要分析一下单链到底具有那些区别于其他数据结构的特征,更重要的是我们需要知道这些特性对我们的算法分析会产生那些影响(包括积极的影响和消极的影响)。对于单链的结构这里不在罗嗦,任何一本数据结构的书都不会吝啬笔墨来讲解单链的结构。从单链的结构出发,我觉得单链的遍历过程具有以下两个特点:
    1> 预知未来。对于当前的结点,我们可以"偷窥"一下这个结点以后的一个或者多个结点;
    2> 覆水难收。对于当前的结点,如果不作记录的话,我们无法再得到曾经经历的结点。
通过这两个特点,我们已经可以隐隐约约感觉了一些东西,这些东西和最终解决单链问题的算法有着千丝万缕的关系。如果你不相信的话,且看下面我的分析。

问题1:反转一个单链(reverse an single-linked list)
对于反转一个单链,最大的问题在于我们需要将当前结点的指向下一个结点的指针指向一个刚刚访问的一个结点,一个简单的解决方法就是将单链的所有结点全部保存起来,然后再按照和他们的访问顺序相反的顺序再一次访问他们。对于这个想法我们很自然的就可以想到使用栈这个数据结构,因为栈的"先进后出"的特性正是我们需要的。这样算法就是:
  1> 从头结点开始,遍历整个单链,将遍历到的每一个结点压入栈中。
  2> 从栈中弹出第一个结点,并将这个结点作为新的头节点。
  3> 将栈中的结点依次压出,并将这些结点按顺序作为新链的尾结点。
  4> 整个过程直到栈为空。
这个算法相当于对单链进行了两次遍历,所以它的时间复杂度为O(n)。由于它另外保存了所有结点,所以它的空间复杂度也为O(n)。对于这个算法,我们还有优化的空间么?仔细想想,其实我们并没有必要保存所有曾经访问过的结点,我们只需要保存当前结点的前一个结点。但是当我们把当前结点的下一个结点设置为前一个结点后,我们就丢失了当前结点的下一个结点。要解决这样问题也不难,只要我们在调整之前,保存原来的下一个结点,这样再设置完以后,我们仍然保持对原有单链的"所有权"。总结下来,我们需要三个额外的变量:

  Node *  pActiveNode  = pHead;              // 当前结点
  Node *  pBehindNode  = NULL;               // 当前结点的前一个结点
  Node *  pAdvanceNode = pHead->Next;       // 当前结点的后一个结点

在遍历的过程中,我们需要将当前结点的下一个结点设置为前一个结点:
  pActiveNode -> Next  =  pBehindNode;

同时我们要按照以下的顺序调整这三个变量:

  pBehindNode = pActiveNode;
  pActiveNode = pAdvanceNode;
  pAdvanceNode = pAdvanceNode->
Next;

整个遍历过程当pActiveNode == NULL时候结束,此时的pBehindNode就是新的单链的头结点。
和前面的算法比较,虽然它们的时间复杂度都是O(n),但是这个算法只遍历了一次。由于这个算法中使用的额外内存空间的大小是固定的,所以它的空间复杂度是O(1)。相对于前面的算法,可以说是不小的进步。

问题2:获得处于单链中间位置的结点
这个问题初看起来并没有什么难度,我们只要想遍历一次单链就可以获得这个单链的长度,有了这个长度,获得处于中间位置的结点就不是什么问题了。这个问题的难点在于如果我们只允许对这个单链进行一次遍历,那我们该如果设计我们的算法呢?有了前面解决问题1的经验,我们意识到可能需要用到两个额外的游标:
  Node *  pSlowNode  =  pHead;                     // 标识当前正在访问的结点
  Node *  pFastNode =  pHead ;                     // 标识当前访问结点后面的某个结点

如果我们将前面游标的步长设为1(每次前进一个结点),将另外一个游标的步长设为2(每次前进二个结点):
  pSlowNode  =  pSlowNode -> Next;              // 前进一个结点
  pFastNode  =  pFastNode -> Next -> Next;        // 前进两个结点

那么后面游标的前进速度是前面游标的前进速度的两倍。当后面游标(快的游标)达到尾结点(或者尾结点的前面一个结点)的时候,前面的游标(慢的游标)正好处于单链中间的位置。Aha! 这正是我们需要的。

总结
由以上的算法分析来看,对于单链的遍历操作,我们通常可以抓住以下几个要点:
1> 在遍历的过程中,我们可以维护以下三个状态:
        1> 当前访问的结点。
        2> 当前访问的结点的前M个结点。
        3> 当前访问的结点的后N个结点。
     其中,第一个状态是所有遍历过程都需要的,其他的两个应该根据具体的问题来判断是否需要维护它们。
2> 在遍历的过程中,遍历的步长是遍历的一个重要特征,正确步长的选取往往是算法设计的关键。
3> 在遍历的过程中,尽量避免多次遍历,可以使用额外内存空间来换取遍历时间的方法将遍历过程缩减到一次完成。

后记
下面我列出了我收集的一些关于单链的问题,大家可以试试用本文所总结的解题思路去思考这些问题,说不定有意想不到的效果:
  1. 判断一个单链是否存在环(Cyclic Single-Linked List);
  2. 获得倒数第M个结点(Mth-to-Last Element of a Single-Linked List);
  3. 判断这个单链是否存在相交(Intersection Between Two Single-Linked List);
  4.将两个已经按升序排列的单链合并成一个仍然按升序排列的单链
  5.单向链表的删除操作,已知 head, p(指向被删除元素),要求复杂度为 O(1);
我很乐意和大家就这些问题展开深入的讨论,如果大家有任何的建议和疑问,记得给我写邮件哟

历史记录
01/09/2007   v1.0
原文的第一版

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值