小肥柴慢慢手写数据结构(C篇)(2-2 单链表 SingleLinkedList self版实现(2)--链表反转与head/tail讨论
目录
2-5 啥是链表反转?
如图,反转就是把原来的链表逆序重新组装起来。建议学习时请遵循:“先画图,弄清步骤再写代码”的原则,一定不能死背代码,要靠自己把反转逻辑推导出来。
2-6 常见的反转四种方法
2-6-1 迭代反转
画图,考虑仅完成部分节点反转的状态:
设当前需要反转的节点为Curr,考虑以下逻辑关系:
(1)反转后Curr->Next应该指向上一个节点,需要维护变量Prev用于保存上一个节点;
(2)我们的目标是把Curr作为游标遍历下去完成整个链表的反转,在(1)中又要求变更Curr->Next的指向,那么只能提前保存Next,需要维护一个暂存变量TmpCell;
(3)Curr节点反转后循环还要继续,需将当前Curr设置为下次遍历的Prev,并用(2)中保存的Next更新Curr;
(4)循环结束,Curr=NULL,此时Prev是原链表最后一个节点,同时也是反转后新链表的第一个节点,所以DummyHadd->Next=Prev。
把以上几点逻辑理顺了,画图推导一遍,就能写出代码了:
List IterationReverse(List L){
if(L == NULL || IsEmpty(L)) //空指针或者链表本身无内容,不必反转,原样返回
return L;
Position Curr = L->Next; //从First节点开始进行遍历,别忘了我们还有一个虚拟头结点哦
Position Prev = NULL; //First节点反转后,Next指向NULL才符合设计,那么Prev初值就应该是NULL
Position TmpCell; //暂存Curr->Next用
while(Curr != NULL){
TmpCell = Curr->Next; //先保存下一个节点指向
Curr->Next = Prev; //反转Curr
Prev = Curr; //更新Prev为当前节点
Curr = TmpCell; //更新Curr为下一个节点
}
L->Next = Prev; //因为有虚拟头结点
return L;
}
注释很清晰,如果有朋友还是不能理解,建议把整个反转操作执行过程想象成你在玩积木铅笔(链表):铅笔尖头方向就是原来链表的NULL方向,也是Next方向,而你正在手动挨个拆卸平头一边的笔头(节点),并反向串成一支积木铅笔(某宝图,侵删)。
review这段代码,似乎能够抓住反转链表规律:
(1)提前保存下一个节点,因为要遍历下去;
(2)反转当前节点后,要更新状态;
(3)反转前后,首尾节点角色的转变要分析清楚。
有了这个感觉,下面套路其他反转策略就很容易理解了。
2-6-2 头插反转
该方法是最容易理解的:把铅笔头(节点)挨个复制,然后头插串联。
List HeadReverse(List L){
if(L == NULL || IsEmpty(L))
return L;
List list = createList();
Position Curr = L->Next;
Position TmpCell;
while(Curr != NULL){
TmpCell = Curr->Next; //同样需要保存下一个节点指向
Curr->Next = list->Next;
list->Next = Curr;
Curr = TmpCell;
}
return list;
}
当然可以更进一步,从原始list中删除First节点,然后头插到新list中,如图(本质上属于设计问题),此时正好借用虚拟头结点:
List HeadReverse2(List L){
if(L == NULL || IsEmpty(L))
return L;
Position Curr = L->Next, Prev = L, TmpCell;
while(Curr->Next != NULL){
TmpCell = Curr->Next;
Curr->Next = TmpCell->Next;
TmpCell->Next = Prev->Next;
Prev->Next = TmpCell;
}
return L;
}
在自力更生实现遍历反转和头插反转两种方法后,我们差不多已经掌握链表反转基本操作了,但如果仔细思考,不难发现现有头插法存的问题:需要重新生成一个虚拟头结点来串联反转!设想设计一种方法,直接用原始链表的DummyHead就能实现反转,请往下看。
2-6-3 原地反转
(1)显然原链表第一个节点A1是反转后的最后一个节点,标记为Last;
(2)每次被反转节点Curr实际上是第二个节点,也就是当前Last指向的Next;
(3)反转节点头插入当前list;
(4)更新Curr游标,遍历下去。
List LocalReverse(List L){
if(L == NULL || IsEmpty(L))
return L;
Position Curr = L->Next->Next; //从第二个节点开始反转
Position Last = L->Next; //原链表第一个节点其实就是反转后新链表最后一个节点
while(Curr != NULL){
Last->Next = Curr->Next; //摘除当前需要反转的节点
Curr->Next = L->Next; //下面两句是经典头插法
L->Next = Curr;
Curr = Last->Next; //更新游标,继续遍历
}
return L;
}
2-6-4 递归反转
借助递归调用栈实现反转,该方法相比前三种反转方式要抽象许多:
(1)递归的本质是将问题的处理延后,先求解问题的最小/最简形式(即递归出口),然后反向解出问题。对于链表而言,递归出口不就是只有一个节点,或者当前处理最后一个节点的情况吗?
(2)递归的一般规律(即中间状态)比较难空想出来,请看下图:
1)递归的出口是原链表最后一个节点从递归中返回,那么已反转的部分必然在当前反转节点Curr的后方,且有一个从递归函数得到的表头newFirst;
2)经过上一轮反转后,已经反转部分的尾结点其实就是Curr的Next指向,且这个节点的Next就是NULL(尾结点的标识:Curr->Next=NULL!);
3)接下来应该反转Curr与Curr->Next,且反转后Curr称为已经反转部分的尾结点,所以需要将Curr->Next置为NULL;
4)本轮递归函数执行完毕,出栈,返回已反转部分的头结点newFirst,进入前一个压栈的rev()函数中继续执行。
基本上很多人第一次学习递归反转,都会有些困惑,我也是按照参考文档[2]的描述,一步步画图求证才彻底弄清执行细节的,用以下形式的原始链表开始模拟递归反转过程(rev()代表反转递归函数):
设想递归到,则链表现状和压栈情况如下图:
<1> rev(4)出栈,链表状态没有变化
<2> rev(3)出栈,开始变化
<3> rev(2)出栈
<4> rev(1)出栈
考虑到虚拟头结点碍事,需单独处理。核心递归函数如下
Position doRecRev(List L){
Position Curr = L;
if(L == NULL || L->Next == NULL)
return L;
List newFirst = doRecRev(L->Next); //下一个节点递归反转
Curr->Next->Next = Curr; //处理相邻两个节点的反转
Curr->Next = NULL; //当前节点本质上就是尾插
return newFirst;
}
头结点处理,实际用于调用的函数
List RecursiveReverse(List L){
Position First = doRecRev(L->Next);
L->Next = First;
return L;
}
将以上4种反转函数贴在.h和.c文件中,Main.c里添加如下测试代码:
printf("\n===============test iteration reverse :==================\n");
printf("org: ");
for(i = 0; i < 10; i++)
InsertLast(i, list);
PrintList(list);
printf("rev: ");
list = IterationReverse(list);
PrintList(list);
printf("\n===============test head reverse :=======================\n");
list = HeadReverse(list);
PrintList(list);
printf("\n===============test local reverse :======================\n");
list = LocalReverse(list);
PrintList(list);
printf("\n===============test recursive reverse :==================\n");
list = RecursiveReverse(list);
PrintList(list);
注:可以先去刷 LeeCode [剑指 Offer 24. 反转链表] 和 [206 反转链表]
2-7 升级反转问题讨论
2-7-1 反转链表前 N 个节点
问题:实现一个函数reversN(List L, int n),1<=n<M,M为链表节点总长,使得链表L的前n个节点反转,参考下图
这个问题不难,可以用递归和迭代两种方式实现,本质是灵活应用基本反转方法。
(1)朴素的迭代法
List reversN1(List L, int n){
Position TmpCell;
Position End = L->Next; //反转后,原链表的头结点就是新反转部分的尾结点
Position Curr = L->Next;
Position Prev = NULL;
int i = 0;
while(i < n){
TmpCell = Curr->Next;
Curr->Next = Prev;
Prev = Curr;
Curr = TmpCell;
i++;
}
End->Next = Curr; //在最后一轮循环中,Curr已经变成不反转部分的首节点,即第n+1个节点
L->Next = Prev; //注意,此时Prev是反转部分的第1个节点!
return L;
}
(2)花哨的递归法(参考文档[3])
再次审视全链表反转实现
List RecursiveReverse(List L){
Position First = doRecRev(L->Next);
L->Next = First;
return L;
}
Position doRecRev(Position Curr){
if(Curr == NULL || Curr->Next == NULL)
return Curr;
Position revFirst = doRecRev(Curr->Next);
Curr->Next->Next = Curr;
Curr->Next = NULL;
return revFirst;
}
以上代码中,原链表的第一个节点(First=L-Next)会转化为反转链表的最后一个节点,所以无需记录。但在反转前n个节点的问题中,原链表First节点就不能这样简单处理了,因为还需要把不反转部分的第一个节点(即第n+1个节点),接到原链表节点First后面!
因此
(1)需要在反转过程中保存第n+1号节点,设置一个变量NextPosition去保存;
(2)递归变量就是n,且每次用n-1继续递归,n=1就是递归出口。
修改下原来的代码:
Position NextPosition;
List reversN2(List L, int n){
NextPosition = NULL; //纯C,简单的设置一个全局变量,用来解决反转部分重新连接非反转部分的问题
Position First = doRecRev2(L->Next, n);
L->Next = First;
return L;
}
Position doRecRev2(Position Curr, int n){
if(n == 1 || Curr == NULL || Curr->Next == NULL){
NextPosition = Curr->Next;
return Curr;
}
Position revFirst = doRecRev2(Curr->Next, n-1);
Curr->Next->Next = Curr;
Curr->Next = NextPosition; //回忆起之前话的图,这里仅仅是NULL被替换成应该指向的第n+1号节点
return revFirst;
}
如果能够轻松理解上面这段代码,说明你对递归反转已经完全吃透了,接下来增加问题复杂程度(接下来的讨论默认输入参数合法)。
2-7-2 反转链表的一部分([92 反转链表II])
问题:给一个索引区间 [m,n](索引从 1 开始),用reverseRange(List L, int m, int n)反转区间中的链表元素,照例分别使用递归和非递归两种方式实现。
(1)迭代法
List reverseRange1(List L, int m, int n){ //写得比较丑
Position Curr = L->Next, Prev = NULL, TmpCell; //反转用标记
Position Before, Start; //前后连接位置标记
int i = 1;
while(i < m){ //还没开始反转,找到第m-1个节点(连接点)和第m个节点(反转开始节点)
Before = Curr;
Curr = Curr->Next;
i++;
}
Start = Curr; //Start标记的节点(第m个)在反转后称为反转部分最后一个节点
while(i <= n){ //反转第m~第n节点
TmpCell = Curr->Next;
Curr->Next = Prev;
Prev = Curr;
Curr = TmpCell;
i++;
}
Before->Next = Prev; //连接m-1和反转后First节点(即原链表n-1号节点)
Start->Next = Curr; //连接n-1和反转后Last节点(即原链表m号节点)
return L;
}
在官方题解和最佳题解中,发现咱们的代码有很多改进空间的,例如:
1)计数器i可以不用正着数,用m - -和n - -可以使代码更清晰;
2)判断条件还是要加上的,不然有特殊的测试用例不能通过(OJ就是这样,哎);
3)如果n正好就是链表长度,那么也需要做特殊处理;
且看完官方题解后,标记Start改为after易读性更好,贴上我的题解(注意:这里没有DummyHead了, 函数名改为了reverseBetween)
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* reverseBetween(struct ListNode* head, int m, int n){
if(head == NULL || head->next == NULL || m >= n)
return head;
struct ListNode *curr = head, *prev = NULL;
while(m > 1){
prev = curr;
curr = curr->next;
m--;
n--;
}
struct ListNode *before = prev, *tail = curr, *third = NULL;
while(n > 0){
third = curr->next;
curr->next = prev;
prev = curr;
curr = third;
n--;
}
if(before != NULL)
before->next = prev;
else
head = prev;
tail->next = curr;
return head;
}
(2)递归法(参考文档[3])
区间递归,应该在反转n个节点的递归策略基础上推导得出:
1)当m=1时,可直接套用doRecRev2(List, int),这是递归出口;
2)当m>1时。将当前处理的节点Curr的下一个节点Curr-Next索引视为1,则反转区间从第m-1个节点开始;同理对Curr->Next->Next将其索引视为1,则反转区间从第m-2个节点开始…不断套娃,对仿照doRecRev2(),将递归结果赋给Curr->Next,自然有:
Curr->Next = doRevRange2(Curr->Next, m - 1, n - 1);
3)递归结束,返回Curr。
List reverseRange2(List L, int m, int n){
Position First = doRevRange2(L->Next, m, n);
L->Next = First;
return L;
}
Position doRevRange2(Position Curr, int m, int n){
NextPosition = NULL;
if(m == 1)
return doRecRev2(Curr, n);
Curr->Next = doRevRange2(Curr->Next, m - 1, n - 1);
return Curr;
}
附上labuladong(知乎账号)的总结
2-7-3 k个一组反转链表(LeeCode [25. K 个一组翻转链表])
(1)迭代法(参考文档[5]和官方题解)
1) 有很多方式去迭代,最简单的就是先统计链表节点个数length,然后分组length/k,按组解决反转问题,需要注意每个组反转结束后首位链接,上代码:
List reverseKGroup1(List L, int k){
if(L == NULL || L->Next == NULL || k == 1)
return L;
Position Curr = L->Next, TmpCell = L->Next, Prev = L;
int len = 0, i, j, groupNum = 0;
while(TmpCell != NULL){
TmpCell = TmpCell->Next;
len++;
}
groupNum = len/k;
for(i = 0; i < groupNum; i++){ //解决了小于k长度的段无需反转的问题
for(j = 0; j < k - 1; j++){ //原地头插反转
TmpCell = Curr->Next;
Curr->Next = TmpCell->Next;
TmpCell->Next = Prev->Next;
Prev->Next = TmpCell;
}
Prev = Curr;
Curr = Prev->Next;
}
return L;
}
上面这种解法巧妙利用了虚拟头结点+类似localrev的头插法完成收尾相连的工作,但为求长度提前变量了一遍链表,没有实现“仅变量一趟”的要求,其实是存在遗憾的,可以尝试以下改动,省去为求长度做的一次全链表遍历(参考summer
的回答):
List reverseKGroup2(List L, int k){
if(L == NULL || L->Next == NULL || k == 1)
return L;
Position Curr = L->Next, TmpCell, Prev = L;
int i;
for(i = 1; Curr != NULL; i++){
if(i%k == 0){ //到了反转节点才开始做反转,不用先读取一遍链表长度了!
Prev = revList(Prev, Curr->Next); //在(Prev,Curr->Next)开区间反转
Curr = Prev->Next;
} else
Curr = Curr->Next;
}
return L;
}
Position revList(Position Prev, Position End){
Position Curr = Prev->Next, Tail = Curr->Next;
while(Tail != End){ //还是原地头插,在参考文档[3]和[4]中有提到
Curr->Next = Tail->Next;
Tail->Next = Prev->Next;
Prev->Next = Tail;
Tail = Curr->Next;
}
return Curr;
}
参考文档[6]中给出了另一种形式的优雅的实现,同以上两种解法类似;官方题解中,有关第一段反转和最后一段不足k个的反转判断有些繁琐,只是参考答案嘛,说不定过段时间还有更厉害的作答呢!所以LeeCode还真是常刷常新。
同时可以联想:如果不考虑兼容有DummyHead/无DummyHead,其实在完成“前N个反转”和“区间反转”两个题目,是可以直接考虑使用原地头插反转的!所以之前的代码还有别的解法,闲下来的时候可以再考虑优化。
(2)递归法(参考文档[4]和LeeCode官方文档)
1)首先回忆下如何反转一个链表
Position revIn2Pisiton(Position Start){
Position Prev = NULL, Curr = Start, TmpCell = NULL;
while(Curr != NULL){
TmpCell = Curr->Next;
Curr->Next = Prev;
Prev = Curr;
Curr = TmpCell;
}
return Prev;
}
2)在此基础上修改下参数,得到入参为两个节点的区间反转,此处可以看到:
a. 迭代反转其实就是End=NULL的特殊情况
b. 提示我们也可以先通过m、n获得两个节点,变相解决区间[m, n]内反转的问题
Position revIn2Pisiton(Position Start, Position End){
Position Prev = NULL, Curr = Start, TmpCell = NULL;
while(Curr != End){
TmpCell = Curr->Next;
Curr->Next = Prev;
Prev = Curr;
Curr = TmpCell;
}
return Prev;
}
3)最后带入递归
List reverseKGroup3(List L, int k){
if(L == NULL || L->Next == NULL || k == 1)
return L;
Position First = doRevKGroup3(L->Next, k);
L->Next = First;
return L;
}
List doRevKGroup3(List L, int k){
Position Start = L, End = L;
int i;
for(i = 0; i < k; i++){
if(End == NULL)
return L;
End = End->Next;
}
List newList = revIn2Pisiton(Start, End);
Start->Next = doRevKGroup3(End, k);
return newList;
}
至此,链表反转问题基本上都能覆盖到了。
2-8 head与tail的讨论
很多单链表实现的范例中并没有使用虚拟头结点DummyHead,而是标记了head和tail,通过维护这两个变量简化头插/尾插操作。注意:这里是“标记”,说明并没有将头结点和尾结点单独列为一个节点,只是记号而已,如下图:
在我看来这仅仅是设计问题,如果使用head和tail而放弃DummyHead(甚至用新的struct包裹head和tail),则需在createList( )等一系列函数中维护tail,同使用虚拟头结点相比工作量没有明显差异(此消彼长);且并没有证据表明哪一种设计方式更优秀,应该根据应用场景选择更合适的设计方式,在非追求性能极限的条件下尽量让代码读得懂;所以还是那句话:核心算法/思想/策略才是最重要的,回想起来严版《数据结构》才会把每个实现的函数都称为算法x-x,不是吗?
2-9 总结与反思
(1)掌握四种常见的反转方式是基础;升级版本反转问题的讨论很锻炼人。
(2)数据结构与算法的学习应该相辅相成,遇到问题顺其自然解决即可。
(3)没事多刷刷LeeCode,多看看别人的实现,可以使人谦逊,可以把代码写得更好看。
PS:这一帖反复核对修改多天,依旧可能存在不足,望看官斧正;感觉到如果自己学生时代能像今天这般认真的学习,可能现在会是另一番景象,惭愧。
下一贴,我们将要讨论环形单链表和经典的“快慢指针”问题,目标就是尽可能的把黑皮书中描述的场景都覆盖,同样会尝试把面试中经典的问题展示出来,对于刷题LeeCode,还是摸着大佬过河吧。
参考:
[1] 单链表反转详解(4种算法实现)
[2] 一文读懂链表反转(迭代法和递归法). (对[1]的补充,添加了压栈动画)
[3] 如何递归反转链表. (知乎专栏,不错哦)
[4] 递归思维:k个一组反转链表. (同[3]作者)
[5] leetcode刷题(25)——k个结点一组反转单链表
[6] [LeetCode]k个一组翻转链表(Reverse Nodes in k-Group)