链表的秘密(2)第三章
链表专题目录
第一章 链表和链表的分类
第二章 单链表的实现
第三章 链表OJ题
第四章 双向带头循环链表的实现
第五章 总结和对比链表和顺序表
下面是用OJ题对我们学习的内容进行巩固和更深入地去理解链表,总共12个基础题目,链表还有更多的问题需要我们以后学了更深入的知识才能解决。即使是12个基础题,也不是投怀送抱的送温暖的题目,其中第7到11题是拔高题,有一定难度。但要学好链表,也需要掌握。
文章目录
1.删除链表中等于给定值 val 的所有结点。
OJ链接
题目的意思很简单,结合测试用例我们可以理解,返回删除后的链表头结点即可。如果没有元素或是全部删完了就返回空。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode LTNode;
struct ListNode* removeElements(struct ListNode* head, int val) {
//创建新链表的头和尾
LTNode* newhead,*newtail;
newhead=NULL;
newtail=NULL;
LTNode* pcur=head;
while(pcur)
{
if(pcur->val!=val)
{
if(newhead==NULL)
newhead=newtail=pcur;
else
{newtail->next=pcur;
newtail=newtail->next;}
}
pcur=pcur->next;
}
if(newtail)
newtail->next=NULL;
return newhead;
}
结合我们上一次学到的链表的节点删除操作,这题就是一个实际的应用。遇到要删的节点的时候执行删除操作。不过是引入newhead和newtail节点,这是为了对链表进行操作。最后newtail后面跟上一个下一个节点置空的尾巴即可,下面有一个动画帮助大家理解上面的代码:
画的有点拙劣和简单还望大家多多包容(* ̄︶ ̄)
其实代码的意思就是if中套个if刚开始newhead为空的时候,把newhead,newtail定到头结点上,再让pcur一步步往下遍历,如果遇到val节点就跳到下一个节点,同时newtail改变原链表节点的指向,否则就让newtail跟着pcur就好。到最后,返回newhead头结点就好。
2.翻转链表
OJ链接
意思浅显易懂,题目又让人有一种熟悉之感。我们在学习数组的时候是不是也做过类似题目呢?死去的记忆又燃了起来,无论是逆置还是字符串逆序。我们当时采用了一种办法叫做左右指针法,内容交换往中靠拢,结束条件是left>right。那现在在链表的场景中我们要如何解决这样的问题呢?
所以我们引出一种新的解题方法,三指针翻转法来搞这个题目:
先看过程:
然后,我再补充一些细节的讲解,用三指针的好处是有益于我们记住前后的节点,否则我们翻转链表改变节点指向后,就会找不到原来节点的next节点,循环结束的条件是判断n3是否为空,其次是n2,是否为空,都为空的时候,新的链表也就形成了。(中间补充了一个链表本身有的尾空指针NULL)
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* middleNode(struct ListNode* head) {
struct ListNode *slow, *fast;
slow = fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
typedef struct ListNode LTNode;
struct ListNode* reverseList(struct ListNode* head) {
if (head == NULL)
return head;
// 可以不额外创建pcur
LTNode *n1, *n2, *n3;
n1 = NULL;
n2 = head;
n3 = head->next;
while (n2) {
n2->next = n1;
n1 = n2;
n2 = n3;
if (n3) {
n3 = n3->next;
}
}
return n1;
}
还有就是如果链表为空就不用翻转了,在最前面加一个判断直接返回NULL就可以了。
3.链表的中间节点
OJ链接
找中间节点,问题的关键其实就是怎么找?人看链表一目了然,但是计算机怎会知道何时应该停下呢?哪个才是要找的中间节点呢?
所以我们不仅需要一个指针指向我们要找的中间节点到时候返回,还要一个指针去判断我们何时找到,何时结束返回。于是这是,我们仍然要请出我们的双指针法出山了。
这个题目给大家演示奇数个节点和偶数个节点两种情况:
1.奇数个链表节点数:
发现了吗,结束条件是fast->next等于NULL
2.偶数个链表节点数:
发现了吗,这个时候是fast==NULL的时候结束
所以说,找中间节点的终止条件应该是(fast!==NULL&&fast->next!=NULL)
但是,如果倒过来,会出现问题。对比第二种情况,是先判断fast不为空的前提下,结束判断跳出循环,这是C语言阶段我们学过的短路性质。如果前置,(fast->next!=NULL&&fast!==NULL)那fast->next就是一个对空指针的解引用访问,出现了Bug,这也提醒我们,C语言阶段的基础打牢了,才能在以后的阶段能够精准判断少犯错误。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode LTNode;
struct ListNode* middleNode(struct ListNode* head) {
LTNode* slow,*fast;
slow=fast=head;
while(fast&&fast->next)
{
slow=slow->next;
fast=fast->next->next;
}
return slow;
}
4. 将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有结点组成的。
OJ链接
这个题目我们的思路会在开始就卡住,两个链表,是值小的那个做头结点,开始往下去链,最后返回头结点。但是,到底是第一个链表还是第二个链表是不确定的,写代码的时候可以硬写,写分支语句来决定returnl1,还是l2.但是这样会显得我们代码很挫。不妨,用我之前讲的一个点,就是哨兵位,能很好地优化我们的代码。哨兵位只存储指向下一个节点的指针,而不存储有效的值。我们让哨兵位做头结点,直接返回它下一个节点即可,思路还是不变的,谁小链谁就是了。
同样,我们来演绎这道题的思路和过程:
这题的newtail是跟着整个合并链表的过程走的,开始之前又忘记给两个链表加上NULL了,后面补上,还请见谅,谁小就链接谁,同时newtail跟上,改变下一个节点的指向。链完后,让L1,L2指针往下走一步,直到遇到空指针为止,L1,L2的存在是为了判断什么时候哪一串链表先链完,不断重复谁小链谁的过程。像上面,第二串链表的节点已经全部链完了,那就把第一串剩下的那些节点接上就可以了。最后返回newhead->next,完毕。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode LTNode;
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
LTNode *l1, *l2;
l1 = list1;
l2 = list2;
if (l1 == NULL) {
return l2;
}
if (l2 == NULL) {
return l1;
}
LTNode *newhead, *newtail;
newhead = newtail = (LTNode*)malloc(sizeof(LTNode));
while (l1 && l2) {
if (l1->val < l2->val) {
newtail->next = l1;
newtail = newtail->next;
l1 = l1->next;
} else {
newtail->next = l2;
newtail = newtail->next;
l2 = l2->next;
}
if (l1 == NULL) {
newtail->next = l2;
}
if (l2 == NULL) {
newtail->next = l1;
}
}
return newhead->next;
}
一样的,只要这两个链表有一个为空,那就直接返回另一个链表的头结点,省事。都为空的话,就返回空。
5.链表的回文结构。
OJ链接
这个题目有点像前面讲过的那个翻转链表,它也是从字符串回文的题目变来的,只不过换成了链表。但是,却没有像字符串回文判断那么方便了,因为单链表的不可逆性。所以我们就只能另想办法解决了,题目供给了一个单链表,那我们只能对这个单链表来操作实现判断。
回想我们做过的前面有两个题目:链表的中间节点,链表的逆置。或许这两个小零件的组合能造出我们的变形金刚,这道题给我们带来的”新大陆”的解法。
这题牛客只给我们提供了C++的环境,不过莫慌C++是兼容C的,我们仍可以用C的解法,只要注意在C++中空指针的表示用nullptr就成。
那怎么利用前面写过的函数来搞定这个新题呢?
像上面这个情况,搞L1,L2两指针,让他们指向的元素分别去比相不相等,直到结束,如果都相等,就是回文链表了。一旦中间哪里不相等,那就不是回文链表。
不过,这边要用到我们之前写的两个函数,我们不像重新写,那就做一会CV工程师,CV一下之前写的代码。配置一下新的函数接口就好了。
struct ListNode* middleNode(struct ListNode* head) {
struct ListNode* slow,*fast;
slow=fast=head;
while(fast&&fast->next)
{
slow=slow->next;
fast=fast->next->next;
}
return slow;
}
struct ListNode* reverseList(struct ListNode* head) {
if(head==nullptr)
return head;
//可以不额外创建pcur
struct ListNode* n1,*n2,*n3;
n1=nullptr;n2=head;n3=head->next;
while(n2)
{
n2->next=n1;
n1=n2;n2=n3;
if(n3)
{
n3=n3->next;
}
}
return n1;
}
class PalindromeList {
//结合找中间节点和反转链表两个小题
public:
bool chkPalindrome(ListNode* A) {
// write code here
struct ListNode* mid=middleNode(A);
struct ListNode* rmid=reverseList(mid);
while(A&&rmid)
{
if(rmid->val!=A->val)
return false;
else
{
A=A->next;
rmid=rmid->next;
}
}
return true;
}
};
6.链表的第一个公共节点
OJ链接
大家需要点进OJ结合示例去理解这道题的意思。
一看到这题,有的朋友会这样想:这还不简单吗,你看这图,遇到相交节点不就是值相等吗,那也太好办了。
等等!!!
1和1相等,但是,相交点的值是8呢!
所以说,这道题万万不可草率地直接用值相等去判断,那会把人给坑死。测试用例里面一定会有那种很坏的例子,比如,所有节点的值都是1。但是,有相交的,也有不相交的。所以说,要判断是否相交,我们要比较的应该是节点的指针,而非节点的值。当指针都指向同一个节点,也就是指针和指针存储的地址相同时,链表才是相交的。
我们还知道一点,单链表节点,只有一个next指针,指向下一个节点。那就意味着,他们如果在同一个节点相交,以后的节点,要么为空,要么以后的节点的指向都完全一样,每个节点一一完全相同。就像两条河流汇成一条河流后不再分开了。
所以我们能确定的一点就是**,如果链表相交,那么直至分别遍历结束前的NULL节点前面的那一个节点一定完全一样。
我们只要让他们分别遍历,看NULL前那个节点指针是否相等就可以了。
下一个问题,如果链表相交,怎么去找相交节点?
注意到,链表A和链表B可能一样长,或者有差,那么,我们可以让长的链表对应的指针(headB)先出发,短链表的指针(headA)后出发,直至它们撞个满怀,就是相交节点了**。
同样配个动画,让大家好好理解:
找相交节点:长链表指针先走几次,取决于gap的值,每走一次,gap–,直到为0,两者一起走。
有一个小细节,在代码里有这么一段:
这是很有用的技巧,在以后我们玩栈和队列,二叉树的堆的时候还会用到。不知道谁大谁小,谁长谁短,就不妨假设A小B大,如果事实是A大B小,是相反的,那就A,B互换,A做大链表,B做小链表。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode LTNode;
struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
LTNode* curA,*curB;
curA=headA;
curB=headB;
int lenA=0;
int lenB=0;
while(curA->next)
{
lenA++;
curA=curA->next;
}
while(curB->next)
{
lenB++;
curB=curB->next;
}
if(curA!=curB)
{
return NULL;
}
//判断完有相交节点之后,找相交节点
LTNode* ShortList=headA;
LTNode* LongList=headB;
if(lenA>lenB)
{
ShortList=headB;
LongList=headA;
}
int gap=fabs(lenA-lenB);
while(gap--)
{
LongList=LongList->next;
}
while(ShortList!=LongList)
{
ShortList=ShortList->next;
LongList=LongList->next;
}
return LongList;
}
7.带环的链表,判断是否有环
OJ链接
直观理解,链表带环的话,那你的指针就会在里面一直跑出不来。如果没有环,无论这个环有多大,总有一天,你的指针会从环里面跑出来,最终遇到NULL。
如果搞两个指针的话,有环的话,那两个指针就会在环里边转悠,那到底会发生什么呢?
让我们来仔细分析一下:
首先,我们明确了用双指针去分析这个题目,如果两个指针速度都一样,你走一步,我走一步,那我们都在环里面,只要我们隔着一定的距离,那就永远遇不到你,除非你在环的路口等我,我们有缘分,我一进环就和你相遇。所以说速度一样,没法追上。
那么,速度不一样的时候,我就有追上你的可能,至于是否一定能追的上,看的是数学,更是造化。一个藏在编程里的数学问题浮出水面,且听我慢慢到来:
第一件事情,我们假设每过一秒,快指针走两步,慢指针走一步。那么,每秒钟,快指针和慢指针之间的距离缩小1。两指针都在环里面绕,最终,快指针将追上慢指针。所以,我们就有以下的代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
typedef struct ListNode LTNode;
bool hasCycle(struct ListNode *head) {
LTNode* slow,*fast;
slow=head;fast=head;
while(fast&&fast->next)
{
fast=fast->next->next;
slow=slow->next;
if(fast==slow)
return true;
}
return false;
}
同样要注意,fast&&fast->next顺序不能交换,这点的原因有在前文讲链表中间节点那道题目的时候提到。
以此看来,这道题目本身似乎不然,代码也很短。但事实上,代码的长短并不是衡量一道题难易的东西。到后面学到二叉树,排序就会发现,短短几行代码需要我们极大思维量去破解和分析。这道题的扩展出来的问题,不止是7,8两个问,如果快指针一次走三步,四步,五步,那最终的结果是什么呢?是会相遇还是谁追上谁?
好,第二件事情,我们就以快指针一次走三步为例,重新来分析一下这个问题。
Q:到底两个指针会不会相遇。如果相遇,相遇的情况是什么?是快指针追上慢指针,还是慢指针追上快指针?
A:给大家做了一个详细的解析:
插入一个细节的动画:
新一轮的追击距离是C-1.
当然快指针一次四步,五步的分析方法也是类似的。大家可以在掌握上述分析方法的基础上尝试去分析一下。对应是什么情况。
8.在7的基础上,尝试返回环的第一个节点或NULL
OJ链接
基于第7题的第一件事情分析(快指针一次两步,慢指针一次一步的情况)和代码分析,我们才能更好地分析这个题目
将分析写成代码
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* hasCycle(struct ListNode *head) {
struct ListNode* slow,*fast;
slow=head;fast=head;
while(fast&&fast->next)
{
fast=fast->next->next;
slow=slow->next;
if(fast==slow)
return slow;
}
return NULL;
}
struct ListNode *detectCycle(struct ListNode *head) {
if(hasCycle(head)==NULL)
{
return NULL;
}
struct ListNode* meet=hasCycle(head);
while(meet!=head)
{
head=head->next;
meet=meet->next;
}
return meet;
}
9.分割链表
OJ链接
这题的总体思路就是设置两个链表,一个是大链表,一个是小链表,意思就是小链表放的是小于x的节点,大链表放的是大于等于x的节点,同时两组newhead和newtai指针去改变节点指针的指向,因为题目说要不改变原顺序,所以采用遍历的方法即可。两组newhead相当于哨兵位,最后把小链表尾插到大链表即可。
注意,最后大链表尾插上去是要补上NULL节点的,不然,新组合的链表没有NULL就找不到尾了。
/*
struct ListNode {
int val;
struct ListNode *next;
ListNode(int x) : val(x), next(NULL) {}
};*/
#include <cstdlib>
class Partition {
public:
ListNode* partition(ListNode* pHead, int x) {
struct ListNode*lesshead,*lesstail,*greaterhead,*greatertail;
lesshead=lesstail=(struct ListNode*)malloc(sizeof(struct ListNode));
greaterhead=greatertail=(struct ListNode*)malloc(sizeof(struct ListNode));
// write code here
struct ListNode*cur=pHead;
while (cur) {
if(cur->val<x)
{
lesstail->next=cur;
lesstail=lesstail->next;
}
else {
greatertail->next=cur;
greatertail=greatertail->next;
}
cur=cur->next;
}
lesstail->next=greaterhead->next;
greatertail->next=nullptr;
pHead=lesshead->next;
free(lesshead);
free(greaterhead);
return pHead;
}
};
10.链表的深拷贝
OJ链接
这道题让人头疼的地方正是复制链表的指针不能指向原点,也就是题目里面加粗的那句话,也就是要完完全全拷贝节点重新拼接,所以说,这个题目又是一个解法,算法特殊的题目。倘若没接触过相似题目,是很难解出这到题目的
我们先来看怎么解这道题:
第一步是复制链表,演示前三个的复制过程
第二步是调整random指针,结合代码和画图体会调整过程
录完发现cur和copy忘跟上了,不过视频后面有补充说明怎么动的,这道题的视频是12题里面最难录的一个,算法比较复杂,也不是很好理解,但还是需要大家掌握,这个题目比较重要。结合代码语句才能更好地分析出算法的思想和过程。
/**
* Definition for a Node.
* struct Node {
* int val;
* struct Node *next;
* struct Node *random;
* };
*/
typedef struct Node LTNode;
struct Node* copyRandomList(struct Node* head) {
//1.复制链表
LTNode* cur=head;
while(cur)
{
LTNode* copy=(LTNode*)malloc(sizeof(LTNode));
copy->val=cur->val;
copy->next=cur->next;
cur->next=copy;
cur=cur->next->next;
}
//2.调整随机指针
cur=head;
while(cur)
{
LTNode *copy=cur->next;
if(cur->random==NULL)
{
copy->random=NULL;
}
else
{
copy->random=cur->random->next;
}
cur=cur->next->next;
}
cur=head;
//3.解下原链表,创建新链表
LTNode* newhead,*newtail;
newhead=newtail=NULL;
while(cur)
{
LTNode* copy=cur->next;
LTNode* next=copy->next;
if(newtail==NULL)
{
newhead=newtail=cur->next;
}
else{
newtail->next=cur->next;
newtail=newtail->next;
}
cur->next=next;
cur=next;
}
return newhead;
}
11.约瑟夫环问题
OJ链接
这个题目很有意思,2024年春晚魔术原理其实就是这个题目变化来的,出自的也是一个数学问题。所以说,数学还是和我们工科很是息息相关的,算法对数学的要求,像微积分,线性代数中矩阵转置,变换还是很高的。
当时小尼的扑克牌没对上,其实就像极了程序的bug和报错。
大家可以在魔术中感受数学的魅力,同时在融进计算机程序分析,感受学科交融的魅力和神奇,
不管在数学,还是计算机领域,他都可以作为一个大的问题的存在去深入扩展和分析。有兴趣的朋友可以去百度一下后面的数学原理和代码逻辑。
回到这题,这个题目起源于一个古代故事,不知道是不是真实存在:
著名的Josephus问题
据说著名犹太历史学家 Josephus有过以下的故事:在罗⻢⼈占领乔塔帕特后,39 个犹太⼈与
Josephus及他的朋友躲到⼀个洞中,39个犹太⼈决定宁愿死也不要被⼈抓到,于是决定了⼀个⾃杀⽅式,41个⼈排成⼀个圆圈,由第1个⼈开始报数,每报数到第3⼈该⼈就必须⾃杀,然后再由下⼀个重新报数,直到所有⼈都⾃杀⾝亡为⽌。
然⽽Josephus 和他的朋友并不想遵从,Josephus要他的朋友先假装遵从,他将朋友与⾃⼰安排在第16个与第31个位置,于是逃过了这场死亡游戏。
于是乎,我们就建模出上面那个OJ问题
举个简单例子帮助大家理解:
从1开始报数,最后活下来的是4号
那怎么用代码实现呢?
这里,我们用了一个哨兵位prev,防止m=1的情况最后删空,如果m=1,那最后返回prev就可以了,count相当于报数的计数器,每到m就执行退出操作并清零重新执行一轮。prev的next作为新的头结点,报1数的人继续,直到最后只剩一个人,就是pcur的next指针指向自己的时候,返回,游戏结束。
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param n int整型
* @param m int整型
* @return int整型
*/
typedef struct ListNode LTNode;
LTNode* BuyNode(int x){
LTNode* newNode=(LTNode*)malloc(sizeof(LTNode));
newNode->val=x;
newNode->next=NULL;
return newNode;
}
LTNode* createList(int n){
LTNode* phead=BuyNode(1);
LTNode* ptail=phead;
for(int i=2;i<=n;i++)
{
ptail->next=BuyNode(i);
ptail=ptail->next;
}
ptail->next=phead;
return ptail;
}
int ysf(int n, int m ) {
LTNode* prev=createList(n);
LTNode* pcur=prev->next;
int count=1;
while(pcur->next!=pcur)
{
if(count==m){
prev->next=pcur->next;
free(pcur);
pcur=prev->next;
count=1;
}
else
{
prev=pcur;pcur=pcur->next;
count++;
}
}
return pcur->val;
}
12.删除链表的倒数第K个节点
OJ链接
这题放在这个位置相当于一个放松的题目了,这题也可以用快慢指针法,快指针先走k步补步差,慢指针再走,也可以纯硬雷用sz-n,一个指针走下去找到倒数第k个,也就是正数sz-n个之后尾删或是头删操作。我自己第一次写的时候就是硬雷,没有用快慢指针的技巧方法,会稍显拙劣些,但也能通过。这题在之前是一个数组版本,就是数组的倒数第K个元素,方法类似。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeNthFromEnd(struct ListNode* head, int n) {
struct ListNode* pcur=head;
int sz=1;
while(pcur->next)
{
sz++;
pcur=pcur->next;
}
if(sz==1)
return NULL;
int ret=sz-n;
struct ListNode* pcur2=head;
if(ret==0)
{
struct ListNode* pcur5=head;
head=head->next;
free(pcur5);
pcur5==NULL;
return head;
}
while(ret)
{
pcur2=pcur2->next;
ret--;
}
struct ListNode* pcur3=head;
while(pcur3->next!=pcur2)
{
pcur3=pcur3->next;
}
struct ListNode* pcur4=pcur2;
pcur3->next=pcur2->next;
free(pcur4);
pcur4=NULL;
return head;
}
快慢指针方法;
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeNthFromEnd(struct ListNode* head, int n) {
struct ListNode* fast,*slow,*cur;
fast=slow=head;
cur=head;
int sz=1;
while(cur->next)
{
sz++;
cur=cur->next;
}
//只有一个节点
if(sz==1)
{free(cur);
cur=NULL;
return NULL;}
while(n--)
{
fast=fast->next;
}
//正数第1个节点头删
if(fast==NULL)
{
head=slow->next;
free(slow);
slow=NULL;
return head;
}
//非正数第一个节点
while(fast)
{
fast=fast->next;
slow=slow->next;
}
struct ListNode* pcur=head;
while(pcur->next!=slow)
{
pcur=pcur->next;
}
pcur->next=slow->next;
return head;
}
总结
想必大家经过上面链表OJ的洗礼之后,对链表也没有那么畏惧而望而却步了。是不是觉得有点好玩,难度也还可以,如此,挺好。若是显得有点吃力,那还得加把劲反复练习一下,争取彻底地消化吸收和理解,到后面,你在很多大魔王的数据结构面前就会发现,链表这种的东西是多么的可爱了。那么,我们这期的内容就到这里,本期内容作者肝了三天时间,呈现巨献不易,还请大家多多关注,一键三连,评论支持呀,我们下期再见