BDS之链表经典问题

来源:互联网 发布:sql 合计多列数据 编辑:程序博客网 时间:2024/06/11 08:59

这篇内容我将介绍几个关于链表的经典问题,很多都是来自互联网(相信绝大部分来自于经典书籍),特在本篇中收集和整理(持续更新,欢迎提供链表相关问题)。

以下问题,简单的我都会列出解题的大体思路,复杂点的我会详细的说明。

问题1:给定一个单向链表,设计一个算法,找到该链表的倒数第m个元素,当m=0时,返回链表最后一个元素。

解法一:

要遍历链表两次,第一次遍历链表取得长度n,然后计算要沿链表移动的步数s=n-m(当然这里假设n>m);第二次遍历链表拿到在s处的链表结点元素。

解法二:

要拿到倒数第m个元素,我们可以设计两个指针p1,p2,它们的在链表中的间距为m(这里间距是通过p1,p2相对于头结点的位序计算),同时移动两个指针,当后一个指针p2到链表末尾时,前一个指针p1就是要取的结点。

问题2:判断一个的单向链表是否有环。

解法:

最经典的解法就是快慢指针法,定义指针p,q,在链表中,p每次前进一步,q每次前进两步,如果p能和q重合,那么具有环。可以这么理解,在每次移动的过程中,可以看做p相对q是静止的,q每次移动一步,所以如果有环,那么q总能赶上p的。

下面我们给出上面问题的证明:

假设假设快慢指针的移动速度分别为vp,vq,并且环的长度为l,在某一时刻t,p和q指针距离环的入口结点相距分别为dp,dq,那么,假设在经过m次移动后,p和q指针重合,即有环,那么满足下面的等式:

dp+vp*m mod(l) = dq+vq*m mod(l);

->   (vp-vq)*m mod(l)=dq-dp

可进一步推导出  (vp-vq)*m - p*l = dq-dp  (p为取模的商)

这里假设 A=vp-vq, B=-l,C=dq-dp 

那么等式为 A*m+pB=C ,其中,A,B,C已知。这里对我们的问题来说,要求取一对数(m,p)使得可以取得m为一正整数即可,当然对于算法来说越小越好。其实,这里这个问题就是经典的拓展欧几里问题,即ax+by=c,若c mod gcd(a,b)=0,则一定存在解x,y满足等式。这里,我们证明的前提是vp=1,vq=2,则A=-1,B=-l,C=vp*t-vp*t,显然gcd(A,B)等于1,

则一定满足C mod gcd(a,b) = 0,所以一定存在解m,p使得等式满足,即当我们用步进分别为1和2的指针循环时,如果链表有环就一定会发生碰撞。关于这个拓展欧几里得算法的详细证明,请参考<MAT-欧几里得及拓展欧几里得算法>一篇。

这里我们给出算法的伪代码:

bool has_loop(head)begin:p=head; q=head; while(q&&q->next){p=p->next;q=q->next->next;if(p==q) reuturn true;}return false;end;

问题3:将一个单向链表逆序

解法:

算法开始的条件应该至少有两个以上的结点数,需要用到三个指针,分别指向前驱结点,当前结点以及后继结点,操作看下面一组图:

1初始p=head->next; q=head->next->next ; t=NULL;


2.判断q是否到链表尾,如果是,结束循环,否则执行循环体,将t指向q的下个元素,同时将q的下个元素修改为前驱结点p,分别向前移动p,q指针,此时q,t指向同一个结点


3.类似步骤2


4.此次循环执行完,p指向末尾结点,q=NULL


5.调整头结点的下个节点A 指向NULL,指向头结点指向p.



相信从上面的图中可以看到逆序的过程,具体算法如下:

list_node* reverse_list(list_node* head){list_node *p,*q,*t;assert(head);if(head->next == NULL || head->next->next==null){ //链表为空或者只有一个元素return head;}p = head->next;q = head->next->next;t = NULL;while(q){t=q->next;q->next = p ;p=q;q=t;}head->next->next = NULL;head->next = p;return head;}

问题4.当单向链表存在环时,找到环的入口点。

解法:

结论: 分别从链表头和碰撞点,同步地一步一步前进扫描,直到碰撞,此碰撞点,即是环的入口。
证明:

假设环外长度为a,环内长度为b,链表的总长度为a+b。我们假定经过x步后,发生碰撞。这里我们主要证明从碰撞点x前进a步即为入口点,因为从链表头经过a步到入口点是显然的。我们可以将具有环的链表想象成小写的字母 'b',定义第i步访问节点用S(i)表示,则

S(i) = i ; i<a;

S(i) = a+(i-a)%b;i >=a

则有S(x)=S(2X)(这里速度分别为1,2),根据环的周期性有
2x=tb+x ;( t为整数)
->x= tb;
又因为碰撞发生在环内,固有x>=a连接点为从起点走a步,即S(a)
S(a)=S(tb+a)=S(x+a); 因此得证:从碰撞点x前进a步即为入口点。
list_node* find_list_loop_entry(){list_node *p=head;list_node *q=head;while(q&&q->next){p=p->next;q=q->next->next;if(p==q) break;}if(!fase || !(fase->next))return NULL;p = head;while(p!=q){p=p->next;q=q->next;}return p;}

问题5:在已知链表有环的情况下,如何计算环的长度?

解法一:

我们可以从碰撞点开始,定义指针s ,让s指针从碰撞点p处单步前进,如果下个指针等于p就停止,否则,长度加1.

解法二:

        还可以继续让p,q分别以步长为1和2前进,下次碰撞时所经过的操作就是环的长度,因为q相比p每次前进1,等于说p转一圈时q已经转了两圈,p转一圈的长度就是环的长度。

问题6.判断两个单项无环链表是否相交?

解法一:

首先,想象下链表链表相交时的结构,是不是类似一个"人"字,也就是说,两个链表从相交处到结尾的结点
都是公用的。因此,我们分别遍历两条链表,判断其末尾的节点是否是同一个节点即可判断是否相交。

解法二:

另一种方法是将一条链表链接到另一个链表的末尾构成一个新的链表,来判断这条新的链表是否有环,如果有环那么显然是
相交的,否则不相交。而这个问题就转换为问题1判断链表是否有环。

问题7:去掉单向链表中的重复元素

解法一:

遍历链表,对于每个结点,在循环内部遍历其后续的所有结点,如果遇到与当前结点重复的元素,则删除该元素。对于长度为n的链表,需要的步n-1+n-2+...+1次操作,所以算法的复杂度为O(n^2).

解法二:

根据链表结点元素,建立一个hash table,然后遍历该链表,对于每个结点如果其对应的hash code存在于hash table,则删除该结点,否则将hash code加入到hash表中,

典型的以空间换时间的优化算法。

或许你会想到List容器中的unique方法,是的这个方法同该问题类似,只是删除的容器中相邻的重复元素,在调用unique方法时首先需要对容器元素进行sort,这样才能删除掉容器内的重复元素。

问题8:求取两个单项链表的交、并、差集。

假设有链表L1,L2,长度分别为m,n , 这里,分别用intersection(L1,L2),union(L1,L2),substract(L1,L2)来表示两个链表的交集、并集、差集,其结果存放在L3链表中。这里差集为L1-L2(即在L1中没在L2中的结点集)

解法一:

intersection :首先对L1遍历,对于L1中的每个结点遍历L2,如果在L2链表中找到该元素,则将其插入到L3中,
并将L2中的该元素删除掉。

union:分别遍历L1,L2链表的每个结点,如果结果链表中没有该结点,则将其插入到L3中。

substract : 对于首先将链表L1复制给L3,对于L2的每个结点k 遍历L3,如果k存在于L3,则从L3中删除k.

解法二:

 解法一中的方法足够直观,但效率很低,O(mn)的时间复杂度。同上个问题类似,这里我们同样可以使用hash table的方法来对算法进行优化。

union: 创建hash表,分别遍历两个链表L1,L2,将结点对应的hash code插入到hash表,插入的时候检测是否已经在hash表中,如果不存在,则同时将结点插入到结果链表L3,如果存在,则继续下个结点。

intersection:同样创建一个hash表,将L1加入到hash表中,对L2链表进行遍历,检测结点是否在hash表中,如果存在,则将其插入到结果链表L3中。

substract:将L2链表映射到hash表中,对于L1中的每个结点,判断结点hash code是否存在于L2中,不存在则将其加入到结果链表L3中。否则,跳过。

问题9:约瑟夫环问题

Josephus环问题描述的是:已知n个人(以编号1,2,3...n分别表示)围坐在一张圆桌周围。从编号为1的人开始报数,数到m的那个人出列;他的下一个人又从1开始报数,数到m的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。现在我们需要知道最后一个出列的人的序号或者出列的整个序列。

解法一:

  对已知的n个人建立一个循环链表,结点结构保存有结点的位序,定义cur,pre指针以及计数器i,分别指向当前结点和前驱结点,首先cur沿链表向后移动,并开始计数++i,当i==m时,将当前元素从链表中删除并重置计数器i, 同时输出移除的位序表示出列,否则,继续向后移动指针cur,pre,直到pre==cur时结束,这时cur指向的结点就是最后一个出列的。这个算法的时间复杂度为O(nm).

解法二:

问题相当于已知n个人(以编号0,1,2,3...n-1表示),从0开始报数,报到m-1的出列,剩下的继续从0开始报,直到最后一个退出。我们知道第一个出列人的编号为m%n-1,

剩下的n-1个人构成新的约瑟夫环,以m%n的编号开始:K,k+1,k+2,....0,1,2,3....k-2 ,并同时从k开始报0,这时对于新环其编号对应为 k->0,k+1->1,k+2->2 .... k-2->n-2,变换后

转换为n-1个人退出的子问题,假设在这个新环中最后退出的人编号为x,则x'=(x+k)%n正好就是n个人中最后退出的编号。这样我们就可以得到一个递推公式f,令f表示i个人

报数k退出最后胜利者的编号,则

f[1]=0 i =1;

f[i]=(f[i-1]+k)%i   i>1;

根据这个递推公式,我们很容易的能够求出n个人出圈时的f[n]值,即最后退出时的编号。

问题10:链表排序

解法一:

链表的排序和一般的数组类似,我们也可以用常见的排序方法来对其排序,比如冒泡,插入,选择,归并等等。这里使用选择排序对链表进行升序排序,对于链表L,遍历其中的每个结点,假设当前遍历的节点k,则遍历其后的所有结点找到最小的结点元素 m,然后将结点k,m进行交换。只是这里的交换操作可以选择是交换结点指针,还是交换结点值,
当然交换结点值更加容易实现,不过对于结点元素较多的链表交换值显然更加低效,交换结点指针可能要更好,但是,这样相对来说也更加复杂。至于如何选择,需要在这两点上进行折中。

简单的交换值的链表选择排序:
sort_list(struct list_node* head)
begin:
   cur=head->next; //第一个结点
   while(cur){
max = get_min_node(cur);//找到cur后最小的结点
swap(cur,max);//交换结点值
cur = cur->next; 
   };
end;

那么,如果我们选择要交换指针,要怎么做呢?下面我们基于一个带头结点的单向链表描述我们的思路:
我们知道,对于升序的排序来说,选择排序每趟都需要找到从某一元素(基准点)开始向后查找最小的元素,然后将其进行交换。那么在链表中,我们要完成这个操作,需要通过两个指针来记录基准点和最小结点,同时,由于交换的需要,我们还必须要保存其前驱结点的指针。所以维护好这些信息才是链表排序的关键。

//链表排序void sort_list(link_node* L){/*q,n分别指向基准点和最小结点,p,m分别为其前驱指针*/list_node *p,*q,*m,*n;list_node *t,*s; //用于扫描链表p = L;q = L->next;while(q){t=n=q;//最小结点初始为基准点s=m=p; while(t){//查找最小结点if(t->data < n->data){m=s;n=t;}s=s->next;t=t->next;}if(n!=q){ //最小结点不是基准点,需要交换p->next = n;p=n;m->next = q;m = q;q=q->next;n=n->next;p->next = q;m->next = n;}else{//调整基准点p=p->next;q=p->next;} }}
当然,基于类似的逻辑,我们还可以写出链表的插入排序算法。

解法二:

 在链表的排序中,我们可以使用归并排序来完成,这里我们可以参考下SGI STL list的sort实现方式,首先看看它是怎么对链表进行排序的。

template <class _Tp, class _Alloc> template <class _StrictWeakOrdering>void list<_Tp, _Alloc>::sort(_StrictWeakOrdering __comp){  // Do nothing if the list has length 0 or 1.  if (_M_node->_M_next != _M_node && _M_node->_M_next->_M_next != _M_node) {    list<_Tp, _Alloc> __carry;    list<_Tp, _Alloc> __counter[64];    int __fill = 0;    while (!empty()) {      __carry.splice(__carry.begin(), *this, begin());      int __i = 0;      while(__i < __fill && !__counter[__i].empty()) {        __counter[__i].merge(__carry, __comp);        __carry.swap(__counter[__i++]);      }      __carry.swap(__counter[__i]);               if (__i == __fill) ++__fill;    }     for (int __i = 1; __i < __fill; ++__i)       __counter[__i].merge(__counter[__i-1], __comp);    swap(__counter[__fill-1]);  }}

sort算法采用非递归的方式自底向上进行归并,其中__counter[64]用来模拟程序栈来完成排序操作,counter[i]保存2^i个元素的子串。因此可以排序的元素个数受限于2^0+2^1+

....2^63 = 2^64-1。算法每次从原list抽取一个元素放入carry中,随后检查counter[i]是否为空,如果为空,则将抽取出的元素放到counter[i]中,否则,需要和上层进行归并,直到遇到空的counter,并将已归并的元素放在其中。需要注意的是,counter.empty判断的是counter已经包含了2^i个元素的子串,这也决定了这样的组织方式,必已2的幂次方的数量进行逐层递推。也就是说,如果算法已经递推到第i层,那么第i层一定是2^i个元素,这为下次while循环内后面的累加提供了依据。看个例子,对序列<9 5 4 1 7 3 8 10>进行排序。

source list :9  5  4  1  7  3  8 10******************************source list:  5  4  1  7  3  8 10counter list array:counter[0]:  9************************************************************source list:  4  1  7  3  8 10counter list array:counter[0]:counter[1]:  5  9************************************************************source list:  1  7  3  8 10counter list array:counter[0]:  4counter[1]:  5  9************************************************************source list:  7  3  8 10counter list array:counter[0]:counter[1]:counter[2]:  1  4  5  9************************************************************source list:  3  8 10counter list array:counter[0]:  7counter[1]:counter[2]:  1  4  5  9************************************************************source list:  8 10counter list array:counter[0]:counter[1]:  3  7counter[2]:  1  4  5  9************************************************************source list: 10counter list array:counter[0]:  8counter[1]:  3  7counter[2]:  1  4  5  9************************************************************source list:counter list array:counter[0]:counter[1]:counter[2]:counter[3]:  1  3  4  5  7  8  9 10******************************result: 1  3  4  5  7  8  9 10
修改list sort,打印出递归的过程,每次外循环结束,打印出原list以及当前使用的counter,这是通过fill来标记的,这里需要注意的是splice和merge的方法。splice每次会从原list 头部抽取一个元素,merge方法将两个有序列表进行排序(结果也是有序的),merge之后,carray会被清空。





0 0
原创粉丝点击