目录
一、总体区别
不同点 | 顺序表 | 链表 |
存储结构上 | 逻辑上连续,物理上也一样连续 | 逻辑上连续,物理上不一定连续 |
随机访问 | 支持下标访问 | 不支持下标访问 |
任意位置插入或者删除元素 | 插入:内存不够时,需要扩容 删除:可能需要搬运元素 | 插入:没有容量的概念 删除:只需要修改指针指向 |
应用场景 | 元素高效存储和频繁访问 | 任意位置插入或删除元素频繁 |
缓存利用率 | 高 | 低 |
二、顺序表
1、定义
由于在物理上是连续的,所以在内存不够时需要手动扩容(动态顺序表)。因此,定义时需要定义三个值:数组、有效数据个数、表的空间大小。
typedef struct SeqList
{
SLDataType* arr;
SLDataType size;//有效的数据个数
SLDataType capacity;//空间大小
}SL;
2、可实现方法
在顺序表的应用中,通常需要以下方法:
//顺序表的初始化
void SLInit(SL* ps);
//顺序表的销毁
void SLDestroy(SL* ps);
//头部插入删除/尾部插入删除
void SLPushBack(SL* ps,SLDataType x);
void SLPushFront(SL* ps, SLDataType x);
void SLPopBack(SL* ps);
void SLPopFront(SL* ps);
//顺序表的打印
void SLPrint(SL s);
//在指定位置之前插入/删除数据
void SLInsert(SL* ps, int pos, SLDataType x);
void SLErase(SL* ps, int pos);
//查找指定位置数据
int SLFind(SL* ps, SLDataType x);
(1)初始化与销毁
在顺序表的初始化与销毁中,我们首先应该清楚动态顺序表的每个变量的含义,其次我们才可以对其初始化。我们之前提到过arr代表指针,指向一个数组,因此我们在初始化时应当先将其置为空指针。而顺序表的大小和容量也应该置为空,而这里我们要把它置为0。因此,动态顺序表的初始化方法为:
void SLInit(SL* ps) {
ps ->arr = NULL;
ps->capacity = ps->size = 0;
}
在进行动态顺序表的销毁前,我们需要简单了解一下内存的相关知识。在我们每次的动态申请内存之后,我们都要讲自己申请的内存还给操作系统,以免造成内存泄漏(通俗来讲就是好借好还,再借不难)。了解了什么是内存泄漏之后,我们就应该才到顺序表的销毁步骤了—没错,那就是释放掉我们动态申请的内存。
//顺序表的销毁
void SLDestroy(SL* ps) {
if (ps->arr) {
free(ps->arr);
}
ps->arr = NULL;
ps->capacity = ps->size = 0;
}
这里我们注意,在释放掉ps->arr之后,我们要将arr指针再次置为空,避免其成为野指针。
(2)检查容量大小(动态开辟内存)
在进行动态顺序表的操作之前,我们知道动态顺序表最重要的就是“动态”二字,那么我们怎么实现“动态”呢?
在C语言的学习过程中,我们学到过动态开辟内存的三个函数malloc、realloc、calloc(这里不清楚的看官们可以自行了解一下)。在动态顺序表的扩容方法中,我们主要用到的就是realloc函数。首先代码如下:
void SLCheckCapacity(SL* ps) {
if (ps->capacity == ps->size) { //首先判断是否够用
//不够得申请
int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
SLDataType* tmp = (SLDataType*)realloc(ps->arr, 2 * newCapacity * sizeof(SLDataType));
if (tmp == NULL) {//判断是否增容成功
printf("realloc fail!\n");
exit(1);//直接退出!
}
//空间申请成功:
ps->arr = tmp;
ps->capacity = newCapacity;
}
}
在内存的扩容时,我们通常会让其呈指数的形式扩大,一般以2的倍数为主。在扩容时,我们还要考虑是否为第一次扩容。如果是第一次开辟空间,我们要先把容量大小设置为非零的数(这里我们设置成4),这里我们用了三目操作符来巧妙地实现。
扩容成原来的2倍之后,接下来需要判断是否成功,如果没有申请成功,就退出程序或返回。最后将新的数组指针赋值给arr,并且将内存大小赋值给capacity。
(3)头插尾插/头删尾删
在学习这四种方法时,我们将插入放在一起,将删除方法放在一起。原因是在执行插入方法时,不论是头插还是尾插,我们都要先判断内存是否够用。
在第一章讲的总体区别时,我们知道动态顺序表的一个缺点就是在进行插入操作时,如果是头插操作,那么需要后移每一个元素。而在尾插时,则是先判断容量大小,并且将size加上一位。
//头插
void SLPushFront(SL* ps, SLDataType x) {
assert(ps);
SLCheckCapacity(ps);
//先后移每一个值
for (int i = ps->size; i > 0; i--) {
ps->arr[i] = ps->arr[i - 1];//arr[i]=arr[i-1]
}
ps->arr[0] = x;
ps->size++;
}
//
//尾插:
void SLPushBack(SL* ps, SLDataType x) {
传过来空顺序表,比较温柔得解决方式为:
//if (ps == NULL) {
// return;
//}
assert(ps);//等价于 assert(ps != NULL)
//尾插之前查看空间大小够不够
SLCheckCapacity(ps);
ps->arr[ps->size++] = x;
}
在进行删除操作时,会比插入操作少了检查容量是否够这一步骤,但在头删操作时,还需要进行数据的前移,在尾删操作时,也要进行size的减一操作。代码如下:
//尾删
void SLPopBack(SL* ps) {
assert(ps);
assert(ps->size);//顺序表是否为空
//ps->arr[ps->size - 1] = -1;//要或不要都不影响
--ps->size;
}
//头删
void SLPopFront(SL* ps) {
assert(ps);
assert(ps->size);
for (int i = 0; i < ps->size - 1; i++) {
ps->arr[i] = ps->arr[i + 1];
}
--ps->size;
}
这里需要注意,进入函数之后,我们都要先判断结构体指针和数据个数是否为空,如果size为空则代表没有数据,则删除就没有意义。
(4)指定位置插入/删除
在指定位置插入或者删除数据,除了在尾部操作之外,都要涉及数据的移动以及size的变化,但不同的是,函数的参数在传递过程中多了两个值:要操作的位置pos以及插入的值x。
//指定位置插入
void SLInsert(SL* ps, int pos, SLDataType x) {
assert(ps);
assert(pos >= 0 && pos <= ps->size);
//判断空间是否够
SLCheckCapacity(ps);
for (int i = ps->size; i > pos; i--) {
ps->arr[i] = ps->arr[i - 1];
}
ps->arr[pos] = x;
ps->size++;
}
//删除指定位置数据
void SLErase(SL* ps, int pos) {
assert(ps);
assert(pos >= 0 && pos < ps->size);
for (int i = pos; i < ps->size - 1; i++) {
ps->arr[i] = ps->arr[i + 1];
}
ps->size--;
}
(5)顺序表的查找
与之前的几个方法不同,查找函数的返回类型为int类型,即返回的是数组下标,并且满足如果没找到,返回一个小于0的值。
//顺序表的查找
int SLFind(SL* ps, SLDataType x) {
assert(ps);
for (int i = 0; i < ps->size; i++) {
if (ps->arr[i] == x) {
return i;
}
}
//没有找到
return -1;
}
三、链表
1、定义
链表是一种逻辑上的顺序表,但在物理方面讲,它却是不连续的,即链表是逻辑上连续,物理上不连续。它的数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
2、链表的分类
在链表的分类中,我们可以将链表分为八类。其中,最常见的就是无头单向非循环链表(即常见的单链表)和带头双向循环链表(即常见的双向链表)。
上面提到链表分为八类,主要根据三个特征:带头和不带头、单向和双向、循环和不循环。
(1)单项或双向
(2)带头或不带头
(3)循环或不循环
上面的带头或者不带头,表示的是带不带头节点,这个头节点又称为哨兵位。
3、单链表
常见的单链表即以上提到的不带头单向不循环链表,这里个人的感觉是它像是处在顺序表和双向链表的之间。它不像顺序表那样,每次使用都要判断容量是否够用、需不需要进行扩容,并且也不需要在插入或删除操作时移动其他元素。但是跟双向链表(带头双向循环链表)相比,它却在进行除尾部之外的操作时都要遍历一边数组。
(1)定义
单链表主要包含数据,以及下一个节点。
typedef int SLTDataType;
//定义单链表
typedef struct SListNode
{
int data;
struct SListNode* next;//指向下一个节点的指针
}SLTNode;
(2)可实现方法
单链表在应用过程中,主要有以下方法:
//创建新节点:
SLTNode* SLTBuyNode(SLTDataType x);
//链表的打印
void SLTPrint(SLTNode* phead);
//链表的尾插
void SLTPushBack(SLTNode** pphead,SLTDataType x);
//链表的头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);
//尾删
void SLTPopBack(SLTNode** pphead);
//头删
void SLTPopFront(SLTNode** pphead);
//查找
SLTNode* SLTFind(SLTNode* pphead,SLTDataType x);
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead,SLTNode* pos,SLTDataType x);
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
//删除pos节点
void SLTErase(SLTNode** pphead,SLTNode* pos);
//删除pos节点之后的数据
void SLTEraseAfter(SLTNode* pos);
//销毁链表
void SListDesTory(SLTNode** pphead);
Ⅰ、 创建新节点和节点的打印
链表虽然不像顺序表,不需要进行容量的判断及扩容,但是在每次有新节点的创建时,都需要额外申请新节点,也就是说我们需要额外malloc空间,用来存放单个节点数据。并且在申请完成之后,要判断是否申请成功。将下一个节点置为空后,返回新节点的指针。
节点的打印只需要循环一边链表,打印出节点数据即可。
//创建新节点:
SLTNode* SLTBuyNode(SLTDataType x) {
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL) {
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
//节点的打印
void SLTPrint(SLTNode* phead) {
SLTNode* pcur = phead;
while (pcur) {
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
Ⅱ、头插尾插/头删尾删
在实现这四种方法之前,我们需要先考虑函数参数,是像顺序表那样传递一级指针吗?在单链表中,我们假设一个指针phead指向链表的头,然后我们进行头插头删时,我们改变的是指向头节点的指针的值,所以这四种方法都需要传递二级指针。
在每次的操作之后,都要考虑结构体中的(节点的)next指向的节点是否发生改变。在头插中,新节点的next指向的是头节点;头删时需要移动phead指向原先的next;尾插时要把新节点的next置为空;尾删时要把尾节点的上一个节点的next置为NULL。
在删除操作中,我们还要注意将删除节点释放并置空,防止内存泄漏。
//链表的尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//空链表和非空链表
if (*pphead == NULL) {
*pphead = newnode;
}
else
{
//先找尾
SLTNode* ptial = *pphead;
while (ptial->next) {
ptial = ptial->next;
}
//ptail指向的就是尾节点
ptial->next = newnode;
}
}
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//空链表和非空链表
newnode->next = *pphead;
*pphead = newnode;
}
//头删
void SLTPopFront(SLTNode** pphead) {
//链表不能为空
assert(*pphead && pphead);
只有一个节点:
//if (((*pphead)->next) == NULL) {
// free(*pphead);
// *pphead = NULL;
//}
//else {
// //有多个节点:
// SLTNode** prev = *pphead;
// *pphead = (*pphead)->next;
// free(prev);
// prev = NULL;
//}
SLTNode** prev = *pphead;
*pphead = (*pphead)->next;
free(prev);
prev = NULL;
}
//尾删
void SLTPopBack(SLTNode** pphead) {
//链表不可以为空
assert(pphead && *pphead);
//链表只有一个节点
if ((*pphead)->next == NULL) { //->的优先级高于*
free(*pphead);
*pphead = NULL;
}
else
{
//链表里面有多个节点
SLTNode* prev = *pphead;
SLTNode* ptail = *pphead;
while (ptail->next) {
prev = ptail;
ptail = ptail->next;
}
free(ptail);
ptail = NULL;
prev->next = NULL;
}
}
Ⅲ、查找
在查找操作中,我们将要查找的数传给函数,并返回值的指针;如果没有找到,需要返回NULL。
//查找
SLTNode* SLTFind(SLTNode* pphead, SLTDataType x) {
assert(pphead);
SLTNode* pcur = pphead;
while (pcur) {
if (pcur->data == x)
{
return pcur;
}
else {
(pcur) = (pcur)->next;
}
}
return NULL;
}
Ⅳ、在指定位置之前/之后插入数据
在插入之前,我们需要判断要插入的数据是否在头节点或尾节点,如果是头节点,则调用前面的头插操作,如果是尾节点,则调用尾插操作。
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
assert(*pphead && pphead);
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
SLTNode* prev = *pphead;
//若pos==pphead
if (pos == *pphead) {
SLTPushFront(pos, x);
}
else {
while (prev->next != pos) {
prev = prev->next;
}
//链接prev newnode pos
prev->next = newnode;
newnode->next = pos;
}
}
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
SLTNode* prev = pos;
prev = pos->next;
if (prev == NULL) {
//尾插
SLTPushBack(pos, x);
}
else {
newnode->next = prev;
pos->next = newnode;
}
}
Ⅴ、删除指定位置数据/删除指定位置之后数据
在删除之前呢,我们同样是需要注意指定位置,如果在头节点,则直接调用头删,在尾节点,则调用尾删操作。同时还需要释放掉指定节点指针并置空。
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos) {
assert(*pphead && pphead);
assert(pos);
SLTNode* pcur = *pphead;
if (*pphead == pos) {
//pos节点为头节点
SLTPopFront(pphead);
}
else {
while (pcur->next != pos) {
pcur=pcur->next;
}
//链接pos前后两个节点
pcur->next = pos->next;
free(pos);
pos = NULL;
}
}
//删除pos节点之后的数据
void SLTEraseAfter(SLTNode* pos) {
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
Ⅵ、销毁链表
在销毁链表操作中,我们不能像顺序表那样一次性销毁。由于链表在物理结构上是不一定连续的,所以我们要循环链表,并一个一个释放置空。
//销毁顺序表
void SListDesTory(SLTNode** pphead) {
assert(*pphead && pphead);
SLTNode* pcur = *pphead;
while (pcur) {
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
4、双链表
(1)定义
双链表,常见的即带头双向循环链表,在定义时除了包含数据外,还要有指向前一个节点和下一个节点的指针变量。
typedef int STDataType;
//定义双向链表节点的结构
typedef struct ListNode
{
STDataType data;
struct ListNode* next;
struct ListNode* prev;
}LTNode;
(2)可实现方法
常见的双链表方法如下:
//构建新节点
LTNode* LTBuyNode(STDataType x)
//初始化
void LTInit(LTNode** pphead);
//打印
void LTPrint(LTNode* pphead);
//尾插
void LTPushBack(LTNode* pphead,STDataType x);//传一级指针即可,哨兵位不需要修改
//头插
void LTPushFront(LTNode* phead, STDataType x);
//尾删
void LTPopBack(LTNode* pphead);
//头删
void LTPopFront(LTNode* pphead);
//在pos位置之后插入数据
void LTInsert(LTNode* pos,STDataType x);
//删除pos节点
void LTErase(LTNode* pos);
LTNode* LTFind(LTNode* phead, STDataType x);
//销毁链表
void LTDesTory(LTNode* pphead);
Ⅰ、构建新节点/打印节点
双链表的新节点的构建和打印与单链表相似,这里不过多介绍。
//构建新节点
LTNode* LTBuyNode(STDataType x) {
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL) {
perror("malloc");
exit(1);
}
node->data = x;
node->next = node->prev = node;
return node;
}
//打印
void LTPrint(LTNode* phead) {
LTNode* pcur = phead->next;
while (pcur != phead) {
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
Ⅱ、初始化及销毁
双链表的初始化就是给链表创建一个哨兵位。
//初始化
//给双向链表创建一个哨兵位
void LTInit(LTNode** pphead) {
*pphead = LTBuyNode(-1);
}
双链表的销毁跟单链表略有不同,主要多了一个头节点(哨兵位)的销毁。
//销毁链表
void LTDesTory(LTNode* phead) {
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead) {
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
//此时pcur指向phead,而phead还没有被销毁
free(phead);
phead = NULL;
}
Ⅲ、头插尾插/头删尾删
双链表的插入操作与单链表类似,但是头插操作需要注意链接头节点与新节点,并且将尾插的新节点的下一个值指向头节点(哨兵位)。
需要注意的是,这里的头插在实际上并不是真正的头插,它是插在哨兵位之后的第一个节点,因此插入之后需要链接哨兵位与新节点。
//尾插
void LTPushBack(LTNode* phead, STDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
//phead phead->prev(尾节点) newnode
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev->next = newnode;
phead->prev = newnode;
}
//头插
void LTPushFront(LTNode* phead, STDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
双链表的删除操作与单链表类似,但是注意尾节点与头节点(哨兵位)的链接。
//尾删
void LTPopBack(LTNode* phead) {
//链表必须有效并且不为空(只有一个哨兵位)
assert(phead && phead->next != phead);
LTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
//头删
void LTPopFront(LTNode* phead) {
assert(phead && phead->next != phead);
LTNode* del = phead->next;
//phead del del->next
del->next->prev = phead;
phead->next = del->next;
free(del);
del = NULL;
}
Ⅳ、在pos节点之后插入数据
在双链表的pos结点之后插入数据,与单链表不同的就是还要链接pos节点的next和新节点的prev。这里不需要区分是不是头插还是尾插。
//在pos位置之后插入数据
void LTInsert(LTNode* pos, STDataType x) {
assert(pos);
LTNode* newnode = LTBuyNode(x);
//pos newnode pos->next
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->next = newnode;
}
Ⅴ、删除pos节点
删除pos节点的数据,只需要链接pos节点之前和后边的数据,随后释放掉pos节点即可。
//删除pos节点
void LTErase(LTNode* pos) {
//pos理论上不能是phead,但是没有参数phead,无法增加校验
assert(pos);
//pos->prev pos pos->next
pos->next->prev = pos->prev;
pos->prev->next = pos->next;
free(pos);
pos = NULL;
}
Ⅵ、查找
双链表的查找方法需要将找的节点指针传递回去,如果没有该节点,则需要返回NULL。
//查找
LTNode* LTFind(LTNode* phead, STDataType x) {
LTNode* pcur = phead->next;
while (pcur!=phead) {
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
//没有找到
return NULL;
}
四、总结
不论是顺序表、单链表还是双链表,我们在调用方法时都需要考虑传递的参数的实际意义。在实际运用中需要考虑各个结构的优缺点进行选择。
顺序表的优点是存储效率高,并且可以通过下标访问,空间连续,提高了缓存命中率。但是不足同样是空间连续,容易造成空间浪费。
链表的优点是任意位置插入空间复杂度为O(1),并且没有增容问题,随插随扩。缺点是以节点为单位,缓存命中率低。
单链表与双链表相比,双链表在访问尾节点时不需要一个一个遍历。