小肥柴慢慢手写数据结构(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)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值