之前学习的单链表相比于顺序表来说,就是其头插和头删的时间复杂度很低,仅为O(1) 且无需扩容;但是对于尾插和尾删来说,由于其需要从首节点开始遍历找到尾节点,所以其复杂度为O(n)。那么有没有一种结构是能使得头插和头删的时间复杂度都为 O(1) 的呢?那么这个结构就是双向循环链表。
目录
1 链表的分类
2 双向循环带头链表的结构
1) 逻辑结构
2) 物理结构
3 双向循环带头链表操作的实现
1) 初始化和销毁
(1) 初始化
(2) 销毁
2) 头插与头删
(1) 头插
(2) 头删
3) 尾插和尾删
(1) 尾插
(2) 尾删
4) 在pos位置之前插入和删除pos节点
(1) 在pos位置之前插入
(2) 删除pos节点
5) 判空和查找
4 代码
5 顺序表与链表的对比
1 链表的分类
链表分类总共根据以下3个因素来分类:
1) 带头或者不带头
2) 循环或者不循环
3) 单向或者双向
其中带头的意思是:链表中有一个头节点,也叫哨兵位,这个头节点仅仅用来当做链表的开始节点,并不存储任何有效数据。有头节点的好处是插入数据时不必再判断链表为空的情况。所以带头节点的链表从物理结构上来说是不会为空链表的。
其中循环的意思是:链表最后一个节点并不指向NULL,而是指向开始节点,以此来达到循环的效果。所以找到循环链表的尾节点,并不是判断尾节点的next为不为NULL,而是看尾节点的next指针是否指向开始节点。
其中单或双向意思是:是否能够通过节点内部指针找到下一个节点或者前一个节点。只能找到下一个节点的链表就是单向链表;能够找到前一个和后一个节点的链表就是双向链表。
为了更好的理解不同链表的结构,在这里可以画一下双向不循环带头链表的结构:
按照以上因素,链表共分为 8 种,分别是:
单向不循环不带头链表 | 双向不循环不带头链表 |
---|---|
单向循环不带头链表 | 双向循环不带头链表 |
单向不循环带头链表 | 双向不循环带头链表 |
单项循环带头链表 | 双向循环带头链 |
所以之前单链表的全称应该是单向不循环不带头链表。
重点一 双向循环带头链表节点结构
2 双向循环带头链表的结构
1) 逻辑结构
有了之前分类的铺垫,双向循环带头链表(以下简称为双向链表)的逻辑结构就很好画出来了:
2) 物理结构
链表是由节点组成的,所以定义链表的物理结构也就是定义链表节点的结构,通过对逻辑结构的分析,可以发现双向链表的结构共有3个域组成,一个是存储数据的数据域(data),还有一个存储下一个节点地址的指针域(next),还有一个存储前一个节点地址的指针域(prev):
//定义数据类型
typedef int LTDataType;
//定义双向链表结点的结构
typedef struct ListNode
{
LTDataType data;//结点里的数据
struct ListNode* prev;//前驱指针,指向前一个结点
struct ListNode* next;//后继指针,指向后一个结点
}LTNode;
同样的,双向链表并不一定只存储整型,所以要用 LTDataType 来 typedef 一下整型,以后如果要存储其他类型的数据,只需要改这一个地方就可以了。
重点二 双向循环带头链表的实现
3 双向循环带头链表操作的实现
对于双向链表,其常用的操作主要有初始化、销毁、头插、头删、尾插、尾删、在pos位置之后插入、删除pos节点、判空以及查找。
虽然双向链表比单链表的结构复杂,但是由于其结构的天然优势,其操作实现实现起来会比单链表简单很多,且不需要考虑链表为空的情况。
1) 初始化和销毁
(1) 初始化
对于双向链表的初始化,由于其结构的特殊性,必须保证是带头且循环,所以在初始化里面就要开辟一个节点,使这个节点成为链表的头节点,让其 next 指针和 prev 指针都指向自己,所以一个逻辑上为空的双向链表物理结构为:
这样才能保证其循环和带头的特性。
(2) 销毁
销毁的话也不是像单链表一样从头节点开始销毁,而是从头节点后面的节点开始销毁,因为最后一个节点的 next 指针是指向头节点的,由于其循环的特性,判断除头节点之外的节点是否都销毁了就是看遍历链表的指针是否指向头节点。
所以这里定义一个 pcur 指针指向头节点的下一个节点,然后循环判断 pcur 是否等于 phead(头节点的地址),在循环里面先保存 pcur 下一个节点的地址 next,然后释放当前节点,再让 pcur 指向下一个节点,最后释放头节点。
2) 头插与头删
(1) 头插
头插有一个需要注意的点:头插是在头节点的后面,头节点后面的第一个节点的前面插入,而不是在头节点的前面插入(在头节点前面插入相当于尾插)。
头插过程如图:
在头插改变指针指向的时候,有一个需要注意的点:一定要最后在 newnode 节点改变next 和 prev 指针指向之后,再改变 phead->next 节点的指向,因为如果先改变 phead->next 节点的指向,那么原来的 phead->next 就找不到了,newnode->next 指针也就没法指向下一个节点了。当然,如果事先保存了 phead->next 节点的地址,先改变谁的指向也就无所谓了。
我们再来看一下特殊情况,那就是只有一个头节点的情况(可以结合下面的代码):当只有一个头节点时,此时phead->next 与 phead->prev 都指向 phead,头插的时候只会改变指针的指向,也不会出现对NULL指针的解引用的特殊情况,所以对于只有一个头节点的双向链表也是可以的。
(2) 头删
既然是删除节点,就先要判断链表为不为空(链表中只剩一个头节点)。双向链表的头删如下图所示:
3) 尾插和尾删
(1) 尾插
在尾插这一接口的实现过程中,可以真正看到双向链表与单链表结构的区别带来的优势,在单链表里面,尾插的时间复杂度仍未O(n),而在双向链表里面,时间复杂度仅为O(1),只需改变指针的指向即可,尾插的过程如图所示:
同样的,尾插对于只有一个头节点的双向链表来说,也不需要特殊处理,因为仅仅改变指针的指向,也不存在对NULL指针的解引用,且逻辑也是可以的。
(2) 尾删
尾删需要先判断链表是否为空(即是否仅有一个头节点)。尾删的过程如图:
4) 在pos位置之前插入和删除pos节点
(1) 在pos位置之前插入
在pos位置之后插入数据,首先先要保证pos位置是有效的位置(即pos不为NULL)。在pos位置之后插入数据和头插、尾插一样,只需要改变指针的指向即可:先开辟一个新的节点 newnode,让 newnode->prev 指向 pos,newnode->next 指向 pos->next指向的节点,再让 newnode->next->prev 指向 newnode,pos->next 指向 newnode(这里就不画图了,逻辑比较简单,依照前两个插入画图即可)。
(2) 删除pos节点
既然是删除节点,那就需要判断链表为不为空。然后删除pos节点也只需改变指针的指向,然后释放pos节点即可。先让 pos->next->prev 指向 pos->prev,再让 pos->prev->next 指向 pos->next,然后释放 pos 即可。
但是删除完 pos 节点之后要把原来传给 LTErase函数(删除pos节点的函数)pos 形参的实参给置为NULL,因为pos节点释放之后,原来的形参就变为了野指针,使用会发生越界访问。
5) 判空和查找
这两个接口比较简单,判空只需要判断 phead->next(phead为指向头节点的指针)是否等于phead ;而查找的话只需要遍历链表,对比每个节点内的值是否是需要查找的值,如果是,返回该节点的地址;如果不是,那就继续判断下一个节点,直到遍历完整个链表,返回NULL。
查找时只需要有一点需要注意,就是判断是否遍历完链表的条件为 pcur (遍历链表时指向每个节点的指针变量)是否等于phead(指向头节点的指针),而不是 pcur 是否等于 NULL。
4 代码
1) List.h文件:
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
//定义数据类型
typedef int LTDataType;
//定义双向链表结点的结构
typedef struct ListNode
{
LTDataType data;//结点里的数据
struct ListNode* prev;//前驱指针,指向前一个结点
struct ListNode* next;//后继指针,指向后一个结点
}LTNode;
//初始化
LTNode* LTInit();
//销毁
void LTDestroy(LTNode* phead);
//打印
void LTPrint(LTNode* phead);
//判空
bool LTEmpty(LTNode* phead);
//尾插
void LTPushBack(LTNode* phead, LTDataType x);
//尾删
void LTPopBack(LTNode* phead);
//头插
void LTPushFront(LTNode* phead, LTDataType x);
//头删
void LTPopFront(LTNode* phead);
//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x);
//删除pos位置数据
void LTErase(LTNode* pos);
//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x);
2) List.c文件:
#include"List.h"
//开辟新结点
LTNode* buyNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail!\n");
exit(1);
}
newnode->data = x;
newnode->prev = newnode->next = newnode;
return newnode;
}
//初始化
LTNode* LTInit()
{
//创建一个头节点,不存储任何有效数据
LTNode* phead = (LTNode*)malloc(sizeof(LTNode));
//要让头节点的前驱指针与后继指针都指向自己,才能循环
phead->prev = phead->next = phead;
return phead;
}
//销毁
void LTDestroy(LTNode* phead)
{
LTNode* pcur = phead->next;
while (pcur != phead)
{
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
//最后释放头结点
free(phead);
phead = NULL;
}
//打印
void LTPrint(LTNode* phead)
{
LTNode* pcur = phead->next;
while (pcur != phead)
{
printf("%d ", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
//判空
bool LTEmpty(LTNode* phead)
{
//只需判断头结点的下一个结点是不是头节点
return phead->next == phead;
}
//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
//由于有头节点,所以不需判断头节点是否为空
LTNode* newnode = buyNode(x);
//phead phead->prev newnode 改变这三个结点指针的指向
newnode->next = phead;
newnode->prev = phead->prev;
phead->prev->next = newnode;
phead->prev = newnode;
}
//尾删
void LTPopBack(LTNode* phead)
{
//删除先判空
assert(!LTEmpty(phead));
//把要删除的结点设为del结点
LTNode* del = phead->prev;
//del->prev phead 改变这两个结点的指向
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
//不是插入头节点,而是在头节点后面插入一个结点
LTNode* newnode = buyNode(x);
//phead newnode phead->next 改变这三个结点之间的指向
newnode->prev = phead;
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
}
//头删
void LTPopFront(LTNode* phead)
{
//先判断是否为空
assert(!LTEmpty(phead));
LTNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
//释放del节点
free(del);
del = NULL;
}
//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = buyNode(x);
//pos newnode pos->next
newnode->prev = pos;
newnode->next = pos->next;
pos->next->prev = newnode;
pos->next = newnode;
}
//删除pos位置数据
//删除完pos位置数据之后,要把原来实参位置置为NULL,否则原指针会变成野指针
void LTErase(LTNode* pos)
{
assert(pos);
//pos->prev pos pos->next
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x)
{
LTNode* pcur = phead->next;
//这里必须判断pcur是否等于phead,不能判断pcur是否为NULL
while (pcur != phead)
{
if (pcur->data == x)
return pcur;
pcur = pcur->next;
}
return NULL;
}
3) test.c文件:
#include "List.h"
void Test3()
{
LTNode* phead = LTInit();
//测试头插
/* LTPushFront(phead, 1);
LTPushFront(phead, 2);
LTPushFront(phead, 3);
LTPushFront(phead, 4);
LTPrint(phead);*/
//测试头删
/*LTPopFront(phead);
LTPopFront(phead);
LTPopFront(phead);
LTPopFront(phead);*/
//LTPopFront(phead);
//测试尾插
LTPushBack(phead, 1);
LTPushBack(phead, 2);
LTPushBack(phead, 3);
LTPushBack(phead, 4);
LTPrint(phead);
测试尾插
//LTPopBack(phead);
//LTPopBack(phead);
//LTPopBack(phead);
//LTPopBack(phead);
//LTPopBack(phead);
//LTPrint(phead);
//测试查找
LTNode* ret = LTFind(phead, 4);
/*if (ret == NULL)
{
printf("没找到!\n");
}
else
printf("找到了!\n");*/
//测试在pos位置之前插入数据
//LTInsert(ret, 6);
LTErase(ret);
LTPrint(phead);
LTDestroy(phead);
}
int main()
{
Test3();
return 0;
}
5 顺序表与链表的对比
最后,我们再来对比一下顺序表与链表。顺序表与链表作为数据结构中最基础的线性表,他们的差异还是挺大的,具体体现在以下几个方面:
不同点 | 顺序表 | 双向链表 |
---|---|---|
存储空间 | 数组,物理结构上是连续的 | 一个一个节点,物理结构上不连续 |
能否随机访问 | 可以,时间复杂度为O(1) | 不支持,需要从头遍历 |
插入数据或者删除数据效率对比 | 需要频繁挪动数据,复杂度为O(n) | 只需改变指针指向,复杂度为O(1) |
插入时开辟空间对比 | 满了需要扩容,且有时会造成空间的浪费 | 没有容量,按需申请。不会浪费空间 |
应用场景 | 元素高效存储和频繁访问 | 频繁插入和删除数据 |