- 小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
前言
在《数据结构与算法----顺序表》中总结了自己对于顺序表的学习,这里简单回顾一下
- 顺序表是采用顺序存储结构进行存储的线性结构,
- 其在查找方面具有比较大的优势,可以根据下标进行查找,时间复杂度为0(1)
- 对于插入和删除操作,其时间复杂度为O(N),查找效率较低
- 在空间方面,顺序表需要初始化时确定空间大小,有可能会出现开辟空间不足或过大的情况,不够灵活,但是在删除元素时不需要考虑空间的释放 本篇博客总结一下线性表的另一种存储结构----链式存储结构。
一、单链表
线性表在内存中以链式结构存储,就是链表。链表根据是否有前驱和后继,又可以分为单向链表和双向链表,本篇先讨论单向链表的情况。
单向链表是指一个结点,包含一个数据域和指针域,其中数据域用于存储数据,指针域存储下一个结点的指针地址。其定义可以如下代码所示:
typedef struct Node {
ElemType data; //!< 数据域
struct Node *next; //!< 指针域,存储下一结点的指针地址
}LinkNode;
1、单向链表的初始化
在进行初始化之前,先解释几个概念:
- 头结点:指向链表的第一个结点,不存储值,数据域为空或者无意义的值
- 首元结点:链表的第一个结点,存储实际意义的值
- 尾结点:链表的最后一个结点,如果是循环链表,其后继指向头结点
实际上头结点起到的是一个标志作用,不设置头结点也可以实现链表,不过在后期的操作时,代码会相对麻烦一些。如下是单链表的初始化代码:
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef int ElemType; /* ElemType为元素类型,根据实际情况而定,这里假设为int */
typedef struct Node {
ElemType data; //!< 数据域
struct Node *next; //!< 指针域,存储下一结点的指针地址
}LinkNode;
typedef LinkNode * LinkList;
Status initLinkList(LinkList *L) {
*L = (LinkList)malloc(sizeof(LinkNode));
if (*L == NULL) { // 容错,开辟失败的情况
return ERROR;
}
LinkList temp = NULL;
LinkList r = *L;
for (int i = 0; i <= 10; i++) { // 此处默认加上了0~10,也可以不加,不加的话for循环可以去掉
temp = (LinkNode *)malloc(sizeof(LinkNode));
temp->data = i;
r->next = temp;
r = temp;
}
r->next = NULL;
return OK;
}
2、单链表的遍历
单链表的遍历比较简单,代码如下:
void displayLink(LinkList L) {
if (L->next == NULL) {
printf("链表为空");
return;
}
LinkList temp = L->next;
printf("链表元素数据为:\n");
while (temp) {
printf("%d ", temp->data);
temp = temp->next;
}
printf("\n");
}
L为单链表的头结点,如果其后继结点为空,则说明该链表没有存储实际意义的值,因此判定链表为空。当遍历链表时,执行 temp = temp->next; 语句,当temp为空时,表明链表遍历完毕。
3、前插法构建单链表
单链表的构建分为两种,即前插法和后插法,本小节先解释一下前插法。
前插法,顾名思义,链表元素由前部插入。链表为空时,插入一个元素,则将头结点指向该元素,再有元素插入时,将新元素的后继指向头结点的后继,再将头结点的后继指向新插入的结点,如下图所示:
其代码如下:
Status headInsert(LinkList *L) {
*L = (LinkList)malloc(sizeof(LinkNode));
if (*L == NULL) {
return ERROR;
}
(*L)->next = NULL; // 头结点的后继先置为NULL,将影响到插入的第一个新结点的后继
LinkList temp = NULL;
for (int i = 0; i <= 10; i++) {
temp = (LinkList)malloc(sizeof(LinkNode));
temp->data = i;
temp->next = (*L)->next; // 每次都将头结点的next赋值给新结点的next
(*L)->next = temp; // 让头结点的next指向新的结点
}
return OK;
}
4、后插法构建单链表
后插法与前插法正好相反,当插入第一个元素时,将头结点的next指向插入元素,该元素的next指向NULL,再有新元素插入时,则将上一个元素的next指向新元素,新元素的next指向NULL,如下图所示:
其代码如下:
Status tailInsert(LinkList *L) {
*L = (LinkList)malloc(sizeof(LinkNode));
if (*L == NULL) {
return ERROR;
}
LinkList temp = NULL;
LinkList r = *L; // 变量 r 记录最后一个结点的位置,初始值为头结点
for (int i = 0; i <= 10; i++) {
temp = (LinkNode *)malloc(sizeof(LinkNode));
temp->data = i;
r->next = temp; // 将新结点插入链表末尾
r = temp; // 变换 r 的值,此时最后一个结点为新插入的结点,将r指向该结点
}
r->next = NULL; // 将最后一个结点的next置为NULL,防止遍历时崩溃
return OK;
}
对照前插法和后插法,可以发现相对于前插法,后插法会多一个临时的标记变量 r,但是实际使用时后插法更加符合逻辑。
5、单链表的插入与删除
需要注意的是,前插法和后插法都是构建单链表的一种方式,都是在链表前部或后面插入新的结点,而不是从中间插入。本小节就看下单链表的插入和删除。
1.5.1 单链表的插入
与顺序结构不同,单链表的插入不需要将插入位置之后的元素都向后移动,而是更改插入位置前一个结点的指向以及新结点的指向即可,具体可以分为以下几步:
- 1、找到插入位置的前一个位置target
- 2、新结点temp的next指向 target的next
- 3、target的next指向新结点 temp
具体看参照下图所示:
需要注意的一点是,第2步必须在第3步之前,否则就无法找到原本的后继结点了。插入代码如下:
Status insertElement(LinkList *L, ElemType e, **int** place) {
if (!(*L) || place < 1) {
return ERROR;
}
LinkList target = (*L)->next;
// 找到插入位置的前一个结点
for (int i = 1; i < place && target; target = target->next, i++) {}
// 判断是否找到位置
if (!target) return ERROR;
// 创建新结点
LinkList temp = (LinkList)malloc(sizeof(LinkNode));
if (!temp) {
return ERROR;
}
temp->data = e;
// 将新结点的next指向 targe的next
temp->next = target->next;
target->next = temp;
return OK;
}
1.5.2 单链表的删除
与插入相比,单链表的删除步骤相对简单,不过都需要一个共同的步骤,即找到操作结点的位置,总结一下分为一下几个步骤:
- 1、找到待删除结点位置的前一个位置target
- 2、将target的next指向待删除结点的next
- 3、释放待删除结点的内存,完成删除
步骤可参照下图:
代码如下所示:
Status deleteElement(LinkList *L, **int** place) {
if ((*L)->next == NULL) {
printf("链表为空");
return ERROR;
}
LinkList target = (*L)->next;
// 找到待删除结点位置的上一个结点
for (int i = 1; i < place && target; target = target->next, i++) {}
if (!target) {
return ERROR;
}
LinkList temp = target->next; // 得到要删除的结点
target->next = temp->next; // 将上一结点的next指向待删除结点的next
free(temp); // 释放待删除结点的内存,完成删除
return OK;
}
二、单向循环链表
单向循环链表与单链表的区别在于,单链表的最后一个结点的next指向 NULL,而单向循环链表的最后一个结点的next指向头结点(如果设置了头结点)或者首元结点(没有设置头结点的情况),如下图所示:
在单链表中我们引入头结点实现了增删改查,本节不使用头结点的方式,来实现一下单向循环链表的增删改查。
1、单向循环链表的初始化
在初始化单向循环链表时,与单链表不同,每次新加入的结点要将其next指向首元结点,这里采用不加入头结点的方式,代码如下:
Status initLinkList(LinkList *L) {
LinkList temp = NULL;
printf("输入你想输入的数字并按空格键,输入0结束输入\n");
LinkList r = NULL;
int item = 0;
while (1) {
scanf("%d", &item); // 采用scanf函数输入的方式
if (item == 0) {
// 输入0结束循环
break;
}
if (*L == NULL) {
// 链表为空的情况
*L = (LinkList)malloc(**sizeof**(LinkNode));
if (!(*L)) {
return ERROR;
}
// 此时L即为首元结点
(*L)->data = item;
(*L)->next = *L; // 与单链表不同的是,这里要指向自己
r = *L;
} else {
// 链表不为空
temp = (LinkList)malloc(**sizeof**(LinkNode));
if (!temp) {
return ERROR;
}
temp->data = item;
temp->next = *L; // 与单链表不同,这一指向首元结点
r->next = temp;
r = temp; // 添加一个变量 r,用于记录最后一个结点的位置,不加的话需要先找到最后一个结点
}
}
return OK;
}
2、单向循环链表的遍历
单向循环链表的遍历,需要考虑两个方面:
- 1、链表为空的判断,循环链表是否为空,判断链表的首元结点是否为空即可,这里直接判断 L != NULL
- 2、循环结束条件,单向循环链表的遍历不同于单链表的遍历,不能简单判断结点是否为空,而是要判断结点的next与首元结点是否相同 代码如下所示:
void display(LinkList L) {
if (L == NULL) {
// 因为未引入头结点,所以直接判断 L 是否为空即可
printf("链表为空");
return;
}
LinkList temp = L;
// 使用do while 循环,否则在while循环前要先调用一次 temp = temp->next;
do {
printf("%d ", temp->data);
temp = temp->next;
} while (temp != L);
printf("\n");
}
3、单向循环链表的插入
因为没有使用头结点,所以需要考虑插入结点是否是首元结点的情况:
- 插入结点为首元结点:与单链表不同,单向循环链表不能直接将新结点的next指向原首元结点,而是要先找到尾结点,并将新结点next指向原首元结点(尾结点的next),再尾结点的next指向新结点,以此保证插入结点后链表依然是循环链表
- 插入结点不是首元结点:找到插入结点的前一个结点target,将新结点的next指向target的next,再将target的next指向新结点,即完成插入
过程如下图所示:
代码分为两种,下标从0开始和下标从1开始,但是其逻辑基本与上图一致,其代码分别如下所示:
1、下标从0开始
Status insertLinkList(LinkList *L, int place, ElemType e) {
LinkList target = NULL;
LinkList temp = NULL;
if (place == 0) {
// 首元结点
// 先创建一个新结点,成功则插入,否则返回 ERROR
// 循环链表,需要先找到最后一个结点
// 将新结点的next指向原来的首元结点,将尾结点的next指向新结点,并将首元结点改为新结点
for (target = *L; target->next != *L; target = target->next) {}
if (target == NULL) {
return ERROR;
}
temp = (LinkList)malloc(sizeof(LinkNode));
if (!temp) {return ERROR;}
temp->data = e;
temp->next = *L;
target->next = temp;
*L = temp;
} else {
// 非首元结点
int i = 1;
for (target = (*L); target->next != *L && i < place; target = target->next, i++) {}
if (target == NULL) {
return ERROR;
}
temp = (LinkList)malloc(sizeof(LinkNode));
if (!temp) {return ERROR;}
temp->data = e;
temp->next = target->next;
target->next = temp;
}
return OK;
}
2、下标从1开始
Status insertLinkList1(LinkList *L, int place, ElemType e) {
LinkList target = NULL;
LinkList temp = NULL;
if (place == 1) {
// 首元结点
// 先创建一个新结点,成功则插入,否则返回 ERROR
// 循环链表,需要先找到最后一个结点
// 将新结点的next指向原来的首元结点,将尾结点的next指向新结点,并将首元结点改为新结点
for (target = *L; target->next != *L; target = target->next) {}
if (target == NULL) {
return ERROR;
}
temp = (LinkList)malloc(sizeof(LinkNode));
if (!temp) {return ERROR;}
temp->data = e;
temp->next = *L;
target->next = temp;
*L = temp;
} else {
// 非首元结点
int i = 1;
for (target = (*L)->next; target->next != *L && i < place - 1; target = target->next, i++) {}
if (target == NULL) {
return ERROR;
}
temp = (LinkList)malloc(sizeof(LinkNode));
if (!temp) {return ERROR;}
temp->data = e;
temp->next = target->next;
target->next = temp;
}
return OK;
}
对比两部分代码,可以发现插入位置为首元结点时,下标为0或者1,对于代码的影响并不大;对于插入位置为非首元结点的情况,则需要修改下查找上一结点位置的代码,其余逻辑也无需改变。
4、单向循环链表的删除
与插入一致,单向循环链表在删除时,也要考虑删除位置为首元结点和非首元结点的情况:
- 首元结点:首先找到尾结点,再将尾结点的next指向待删除结点的next,之后删除结点,并释放内存
- 非首元结点:找到待删除结点的前一个位置targe,将target的next指向待删除结点的next,之后删除结点,并释放内存。 代码如下所示(这里只展示下标从1开始的情况):
Status LinkListDelete(LinkList *L,int place){
LinkList temp,target;
int i;
//temp 指向链表首元结点
temp = *L;
if (temp == NULL) return ERROR;
if (place == 1) {
//①.如果删除到只剩下首元结点了,则直接将*L置空;
if ((*L)->next == (*L)){
(*L) = NULL;
return OK;
}
//②.链表还有很多数据,但是删除的是首结点;
//1. 找到尾结点, 使得尾结点next 指向头结点的下一个结点 target->next = (*L)->next;
//2. 新结点做为头结点,则释放原来的头结点
for (target = *L; target->next != *L; target = target->next);
temp = *L;
*L = (*L)->next;
target->next = *L;
free(temp);
} else {
//如果删除其他结点--其他结点
//1. 找到删除结点前一个结点target
//2. 使得target->next 指向下一个结点
//3. 释放需要删除的结点temp
for (i=1,target = *L;target->next != *L && i != place -1;target = target->next,i++) {};
temp = target->next;
target->next = temp->next;
free(temp);
}
return OK;
}
总结
本文主要总结了单向链表的操作,包括单链表和单向循环链表。在探索的过程中对于是否引用头结点,做了对比,可以发现在不引入头结点的情况下,需要多考虑一种情况,当然如果不在意这一点也可以不引入,这一点看个人习惯。
通过本篇的总结,我们可以发现只看单链表删除和插入操作时,其时间复杂度为O(1),而其查询操作为O(n),但是在开辟空间时不需要考虑容量的问题,通过自己手动释放内存来进行内存的管理。本篇主要是单向链表的总结,如果有不正确的地方欢迎大家指正,下一篇将进行双向链表的总结。