一、数据结构----链表
1.什么是链表
链表 [Linked List]:一种线性表,是一种在逻辑上连续,但在物理储存结构上非连续、非顺序的储存结构。其数据元素的逻辑顺序是通过链表中的指针链接次序实现。
2.链表共分几类
链表大致分为以下三类,每种可以带头节点与不带头结点,不同种类也可组合成为新的链表结构
1.单链表:
2.双向链表:
3.循环链表:
单向
双向
链表的核心操作有三种:插、删、查(遍历);下面将通过两种链表实例体会一下不同链表核心操作的不同之处
二、无头结点单向非循环链表
1.单向链表的空间开辟方式【重点】
单链表是一种链式存取的数据结构,其表中的数据是以结点来表示的,每个结点构成有:元素(本结点数据)+指针(后继元素储存位置)。
其中,DATA数据元素可以存储你需要的任何数据格式,可以是数组、int、甚至是结构体(也就是传说中的结构体套结构体)等等;
NEXT作为一个结点指针,指向下一个结点,而下一个结点内部也有一个结点指针指向下下个结点;
这里是一个单链表结点的结构体定义:
typedef int DataType; //类型重命名
//定义链表结点
typedef struct SListNode
{
DataType data; //数据域
struct SListNode* next; //指针域
}SLNode; //struct SListNode类型的重命名为SLNode
链表就是通过许多这样的结点形成一种在物理上非连续,逻辑上连续的线性表,但由于每个结点只有下一个结点的地址,故若要找到一个结点,需从起始结点开始向下遍历;
故由于单链表的这种特性,单链表在开辟时有带头结点和带头指针(不带头结点)两种开辟方式:
不带头结点
不带头结点链表在开辟时直接初始化一个头指针指向链表第一个结点即可:
SLNode* pList = NULL;
带头结点
使用头结点前,我们先弄清楚
头结点是什么?
头结点是链表里的第一个结点,但是它并不包括在链表的数据集当中;
头结点的数据域不存任何信息或说无意义,指针域指向链表的一个有意义元素结点的地址;
头结点的存在使链表在任何情况下第一个结点非空,并使对单链表的插入、删除操作不需要区分是否为空表或是否在第一个位置进行,从而与其他位置的插入、删除操作一致!
带头节点链表在开辟时须先为链表初始化一个头结点:
//初始化
SLNode* SListCreat()
{
SLNode * head = buySListNode(0);
head->next = NULL;
return head;
}
有无头结点区别
不带头结点的单链表 对于第一个节点的操作与其他节点不一样,需要特殊处理,这增加了程序的复杂性和出现bug的机会,因此,通常在单链表的开始结点之前附设一个头结点。
带头结点的单链表,初始时一定返回的是指向头结点的地址,所以一定要用二维指针,否则将导致内存访问失败或异常。
带头结点与不带头结点初始化、插入、删除、输出操作都不样,在遍历输出链表数据时,带头结点的判断条件是while(head->next!=NULL),而不带头结点是while(head!=NULL)
有关带头结点更详细的要点可以参考这两位大佬的总结,非常全面:
链接: 单链表:带头结点和不带头结点 总结.
链接: 链表带头节点与不带头节点的区别.
2.申请新结点
链表不同于顺序表在开辟初始需申请一段连续的储存空间存放全部元素,而是随用随申,在每次存放或插入新结点时便需动态申请一个结点大小的空间;
这里用SLNode* BuySListNode(DataType x)
函数实现该功能:
//动态申请一个结点空间
SLNode* BuySListNode(DataType x) //参数为新结点的数据域
{
SLNode* newNode = (SLNode*)malloc(sizeof(SLNode));
if (NULL == newNode)
{
assert(0);
return NULL;
}
newNode->data = x;
newNode->next = NULL;
return newNode;
}
3.释放链表(不要忘记)
既然链表的每个结点是动态在堆上开辟的,那么在链表调用结束后一定记得释放堆空间,否则有可能造成内存泄漏!
// 释放链表
void SListDestroy(SLNode** pplist) //参数为链表头指针地址(由于会修改头指针的数值,故只能地址传参)
{
assert(pplist); //断言头指针地址是否存在 头指针不存在的情况不合法
SLNode* cur = *pplist;
while (cur) //依次向下遍历直至链表结点不存在
{
*pplist = cur->next;
free(cur); //依次释放每一位结点
cur = *pplist;
}
*pplist = NULL;
}
4.单链表插入结点(尾插&头插)
在无头结点单链表每次执行插入操作时,需考虑这两个问题:
a.链表是否为空
链表为空:这时给一个空链表插入元素就是通过链表的头指针开辟链表,直接让头指针存放新结点地址即可;故时间复杂度为O(1)
链表不为空:
尾插 不会影响头指针数值,但插入前需先将链表遍历找到最后一位结点才能将新元素插入到链表最后;故时间复杂度为O(N)
头插 会影响头指针数值,直接插入即可;故时间复杂度为O(1)
b.传参为什么用二级指针?
在C语言函数传参中,有两种传参方式:传值传参和传址传参,由于函数调用是在堆栈上开辟一段临时的空间,函数调用结束后会释放堆栈空间,且传的参数在函数内是作为临时变量存在的,故若要修改传参本身的值是不能采用【传值传参】,这时就需要通过传该参数的地址,也就是【传址传参】,通过解引用传进来函数的地址即可修改原参数的值。
那么如果这个函数参数是一个指针呢? 那么这个参数其实就是一个指针变量,若需修改指针变量存放的地址,就需进行传址传参,传这个指针的地址,也就是二级指针。
在无头结点的链表中为什么传二级指针参数? 在这里的参数是链表的头指针,而在链表的头插与头删时,头结点会发生改变,那么指向头结点的头指针就需要修改其存的地址,那么这是就需要【传址传参】。
// 单链表尾插 空链表尾插O(1)、一段链表尾插O(N)
void SListPushBack(SLNode** pplist, DataType x)
{
assert(pplist); //指向链表的指针是否存在
//1.链表为空
if (NULL == *pplist)
{
*pplist = BuySListNode(x);
return;
}
else //2.链表不为空
{
//a.找当前链表最后一个节点
SLNode* cur = *pplist;
while (cur->next != NULL)
{
cur = cur->next;
}
//b.插入新元素
cur->next = BuySListNode(x);
}
}
// 单链表的头插 时间复杂度O(1)
void SListPushFront(SLNode** pplist, DataType x)
{
/*
assert(pplist);
SLNode* newNode = BuySListNode(x);
if (NULL == *pplist){
*pplist = newNode;
}
else
{
newNode->next = *pplist;
*pplist = newNode;
}
*/
//对上方代码优化一下:
assert(pplist);
//无论链表是否有值,头插法均可直接插值 因为若为空,*pplist == NULL
SLNode *newNode = BuySListNode(x);
newNode->next = *pplist;
*pplist = newNode;
}
5.单链表删除结点(尾删&头删)
单链表的删除需注意以下要点:
a.链表是否为空
通过
NULL == *pplist
判断链表头指针是否指向结点,若为空链表直接退出
b.链表不为空
尾删:
若只有一个结点,需修改头指针的值;
若有很多结点,需先将最后一个结点遍历出来再删除;故时间复杂度为O(N)
头删:无论几个节点,直接修改头指针指向结点即可;故时间复杂度为O(1)
【注】 删除操作记得释放被删除结点!
// 单链表的尾删
void SListPopBack(SLNode** pplist)
{
assert(pplist);
//1.链表为空
if (NULL == *pplist)
{
printf("链表为空\n");
return;
}
else if (NULL == (*pplist)->next) //2.链表只有一个结点
{
free(*pplist);
*pplist = NULL;
}
else //3.链表有多个结点
{
//a.找到最后结点,并为前一个结点留一个标记指针
SLNode* cur = *pplist;
SLNode* pre = NULL;
while (cur->next != NULL)
{
pre = cur;
cur = cur->next;
}
//b.删除结点
pre->next = NULL;
free(cur);
}
}
// 单链表头删 时间复杂度O(1)
void SListPopFront(SLNode** pplist)
{
assert(pplist);
//1.空链表直接退出
if (NULL == *pplist)
{
printf("链表为空\n");
return;
}
else //2.链表非空
{
SLNode* delNode = *pplist;
*pplist = delNode->next;
free(delNode);
}
}
6.单链表按数据域查找
按结点数据域的值进行查找,返回对应结点的地址:
【注】链表的查找无需修改头指针的数据故【传值传参】即可
// 单链表查找
SLNode* SListFind(SLNode* plist, DataType x)
{
SLNode* cur = plist;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
7.单链表在任意位置插入&删除结点
一般在给定pos位置之后插入和删除。
a.为什么不在pos位置之前插入
由于单链表的一个结点只能指向下一个结点,我们无法只凭借单链表的一个结点找到上一个结点;
b.为什么不删除pos位置
原因同上,若删除pos位置的结点,那么就需修改pos上一个结点的next指针域,但我们无法得知上一个结点的位置;
8.单链表的打印(正序&逆序)
这里正序通过循环遍历打印;逆序通过递归调用打印
// 单链表打印
void SListPrint(SLNode* plist)
{
SLNode* cur = plist;
while (cur)
{
printf("%d--->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
// 单链表逆序打印----递归方法
void SListReversePrint(SLNode* plist)
{
if (NULL != plist)
{
SListPrint(plist->next);
printf("%d-->", plist->data);
}
}
源代码
具体数据结构源代码详见git项目
链接: https://gitee.com/doooyh/doooyh_code/tree/master/SList.
三、带头结点双向循环链表
双向链表可以简称为双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
1.带头节点双向循环链表的空间开辟方式【重点】
a.双向循环链表结点数据结构
由于单链表的每个结点只能指向下个结点,具有单向性,在需要大量搜索结点是每次都需从头结点开始,效率非常低。
那么在单链表的基础上,每个结点增加了一个指向前一个结点的prev指针域
双链表的结点定义代码:
typedef int DataType;
typedef struct DListNode
{
struct DListNode * prev;
DataType data;
struct DListNode * next;
}DLNode;
b.动态申请新结点
双链表申请每个新结点与单链表并无差异,都是动态申请的方法:
//动态申请一个结点空间
DLNode * buyDListNode(DataType data)
{
DLNode * newNode = (DLNode*)malloc(sizeof(DLNode));
if (NULL == newNode)
{
assert(0);
return NULL;
}
newNode->prev = NULL;
newNode->data = data;
newNode->next = NULL;
return newNode;
}
c.双链表创建头结点
通过调用DLNode * DListHead = DListCreat();
实现
这里要注意,对于带头节点的循环链表来说,若链表只有一个头结点,仍需将其构成循环结构;
//创建链表 其实就是创建链表的头结点
//当循环链表只有头结点时,其指针指向自身,组成循环链表
DLNode * DListCreat()
{
DLNode * head = buyDListNode(0);
head->prev = head;
head->next = head;
return head;
}
2.释放双链表(不要忘记)
链表销毁需要将头结点指向空,需修改头结点自身,故传二级指针
//双向链表销毁
//链表销毁需要将头结点指向空,需修改头结点自身,故传二级指针
void DListDestory(DLNode** pplist)
{
assert(pplist);
DLNode* cur = (*pplist)->next;
//采用头删法
while (cur != *pplist)
{
//链表的销毁只要找到下一位结点遍历即可,无需处理结点的prev指针
(*pplist)->next = cur->next;
free(cur);
cur = (*pplist)->next;
}
//当只剩一个结点时退出循环
//释放头结点
free(*pplist);
*pplist = NULL; //这里修改了指针内容 故需二级传参
}
3.双循环链表的尾插&尾删
双向循环链表的尾插与尾删可以通过头结点的prev指针找到链表最后一个结点,无需遍历;
故时间复杂度为O(1)
由于该链表带有头结点,其头插与头删均不考虑是否只有一个结点的情况
【注】尾删需提前判断链表是否为空,通过int DListEmpty(DLNode* head)
函数实现:
//判断链表是否为空 (只有头结点)
int DListEmpty(DLNode* head)
{
assert(head);
return head->next == head;
}
尾插图示:
//双向链表尾插 (通用,无论是否是只有头结点)
void DListPushBack(DLNode* head, DataType x)
{
assert(head);
DLNode* newNode = buyDListNode(x);
newNode->next = head; //步骤一
newNode->prev = head->prev; //步骤二
head->prev->next = newNode; //步骤三
head->prev = newNode; //步骤四
//方法二:借用pos方法
//DListInsert(head, x);
}
尾删图示:
//双向链表尾删
void DListPopBack(DLNode* head)
{
//若链表为空
if (DListEmpty(head))
{
return;
}
DLNode* delNode = head->prev;
head->prev = delNode->prev; //步骤1
delNode->prev->next = head; //步骤2
free(delNode); //步骤3
delNode = NULL;
//方法二:借用pos方法
//DListErase(head->prev);
}
4.双循环链表的头插&头删
双向循环链表的头插与头删同尾插尾删,时间复杂度为O(1)
//双向链表头插 (通用,无论是否是只有头结点)
void DListPushFront(DLNode* head, DataType x)
{
assert(head);
DLNode* newNode = buyDListNode(x);
//头结点后加新结点即可
newNode->next = head->next;
newNode->prev = head;
head->next = newNode;
newNode->next->prev = newNode;
//方法二:借用pos方法
//DListInsert(head->next,x);
}
//双向链表头删
void DListPopFront(DLNode* head)
{
//若链表为空
if (DListEmpty(head))
{
return;
}
DLNode* delNode = head->next;
head->next = delNode->next;
delNode->next->prev = head;
free(delNode);
delNode = NULL;
//方法二:借用pos方法
//DListErase(head->next);
}
5.双循环链表按数据查找
通过遍历查找相同数据域的结点返回结点地址
//双向链表查找
DLNode* DListFind(DLNode* head, DataType x)
{
assert(head);
DLNode* cur = head->next;
while (cur != head)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
6.双循环链表在任意位置插入&删除
在pos前一个结点插入&删除pos结点;
【可以试试通过这两个函数替换前面的头删(插)与尾删(插)函数】
//双向链表在pos的前面进行插入
void DListInsert(DLNode* pos, DataType x)
{
if (NULL == pos)
{
return;
}
DLNode* newNode = buyDListNode(x);
newNode->next = pos;
newNode->prev = pos->prev;
pos->prev->next = newNode;
pos->prev = newNode;
}
//双向链表删除pos位置的节点
void DListErase(DLNode* pos)
{
if (NULL == pos)
{
return;
}
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
7.获取双循环链表元素个数
循环遍历获取
//获取链表节点个数
int DListSize(DLNode* head)
{
assert(head);
DLNode* cur = head->next;
int count = 0;
while (cur != head)
{
count++;
cur = cur->next;
}
return count;
}
8.双循环链表的打印
循环遍历打印
//双向链表的打印
void DListPrint(DLNode* head)
{
assert(head);
DLNode* cur = head->next;
while (cur != head)
{
printf("%d-->", cur->data);
cur = cur->next;
}
printf("\n");
}
源代码
具体数据结构源代码详见git项目
链接: https://gitee.com/doooyh/doooyh_code/tree/master/DList.
四、总结
1.链表和顺序表的区别
- 链表和顺序表都是线性表的一种,他们在逻辑上都是连续的,而在物理内存上,顺序表是连续的(类似数组),链表是非连续的,这是两者最大的区别;
- 链表和顺序表由于储存结构上的差异,导致其插、删、查操作也有者不同的特点:
2.单链表与双链表区别
单向链表:只有一个指向下一个节点的指针;适用于节点的增加删除。
优点:单向链表增加删除节点简单。遍历时候不会死循环;
缺点:只能从头到尾遍历。只能找到后继,无法找到前驱;
双向链表:有两个指针,一个指向前一个节点,一个后一个节点;适用于需要双向查找节点值的情况
优点:可以找到前驱和后继,可进可退;
缺点:增加删除节点复杂,需要多分配一个指针存储空间;