赞
踩
【前言】详细的介绍了链表的相关知识。所用语言---c
链表的表示与实现
顺序表与链表的区别与联系
leetcode刷题
请在看代码时,自己也动手画起来,才能理解的更深刻。学习数据结构是一定要画图的!!!
目录
上一篇我们讲解了顺序表,但不管是动态还是静态,顺序表是存在很多缺陷的。
如果空间不够需要扩容,扩容多了很容易造成空间的浪费,扩少了扩容次数又要多。
另外就是插入删除的效率太低了。
那么会这样思考,有没有一种方法可以达到“按需索取”的效果,我要存220个元素,那就有220个空间去存,而不会过大的扩容造成浪费呢?而且若不要求物理地址连续的话,其删除插入操作将不需要大量的挪动数据。这样是不是相较于顺序表会更方便呢?
答案是使用指针相连贯前后的链表。
链表是一种物理存储结构非连续的存储结构,数据元素的逻辑顺序由指针链接来实现。
接下来就来看看单链表的表示与实现过程吧。
(图源百度百科)
其中的next就是指向下一个节点的指针。
上图所展示的是一种逻辑结构,但在物理上,链表的存储是不连续的。节点node1的指针种存储的就是node2的第一个字节的地址。当结束时,如上图所示,节点n的指针指向的是NULL
第一步仍然还是定义一个结构体,包含一个节点的数据data与指向下一个节点的指针next
- typedef int datatype;
- typedef struct slistnode
- {
- datatype data;
- struct slistnode* next;//指向这种类型的指针,但注意,结构体不能嵌套结构体哦
-
- }snode;
除此之外还需要一个phead,用以指向头节点,在初始化常置为NULL
再来介绍链表的打印,其中的cur=cur->next是需要重点理解的一个地方。
- //打印链表
- void printslist(snode* phead)
- { //创建一个指针指向phead的下一个节点
- snode* cur = phead;
- while (cur != NULL) //当cur移动到NULL时,即遍历完整个链表。
- {
- printf("%d", cur->data);
- cur = cur->next; //cur指向此结构体中的next,也就是下一个节点的data。
- }
-
- }
接下来先介绍尾插法。
- //尾插
- void slistpushback(snode** pphead,datatype x)
- {
- snode* newnode = (snode*)malloc(sizeof(snode));//创建一个新节点
- newnode->data = x;
- newnode->next = NULL;//注意不要忘了最后一个节点的next指向NULL哦
- if (*pphead == NULL)
- {
- *pphead = newnode;
- }
- else
- {
- //利用tail来找链表尾巴
- snode* tail = *pphead;
- while (tail->next != NULL)
- {
- tail = tail->next;
- }
- //链接尾节点
- tail->next = newnode;
- }
- }

其中有需要注意的几个地方。
1. 在利用tail去寻找链表的尾巴时,不能使用while(tail != next),而是tail.next不为空,最后tail才能指向尾节点,否则tail将会指向尾节点之后。
这一点如果无法理解建议自己画一个链表来逐步的画出步骤,这样可以加深你对链表尾插的理解。
2.为什么使用二级指针来传入函数参数?我们想要改变的是phead。所以根据形参是实参的一份拷贝的说法,如果传入phead,那么函数里的phead作为形参,其改变是无法影响实参的。所以我们就用了指针的指针来唯一的标识phead,也就是将指针phead的地址当作了门牌号,我们通过门牌号来访问phead。
3.为何要判断我们插入的是不是首个节点?最开始链表为空,则只有一个phead,phead指向为空,tail也是空的,那么tail->next又从何谈起呢?所以需要在插入首节点时直接将phead=newnode
这也就是三个比较重要的需要理解的点。如果到这里能全部理解的话,后面的知识点就会易懂很多了。
那么就可以对其进行测试了。
- void testlist()
- {
- snode* phead = NULL;
- slistpushback(&phead, 3);
- slistpushback(&phead, 3);
- slistpushback(&phead, 3);
- slistpushback(&phead, 3);
- printslist(phead);
- }
经过测试确实打印出了3 3 3 3,证明函数书写无误。
头插法就简单很多了。
- //头插
- void slistpushfront(snode** pphead, datatype x)
- {
- snode* newnode = (snode*)malloc(sizeof(snode));//创建一个新节点
- newnode->data = x;
- newnode->next = NULL;//注意不要忘了最后一个节点的next指向NULL哦
- newnode->next = *pphead;
- *pphead = newnode;
- }
不管链表里有无节点,进行头插都是一样的代码,这点不同于尾插,尾插需要考虑tail无法指向NULL的情况。
头删法:
- //头删
- void slistpopfront(snode** pphead)
- {
- //用一个指针保存头节点的next(也就是第二个节点),再释放第一个节点
- snode* next = (*pphead)->next;
- free(*pphead);
- //将第二个节点地址存入phead
- *pphead = next;
- }
尾删法:
重点是找到tail的前一个,不然将tail那个节点释放了,tail前一个的地址去哪里找呢?我们需要将tail的前一个置为NULL才行啊,否则变成了野指针的话会造成很多难以预料的结果。
- //尾删
- void slistpopback(snode** pphead)
- {
- //主要是要找到尾的前一个,将其置向null,不然就成了野指针了。
-
- if (*pphead==NULL)
- {
- return;
- }
- else if ((*pphead)->next == NULL)
- {
- free(*pphead);
- *pphead = NULL;
- }
- snode* prev = NULL;
- snode* tail = *pphead;
- while (tail->next != NULL)
- {
- prev = tail;
- tail = tail->next;
- }
- free(tail);
- prev->next = NULL;
- }

还是用常用的方法,使用两个指针,当tail向后移动时,prev移动到tail的前一个节点的位置就好了。
参考上文,如果为空链表,那么tail.next仍然会出现问题。所以我们添加了判断,如果为空则直接返回。
若是有一个节点呢?那么prev.next会出现同样的问题,则还需多判断只有一个节点的情况。
都如上图代码所示了。不理解的请画图加深印象。
接下来介绍查找元素,并在其前面插入元素的方法。
查找:
- //查找
- snode* slistfind(snode* phead, datatype x)
- {
- snode* cur = phead;
- while (cur)
- {
- if (cur->data == x)
- {
- return cur;
- }
- cur = cur->next;
- }
- reutrn NULL;
- }
如何插入呢?还是有老问题,我们知道cur,却不知道cur指向的上一个节点在哪里。所以仍然需要两个指针。或者用下面这种方法,只用一个prev,然后判断prev.next的情况。
- //插入
- void slistinsert(snode** pphead, snode* pos, datatype x)
- {
- if (pos == *pphead) // 当pos就在第一个位置时,直接相当于头插,如果不这样,prev无法判断pos位,他的起始判断位置就在prev->next
- {
- slistpushfront(pphead, x);
- }
- else
- {
-
- snode* newnode = (snode*)malloc(sizeof(snode));//创建一个新节点
- newnode->data = x;
- newnode->next = NULL;
- snode* prev = *pphead;
- while ((prev->next) != pos)
- {
- prev = prev->next;
- }
- prev->next = newnode;
- newnode->next = pos;
- }
- }

接下来讲解如何删除
- //删除
- void slisterase(snode** pphead, snode* pos)
- {
- snode* prev = *pphead;
- while (prev->next != pos)
- {
- prev = prev->next;
- }
- prev->next = pos->next;
- free(pos);
- }
但头删出现了问题。
改进,当头删时需要单独考虑
- //删除
- void slisterase(snode** pphead, snode* pos)
- {
- if (pos == *pphead)
- {
- slistpopfront(pphead);
- }
- else
- {
- snode* prev = *pphead;
- while (prev->next != pos)
- {
- prev = prev->next;
- }
- prev->next = pos->next;
- free(pos);
- }
- }

以上就是单链表的全部操作了。
但单链表是存在缺陷的。例如查找速度慢,找不到前驱,不能从后往前等等的问题。
在实现尾插,尾删,插入时大多具有O(N)的时间复杂度,而造成这样的原因也是因为找到前一个很困难。以此我们提出了双向链表。
双向链表不仅有后继,还具备前驱。相比单链表会更适用。
(图源百度百科)
先来介绍一下带哨兵位的头节点,我们将phead指向的第一个节点(存储第一个节点的地址),但是第一个节点不存有效数据,那么在进行一些接口函数的实现时,就不需要再传二级指针了。因为不需要改变这个哨兵位。
但是请注意,哨兵位不要写入链表长度这样的操作,因为链表不应该只限制一种数据的存入,可以同时存入多种类型数据。
所以我们使用哨兵位主要就是为了不使用二级指针。
再来介绍下循环,尾指向头就是循环,指向空就是不循环,所以单向,双向,带头,不带头,循环,不循环,可以组成八种不同类型的链表。
最简单的无头单向非循环链表,结构是最简单的,但是不会用来单独存数据,而是作为了一些数据结构的子结构来使用。在一些笔试面试中适用场景出现非常多。
而带头双向循环链表,作为最复杂的一种结构,就是我们今天要讲解的例子,虽然实现复杂,但是会带来很多的优势,实现反而更加的简单。
请一定要熟练掌握以上两种链表。
话不多说,上代码开始讲解。
第一步老规矩,还是先写节点的结构体。
- typedef int datatype;
- struct listnode
- {
- struct listnode* next;
- struct listnide* prev;
- datatype data;
- };
紧接着的就是初始化操作。
在初始化的时候要注意是带有哨兵位的,而且如果要循环,请一定将next和prev指向自己。之后就明白其奇妙之处了,初始化是需要改变phead的,所以可以如上文所示传入二级指针对phead进行一个更改,在此不多赘述,而是提供一个不需要传入二级指针的办法。
- //初始化
- listnode* listinit()
- {
- //申请新节点。
- listnode* phead = (listnode*)malloc(sizeof(listnode));
- phead->data = 0;
- phead->next =phead; //指向自己
- phead->prev = phead;
- return phead;
-
- }
- void test()
- {
-
- listnode* phead=listinit();
-
-
-
- }

如上所示,和上文不一样的是,我们选择了返回一个phead,所以将函数前类型改成listnode*
这样就不用使用二级指针了。
接下来介绍尾插法。此时循环的好处就体现出来了。
此时找尾就直接可以找到了。phead的prev即是尾了。
- /尾插
- void listpushback(listnode* phead,datatype x)
- {
- listnode* tail = phead->prev;
- listnode* newnode = (listnode*)malloc(sizeof(listnode));
- newnode->data = x;
- //核心代码,不理解请一定要画图哦
- tail->next = newnode;
- newnode->prev = tail;
- newnode->next = phead;
- phead->prev = newnode;
-
-
- }
同时可以发现的是,不管链表是不是为空,都是适用的,所以不需要单独讨论空链表的情况。
接下来是头插法。
就是在phead这个哨兵位和插入了有效数据的第一个节点(我们记为first)之间。
- void listfrontpush(listnode* phead, datatype x)
- {
- listnode* first = phead->next;
- listnode* newnode = (listnode*)malloc(sizeof(listnode));
- newnode->data = x;
- phead->next = newnode;
- newnode->prev = phead;
- newnode->next = first;
- first->prev = newnode;
-
- }
头删法:
仍然将first指向第一个节点(带有有效数据的),second指向第二个
- void listfrontpop(listnode* phead)
- {
- assert(phead->next != phead);
- listnode* first = phead->next;
- listnode* second = first->next;
- phead->next = second;
- second->prev = phead;
- free(first);
-
- }
注意只剩phead就不要再删了。
尾删法也是同样的思路,只需要有倒数两个节点就行,不断地改变next和prev的指向就ok了。
- //尾删
- void listbackpop(listnode* phead)
- {
- assert(phead->next != phead);
- listnode* tail = phead->prev;
- listnode* prev = tail->prev;
- prev->next = phead;
- phead->prev = prev;
- free(tail);
- //listerase(phead->prev);
- }
接下来就是在pos位置前进行插入的操作。
仍然需要通过指针找到pos的位置才行。
非常简单,定义一个cur指针从头遍历到尾即可,没有就返回NULL即可。
- //查找pos
- listnode* listfind(listnode* phead, datatype x)
- {
- assert(phead);
- listnode* cur = phead->next;
- while (cur != phead)
- {
- if (cur->data == x)
- {
- return cur;
- }cur = cur->next;
- }
- return NULL;
- }
这样就找到了我们需要寻找的数据的位置。同时通过设置assert和一些判断语句增强了代码的严谨性。
而且查找函数可以充当改的函数。将cur的指向的位置的data进行更改即可。
查找附带着修改data的作用。
接下来介绍插入
- //插入
- void listinsert(listnode* pos, datatype x)
- {
- assert(pos);
- listnode* prev = pos->prev;
- //申请节点
- listnode* newnode = (listnode*)malloc(sizeof(listnode));
- newnode->data = x;
- newnode->next = pos;
- prev->next = newnode;
- newnode->prev = prev;
- pos->prev = newnode;
-
- }
可以发现的是,如果不想对指针指向的修改有顺序的限制,那么只需多定义一个指针prev,就可以随意的对指针的指向进行修改了。非常的方便,如果只有一个pos的话就要考虑到改变指向的顺序了。
怎么进行删除呢?
删除也是同样的道理,定义出prev和next,直接更改就完了,而且哪怕pos处在最尾,因为有循环,所以根本不影响。不理解的老铁还是同样的,画图加深理解。
- //删除
- void listerase(listnode* pos)
- {
- assert(pos);
- listnode* prev = pos->prev;
- listnode* next = pos->next;
- prev->next = next;
- next->prev = prev;
- free(pos);
- }
到这里,双向循环链表的增删查改就介绍完毕了。是不是很简单呢?
虽然结构复杂,但其功能的实现却方便快捷,书写难度低,同时通过定义俩指针,就不用考虑指针的指向修改顺序,无脑改就完了。而且因为有循环,所以对空链表,或者对尾处理等操作就不像单向链表需要考虑更多的情况了。
但是代码仍然可以优化。代码复用嘛。
因为我们写的代码很多地方其实是重复的。所以可以对一些函数进行更改。
例如
尾删替换为
listerase(phead->prev);
头删替换为
listerase(phead->next);
头插和尾插呢?
一样嘛,将pos的位置进行改变就行了。
头插
- listnode* pos=phead->next;
- listinsert(pos,x);
尾插
listinsert(phead, x);
剩下的就是destory,定义一个cur和next,cur指向第一个节点,next指向cur的next,free掉cur,再将cur=next,不断循环即可。这里就不贴出代码了,感兴趣的老铁试着自己书写下。
记得phead也要释放哦。
这样我们就做成了一个功能齐全且代码维护方便的链表。可以被称作很优秀的结构了。除了查找的时间复杂度是O(N)外,其他一切都很完美。
但是查找我们也不用链表来使用,而是平衡搜索树,哈希表,b树,这样的非常复杂的结构,感兴趣的老铁自行度娘吧。
例如 1-》2-》3-》4
反转 4-》3-》2-》1
思路1:将指针的方向反一圈
定义三指针。两个指针的功能是反转指针,还有一个拿来存储下一个需要反转的地址,不然两个指针反转完了会找不到下一个地址。
然后不断的重复。
- #include<stdio.h>
- struct listnode* reverse(struct listnode* head)
- {
- if (head == NULL) //针对空链表的处理。
- {
- return NULL;
- }
- struct listnode* n1 = NULL;
- struct listnode* n2 = head;
- struct listnode* n3 = n2->next; //如果是空链表,n2的next会出错。
-
- while (n2)
- {
- n2->next = n1;
- n1 = n2;
- n2 = n3;
- if (n3) //n3可能为空,那么n3的next也就会报错
- {
- n3 = n3->next;
- }
- }
- //链表的头换成了n1
- return n1;
-
- }

在做单向链表时请一定小心各种->next,因为他很有可能为空。
思路2:头插法
用头插法来插123,就会得到321了
所以就是取原链表的节点头插到新链表里去。
定义俩指针,一个保存头插过后的下一个节点的地址。一个取节点去新链表头插。
- struct listnode* reverse(struct listnode* head)
- {
- struct listnode* cur = head;
- struct listnode* newhead = NULL;
- while (cur)
- {
- struct listnode* next = cur->next;
- cur->next = newhead;
- newhead = cur;//将newhead当作新的头
- cur = next;
- }
- return newhead;
- }
给一个头节点为head的非空单链表,返回其中间结点。
若有两个,则返回第二个。
要求:只可以遍历一遍
思路:定义两个指针,fast与slow,如其名,对其速度有所要求,slow走一步,fast走两步。
当fast走到尾指针时,slow就走到了中间。但是需要区别下偶数和奇数的节点的链表,会发现是有细微的差别的。偶数时,fast指向了链表最后一个,而奇数时,fast走向了空。
- struct listnode* middlenode(struct listnode* head)
- {
- struct listnode* slow = head;
- struct listnode* fast= head;
- while (fast && fast->next )//有一个为空就停止。
- {
- slow = slow->next;
- fast = fast->next->next;
- }
- return slow;
- }
将两个升序的链表合并为新升序链表,新链表由旧节点组成。
思路:从头开始,取两链表中小的尾插到新链表。
和以前讲的一道题很类似,分别定义俩指针,指向两个链表,不断的比较插入。不过需要改变的是可以给新链表附加一个head和tail,方便找到嘛。tail就随着插入不断的向后移。当一个链表的指针指向空,就结束,将另一个剩下的拼接到新的后面。
- struct listnode* mergetwolists(struct listnode* l1, struct listnode* l2)
- {
- if (l1 == NULL)
- {
- return l2;
- }
- if (l2 == NULL)
- {
- return l1;
- }
- struct listnode* head = NULL;
- struct listnode* tail = NULL;
- while (l1 != NULL && l2 != NULL) // 一个结束就都结束了
- {
- if (l1->val < l2->val) //val是题给的值,相当于上文讲的data
- {
- if (tail == NULL)
- {
- head = tail = l1;
- }
- else
- {
- tail->next = l1;
- tail = tail->next;
- }
- l1 = l1->next;
- }
- else
- {
- if (tail == NULL)
- {
- head = tail = l2;
- }
- else
- {
- tail->next = l2;
- tail = tail->next;
- }
- l2 = l2->next;
- }
- }
- if (l1 != NULL)
- {
- tail->next = l1;
- }
- if (l2 != NULL)
- {
- tail->next = l2;
- }
-
- }

给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true 。 否则,返回 false 。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1
输出:false
解释:链表中没有环。
思路:仍然使用fast与slow指针,fast会比slow更快进环,当两个指针都进环,在某个点相遇,不带环则快指针会更快出去。
- bool hasCycle(struct ListNode *head) {
- struct ListNode* slow =head, *fast=head;
- while(fast && fast->next)
- {
- slow=slow->next;
- fast=fast->next->next;
- if(slow==fast)
- {
- return true;
- }
-
- }
- return false;
- }
思考:slow和fast一定会在环里相遇吗?
假设slow走一步,fast走两步,slow开始进环,fast开始追逐, 若距离为n,则在追逐的过程中距离该如何变化呢?
n,n-1,n-2,n-3,n-4,````0,其距离会越来越小,所以一定会相遇。
若fast走3步呢?
n,n-2,n-4,n-6`````,不一定会被减到0。
n是偶数,就会最终相遇,而n为奇数,就会得到-1,fast比slow还快了一步。
那么,此时他们的距离就是环长-1,若环长-1等于偶数,则可以追上,若是奇数,那就永远追不上了。因为距离被永久定位了环长-1了。
奇数意味着快追上时,fast又会反超slow。
若fast走4步?走5步?感兴趣的铁子自行证明一下。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。