提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
初学顺序表和线性表的过程中,遇到了很多问题,起初感觉很难,现在返过头来看,很多问题豁然开朗,但复习的过程中收获良多,本文旨在梳理线性表的知识和总结相关问题。
其实,从字面上来看,线性表就是具有线一样性质表,就像一根线把数据串联起来,它是零个或者多个数据元素的有限序列。
值得强调的是,既然线性表是一个序列,那么序列中的元素就是有一定顺序的,且是有限的。就像几个小朋友手拉手排成一列,谁在左谁在右,谁来当队头,谁来当队尾,这都是有序的,不能乱作一团,互相拉扯。一个序列中,有多少个小朋友,即多少个数据元素,就代表了线性表的长度,如果一个小朋友都没有,那就是一个空的线性表。
线性表有两种存储结构,一种是顺序存储结构
,一种是链式存储结构
。
顺序存储结构:用一段地址连续的存储单元依次存储线性表的数据元素。
链式存储结构:在内存中开辟多处内存,并将这些存储单元以指针的形式链接起来。
我们就简称为顺序表和链表,而为什么要会有这两种存储结构呢?那我们先来看顺序表的不便之处。
第一,如果你是插入或者删去一个数据,那么必然也会改变其他元素的位置。
第二,你所使用的数组是必须提前规定好了大小,一旦超过了数组大小,就要另外开辟内存空间,要不然就会越界,导致系统崩溃。如果你说,那就把数组给大点,那不就不会越界了嘛,也不需要频繁扩容,但是万一你要存放的数据其实并不多呢,那多开辟的那么多空间不就是白白浪费了嘛,为了解决这个问题,所以我们有了链式存储结构,这种数据结构能完美解决顺序存储结构的这些缺点。
先来说说顺序表,刚才提到的数组就是一种顺序表,它们在逻辑结构上是相邻的数据元素,在物理结构上也是相邻的元素。它的好处在于,顺序表是一种随机存取的储存结构,我们想访问他的哪一个数据,只需要知道其下标,我们就能直接访问。
顺序表有三大要素:
数组
)元素个数
)数组大小
)int
定义为了顺序表的数据类型,如需改变数据类型,可直接将int
改为其他数据类型。typedef int SLDatatype;//将int定义为顺序表中元素的数据类型
typedef struct SeqList
{
SLDatatype* data;//data即是存放数据的顺序表
int size;//size是顺序表中的元素个数
int capacity;//顺序表的容量,能存放多少个元素
}SeqList;
void initSeqList(SeqList* sl)//初始化顺序表
{
//data是一个开辟了10个int大小的数组指针,数据可以存放在data指向的数组中
sl->data= (SLDatatype*)malloc(10*sizeof(SLDatatype));
sl->size = 0;//此时size=0;
sl->capacity = 10;//容量初始化为10,后续超过了10还可以再扩容
}
int main()
{
SeqList sl;
initSeqList(&sl);
return 0;
}
上面的代码就创建了一个起始容量为10
个int
大小的空的顺序表,每当有数据进入到顺序表中时,size
即顺序表的元素个数
会+1
,而当size
等于capacity
,即顺序表已经被数据填满了,如果想继续插入数据,我们就需要对原数组进行扩容。
顺序表的扩容实际上是增大数组的空间,这也是顺序表和静态数组的区别所在,因为一般的静态数组
创建之后,直接在栈
上开辟空间,是不能直接改变其大小的
,而顺序表的数组是动态开辟
的,是在堆
上开辟空间,是可以改变其大小的
。
代码如下:
void Checkcapacity(SeqList* sl)
{
//判断数据个数是否与数组容量相等
if (sl->size == sl->capacity)
{
//新的容量大小为原来的二倍
int newcapacity = 2 * sl->capacity;
SLDatatype* tmp = (SLDatatype*)realloc(sl, newcapacity * sizeof(SLDatatype));
//判断是否扩容成功,realloc扩容失败会返回一个空指针
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
//将扩容好的空间赋给原数组指针,并将容量改成新的容量大小
sl->data = tmp;
sl->capacity = newcapacity;
}
}
顺序表的尾插实质就是数组的尾插,但又有一些区别:
1.插入新的数据之后,应该将数据个数
size
加1
2.插入新数据时,应该考虑此时的数据个数size
是否超过了数组大小capacity
,如果是就要对数组进行扩容再插入。
代码如下:
void SeqListpushback(SLDatatype* sl, SLDatatype x)
{
//检查数组是否已被数据填满并扩容
Checkcapacity(SLDatatype * sl);
//插入数据并使数据个数加1
sl->data[sl->size] = x;
sl->size++;
}
顺序表的尾删则简单了许多,直接将size-1
,就视为将最后一个元素删去,此时虽然数组中的数据不会变,但对你之后的增删查改的操作没有任何影响,就拿尾插举例,当你尾插时,此时插入的数据会在当前的size
位置进行插入,就会将尾上的数据覆盖。
但注意:当
size
等于0
的时候,顺序表就为空了,那此时如果再进行尾删,就会使得size
等于-1
,导致数组越界,一旦进行了其他不当操作,后果不可预知,是必须避免这种情况的发生。
代码如下:
void SeqListPopback(SeqList* sl)
{
assert(sl);
//为防止size小于0,暴力方式可以用断言,直接终止操作
assert(sl->size >= 0);
//较温和的方式可以直接返回
if (sl->size == 0)
{
return;
}
sl->size--;
}
顺序表的头插和头删与尾插尾删差别不大,但由于顺序表
顺势存储结构
的特性,导致头插头删的效率要比尾插尾删低很多,因为头插头删每次都涉及到其他元素的移动,反之,顺序表尾插尾删的效率就高了不少,这种特性既是顺序表的缺点,也是优点,甚至后面我们要学到的栈
正是充分利用了这份特性,但这都是后话了,我们后面再谈栈
、队列
、堆
等其他数据结构,想必到时你一定会对这些有更深的理解。
总结:顺序表的优缺点:
优点:无须为表中元素之间的逻辑关系而增加额外的存储空间;可以快速的存取表中任一位置的元素。
缺点:插入和删除操作需要移动大量元素;当线性表长度较大时,难以确定存储空间的容量;造成存储空间的“碎片”。
顺序表中的数组是动态开辟的,最后销毁顺序表时,一定要将该动态内存释放掉,不然会导致内存泄漏,并且,应该将该数组指针也置为
NULL
,同时应该将数组的元素个数置为0
,数组的容量大小设置为0
void SeqListdestroy(SeqList* sl)
{
//防止传入空指针
assert(sl);
//释放数组空间
free(sl->data);
//将数组大小和元素个数设为0
sl->size = 0;
sl->capacity = 0;
}
在链式结构中,我们为了避免顺序结构中,因为地址连续,增删查改会影响到周围元素的情况,那我们就在内存中,任意开辟多块空间,每块空间存放对应的数据,再将这些内存以链接的形式联系起来,那就实现了我们的链表。
而为了能使这些数据链接起来,那在链式结构中,除了要存储数据元素的信息外,还要存储它的后继元素的存储地址。那我们就可以将这两者封装在一个结构体中,结构体中创建两个变量,一个变量来存放数据,一个变量存放下一个数据的地址,那么我们就能将这些数据链接起来了,而我们这个结构体称为结点,存放数据的称为数据域,存放下一个结点地址称为指针域。
对于链表,也必须有头有尾,所以我们把链表中第一个节点的存储位置叫做头指针,整个链表的存取都必须从头指针开始进行,之后的每一个节点都是上一个节点后继指针指向的位置,那么,问题来了,最后一个节点该指向什么呢?总不可能链表无限延伸下去吧,答案是,我们直接将尾结点的指针指向空就行了,即NULL
。
对于单链表,我们真正需要的其实只是一个头指针,通过这个头指针,我们就能访问到链表上的每一个节点,所以当我们初始化单链表的时候,我们甚至于不需要一个单独的初始化函数,而是直接创建一个空的头指针即可。
struct ListNode* List=NULL;//指向空链表的头指针
这里介绍一种快速创建链表的方式,是通过创建6
个节点
,然后分别给每个节点赋值,再将每个节点通过指针连接起来的。
```c
struct ListNode {
int val;//val是当前节点存放的数据
struct ListNode* next;//next存放的是后继节点的地址
};
int main()
{
//直接创建n1到n6六个节点,n1是头结点,n6是尾结点
struct ListNode* n1 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* n2 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* n3 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* n4 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* n5 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* n6 = (struct ListNode*)malloc(sizeof(struct ListNode));
//分别给n1到n6节点的数据域和指针域赋值,将六个节点链接起来
n1->val = 1;
n1->next = n2;
n2->val = 2;
n2->next = n3;
n3->val = 3;
n3->next = n4;
n4->val = 4;
n4->next = n5;
n5->val = 5;
n5->next = n6;
n6->val = 6;
n6->next = NULL;//尾结点的指针域指向空
return 0;
}
打印函数:
void print(struct ListNode* head)
{
struct ListNode* cur = head;
while (cur!=NULL)
{
printf("%d", cur->val);
cur = cur->next;
}
}
通过print
函数,我们将链表打印出来,发现这六个节点已经成功链接起来了,形成了一个链表。
但当数据很多的时候,我们不可能还是以这种方式,去一个一个创建节点,然后将它们再一一链接起来,那我们就可以先创建一个空链表,然后再定义链表的各种功能的函数去实现链表的建立。
链表的尾插是创建一个个新节点,然后将后创建的节点,连接在在其之前创建的相邻节点的后面。
```c
void Listpushback(struct List** sl, int x)
{
//创建一个节点,节点的数据存放x,节点的指针指向NULL
struct List* newcode = (struct List*)malloc(sizeof(struct List));
newcode->date = x;
newcode->next = NULL;
//判断传进来的头指针是不是NULL,如果是,就将新节点赋给头指针
//如果不是,就先找到尾结点,将新节点链接在尾结点的后面
if (*sl == NULL)
{
*sl = newcode;
}
else
{
struct List* tail = *sl;
while (tail->next != NULL)//寻找尾结点
{
tail = tail->next;
}
tail->next = newcode;//找到尾结点,将新节点连接在尾结点后
}
注意:
NULL
,即传递给尾插函数的是一个空指针,此时,应该直接将新创建的节点地址赋给头指针。NULL
。void Listpushfront(struct List** sl, int x)
{
//创建新节点并赋值
struct List* newcode = (struct List*)malloc(sizeof(struct List));
newcode->date = x;
//将新节点连接在最前面
newcode->next = *sl;
//将头指针指向新节点
*sl = newcode;
}
注意:
单链表尾删也是需要从头遍历一遍,找到尾结点,将尾结点用
free
释放掉,并将上一个节点的指针域指向NULL
。
void Listbackdel(struct List** sl)
{
assert(*sl);
if ((*sl)->next == NULL)//判断是否只有一个节点,如果是,直接释放该节点
{
free(*sl);
*sl = NULL;
}
else
{
//创建两个快慢指针,快指针去寻找尾结点,慢指针记录快指针的上一个节点,当快指针找到尾结点时,慢指针此时指向尾结点的上一个节点
struct List* tail1 = *sl;
struct List* tail2 = *sl;
while (tail1->next != NULL)
{
tail2 = tail1;
tail1 = tail1->next;
}
tail2->next = NULL;//将尾结点上一个节点的指针域赋为NULL
free(tail1);//释放尾结点
tail1 = NULL;//将尾结点的指针赋为NULL,避免成为野指针
}
}
注意:
NULL
,不然,则上个节点的指针域仍然指向尾结点的空间,但由于该空间已被释放,则上个节点的指针便是一个野指针,如果操作不当,后果无法预料。单链表的头删很简单,可以创建一个临时指针来记录头结点,然后把下一个节点的地址赋给头指针,然后释放临时指针所指向的空间,就能成功把头结点释放掉。
//void Listfrontdel(struct List** sl)
{
assert(*sl);
struct List* ret = *sl;//ret记录头结点
*sl = (*sl)->next;//头指针指向下一节点
free(ret);
ret = NULL;
}
注意:
不是每次
free
掉一个指针之后,在函数内部都要把这个指针置为NULL
,要看是否能访问到这个指针,如果这个指针不会被函数之外调用,那么置不置空都可,或者该指针是外界传递过来的形式参数,那么free
完之后将其置空,也要看传递过来的是否是二级指针,因为只有传递了二级指针,才能改变一级指针,将其置为NULL
.
此处的
pos
位置是指链表中一个指定值x
的位置,在其后面插入一个新节点,新节点值为y
,同样是遍历去寻找值为x
的节点,但由于是在其之后插入,就无须记录其前一个节点,与在pos
位置之前相比,效率较高。
void Listposinsert(struct List** sl, int x,int y)
{
struct List* tail = *sl;
while (tail)
{
if (tail->date == x)
{
struct List* newcode = (struct List*)malloc(sizeof(struct List));
newcode->next = NULL;
newcode->date = y;
if (tail->next == NULL)//判断找到pos时,该节点是否是尾结点,如果是直接尾插
{
tail->next = newcode;
break;
}
else
{
newcode->next = tail->next;//新节点要指向pos的下一个节点
tail->next = newcode;//pos要指向新节点
break;
}
}
else
{
tail = tail->next;
}
}
}
不同于在
pos
位置之后插入节点,在pos
位置之前插入节点的同时,也要让pos
位置最初的上一个节点指向这个新节点,所以,在寻找pos
位置节点的时候,也是要创建两个快慢指针
,以便找到pos
位置的上一个节点。
void Listposfinsert(struct List** sl, int x, int y)
{
assert(*sl);
struct List* tail = *sl;
struct List* tail0 = *sl;
//循环寻找到pos位置
while (tail->date != x)
{
tail0 = tail;
tail = tail->next;
}
//创建新节点并赋值
struct List* newcode = (struct List*)malloc(sizeof(struct List));
newcode->date = y;
newcode->next = NULL;
if (tail == *sl)//如果头结点就是pos位置的节点,头插的话,头指针要改变
{
newcode->next = tail;
*sl = newcode;
}
else
{
newcode->next = tail;
tail0->next = newcode;
}
}
查删实质上与查找插入区别不大,也分两种情况,一种是在删除
pos
位置之前的节点,一种是删除pos
位置之后的节点,根据我们刚才的分析,第一种情况是需要记录pos
位置的上一个节点的,也就是需要快慢指针
,而第二种情况则不需要,效率也要高一些。
void Listfinddel(struct List** sl, int x)
{
assert(*sl);
struct List* tail = *sl;
struct List* tail0 = *sl;
//循环找到pos位置
while (tail->date != x)
{
tail0 = tail;
tail = tail->next;
}
if (tail == *sl)//判断pos是不是头结点
{
*sl = tail->next;
free(tail);
tail = NULL;
}
else
{
tail0->next = tail->next;
free(tail);
tail = NULL;
}
}
链表最后一定要销毁,如果不销毁,会导致内存泄漏,而在释放动态内存的时候,也一定避免使用野指针的情况,最好是养成
free
掉指针之后,就立马将指针置为NULL
。
void Listdestroy(struct List* head)
{
struct List* cur = sl;
while (cur != NULL)
{
//记录头结点的下一个节点
struct List* Next = cur->next;
free(cur);
//将下一个节点的地址赋给头结点
cur = Next;
}
}
malloc
和realloc
等函数,时刻考虑到数据个数和容量,很多问题还是非常简单的。1.多画图,在两节点之间用横向箭头表示链接关系,用一个指向当前节点的竖直箭头表示指向该节点的指针,当删除节点或者插入新节点时,既要考虑到删除的节点或者插入的新节点与上下节点之间的链接关系,也要考虑到指向节点的指针的移动,因为我们要用这些指针来记录上下节点,才能改变链接关系。
2.多思考,画图是为了我们更直观的分析问题,而思考才是我们解决问题的关键,如果没有清晰的逻辑思维,没有考虑清楚节点之间的链接关系,能不能有效使用快慢指针的方法,都关系到我们能不能正确地解决问题。
3.多练习,多敲代码,多看看其他人的解法。在练习的过程中去思考,尝试敲出自己的解题代码,实在解不出,再去看别人的解法,看别人怎么解的,同时思考,他为什么要这样写,或者是,如果不像他这么写,我自己能怎么写代码,要养成自己的解题思路。