转:C/C++面试之算法系列--怎样快速检测出一个巨大的单链表中是否具备死链及其位置
怎样快速检测出一个巨大的单链表中是否具备死链及其位置
Sailor_forever sailing_9806@163.com 转载请注明
http://blog.csdn.net/sailor_8318/archive/2008/10/13/3066292.aspx
汤姆逊的面试试题:怎么快速检测出一个巨大的单链表中是否具备死链及其位置?
先给出各种链表的定义:
循环链表(Circular Linked List)是另一种形式的链式存储结构。它的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。由此,从表中任一点出发均可找到表中其他结点。
单链表是指表中的每个结点只包含一个指针域,除头结点外每个结点只有一个直接前驱,除最后一个结点外每个结点只有一个直接后继。
死链:链表的某个节点的next指针指向了曾经出现过的接点或自己,导致链表死循环
死链的特点是必然不存在next域为NULL的节点。如果能事先知道链表长度或是所有链表节点可以遍历一遍检查一下就行了。
如果你可以知道链表的长度,那么你遍历这个链表,每经过一个节点, 长度减一,当长度为0,但链表还没有遍历完时, 说明存在死链。时间效率恒定,未申请新空间
×××××××××××××××××××××××××××××××××××××××
查找表比较
如果没有任何其它信息,链表节点中又没有可以自由设置的域可以使用(如果有的话就可以设置访问标志了),可能就需要用一个查找表了。把所有访问过的节点指针放到一个查找表中,遍历链表直到next是NULL(无死链表)或是出现在查找表中(有死链)。效率是查找表的访问效率*N。比如用二分搜索树作为查找表效率就是(N*Log(N))——和排序同级
由于死链可能存在于链表的任何位置,这样做就最坏的结果就是将链表遍历一遍(没有死链)有死链的话不用遍历完整个链表就可以找出来了
问题在于事先不知道链表的长度,不能申请数组来保存地址啊,申请链表又要浪费大量的动态空间啊
实际应用情况通常是知道链表的最大个数的,为了简单起见,tab定义为长度足够的数组,也可以建立一个链表。由于原则上链表接点的地址是没有规律的,所以依据地址表本身的其它快速(如二分等)算法似乎也不可行。
void *p[MAX];
以p[0]为根,p保存的是各节点next的地址,使用动态查找表中的二叉排序树的办法,建一个新的二叉排序树。如果在建树的过程中出现了相同的节点,就找到了死链
书上说查找的平均效率是log(n),又因为有n个节点,所以算法的时间复杂度应当是n*log(n)
空间复杂度是n
也可以利用原有的链表作为查找表,但是时间复杂度比较大,是1+2+...+n = n*(n+1)/2 时间复杂度O(N^2)
简要描述如下:每往下探测一次,就检查->next是否指向已经探测过的节点。 每次比较时,从head开始,用head变相保存了各节点的地址,无需额外申请空间 ,因此空间复杂度为0,相比查表法好,时间复杂度比较高,但是还是比较安全
Temp 为当前检测的指针,Temp2 为已经检测完了的用来与当前指针比较的节点
void checkdeadlink(LINK *head)
{
LINK *ptmp=head;
while(ptmp!=NULL)
{
LINK *ptmp2=head;
while(!ptmp==ptmp2)
{
if(ptmp->next=ptmp2->next)
{
SET_DATH_LINK(true);
return;
}
ptmp2=ptmp2->next;
}
ptmp=ptmp->next;
}
SET_DATH_LINK(false);
return;
}
×××××××××××××××××××××××××××××××××××××××
K步进插入“示踪节点”
依次访问每一个节点,每访问n个节点后,插入一个特殊的“示踪”节点,以后如果在访问过程中碰到了曾经插入的示踪节点,则该链表存在死链
插入示踪节点时进行计数,工作完成后,利用此计数将插入的节点删除。即使是只读,此法也不会更改其内容,而是更改next,而且工作完成后恢复。
n的设置需要计算一下插入一个节点的消耗,通过next查找下一个节点的消耗,以及删除节点的消耗等等,估计了一下,应该在20到100之间比较好。
不过如果链表节点比较大的时候会导致空间占用比较大。并且如果链表中有没有使用的域,我们就可以把它作为已访问标志来完成目的,不需要插入标志;如果没有这种域,如何分辨一个节点是否插入的标志节点将是关键?
实际使用中还是相对容易找到示踪原子的,如果仅仅判断有没有死链,用示踪法办法应该是最好的
当示踪法的N=1时,申请空间上就类似于查找表的方法。对于查找表法,如果采用N步保存地址的方式,虽然时间复杂度没有降低,但是总时间和空间都变为1/N
之所以采用插入节点而不用记录节点地址,是因为将来要查找这些地址很麻烦(注意:非常大的链表),如果记录的节点数量太多,显然影响效率。而插入法进行一遍扫描,也不需要和什么值进行比较,所以时间复杂度是O(N)。而如果要记录节点查表,每个新的节点都要和你记录下的这些节点比较,即使你先排序,然后使用折半查找法,时间复杂度也是 n * log(n) ,当数据量非常大的时候,其时间消耗会迅速的超过插入新节点的方法。总体的比较次数就和链表长度的平方成正比了O(N^2)。
因为是单链,没法回溯,因此插入法没法一次检测出死链的具体位置,只能知道“在该接点前面n个接点之内”,若在每个接点后面都插入就可以找到死链位置
标志节点法需要遍历至少两遍,第一遍插入标志节点并检测死链,第二遍删除所有的标志节点。不过可以改进:再加一个线性表来保存下所有的标志节点的前继可以加快删除的速度。
当确定死链第一次出现后,即可利用前继找到上一个插入节点,此时死节点就位于二者之间,再利用查表比较法,即可确定死链位置。然后利用线性表保存的前继节点就可快速的删除添加的节点恢复原有的链
插入节点的步长为Setp,存放这些插入的节点的线形表为p[]
如果在插入p[m]之后,碰到了曾经插入的节点p[n],则可以断定,出问题的节点位于p[n-1]到p[n]之间,这是个常量,最多再进行2*Step*step次比较即可找到这个节点。
×××××××××××××××××××××××××××××××××××××××
追赶法
开两个遍历链表的指针,一个一次往前走一个结点,另一个往前走两个结点,当这两个指针相遇时,则说明有死链。否则快节点已经到了末尾NULL处。
关于数学上的证明:如果没有死链,两个指针是不会相遇的,除非在两头。就相当与一个人跑的快,另一个人跑的慢,如果不循环跑,两个人相遇的地点只能在起跑点。
追赶法的优势在于:
简单,不用进行插入节点、删除节点一类比较麻烦的操作
广泛的适应性,与节点的类型无关,而示踪原子法需要依据不同的节点选择不同的示踪节点,这个比较麻烦,如果节点不能找到示踪节点怎么办?这是客观存在的。而且在实际应用中,这样可以通过只读就能解决的问题最好不要对链表写入东西。
2是fast pointer的最佳步长
用快慢指针,一个一次走一步,一个一次走两步,都进入死链后,快慢指针在第一圈就能相遇(因为每走一步,快指针和慢指针的距离将增加1,假设环长为L,则他们的距离将会是i, i+1, i+2,...,L-1, i取0--L-1; 当距离为L-1时,下一步他们将相遇),第一次相遇后,开始记录,当第二次相遇时,记录值就是环的长度L,就是死链的长度
假设死链大小是s(2n或者2n+1个);快慢指针一个每次移动1个大小,可称之为1格,速度为v,另外一个指针每次移动2格,速度为2v。那么假设慢指针在没到s的时候就被赶上了,此时移动次数为t,可以列出等式:
vt = 2vt - K(s-a)
其中a 为循环的开始处的前一个位置的格数;
那么解出t=k(s-a)/v;那么不管s或者a是2n或者2n+1,t解出来都是整数,符合开始的假设,所以现在只要找到a,一切搞定
主要是确定K,还有一个条件需要加进来,就是第一次被追到时,慢指针走的距离vt肯定大于a,得出K(s-a)>a,K>a/(s-a);那么慢指针第一次被追到的时候K为大于a/(s-a)的最小整数。现在来算k,第一次快慢指针相遇的时候可以得出慢指针走了vt=K(s-a),也就是说K(s-a)可以被算出来。
那么再让快慢指针跑,那么下一次它们相遇的时候就可以求出s-a,也就是循环的一圈的长度。两个相除,就可以求出K的大小。(其实如果不关心K的大小,也可以不求K,为了完整起见,求一求也无所谓),现在主要是求s和a,我们知道,相遇之后,慢指针的位置离开始的位置偏移了K(s-a)。那么如果慢指针再循环K次,它还是在原地,此时该慢指针走过的路程为K(s-a);而如果在开始处再设一个慢指针,二者速度相等,当原慢指针循环K次回到原地时,此新增指针正好移动K(s-a),也到了原来的慢指针的位置,这说明这两个指针可以相遇,而且第一次相遇的点是循环的入口(他们速度一样,所以如果在点K(s-a)处相遇,那么也在进入循环开始的时候相遇),这样一来求到入口地址,顺便也求到了a,由于s-a一开始就被算出来了,这样也求到了s
如上图,假设慢指针移动速度为单位距离1,快指针移动速度为单位2,死链入口距离为A,假设在X距离处相遇(X可由程序求出来),则慢指针必定为第一次进入S1S4区间,且快指针为第K次到S3点,
S3S1 = S3S0 – S1S0 = X – A
S5S4 = S5S0 – S4S0 = 2X – S
快指针到达S4后遇到死链将返回到S1处继续前移,在S3处与慢指针相遇,此时可能快指针已经在S1S4之间往返多次了,设为M次,则
则2X-S-M(S-A)= X - A 》》 X = (M + 1 )(S-A)
死链部分长度为S-A,慢指针再移动N次后,快慢指针再次相遇必将在S3点(把S3当作环行跑道的起始点,速度相差一倍的人相遇只能是起始点,此时较慢的人正好跑了一圈),故N = S-A
此时即可求出M了
若M为0,说明快指针为第2次到S3点,二者相遇,则有 X = (M + 1 )(S-A)= S-A = S1S4
S3S4 = S0S4 – S0S3 = S – X = A = S0S1,即慢指针和S0处的慢指针同时前移,二者指向同一点时即为入口S1
若M不为0,原慢指针移动(M + 1 )N次后回到当前位置,此时同速的S0处慢指针将移动(M + 1 )N = (M + 1 )(S-A) = X,也到达原慢指针的位置,说明二者已经相遇了,相遇地点即为死链入口
链表遍历没有真正的“跳过”,“快”指针比“慢”指针快不是因为“跳过”,而是每一次迭代访问两个节点。因此在处理时也没必要只对第二个节点与“慢”指针比较,完全可以两个节点都与“慢”指针比较,这样当“快”指针从“慢”指针的后面到前面这一次迭代过程中必然可以发现有一次与慢指针相等,不会出现在环中时慢指针落在快指针的空当。也可以证明,慢指针进入圈内后,在第一圈内,快指针必定可以追上。
事实上算法的效率与快慢指针的速度比有关,比值越大在慢指针进入环道后越快被追上,但也增加了在慢指针没有进入环道之前快指针在环道上作的无用功。
×××××××××××××××××××××××××××××××××××××××
标志法
从头结点开始遍历结点,每遍历一个结点,即把该结点的next域取反
如果遇到NULL的next域,那么说明不存在死链
如果出现内存保护错误,那么说明有死链,即next被修改了,曾经出现过
此算法的优点是,线性时间复杂度,零空间复杂度
缺点是,如果存在死链,那就连程序都一起死掉了
但在实际应用中,这种算法有一个可行的版本。大家都知道,链表结点都是结构体并至少包含一个next域,这是一个指针,这就意味着链表结点大小通常都大于4个字节。在自然对界情况下,编译器/硬件要求分配此类内存时需要对齐,对齐单位一般是四字节或八字节。所以next指针的后面两个或三个bit通常都肯定为0。所以可以用最后一个bit做标志位,下面简述算法如下:
遍历结点,从头节点开始:
提取next域指针。
如果next域末位bit为1,死链,结束。
如果next域为NULL,没有死链,结束。
置next域末位bit为1。
迭代next指针指向的下一个结点。(千万别用修改了末位bit的指针)
算法结束后,恢复next域指针的值到初始情况也是很容易的,遍历并&=(~
这就是一个无空间复杂度的线性时间复杂度的超强算法
×××××××××××××××××××××××××××××××××××××××
链表反向法
"回溯法"或"顺逆法",思路是"以始为终",虽然不知道有没有结尾,但它一定有开头,那么就把开头改成结尾,方法如下:
首先把头元素的next域置为null,然后对后面的指针依次反向,也就是指向前一个元素,那么无论是否有环最后必定会遇到null,这时判断:如果最后一个元素是头元素,那么就是有环的,否则就没有。
复杂度:用逆转链表的算法是一定可以在线性时间内检测出来的。应该在a*N到a*2N之间,但是a可能比较大。
缺点:只能检测是否有环,不能定位环
链表被破坏(没环时所有指针反向,有环时环的方向改变),需要再进行一次如上的过程才能恢复。