一.链表与顺序表的概念
链表与顺序表是很基础的两个数据结构,但他很重要
链表与顺序表都是线性表的一种.
线性表是指一种在逻辑结构上连续,而在物理结构上不一定连续的数据结构.
顺序表是一种逻辑结构上连续,内存存储上也连续的一种数据结构。
链表是逻辑结构上连续,内存存储上不一定连续的数据结构
逻辑结构是指人类想象出来的结构。
比如说我们看到 “线性表” 中的 “线” 一字是不是很容易就联想到生活中的线,我们想象他们是像线一样的、是连续的、没有断开的。
物理结构是说他们在内存存储上的结构。
我们都知道数组在内存的存储上是连续的,是一块空间紧挨着一块空间,而顺序表的底层就是通过数组来实现的,所以顺序表在物理结构上是连续的
而链表呢,我们想象他们是连续的,但实际在内存存储上却不一定连续,链表就像下面这张图一样是被一个东西链接起来的。只要把这个东西断开,你就很难很难再找到这个节点了
这个东西就是指针。链表由一个个的节点组成,这些节点一般由两部分组成一个是需要储存的数据,另一个是下一个节点的地址,像这样。
链表又分为很多种,这里便是一个单链表(另外几种以后再介绍)
单链表就是只能像一个方向移动,你不能说我走到0x330这个节点后,再回头走,走到0x220这个节点,这不现实,因为你没有它的地址。
所以
顺序表是一种逻辑结构上连续,内存存储上也连续的一种数据结构。
链表是逻辑结构上连续,内存存储上不一定连续的数据结构
二、顺序表
1.循序表结构的定义
上面我们说,顺序表的底层是基于数组实现的,那么他的结构该如何定义呢?
struct SequenceTable
{
int a[100];//存储数据的数组
int size;//有效数据个数
};//这便是一个顺序表的定义
上面这种定义叫做静态顺序表,何为静态?就是说数组的大小是固定的定义完以后便不能修改了,这种顺序表用的很少,除非你确确实实知道你大概需要多大的空间。
下面来看动态顺序表
struct SequenceTable
{
int* a;//存储数据的数组
int size;//有效数据个数
int capacity;//当前空间大小
};
这便是一个动态顺序表,可以看到,结构中原来固定大小的数组变成了一个指针,并且还多了一个变量记录空间大小。这是因为我们可以用malloc为数组开辟空间,并且空间不够时也可以使用realloc为它扩大空间,这就使得数组空间容量是可变的,但同时也得要一个管理空间大小的变量,不然你怎么直到什么时候满,什么时候不满,什么时候需要扩容呢?
为了方便使用我们对顺序表和数据类型进行重命名一下
typedef int Datatype;//以后若要存储不同类型的数据,修改起来会很方便
typedef struct SequenceTable
{
Datatype* a;//存储数据的数组
int size;//有效数据个数
int capacity;//当前空间大小
}Seq;
2.对顺序表各种操作的实现
以下是我们需要实现的操作,别看很多其实很简单
//因为我们要对顺序表进行修改所以要传递指针
void InitSeq(Seq* seq);//初始化顺序表
void PrintSeq(Seq* seq);//打印顺序表中的数据
void Dilatation(Seq* seq);//扩容
void PushBack(Seq* seq, Datatype x);//在顺序表尾部插入数据
void PushFront(Seq* seq, Datatype x);//在顺序表头部插入数据
void PopBack(Seq* seq);//在顺序表尾部删除数据
void PopFront(Seq* seq);//在顺序表头部删除数据
void InsertPosition(Seq* seq, Datatype x,int pos);//在顺序表任意位置插入数据
void DeletePosition(Seq* seq,int pos);//在顺序表任意位置删除数据
void DestroySeq(Seq* seq);//销毁顺序表
这是测试函数,传参的时候都要传递地址
void test()
{
Seq s;
InitSeq(&s);
}
(1)“InitSeq” 初始化顺序表
void InitSeq(Seq* seq)
{
assert(seq);//如果连结构变量都没有还谈什么操作
//所以seq是坚决不能为空的!!!
seq->a = NULL;//如果这里不喜欢给空,用malloc给他开辟一些初始空间也是可以的,
//但同样,capacity也要进行相应的变化
seq->capacity = seq->size = 0;
例如:
//seq->capacity = 4;
//seq->a = (Datatype*)malloc(sizeof(Datatype) * seq->capacity);
开辟4个int类型大小的空间
}
(3)“PrintSeq” 打印顺序表中的数据
void PrintSeq(Seq* seq)//打印顺序表中的数据
{
assert(seq);
for (int i = 0; i < seq->size; ++i)
{
printf("%d ", seq->a[i]);
}
putchar('\n');
//从上面可以看到我们并没有对顺序表进行任何修改,那么不传递指针可以吗?
//可以,非常可以,但为了接口的一致性,依然要传递指针
//什么接口一致性?
//就是说对于我们提供给别人使用的函数,其参数类型应是相同的,你不能说一会这里用指针
//一会那里用变量,别的地方又用了二级甚至三级指针,使用时很麻烦,
// 反正就突出一个字——便利(懒)
}
(3)“Dilatation” 扩容
void Dilatation(Seq* seq)//扩容
{
assert(seq);
//这不能下面扩失败了,这里的空间大小还变了
Datatype newcapacity = seq->capacity == 0 ? 4 : seq->capacity * 2;
//看一下初始化是否分配了空间,没有就给一块初始空间,否则就按照两倍的大小扩容
Datatype* tmp = (Datatype*)realloc(seq->a, newcapacity * sizeof(Datatype));//防止扩容失败原数据丢失
if (tmp == NULL)
{
perror("Dilatation :: realloc : fail");
return;
}
seq->capacity = newcapacity;
seq->a = tmp;
}
//这里扩容多大并没有一个明确的规定,你可以按照1.5倍,或者3倍都可
//以,但是要尽量做到在 不浪费空间 的情况下又可以达到 较少的开辟次数
(4)“PushBack” 尾插
void PushBack(Seq* seq, Datatype x)
{
assert(seq);
//首先需要检查顺序表空间是否足够
if (seq->capacity == seq->size)//空间大小如果等于有效数据的个数就证明顺序表已满
{
Dilatation(seq);
}
seq->a[seq->size] = x;//在尾部插入数据
seq->size++;//有效数据个数+1
}
我非常建议写完一段代码就写几个测试检查一下写的对不对!!!不要全都写完了才检查,到时候一测试,代码崩溃了你也崩溃了!!!
void test()
{
Seq s;
InitSeq(&s);
PushBack(&s, 1);
PushBack(&s, 2);
PushBack(&s, 3);
PushBack(&s, 4);
PrintSeq(&s);
}
(4)“PushFront” 头插
void PushFront(Seq* seq, Datatype x)
{
//在数组中下标为0的位置插入
assert(seq);
//首先需要检查顺序表空间是否足够
if (seq->capacity == seq->size)//空间大小如果等于有效数据的个数就证明顺序表已满
{
Dilatation(seq);
}
int end = seq->size;//end如果等于size-1,那下面的循环条件就得>=0了
while (end > 0)//把数据都向后挪动
//这里一定要注意,不能等于否则就越界了
{
seq->a[end] = seq->a[end - 1];
end--;
}
seq->a[0] = x;
seq->size++;
//在顺表头部插入数据需要把所有数据都向后挪动,这很麻烦,也很慢,
// 每次头插都要挪动数据,所以每次头插的时间复杂度就是O(N),
//这也是顺序表的弊端所在,只适合尾插,不适合头插
}
void test()
{
Seq s;
InitSeq(&s);
PushFront(&s, 5);
PushFront(&s, 6);
PushFront(&s, 7);
PrintSeq(&s);
}
(5)"PopBack"尾删
void PopBack(Seq* seq)
{
assert(seq);
if (seq->size > 0)//没有数据可不能删
{
seq->size--;//也不用删除数组中的数据,只需要把有效数据个数减少就可以了
//到时候有新数据插入时会覆盖那些删除的数据
}
else
{
printf("链表中已无数据\n");
}
}
void test1()
{
Seq s;
InitSeq(&s);
PushBack(&s, 1);
PushBack(&s, 2);
PushBack(&s, 3);
PushBack(&s, 4);
PrintSeq(&s);
PopBack(&s);
PopBack(&s);
PopBack(&s);
PrintSeq(&s);
PopBack(&s);
PopBack(&s);
}
我建议多写几个test,要不然你哪个测试的哪些东西都不知道
(6)"PopFront"头删
void PopFront(Seq* seq)
{
//删除下标为0位置的数据
assert(seq);
if (seq->size > 0)//没有数据可不能删
{
//直接利用后面的数据进行覆盖
for (int i = 0; i < seq->size-1; i++)
{
seq->a[i] = seq->a[i + 1];
}
seq->size--;
}
else
{
printf("链表中已无数据\n");
//像这一句提示你们写或不写都可以,
//或者直接用assert检查是否小于等于0
}
}
(6)"InsertPosition"任意位置插入
void InsertPosition(Seq* seq, Datatype x, int pos)
{
assert(seq);
//首先需要检查顺序表空间是否足够
if (seq->capacity == seq->size)//空间大小如果等于有效数据的个数就证明顺序表已满
{
Dilatation(seq);
}
//要保证pos有效,不能越界
if (pos > 0 && pos <= seq->size+1)//最后一个元素的下一个位置不就是尾插嘛
{
int end = seq->size;
while (end >= pos)//向后挪动数据
{
seq->a[end] = seq->a[end - 1];
end--;
}
seq->a[pos-1] = x;
seq->size++;
}
else
{
printf("无效位置\n");
}
//这是站在用户角度实现的(当然,你也可以用下标)
//在用户眼里没有什么下标,第一个就是第一个而非第0个
//最后一个也一样
}
(7)"DeletePosition"任意位置删除
void DeletePosition(Seq* seq, int pos)
{
assert(seq);
//用户视角
if (pos > 0 && pos <= seq->size)
{
for (int end = pos-1; end < seq->size - 1;++end)//挪动数据进行覆盖
{
seq->a[end] = seq->a[end+1];
}
seq->size--;
}
else
{
printf("无效位置\n");
}
}
测试时要尽量想一些极端情况,例如边界处理
(8)"DestroySeq"销毁顺序表
void DestroySeq(Seq* seq)
{
assert(seq);
free(seq->a);
seq->a = NULL;
seq->capacity = seq->size = 0;
}
这个销毁最好调试起来看
3.顺序表完
在结构体中不是还有一个东西叫做柔性数组嘛,你们可以尝试一下用柔性数组来实现一下顺序表,我不知道这个想法可不可行,因为我还没试过但是想用柔性数组有几个点是需要注意的
1.使用sizeof计算包含柔性数组的结构体时,sizeof的返回值不会包含柔性数组的空间
2.对于不同的编译器,柔性数组的定义可能不同
//定义1
struct ListNode
{
int size;
int capacity;
int arr[];
};
//定义2
struct ListNode
{
int size;
int capacity;
int arr[0];
};
三、链表
1.链表节点结构的定义
链表由一个个的节点组成,这些节点一般由两部分组成一个是需要储存的数据,另一个是下一个节点的地址,像这样。
那么链表的定义就该为
typedef int slData;
typedef struct Link
{
slData data;//存储数据
struct Link* next;//存储下一个节点的地址
}SLinkNode;
2.链表各种接口的实现
//单链表需要传递二级指针,这跟他的创建方式有关
void LinkPrint(SLinkNode* phead);//打印链表
SLinkNode* CreateNode(slData x);//创建链表节点
SLinkNode* FindNode(SLinkNode** phead, slData find);//查找链表中的数据所在节点
void LinkPushBack(SLinkNode** phead, slData data);//尾插
void LinkPushHead(SLinkNode** phead, slData data);//头插
void BackDel(SLinkNode** phead);//尾删
void InsertFront(SLinkNode** phead, SLinkNode* pos,slData data);//任意位置前面插入
void InsertAfter(SLinkNode* pos, slData data);//任意位置后面插入
void DeletePos(SLinkNode** phead,SLinkNode* pos);//删除任意位置
void DeleteAfter(SLinkNode* pos);//删除任意位置后一个节点
void DestroyLinkList(SLinkNode** phead);//销毁链表
(1)“test”测试链表接口
void test()
{
SLinkNode* plist = NULL;//看这里是个指针,而不是结构变量
//而我们又要修改plist的指向,所以要传二级
//如果是结构变量的话,那它就是带头单向不循环链表了
//咱写的这个叫无头单向不循环链表
//简称单链表
LinkPrint(&plist);
}
(2)“LinkPrint”打印链表
//可以用一级,但接口一致性
void LinkPrint(SLinkNode** phead)
{
assert(phead);
//不可以修改plist的指向,所以需要一个临时变量
SLinkNode* cur = *phead;
while (cur)
{
//打印节点的数据
printf("%d ->", cur->data);
//让cur指向下一个节点
cur = cur->next;
}
printf("NULL\n");
}
(3)“CreateNode”创建链表节点
SLinkNode* CreateNode(slData x)//需要插入的数据
{
//链表中每个节点都是单独malloc出来的
SLinkNode* node = (SLinkNode*)malloc(sizeof(SLinkNode));
assert(node);//检查node是否开辟失败
node->data = x;
node->next = NULL;
return node;
}
(4)“LinkPushBack”尾插
void LinkPushBack(SLinkNode** phead, slData data)
{
//phead是二级
//phead不能为空,但它的指向可以
assert(phead);
//创建一个节点
SLinkNode* node = CreateNode(data);//可以再检查一下node是否为NULL
//如果phead中没有空间
if (*phead == NULL)
{
//直接把node指向的节点赋给*phead
*phead = node;
return;
}
//如果phead中有空间
SLinkNode* back = *phead;
//*phead从始至终都需要指向链表头!!!
//*phead的指向不能变,所以需要一个临时变量
while (back->next)
{
//寻找链表中的最后一个节点
back = back->next;
}
back->next = node;//把新节点链接进链表中
}
//在这个单链表中可以发现,尾插需要找尾,找尾就是O(N),
//所以单链表并不适合尾插
(5)“LinkPushHead”头插
void LinkPushHead(SLinkNode** phead, slData data)
{
//phead不能为空,但它的指向可以
assert(phead);
SLinkNode* node = CreateNode(data);
//让新节点的next指向头节点
node->next = *phead;
//让新节点成为新的头节点
*phead = node;
//如果链表为空,那么新节点的next就会指向NULL
//然后phead会指向新节点
}
对于所有的插入和删除操作最好都测试一下链表为NULL的情况
(6)“BackDel”尾删
void BackDel(SLinkNode** phead)
{
//phead不能为空
assert(phead);
//链表没有节点
assert(*phead);
//链表只有一个节点
if ((*phead)->next == NULL)
{
free(*phead);
*phead = NULL;
return;
}
//链表有多个节点
SLinkNode* prev = NULL;
SLinkNode* del = *phead;
while (del->next)//找链表最后一个节点
{
//当del为最后一个节点时,prev指向倒数第二个节点
prev = del;
del = del->next;
}
free(del);
prev->next = NULL;
}
(7)“BackDel”t头删
void HeadDel(SLinkNode** phead)
{
assert(phead);
assert(*phead);//链表不能没有节点
//记录头节点
SLinkNode* del = *phead;
*phead = (*phead)->next;
//头的下一个节点成为新的头
free(del);
//如果链表只有一个节点,那就释放该节点
//然后指向NULL
}
(8)“FindNode”查找链表中的数据所在节点
SLinkNode* FindNode(SLinkNode** phead, slData find)
{
//assert(*phead);
if (*phead == NULL)
{
printf("该链表为空链表\n");
return NULL;
}
SLinkNode* temporary = *phead;
while (temporary->data != find)
{
temporary = temporary->next;
if (temporary == NULL)
{
printf("该链表中没有您要找的数据\n");
return NULL;
}
}
//出循环就证明找到find所在节点了
return temporary;
}
(9)“InsertFront”在任意位置前面插入
void InsertFront(SLinkNode** phead, SLinkNode* pos, slData data)
{
assert(phead);
assert(*phead);//链表不为空链表
assert(pos);//pos不为空,或者可以写pos为NULL就直接尾插或头插
SLinkNode* node = CreateNode(data);
if (*phead == pos)//只有一个节点的情况,即是pos就是第一个节点的情况
{
node->next = *phead;
*phead = node;
return;
}
//找pos前面的一个节点
SLinkNode* temporary = *phead;
while (temporary->next != pos)
{
temporary = temporary->next;
}
//pos temporary node
//让新节点链接到原链表
node->next = pos;
temporary->next = node;
}
(10)“InsertAfter”在任意位置后面插入
//在节点前面插入,需要头节点来找pos的前一个节点
//而在pos后面插入根本不需要头节点
//但在参数列表里也可以把头节点写上,这里我就不写了
void InsertAfter(SLinkNode* pos,slData data)
{
//pos不能是无效数据
assert(pos);
SLinkNode* node = CreateNode(data);
//把新节点与pos后面的节点联系起来
node->next = pos->next;
//把node插进链表
pos->next = node;
}
(11)“DeletePos”删除任意节点
void DeletePos(SLinkNode** phead,SLinkNode* pos)
{
assert(phead&&pos);
//If pos is first node
if (pos == *phead)
{
*phead = pos->next;
free(pos);
return;
}
SLinkNode* del = *phead;
while (del->next != pos)
{
del = del->next;
}
//then "del" in front of pos
del->next = pos->next;
free(pos);
//pos指向空or不都可以
pos = NULL;//出于代码规范
}
(12)“DestroyLinkList”销毁链表
void DestroyLinkList(SLinkNode** phead)
{
assert(phead);
//链表为空链表
if (*phead == NULL)
{
printf("The link list is already empty\n");
return;
}
//销毁链表中每个节点
SLinkNode* cur = *phead;
while (cur)
{
SLinkNode* destroy = cur->next;
free(cur);
cur = destroy;
}
//plist置为NULL
*phead = NULL;
}
3.单链表完
其实我这写的接口是有些过多的,在真正的库中,不会有这么多的接口,库里面一般只有关于这个结构效率最高的几种操作。
我这为什么写这么多呢?
就是想让你们看看一种数据结构到底都有哪些操作,底层是怎么实现的,顺便再练习一下咱的代码能力
四.链表与顺序表的区别
1.其他链表的介绍
链表有很多种结构,合计共有8种
咱上面写的那个就是不带头单向不循环
循环就是尾节点不指向空,而是指向链表头节点
带头是什么?
带头就是除开存储有效数据的节点外,多开一个不存储有效数据的节点,我们一般叫他哨兵位或者叫哑节点
这样的结构就是带头单向不循环链表
使用哨兵位可以很好的处理边界问题(头节点为NULL)
21. 合并两个有序链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
这是LeetCode上的一道OJ题,使用哑节点可以很好的处理list1,list2为空的情况
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
// //创建一个新链表(不是深拷贝那样的链表),用一个哑节点,cur1和cur2分别指向list1和list2
// //谁小就连接在哑节点创造出的新链表里
struct ListNode* head=(struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* tail=head;
struct ListNode* cur1=list1;
struct ListNode* cur2=list2;
while(cur1 && cur2)
{
if(cur1->val <= cur2->val)
{
tail->next=cur1;
cur1=cur1->next;
tail=tail->next;
}
else
{
tail->next=cur2;
cur2=cur2->next;
tail=tail->next;
}
}
//当两个链表不一样长时,先等于空的那个链表的next不会链接上另一个链表
if(cur1==NULL)
{
tail->next = cur2;
}
else if(cur2==NULL)
{
tail->next = cur1;
}
return head->next;
}
链表中有一个很好的结构,它尾插时可以直接通过头节点的prev直接找到尾节点,其实也算是一种以空间换时间的方法
2.链表与顺序表的区别
顺序表的优势:
1.支持随机访问,可以使用下标直接定位到任意位置
2.尾插、尾删效率高
3.内存空间连续,CPU命中率高(CPU读取数据时不是一个字节一个字节的读取,而是直接读取一个块,因为顺序表的内存空间连续,所以CPU有可能一次性就把顺序表中所有的数据都读入内存)
缺点:
1.插入数据(除尾外)效率很低
2.扩容时容易造成空间浪费或频繁扩容降低性能
应用场景:元素高效存储频繁访问
链表的优势:
1.插入操作效率极高(带头双向循环链表)(中间位置插入稍微慢些)
2.不会造成空间浪费
缺点:
1.内存空间不连续,CPU命中率低
应用场景:频繁插入、删除
五.总结
顺序表是一种逻辑结构上连续,内存存储上也连续的一种数据结构。
链表是逻辑结构上连续,内存存储上不一定连续的数据结构
链表顺序表各有优缺点,看需求使用,没有说哪个一定比哪个好
哨兵位/哑节点在做题时可以很好的处理边界问题,但在实际的应用场景中意义不是很大
单链表(单向不循环)链表一般不会单独用于存储数据,只是各种编程题种会频繁出现
对于带头双向不循环链表我不知道还要不要介绍,要是写的话文章就太长了(虽然现在就很长),要不我给你们一些需要实现的接口,然后你们自己去尝试着写吧
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next; //指针保存下⼀个节点的地址
struct ListNode* prev; //指针保存前⼀个节点的地址
LTDataType data;
}LTNode;
//void LTInit(LTNode** pphead);
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);//删除pos节点
LTNode *LTFind(LTNode* phead,LTDataType x);//查找数据所在节点
这个双向链表一定要尝试一下,很简单的,并且他的头插尾插还可以调用任意位置前插入和任意位置后插入的那几个函数,这差不多就相当于你把任意位置前和后插入的函数写完整个链表就差不多完了
如果有一天你的面试官给你说10分钟写完一个链表,你就可以写这个