不知道大家对链表熟悉还是陌生,我们秉着基础不牢,地动山摇的原则,会一点点的介绍链表的,毕竟链表涉及的链式存储也很重要的。在这之前,我们认识过顺序存储的顺序表,它其实就是一个特殊的数组。那链表到底是什么?又有什么用呢?与顺序结构有什么不一样呢?
一、认识链表
第一点:链表是什么?
链表是一种数据结构,用于以线性的方式存储一组数据。它与数组不同,链表的大小可以动态调整,且它的元素(称为节点),在内存中不必是连续存储的。每个节点包括两部分:数据域与指针域。
链表的基本结构:
头节点->节点1->节点2->节点3->尾节点
链表的分类:
根据不同的分类标准分为:单向和双向;循环与不循环;带不带头节点(又称哨兵节点)
常见的链表是:不带头节点的单向非循环链表,带头结点的双向循环链表。
链表的优点:
动态大小:方便插入和删除节点,适合频繁的插入和删除操作。
链表的缺点:
随机访问效率低,必须从头节点开始逐一遍历
每个节点需要额外的存储指针,空间开销较大
第二点:链表有什么用?
一方面,由于是动态大小,内存不必连续,这就可以充分利用碎片内存,对内存的利用率高。
另一方面,链式存储的概念在实现其它数据结构(栈和队列)以及相关算法方面有着很大的用途。
第三点:与顺序结构有什么不同?
1.存储方式:
顺序存储(数组):内存连续,通过下标直接访问元素,效率高。
链式存储(链表):内存不连续,每个节点包含数据域和指针域,节点间由指针相互链接。
2.动态大小:
顺序:大小在创建时确定,扩展和缩减不方便,需要重新分配内存进行扩容。
链式:大小可以动态变化,插入和删除节点时不需要移动其它元素,只需要改变指针域的指针指向
3.访问效率:
顺序:支持随机访问,时间复杂度O(1)
链式:只能从头遍历,时间复杂度O(n)
4.修改操作:
顺序:插入和删除需要移动大量数据,时间复杂度O(n)
链式:插入和操作只要调整指针指向,时间复杂度O(1)
5.内存占用:
顺序:由于是连续存储,内存发呢哦欸相对简单,但可能造成内存浪费(预留空间)。
链式:每个节点需要额外的存储空间来保存指针,整体的开销相对较大。
6.适用场景:
顺序存储:适合频繁访问的场景:查找、索引
链式存储:适合频繁插入和删除的场景:例如:栈、队列
二、链表的API
基础操作:(数据结构)
增、删、查、(改)遍
增--插入:在链表的指定位置添加一个节点
删--删除:删除指定位置的一个节点
查--搜索:查找链表中特定位置的值、查找节点中是否中存在某值
遍--遍历:按顺序访问链表的所有节点、获取链表的长度
进阶操作:(算法提高)
合并两个有序链表:给出两个有序链表,现在要求能够合并两个链表,新的链表仍然有序
原地反转单向链表:给出一个单向链表,现在要求能够将链表反过来,要求不增加新链表
判断链表是否有环:给出一个单向链表,判断这个链表中是否存在环,没有什么特殊要求
三、C语言实现链表
这里不使用C++是后面我们还会用C++模拟实现STL中的list容器(有点恼火,估计要磨几天)。
单链表实现
定义链表节点
typedef int T;//T是int的别名,方便之后使用其它类型数据,增加可维护性
typedef struct ListNode {
T data;
struct ListNode* next;
}Node;
创建新节点
Node* CreateNode(T _val)
{
//使用malloc在堆区申请一个节点空间
Node* new_node = (Node*)malloc(sizeof(Node));
assert(new_node);
new_node->data=_val;
new_node->next=NULL;
return new_node;
}
注意1:如果不在堆区申请空间,而是直接定义一个指针,那么当函数返回时,指针就会被自动释放,因为栈区的变量的生命周期就是定义开始到作用域结束。这样就导致后面访问时虽然还是那个地址,不过属于非法访问了已经。
注意2:assert()是断言函数,用来判断表达式是否为真,如果不为真,就强制结束程序。在这进行判断是为了保证申请空间成功。如果失败就不要后续的非法访问了。
尾插
void insert_tail(Node** pphead, T _val)
{
assert(pphead);
if(*pphead==NULL){//如果是空链表
*pphead=CreateNode(_val);
return;
}
//如果还没返回,那就是非空链表,在后面插入
Node* tail=*pphead;
while(tail->next){
tail=tail->next;
}
//找到了最后一个节点,在最后一个节点后面插入一个节点
tail->next=CreateNode(_val);
}
注意1:尾插是在链表的最后一个节点后面进行插入。需要考虑这个链表是空链表还是非空链表。如果是空链表,那么我将新节点当成头节点就成了。如果是非空链表,我需要遍历到尾节点处,尾节点的特点是next指针是空,那么我不知道具体循环多少次就可以使用while循环。当找到了尾节点,我让尾节点的next指针指向新的节点那么我的插入操作就完成了。
注意2:传入的参数是二级指针。why?首先链表的头本身是一个指针,然后我想要在这个链表上进行修改的操作,那就需要传入这个指针变量的指针。
例如:phead是一个指向链表头节点的指针,只不过此时存储的值是NULL。右面是新建的节点。
假设红色的是函数的一级指针参数, 我们想要插入一个节点,我们其实是想让phead指向这个新创建的节点(0x0001),也就是让phead的值改为0x0001。但我们的函数与phead没有任何的关系,只有_phead的值与phead的值一样,假如我让_phead的值为0x0001,那么_phead指向了新节点,一旦函数结束,形参的生命周期结束,这个指向关系将会终止,连新建的节点都不知道怎么去访问了。造成了内存泄露。
总结:头节点传二级指针,判断链表是否为空,寻找尾节点是看尾节点的next是否指向NULL
头插
void insert_head(Node** pphead,T _val)
{
assert(pphead);
Node* newnode = CreateNode(_val);
newnode->next=*pphead;
*pphead=newnode;
}
头插比较简单,不需要考虑是否为空,不管是不是空都是在头部插入。
注意1:创建完节点后,不要让头指针直接指向新节点,要先让新节点的next指针指向头指针,然后再让头指针指向新节点。不然原来的链表的地址将会丢失。
尾删
void del_tail(Node** pphead)
{
assert(pphead && (*pphead));
Node* tail=*pphead;
if(tail->next==NULL){//如果只有一个节点
free(*pphead);
*pphead=NULL;
return;
}
while(tail->next->next){
tail=tail->next;
}
free(tail->next);
tail->=NULL;
}
注意1:头指针不能为空,而且头指针指向的节点也不能为空。
注意2:我们要删除尾节点,那么就是将我们尾节点释放并将前一个节点的next指针置空。找到尾节点时,我们尾节点的前一个节点的状态是什么样子的呢?cur->next就指向tail,而tail->next就指向NULL,所以不妨直接将循环截至条件设置为cur->next->next==NULL时截至,然后释放cur->next也就是tail,再将cur->next置空NULL。
注意3:要完成注意2的前提下,我们需要判断尾节点是否有前一个节点,也就是是否只有一个节点。如果只有一个节点,我们将头节点释放置空就完事了。
头删
void del_head(Node** pphead)
{
assert(pphead && (*pphead));
Node* tmp_node=(*pphead)->next;
free(*pphead);
*pphead=tmp_node;
}
注意1:解引用符*与访问符->的优先级的问题。需要我们将*pphead整体括起来。
注意2:先存储后释放,再重设头节点。
遍历与查找
Node* find(Node* phead,T _val)
{
Node* cur=phead;
while(cur){
if(cur->data == _val){
return cur;
}
cur=cur->next;
}
return NULL;
}
直接循环完事了,对于不用修改操作的函数,用二级指针的必要都没有,循环遍历,如果头指针为空,那么循环都进不去,返回NULL。
在第i个位置插入节点
void insert(Node** pphead,int i,T _val){
assert(pphead);
assert(i>=0&&i<length(*pphead));//int length(Node* phead),遍历计数实现
if(i==0){
insert_head(pphead,_val);
return;
}
Node* new_node =CreateNode(_val);//新建一个待插入节点
Node* cur=*pphead;i--;
while(i--){//cur遍历到第i-1个位置
cur = cur->next;
}
new_node->next=cur->next;//将第i个节点放在后面,
cur->next=new_node;//连接上前面的节点,加入节点
}
删除第i个位置节点
void del(Node* pphead,int i)
{
assert(pphead&&(*pphead));
assert(i>=0&&i<length(*phead));
if(i==0){
del_head(pphead);
return;
}
if(i==length(*phead)){
del_tail(pphead);
return;
}
Node* cur=*pphead;i--;
while(i--){//找到第i-1个节点
cur=cur->next;
}
Node* tmp_node=cur->next;
cur->next=cur->next->next;//如果上面不判断i==length,那么就不会有cur->next的存在
free(tmp_node);
}
双链表实现
双链表节点定义:
typedef int T;
typedef struct TwoListNode{
T data;
struct TwoListNode* prev;//前驱节点指针
struct TwoListNode* next;//后继节点指针
}TNode;
其他的操作类似,此处不再赘述。
算法题练习
//Definition for singly-linked list.
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(NULL) {}
};
合并链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
(双指针算法:基础算法--双指针【概念+图解+题解+解释】-CSDN博客)
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode* tmp = new ListNode;
ListNode* ans = tmp;
while (list1 != nullptr && list2 != nullptr) {
tmp->next = new ListNode;
tmp = tmp->next;
if (list1->val < list2->val) {
tmp->val = list1->val;
list1 = list1->next;
}
else{
tmp->val = list2->val;
list2 = list2->next;
}
}
while (list1 != nullptr) {
tmp->next = new ListNode;
tmp = tmp->next;
tmp->val = list1->val;
list1 = list1->next;
}
while (list2 != nullptr) {
tmp->next = new ListNode;
tmp = tmp->next;
tmp->val = list2->val;
list2 = list2->next;
}
return ans->next;
}
};
反转链表
给定单链表的头节点
head
,请反转链表,并返回反转后的链表的头节点。
(递归算法:基础算法--递归算法【难点、重点】-CSDN博客)
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (!head || !head->next) {
return head;
}
ListNode* second = head->next;
ListNode* newHead = reverseList(second);
second->next = head;
head->next = nullptr;
return newHead;
}
};
环形链表
给定一个链表的头节点
head
,返回链表开始入环的第一个节点。 如果链表无环,则返回null
。如果链表中有某个节点,可以通过连续跟踪
next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。如果pos
是-1
,则在该链表中没有环。注意:pos
不作为参数进行传递,仅仅是为了标识链表的实际情况。不允许修改 链表。
(双指针算法:基础算法--双指针【概念+图解+题解+解释】-CSDN博客)
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
if(head==nullptr)return nullptr;
ListNode* fast = head->next;
ListNode* slow = head;
while (fast != nullptr && fast->next != nullptr) {//确保快指针能走两步
if (fast == slow) {
//如果快慢指针相等了,说明是环,进行环的首节点锁定操作
while (head != slow->next) {
slow = slow->next;
head = head->next;
}
return head;
}
fast = fast->next->next;//快指针走两步
slow = slow->next;//慢指针走一步
}
return nullptr;
}
};
*跳表
从第一节的对比中可以看出,链表虽然通过增加指针域提升了自由度,但是却导致数据的查询效率恶化。特别是当链表长度很长时,对数据的查询还得从头依次查询,这样效率会很低。跳表的产生就是为了解决链表过长的问题,通过增加链表的多级索引来加快原始链表的查询效率。这样的方式可以让查询的时间复杂度从O(n)提升到O(logn)
跳表通过增加的多级索引能够实现高效的动态插入和删除,其效率和红黑树和平衡二叉树不相上下。目前redis和levelDB都有用到跳表。
从上图可以看出,索引级的指针域除了指向下一个索引的next指针,还有一个down指针指向低一级的链表位置,这样才能实现跳跃查询的目的。