反转链表
该问题是“给定链表(表头节点),将链表逆序,要求原地完成(由原节点构成)且只占常数额外空间”。先考虑递归程序,思路是递归进入到链表尾,然后在每次递归退出时,让外层递归把当前节点原后继的next反过来指向当前节点。需注意最后一次递归结束后,其当前节点的next没有发生变化,所以要特别的将其指向空,那为了方便干脆在每轮递归结束前都加一步把当前节点的next指向空。然后是如何记录新表头,上述递归没有用到函数返回值,可以一层层返回新表头。
然后考虑只遍历一次的非递归程序,如上图所示,先把链表分为“已反转子链表L_1”和“未反转子链表L_2”,并要求每次迭代后,L_1,L_2的每个节点的next都符合已反转子链表、未反转子链表的意义的。表示L_1,L_2表头的变量h_1,h_2在每次迭代后都是符合定义的。迭代开始前h_1为空,h2_2为原链表表头。然后执行如下迭代步骤:
1) 建立临时变量tmp指向h_2.next。tmp即将作为新h_2;
2) 将h_2.next指向h_1。h_2即将作为新h_1;
3) 把h_1指向h_2,再把h_2指向tmp。当前迭代的工作已做完,这步是为下轮迭代提供正确的h_1,h_2;
4) 当前h_2为空则程序结束,当前h_1就是新的表头,否则继续返回(1)迭代;
逆序存储链表
该问题是“给定链表(表头),将其按遍历逆序存入数组”。该问题很简单,如果用递归遍历,就不用反转链表或数组。
按大小划分链表
该问题是“给定链表和x,使所有小于x的节点在不小于x的节点前,且两部分链表节点各保持原有顺序,要求原地完成(由原节点构成)且只占常数额外空间”。这个问题就是稳定的快速排序算法在每次迭代时要解决的问题。这里的思路是想办法按小于x和大于等于x分割成2个链表,再把两个链表接起来。具体就是先遍历原链表L_1,遇到小于x的元素不操作,遇到大于等于x的元素就将其从L_1取下接到L_2尾部。遍历结束后再将L_1表尾指向L_2表头即可。
这个问题的逻辑本身简单,但用程序实现时要考虑的边界比较多,这也是链表类问题的特点。比如删L_1元素的迭代过程要记录待删除节点的前驱才能实现,且还要考虑到表头被删的情况。对于L_2则要记得把最终表尾的next指向空,且还要考虑到初始时L_2是空的。为了处理这些情况,可以在原表头前加一个自己创建的节点作为新表头,最后记得还原回去即可。这样的好处是“删除表头”、“表头为空”等情况好处理,都可借助自己创建的节点的next完成。
合并排序链表
该问题是“给定A,B两个升序链表(表头),将其合并为一个,要求原地完成(用原来的节点)且只占常数的额外空间”。先创建存储结果的空链表L,使用上面提到的技巧,先自己创建一个节点作为其表头。然后跟合并排序数组的思路一样,每轮比较A,B最左端两个数,取下其中最小的节点追加到L尾部,直到有一个链表为空,把另一个链表追加到L的尾部即可。最后L的表头(自己创建的节点的后继节点)就是结果链表的表头。具体实现如下。
链表加法
该问题是“给定A,B链表(表头),两个链表分别表示两个整数,其中链表元素是0~9的1位十进制数,链表元素的次序表示从低位到高位,比如3=>2=>1表示123。要求输出同样格式的链表能表示上述两数之和”。这个问题的关键点是进位以及进位导致的边界情况的处理,由于每次的位相加最大只能得到9+9+进位=19,所以只需一个额外变量描述上次是否进位即可。当A,B都遍历完时,仍有可能还有1个之前的进位,需要为其再创建一个节点。
两两交换链表节点
该问题是“给定链表(表头),从左到右两两交换节点位置,剩一个元素时无需交换。比如1-2-3-4-5变为2-1-4-3-5”。这个问题的关键是区分边界,因为表头会改变,所以这里先创建一个节点N,把原表头接到其后面。想要实现交换2个节点的位置,那这2个节点必须要能表示成“后继”和“后继的后继”。这里给出具体迭代步骤:
1) 初始化h = N;
2) 若h.next或h.next.next不存在,则h之后的部分无需再交换,程序结束返回N.next;
3) 交换h.next, h.next.next的位置并维护相关指针的正确性;
4) h = h.next.next然后返回步骤(2);
上述步骤中每一轮都是利用x来交换x.next,x.next.next,一轮结束后x前进两步,就又能交换后面另外两个节点,当某一轮的x发现后不到2个节点时,就说明无需继续交换,程序退出。
奇偶链表
该问题是“给定链表(表头),其节点是从1开始编号的,整理链表使奇数编号节点都在偶数编号节点前,并维持原顺序。要求原地完成(用原来的节点)并只占常数额外空间,比如输入a-b-c-d-e输出a-c-e-b-d”。这个问题的解决框架和上述“两两交换链表节点”类似,每次迭代都以“当前节点、当前节点后继、当前节点后继的后继”3个为一组操作,每次都把奇数节点取下接入“奇数链表”,最后当前链表会是“偶数链表”,合并两链表即可。程序实现如下,这里还是用创建表头的技巧处理边界。
倒数第n个链表节点
该问题是“给定链表(表头),求其倒数第n个节点(从1开始计数)”。这个问题容易想到的方法是先遍历一次求出节点数,换算出其正序编号,然后遍历第二次找到这个节点。这里看一种只遍历一次的方法,首先设两个变量x,y指向表头,然后开始迭代,每次迭代结束做x = x.next。当第n次迭代结束时后,之后每次迭代结束还要额外做y = y.next。最终当x到达表尾程序结束时,y指向的就是倒数第n个节点。由于用了2个变量(指针),所以这种方法也叫“双指针法”。具体实现如下。
排序链表的中位数
该问题是“给定升序排序的链表(表头),求其中位数节点,当节点数为偶数,则返回中间两个节点靠左的”。这里使用另一种基于2个指针的策略,称为“快慢指针法”。同样是先用两个变量x,y指向表头,然后开始迭代,每轮x前进1个节点,y前进2个节点。当y到达链表尾时,x就停在中位数节点上。实现时要处理好y前进2次导致的边界情况,具体步骤如下:
1) 初始时x,y都指向表头;
2) y前进一步,如果失败(y.next指向空)则程序结束返回x;
3) y前进一步,如果失败(y.next指向空)则程序结束返回x;
4) x前进一步,然后回到(2)。如果能到达这一步,那这一步不可能失败;
只要链表有至少1个节点,该步骤就能找到题意的节点。节点数为奇数时程序在(2)结束,偶数时在(3)结束。
判断链表环
该问题是“给定链表(表头),其允许节点的next指向比其遍历序更前的节点,这时会形成环路,请判断该链表是否有环”。容易想到的方法是遍历时用额外数组记录已访问节点,每次遍历都去这个数组中检查,其代价是O(n^2)并占用O(n)的额外空间,这其实就是深度优先搜索。这里用“快慢指针法”实现更优的程序,先看上面“排序链表的中位数”程序,如果某轮迭代退出则直接说明链表无环,有环的话迭代永远不会结束。如果一轮迭代没退出,那么y前进2次,x前进1次,所以当有环时,无论环多大,一定在某轮迭代结束时x,y指向相同元素,那么在每轮迭代结束时加一步检查x,y,相同则判定有环并返回。
简单讨论下时间复杂度,首先可以确定x在环上至多绕一圈,因为x如果在环上,y必然也在环上,如果x绕了一圈程序还未退出,那么y就绕了2圈,y一定会在某个节点与x相遇。这说明x至多走O(n)步,程序整体代价为O(n)。
链表环的入口
该问题是“给定链表(表头),判断其是否有环并找到环入口(顺序遍历时最先到达的环节点)”。这里以上面的“判断链表环”的程序为基础来讨论,设从表头出发走s\geq 0步能到环入口,从环入口出发走k\geq 1步能到其自身(也是环节点数)。
为讨论s与k的倍数的关系,将s表示为s = nk + m,0\leq m<k与n\geq 0为整数。当x走到环入口时y多走nk+m步,y的当前位置可看作其从环入口出发走了m步,此时y继续走k-m步可达x,但在程序里y是跟着x走的,那就让x走k-m步,y会多走k-m步刚好能追上x相遇,这说明从相遇点出发走m步可到达环入口,根据s = nk + m,这意味着相遇后,从相遇点和表头出发2个指针,每次都只走1步,经过nk + m步两指针会在环入口相遇。由于nk + m是未知的,在程序中两个指针每走一次就互相看一下是否相等即可。具体程序如下,这里直接使用一部分上面的函数。