定义一个函数,输入一个链表的头结点,反转该链表并输出反转后链表的头结点。
思考题:
- 请同时实现迭代版本和递归版本。
数据范围
链表长度 [0,30]。
样例
输入:1->2->3->4->5->NULL
输出:5->4->3->2->1->NULL
正好这个题目用于加深理解递归和迭代的含义与区别。
一、递归的写法
//递归的写法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
// 递归基:如果head为nullptr或者head->next为nullptr,直接返回head
if(head==nullptr||head->next==nullptr){
return head;
}
/*这里就是正常的逻辑,刚开始判断这个头指针是不是为空,为空就不行;还有是不是只有一个结点,如果只有一个结点,那也不需要做反转链表的操作,所以才会这么写,这就相当于这个递归的终止条件。*/
// 递归调用,返回反转后的链表头结点newHead
ListNode* newhead=reverseList(head->next);
/*这里就是让head指针不断前进,当指针为空时,就回溯来解决问题,这里就是这个意思。*/
// 将当前head的下一个节点的next指针指向head,然后将head的next指针设为nullptr,完成当前节点的反转
head->next->next=head;
head->next=nullptr;
/*这里的这个递归条件看着很简单,其实很难理解,这是回溯的结果,而且需要画图来结合分析,不然你是搞不清楚指针的变化的,图我会下面画的。*/
return newhead;// 返回新的头结点
/*输出的结果虽然也是值,本来这个newhead只是个指针,按道理来说返回的应该时地址值,可是这运行结果直接返回值序列,为什么呢?这是因为这个格式下的代码只要把运行的主体逻辑写好就是了,那些细枝末节的操作别人都做好了,就不用管了,这是我的理解*/
}
};
对这个代码的理解:观察题目可看到,已经为我们建立了头结点head,在链表里面,那个链表一般叫什么名字,那他的头指针就叫什么名字。这个head不仅是这个反转前链表的头结点,也是一个指向这个头结点的一个指针,这个head存储着这个头结点的地址,此时的head这个指针是指向这个头结点的,这是题目告诉我们的,我们就不需要额外的去管反转前头结点的事情了。
1、递归与迭代的区别:
我的理解就是,感觉迭代似乎更倾向于具体的操作的样子,而递归是更倾向于逻辑来写代码。
迭代通常更接近具体的操作。它涉及到明确的循环结构或者迭代过程,直接处理每一步操作,更像是一种线性的、直截了当的方式来解决问题。因为迭代往往需要处理每一步的细节,所以它更强调具体的操作步骤和实现细节。
递归则更倾向于逻辑的抽象和分解。递归算法通过将问题分解为更小的问题来解决,这需要设计出适当的递归调用和基本情况,以确保递归能够正确地终止。递归更侧重于逻辑的设计和分解问题的能力,因为它要求将问题抽象为一系列更简单的步骤或者子问题,然后通过逐层回溯来达到解决原始问题的目的。
因此,迭代在实现上可能更加直观和操作性强,而递归则更注重逻辑的清晰性和对问题结构的抽象能力。选择使用迭代还是递归通常取决于问题的性质、算法的效率要求以及程序员个人的偏好和经验。而且,因为递归和迭代都是反复执行操作,而两者又有点差别,递归通常不怎么用循环,因为他要自己调用自己;而迭代更多的是重复某个操作,循环那些是经常用的。这是这两个的小区别,但大体上逻辑都是不断重复相同的操作,可根据自己的需要来决定使用哪种方法。
好,上面是我自己的理解,接下来继续说上面递归代码的运行与为什么要这么写:
递归是更倾向于逻辑来写代码,所以我们写代码的时候不必过于在乎每一次是怎么运行的,我们只需要将逻辑理顺,怎么运行正确一次就可以了。所以我们就根据逻辑走,首先,题目虽然定义了head指针和反转前链表的头结点,但是并不知道是什么情况,只是定义了。所以我们要先判断,head指针是否为空指针,若为空指针,就说明是空结点,就没必要继续操作,直接返回head指针的值;还有一种情况,我们既然是要反转链表,那么肯定结点数要大于一个结点,所以就有第二个或者的判断:head->next==nullptr,若得出头结点next指针域的值为空,说明后面没有结点了,只有一个头结点,对于这种情况,也是不行的,所以也要返回head的指针值。这就是根据逻辑而得到的代码,而且这也正好是这个递归的结束条件,为后面的回溯做了铺垫。
好,继续来,直接看我上面图举的例子。对应代码来说的话,刚开始head指针是指向1这个结点的,我们的逻辑是:1->2->3->4->5->NULL我们要变成5->4->3->2->1->NULL的话,我们想知道5这个结点的地址值,好拿来当作反转链表的头结点,然后就能得到5->4->3->2->1->NULL这个样子的结果。所以就有这句话:ListNode* newhead=reverseList(head->next);这句话怎么理解呢,是这样的:我们不断的调用自己这个函数,让head指针不断的前进,当head->next指针指向空的时候,就返回了head指针,这时候head指针指向的是5这个结点。所以此时的newhead,也就是反转链表的第一个头结点,就是5这个结点,但是因为这反转链表现在只有一个头结点,不满足反转的条件,所以还得继续操作,继续回溯。 此时的head指针指向5这个结点,回溯就是返回上一级操作,上一级操作是head指针指向4,而head->next指针指向5这个结点,所以此时4这个结点的指针为head,而5这个结点的地址变为了head->next。我们的目的是让5这个结点指向4这个结点,以达到反转链表的效果,所以有head->next->next=head;这句代码,这是5这个结点指向4这个结点,就是4它的地址值赋给了指向5这个结点的指针,在链表里面,你要想让甲结点指向乙结点,那么就得把乙结点的地址值赋给指向甲结点的指针,这样就好了;也可以从左往右看,既然是5这个结点指向4这个结点,那么就是head->next->next=head;这句代码,表示5这个结点指向4这个结点,这样感觉更直观一些。好,那继续。5这个结点指向4这个结点后,4的后面是没有结点了,至少在这一轮的回溯加操作里面是没有了,所以有head->next=nullptr;这一句代码。好,到了这里,这个代码就解释完了,这就是递归的写法。
二、迭代的写法
//迭代的写法:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev=nullptr;// 用于存储已反转部分的链表头
/*创建反转链表的头结点,并且指针此时为nullptr,因为刚开始还没有结点,所以先初始化为nullptr*/
ListNode* curr=head; // 当前处理的节点,从头结点开始
/*这里是定义了一个可操作的指针,方标移动和进行后续操作,刚开始是指向反转前链表的头结点的,反转前链表的头结点地址为head,所以把head这个地址值赋值给curr指针,所以才使得curr指针刚开始就指向反转前链表的头结点*/
/*定义的前面这两个指针相当于是有用的,一个是用来构建反转链表的,一个是拿来操作的指针,一个是让他有,一个是初始化,不是在操作中所需要不停变换的语言,所以不放入迭代的循环体里面*/
while(curr!=nullptr){
ListNode* nextTemp=curr->next;// 暂存当前节点的下一个节点,防止丢失
/*每一次迭代的操作都会刚开始存curr->next的地址值,因为后面会操作,会导致数据被覆盖从而会导致错误,所以先存下来,这样后面要用的时候就不会出错了。*/
curr->next=prev; // 当前节点指向已反转的部分的头部
prev=curr;// prev向前移动一步,指向当前节点
curr=nextTemp; // curr向前移动一步,处理下一个节点
}
return prev; // prev现在是反转后链表的头结点
}
};
迭代也画图吧,图如下:
迭代不同于递归的是,迭代喜欢以操作为主,也就是直来直去的,再麻烦也要想办法弄。所以说某些程度上来说操作会比递归更繁琐具体,但是比递归更好理解。
这个迭代的给我的感觉就是,里面的操作会很繁杂,而且很多还涉及数据留存的问题,为了保证数据使用的正确性,还专门定义了其他的指针变量来存之前的地址值,比如上面代码里面的nextTemp,curr,prev这些指针要么用来存储之前的地址值拿来备用,要么就是操作,每次的操作都要用新定义的指针,尽量不用之前的,按照上面代码来说的话,就是head指针再操作里面几乎没有,尽量不动用他,视为一个参数就好。
while(curr!=nullptr){
ListNode* nextTemp=curr->next;// 暂存当前节点的下一个节点,防止丢失
/*每一次迭代的操作都会刚开始存curr->next的地址值,因为后面会操作,会导致数据被覆盖从而会导致错误,所以先存下来,这样后面要用的时候就不会出错了。*/
curr->next=prev; // 当前节点指向已反转的部分的头部
prev=curr;// prev向前移动一步,指向当前节点
curr=nextTemp; // curr向前移动一步,处理下一个节点
}
这部分代码是迭代的重要部分,是迭代的循环体,这得单独讲讲。
ListNode* nextTemp=curr->next;这一句已经说了,接着下一句。
我们想反转链表,我们要做什么呢?我们先存下了curr->next的地址值,接下来,就让2这个结点指向反转链表这个空链表。而反转链表的头结点我们已经定义了,且指向这个反转链表头结点的是指针prev,所以我们要实现图中圆圈1的操作,即:让2这个结点指向prev,那就是把prev的地址值赋值给curr->next,所以才会有 curr->next=prev;这句代码。 然后此时反转链表已经有了一个头结点2了,那么按照题目的结果示例,肯定2这个结点要指向1结点的。因为我们最后我们要的是反转链表,所以在此时,就要在这个反转链表中看了,以反转链表为主,其他的操作为辅。我要在反转链表中让2结点指向1结点,那我就要知道这两者的地址值,此时2这个结点的地址值就是prev,1结点的地址值就要在前面没有反转之前的链表中找了,找到了,得到1结点的地址为curr,所以就有prev=curr;这一句代码就表示2这个结点指向1结点。从右往左去理解的话就是:因为是2结点指向1结点,所以就要反过来,把1结点的地址值赋值给2结点,所以就有了那一句代码;如果是从左往右的理解的话:那就是2结点指向1结点,2结点的地址从左,给到右的1结点的地址就行了。其实这两种理解方法和上面递归里面说的是差不多的。 好最后一句代码:curr=nextTemp;表示,我进行完了一次操作,那么就该让操作指针curr往前移动一位,所以就把上一次nextTemp的地址值赋值给curr指针,表示继续向前操作。这就是迭代的循环体操作,这是我把他结合实例和代码理解的,是自己的理解,可能会有错的。
ok,解决。