一、线性表的概念
线性表(linear list)是n个具有相同特性的数据元素的有限序列。
线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...
线性表在逻辑上是线性结构,也就说是连续的一条直线。
但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储。
注意:线性表只是逻辑上连续,但物理上不一定连续!!
二、顺序表
注:顺序表的数据是连续存储的,不仅逻辑上是连续的,物理上也是连续的!!
1、顺序表的概念及结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。
在数组上完成数据的增删查改。
顺序表一般可以分为:
(1)静态顺序表:使用定长数组存储元素。--就是长度固定
ex:
注:静态顺序表的实用价值很小
原因:在实际应用场景中不好确定数组的大小N要写多少
1、N给小了不够用
2、N给大了浪费
(2)动态顺序表:使用动态开辟的数组存储。
就是数组的内存是用malloc、calloc...等动态开辟的内存,然后还可以根据实际需求,用realloc对内存的大小进行调整。--要记得对动态开辟的内存进行free。
优点:用多少就开多少,可以减少浪费--但还是会有一点浪费
ex:
typedef struct SeqList
{
SLDataType* array;//指向动态开辟的数组
size_t size;//有效数据个数
size_t capicity;//容量空间的大小
}SL;
容量不够就增容
注意:增容到底一次增多少呢?
答:这是根据实际情况而定,可以一个一个慢慢增,也可以一次增个几倍什么的,反正就是具体情况具体分析,数据结构没有规定一次到底要增多少。
补充:一般情况下会是2倍增容--经验总结
2、顺序表的接口实现
//Sequential List--顺序表英文
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
typedef int SLDataType;//方便以后修改成其他类型的数组
// 顺序表的动态存储
typedef struct SeqList
{
SLDataType* array;// 指向动态开辟的数组
size_t size;// 有效数据个数
size_t capicity;// 容量空间的大小
}SL;
// 基本增删查改接口
// 顺序表初始化
void SeqListInit(SL* psl)//要传址
{
assert(psl);//多多断言,良好的代码习惯
psl->array = calloc(10, sizeof(SLDataType));//初始化这里可以开空间也可以不开
psl->size = 0;
psl->capicity = 0;
}
// 检查空间,如果满了,进行增容
void CheckCapacity(SL* psl)
{
assert(psl);//多多断言,良好的代码习惯
if (psl->size == psl->capicity)
{
size_t newcapicity = psl->capicity == 0 ? 4 : psl->capicity * 2;//如果capicity的初始值为0,则赋值为4,否则直接扩容一倍
SLDataType* tmp = (SLDataType*)realloc(psl->array, sizeof(SLDataType) * newcapicity);
//这里要先用一个tmp接收,为了以防万一内存开辟失败,返回的NULL赋给了psl->array,会直接把原本的数据也给清空了,赔了夫人又折兵
if (tmp == NULL)
{
perror("realloc");
return;
}
psl->array = tmp;
psl->capicity = newcapicity;
}
}
// 顺序表尾插--大量使用(使用N个)尾插的时间复杂度就只有O(N)
void SeqListPushBack(SL* psl, SLDataType x)//注意前面有个对int类型的重命名
{
assert(psl);//多多断言,良好的代码习惯
CheckCapacity(psl);
psl->array[psl->size] = x;
psl->size++;
}
// 顺序表尾删
void SeqListPopBack(SL* psl)
{
assert(psl);//多多断言,良好的代码习惯
//这边也可以使用暴力一点的方式对psl->size进行检查:使用断言assert(psl->size>0);
if (psl->size != 0)
//传统方式
//psl->array[psl->size-1]=-1;--这里改成-1不太好,因为数组的类型可能会变,而且可能原本存放的数据就是-1呢
//psl->size--;
psl->size--;//实际上直接psl->size--就可以了,psl->size--之后它就不是有效的数据了,而且再次尾插时还可以覆盖掉那个数据
else
printf("没有数据可以删除\n");
}
// 顺序表头插--不建议大量的使用头插,因为大量使用(使用N个)头插的时间复杂度(O(N^2))较高
void SeqListPushFront(SL* psl, SLDataType x)
{
assert(psl);//多多断言,良好的代码习惯
CheckCapacity(psl);
//头插有三种方法:
//1、把数据从后面开始一个一个往后挪
//2、memmove
memmove(&psl->array[1], psl->array, (psl->size) * (sizeof(SLDataType)));
//3、使用链表--可以达到不需要挪动原本的数据就能插入
psl->array[0] = x;
psl->size++;
}
// 顺序表头删
void SeqListPopFront(SL* psl)
{
assert(psl);//多多断言,良好的代码习惯
assert(psl->size > 0);//暴力检查
//方法一:用memmove挪动数据
memmove(psl->array, &psl->array[1], psl->size * (sizeof(SLDataType)));
//方法二:一个一个往前挪
psl->size--;
}
// 顺序表查找
void SeqListFind(SL* psl, SLDataType x)
{
assert(psl);//多多断言,良好的代码习惯
assert(psl->size >= 0);
int i = 0;
while (i < psl->size)
{
if (psl->array[i] == x)
{
printf("找到了,它在顺序表中的位置是:%d\n", i+1);
return ;
}
i++;
}
printf("找不到\n");
}
// 顺序表在pos位置插入x
void SeqListInsert(SL* psl, size_t pos, SLDataType x)
{
assert(psl);//多多断言,良好的代码习惯
assert(pos >= 0 && pos <= psl->size);
CheckCapacity(psl);//检查一下有没有足够的空间用于插入
memmove(&psl->array[pos], &psl->array[pos-1], (psl->size - pos+1) * (sizeof(SLDataType)));
psl->array[pos-1] = x;
psl->size++;
}
// 顺序表删除pos位置的值
void SeqListErase(SL* psl, size_t pos)
{
assert(psl);//多多断言,良好的代码习惯
assert(pos >= 0 && pos < psl->size);//size位置是没有数据可以删除的
memmove(&psl->array[pos-1],&psl->array[pos],(psl->size-pos)*(sizeof(SLDataType)));
psl->size--;
}
// 顺序表销毁
void SeqListDestory(SL* psl)
{
assert(psl);//多多断言,良好的代码习惯
if (psl->array != NULL)
{
free(psl->array);
psl->array = NULL;
psl->size = 0;
psl->capicity = 0;
}
}
// 顺序表打印
void SeqListPrint(SL* psl)
{
assert(psl);//多多断言,良好的代码习惯
size_t i = 0;
for (i = 0; i < psl->size; i++)
{
printf("%d ", psl->array[i]);
}
printf("\n");
}
补充:顺序表的特点所带来的优势和劣势
特点:顺序表的物理空间连续
优势:
1、顺序表支持随机的下标访问,顺序表访问数据更方便高效
劣势:
1、不方便在中间和头部插入或删除数据--增删不方便
2、增容总是会有空间被浪费
3、顺序表的问题及思考
问题:
1. 尾部插入效率还算不错,中间/头部的插入删除,需要挪动数据,时间复杂度为O(N)。
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:如何解决以上问题呢?下面给出了链表的结构来看看。
三、链表
1、链表的概念及结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
注:
1、链表中讲一块一块的小内存称之为节/结点。
2、每个节点的地址是没有关联的,是随机的,东一个,西一个。
3、节点一般都是从堆上申请的(申请的空间,可能连续,也可能不连续)。
4、链表是可以一部分一部分free释放空间的,因为每个节点都是单独的一次一次的在堆上申请空间的。
2、链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
(1)单向或者双向
(2)带头或者不带头
注:带头指的就是带个哨兵位的链表--哨兵位:不存储有效数据的头节点--实际中基本没啥用(但在某些特定的场景底下还是很好用的,ex:牛客网练习之链表分割)
补充:哨兵位的优势和劣势
优势:
在进行尾插时,很好用,不需要分类讨论第一个节点插入的情况和其他节点插入的情况
劣势:
程序退出前,要记得free掉哨兵位
(3)循环(尾指向头(单向就只有尾指向头,双向还多个头指向尾),不是带环,是带环的一种)或者非循环
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
1.无头单向非循环链表
特点:无头单向非循环链表:结构简单,一般不会单独用来存数据。
实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。
另外这种结构在笔试面试中出现很多。
2.带头双向循环链表
特点:带头双向循环链表:结构最复杂,一般用在单独存储数据。
实际中使用的链表数据结构,都是带头双向循环链表。
另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了。
3、无头单向不循环链表的实现
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int SLNDataType;
typedef struct SListNode
{
SLNDataType val;
struct SListNode* next;//下一个节点的地址
}SLTNode;
//动态申请一个新的节点
SLTNode* CreateNode(SLNDataType x)
{
//开辟一个新的节点
SLTNode* newtail = (SLTNode*)calloc(1, sizeof(SLTNode));
if (newtail == NULL)
{
perror("calloc");
exit(-1);//只有0是正常终止程序
//用return太麻烦了,下面调用该函数时还要判断一下返回的是不是NULL指针,反正新的节点开不出来,直接终止程序就好了
}
newtail->val = x;
newtail->next = NULL;//让这个节点成为新的尾部
return newtail;
}
//链表的打印
void SLTprint(SLTNode* phead)
{
SLTNode* cur = phead;//尽量不要动phead,不然就找不到头了
//链表的遍历
while (cur != NULL)
{
printf("%d ", cur->val);
cur = cur->next;
}
printf("\n");
}
//链表的尾插
//tail--尾部的英文
void SLTPushBack(SLTNode* * pphead, SLNDataType x)//这里的二级指针phead存放的是plist的地址
{
//链表不为空时
if (*pphead != NULL)
{
SLTNode* tail = *pphead;//找到尾部(最后一个节点)
while (tail->next != NULL)//不能写成tail != NULL来判断,因为当tail==NULL时,tail已经变成了空,已经是出了这个链表了,不在尾部的节点中了
{
tail = tail->next;
}
tail->next = CreateNode(x);//原本的尾部存放的是NULL,现在存放新尾部newtail的地址--这里相当于算是在改变结构体的成员
}
//链表为空时,需要二级指针
else
{
*pphead = CreateNode(x);
}
}
//链表的尾删
void SLTPopBack(SLTNode** pphead)
{
assert(*pphead);
//如果只有一个节点,需要二级指针
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
//如果有多个节点
else
{
//先找尾
//方法一,用两个指针,一前一后,一个指向尾,一个指向尾的前一个
SLTNode* prev = NULL;//prev--previous--先前的,上一次的
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);//要free,不要直接把倒数第二个节点中存放最后一个节点地址的指针置为NULL。因为这会导致最后一个节点发生内存泄漏
tail = NULL;//这里的tail置不置空都无所谓,因为出了这个函数,tail就销毁了,没人找的到它的,但也可以置空一下,这是使用动态内存的好习惯
prev->next = NULL;
//方法二,找到尾部节点的上一个节点
//一个指针解决
//SLTNode* tail = *pphead;
//while(tail->next->next!=NULL)
//{
//tail=tail->next;
//}
//free(tail->next);--free(void* ptr)--free是free掉ptr所指向的那块动态开辟的空间
//tail->next=NULL;
}
}
//一定要分情况讨论,画图理解,不然容易写出问题(ex:写出用野指针去访问其他东西)
//链表的头插
void SLTPushFront(SLTNode** pphead, SLNDataType x)
{
//链表为不为空都无所谓
SLTNode* newhead = CreateNode(x);
newhead->next = *pphead;//指向原本的头部节点的地址
*pphead = newhead;//将新的头部的地址赋值回去
}
//单链表非常适合头插
//链表的头删
void SLTPopFront(SLTNode** pphead)
{
assert(*pphead);
//可以不分情况,如果只有一个节点,则cur中保存的是NULL
SLTNode* cur = (*pphead)->next;//要先保存一下第二个节点的地址,不然到时把第一个节点的free之后,第二个节点的地址就找不到了
free(*pphead);
*pphead = cur;
//经典的错误写法
//SLTNode* cur = *pphead;
//free(cur);
//*pphead = (*pphead)->next;--这里的*pphead这时候就变成了野指针
//但是改一下代码的顺序,这个写法就又对了
//SLTNode* cur = *pphead;
//*pphead = (*pphead)->next;--先把第二个节点的地址赋给*pphead,再通过上面的cur指针free掉第一个节点
//free(cur);
}
//链表的查找
SLTNode* SLTFind(SLTNode* phead, SLNDataType x)
{
//assert(phead);--没必要判断是不是空链表,可以想想为NULL的情况代入
SLTNode* cur = phead;
while (cur!=NULL)
{
if (x == cur->val)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
//链表的插入(在pos位置插入)
//要在其后面插入
void SLTInsert(SLTNode* pos, SLNDataType x)//因为在后面插入,所以永远不会出现头插的情况,就不会改变plist,素以无需传plist的地址,也无需二级指针接收
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc");
printf("\n插入失败\n");
return;
}
newnode->val = x;
newnode->next = pos->next;
pos->next = newnode;
printf("插入成功\n");
}
//这个也可以做到在pos位置的前面插入(在不给你头指针&plist的情况下)
//直接就在pos位置的后面插入一个节点,再把这两个节点中的数据进行交换即可!!
//方法总比困难多!!
//链表的删除(在pos位置删除)
void SLTErase(SLTNode** pphead,SLTNode* pos)//因为可能会有头删,会改变plist,所以要传plist的地址,所以这里要用二级指针接收
{
if (*pphead == pos)
{
//头删
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
//链表的销毁
void SLTDestroy(SLTNode** pphead)
{
assert(*pphead);
while (*pphead)
{
//不断头删就好了
SLTPopFront(pphead);
}
}
总结:不带头单向不循环链表只适合头插、头删,进行其他的操作效率都不高
不带头单向不循环链表只有一个next,所以要注意不带头单向不循环链表的相交不可能是一个X型,两个不带头单向不循环链表相交一定是Y型
4、带头双向循环链表的实现
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* prev;
LTDataType data;
struct ListNode* next;
}LTNode;
//链表的初始化--创建一个哨兵位
//初始化是需要改变指针Plist的,所以要传址
void LTInit1(LTNode** phead)
{
*phead = CreateNode(-1);
(*phead)->next = *phead;
(*phead)->prev = *phead;
}
//但也可以不用二级指针,将创建好的哨兵位的地址return
LTNode* LTInit2()
{
LTNode* phead = CreateNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
//当链表为空时,哨兵位的prev和next都指向它自己
//节点的创建
LTNode* CreateNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc");
exit(-1);
}
newnode->data = x;
newnode->prev = NULL;
newnode->next = NULL;
return newnode;
}
//查找
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
//在pos位置进行插入--可以转化为头插和尾插
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);//如果pos为哨兵位(就是直接传哨兵位进来),效果就像尾插
LTNode* newnode = CreateNode(x);
newnode->next = pos;
newnode->prev = pos->prev;
pos->prev->next = newnode;
pos->prev = newnode;
}
//删除pos位置的节点--可以转化为头删和尾删
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* next = pos->next;
LTNode* prev = pos->prev;
prev->next = next;
next->prev = prev;
free(pos);
//pos = NULL;--这里可置空也可不置空,因为没有意义,pos只是形参,你置不置空它出了这个函数都会被直接销毁
}
//尾插
void LTPushBack(LTNode* phead, LTDataType x)//和无头单向不循环链表相比,不需要传二级指针,因为有哨兵位
{
assert(phead);//也可以不断言,因为带头链表是带着个哨兵位的,不会为空,但万一出现忘记初始化链表(忘了创建哨兵位),那就出问题了
//无需讨论只有一个节点(哨兵位)还是有多个节点
//因为当只有一个节点(哨兵位)时,头和尾都是这个哨兵位,phead的prev和next都是指向phead自己
LTNode* newtail = CreateNode(x);
newtail->prev = phead->prev;//新的节点的头prev指向原本的尾
phead->prev->next = newtail;//原本的尾的next指向新节点的头
newtail->next = phead;//新的节点的next指向头
phead->prev = newtail;//头的prev指向新的节点
//LTInsert(phead,x);--尾插的另一种方式
}
//尾删
void LTPopBack(LTNode* phead)
{
assert(phead && phead->prev != phead);
LTNode* tail = phead->prev->prev;
tail->next = phead;
free(phead->prev);
phead->prev = tail;
//LTErase(phead->prev);--尾删的另一种方式
}
//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newfront = CreateNode(x);
newfront->next = phead->next;//新的头的next指向原本的头
phead->next->prev = newfront;//原本的头的prev指向新的头
newfront->prev = phead;//新的头的prev指向哨兵位
phead->next = newfront;//哨兵位的next指向新的头
//LTInsert(phead->next,x);--头插的另一种方式
}
//头删
void LTPopFront(LTNode* phead)
{
assert(phead && phead->next != phead);//注意:哨兵位不能被删了
phead->next = phead->next->next;//哨兵位的next指向第二个节点
free(phead->next->prev);//free掉头
phead->next->prev = phead;//第二个节点现在是头了
//LTErase(phead->next);--头删的另一种方式
}
//链表的打印
void LTPrintf(LTNode* phead)
{
assert(phead);//避免出现没有初始化链表(创建哨兵位)就直接进行打印的情况
LTNode* cur = phead;
while (cur->next != phead)
{
printf("%d ", cur->next->data);
cur = cur->next;
}
printf("\n");
}
//链表销毁--不传址(一级指针)版本
LTNode* LTDestory1(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
phead = NULL;//可置空也可不置空,因为没有意义
return phead;//可以直接选择返回NULL就好
}
//链表销毁--传址(二级指针)版本
void LTDestory2(LTNode** phead)
{
assert(*phead);
LTNode* cur = (*phead)->next;
while (cur != *phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(*phead);
*phead = NULL;
}
总结:带头双向循环链表有个特点:无死角(增删查改时不容易出现空指针,但可能出现野指针)
对于两种链表实现的总结:
1.改结构体指针的时候需要二级指针,修改结构体时只需要一级指针--ex:两种链表进行新节点插入时的函数参数不同
2.如何10分钟写出一个链表?
答:写个带头双向循环链表,实现一个LTInit,一个CreateNode,一个LTDestory,一个LTFind,一个LTInsert(可兼容头插和尾插),一个LTErase(可兼容头删和尾删),一个LTPrintf即可
补充:链表的特点所带来的优势和劣势
特点:链表的空间并不连续
优势:
1、增删十分方便,无需挪动数据--可以直接free掉那个想要删除的节点
2、增容不会带来空间的浪费
劣势:
1、链表不方便访问数据--链表不支持下标访问--数据访问效率这方面不如顺序表
5、顺序表和链表的区别--顺序表对比带头双向循环链表
链表(双向)的
优势:
1.任意位置(已知该位置)的增删都非常方便(时间复杂度O(1))
2.按需申请释放,合理利用空间,不存在浪费
劣势:
1.不支持下标的随机访问,访问某个随机位置的数据不方便(时间复杂度O(N))--ex:链表不好排序
顺序表的
优势:
1.支持下标的随机访问,访问某个随机位置的数据非常方便(时间复杂度O(1))
2.CPU的高速缓存命中率比较高--顺序表数据存储在物理上是连续的优势
劣势:
1.头部和中间的插入删除效率都很低,要挪动数据(时间复杂度O(N))
2.空间不够时,需要扩容,扩容有一定的消耗,且可能存在一定的空间浪费
3.只适合尾插和尾删
那么文章到这就结束了,感谢您的阅读,如果本文中有什么不足之处,希望您能在留言区留言指点,有什么问题也可以在留言区留言,谢谢。