前言
🏠个人主页:泡泡牛奶
🌵系列专栏:[C语言] 数据结构奋斗100天
本期所介绍的线性表包括顺序表和链表,那么线性表优势什么一回事呢?就让我们带着以下问题,了解基本数据结构吧( •̀ ω •́ )✧
- 什么是线性表?
- 线性表有哪些?
- 什么是顺序表?
- 链表的物理结构和逻辑结构是什么样的?
文章目录
一、线性表
1. 线性表定义
线性表就是有n个具有 相同性质的数据元素 组成的有限序列,线性表是一种在实际生活钟广泛运用的数据结构。常见的线性结构有:顺序表、链表、栈、队列、散列表…
2. 线性表的逻辑结构
线性表在逻辑上是线性结构,也可以说是连续的一条直线。但经常在物理结构上并不一定是连续的,线性表子啊物理上存储时,通常以数组或者链式结构让它理论上是一条直线。
二、顺序表
现在我们知道了什么是线性表,那么在你的记忆中哪种结构最贴合线性表的逻辑呢?
没错,是数组,我们知道 数组 有一个特性,那就是可以 在内存中连续存储,利用这个特性我们是不是也可以封装成一个顺序表呢?
1. 顺序表的静态存储
设计一个静态顺序表我们需要考虑下面3种情况:
- 需要规定静态顺序表的大小
- 记录当前元素个数,方便判断顺序表是否已满
- 顺序表具有一定的可维护性
那么,我们就可以对结构体进行如下封装:
#define N 10
typedef int SLDataType;
typedef struct SeqList
{
SLDataType data[N];
size_t size;//当前元素个数
}SeqList;
而在日常生活中,一个 静态的顺序表 很明显 不能满足 我们的大多需求。若静态顺序表数量较少时,存储了大量的数据,那么就稍显不合适。这时,就有些人会有些疑问,那我直接一开始就定义很大的空间不就好了, 那当你定义了很大的空间之后,我又只需要少量的空间呢?
2. 顺序表的动态存储
此时,静态的顺序表就不能满足我们的需求,接下来就由我来带大家看看动态顺序表是怎么实现的吧φ(゜▽゜*)♪
想要实现动态的顺序表,我们可以对静态顺序表进行简单的修改:
- 去除了限制的容量,那我们就需要添加一个现在开辟的容量大小,方便对是否需要扩容进行判断
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* data;
size_t size;//当前元素个数
size_t capacity;//当前容量
}SeqList;
1)顺序表的初始化
对于一个封装的顺序表来说,初始化是非常重要的。创建了一个顺序表,若不进行初始化,那么size
和capacity
就是随机值,为了避免这种情况,最好对顺序表进行初始化一下。
具体代码如下:
void SLInit(SeqList* psl)
{
assert(psl);
psl->data = NULL;
psl->capacity = psl->size = 0;
}
2)顺序表的删除
有了初始化,当然也少不了删除,删除可以将动态开辟的空间归还。
代码如下:
void SLDestory(SeqList* psl)
{
assert(psl);
free(psl->a);
psl->a = NULL;
psl->capacity = psl->size = 0;
}
3)顺序表插入元素
在考虑插入元素之前,我们首先耀有一个概念,那就是顺序表是基于数组之上的封装,而数组的实际元素数量有可能超过数组本身的长度,例如下面的情况:
因此,插入元素时我们需要考虑两种种情况:
- 普通插入
- 超容量插入
尾部插入
普通插入这也是最简单的一种插入情况,直接把元素插入到数组尾部空间就好了。
超容量插入,这又是什么意思呢?
假设现在有一个长度为7的数组,但已经被装满了,这时你突然想插入一个新元素。
而这就需要我们对数组进行 扩容 。好了,现在我们知道要对数组进行扩容了,可是又应该怎样扩容?扩容扩多大呢?
怎样扩容?
在C语言中有一个函数realloc
可以将开辟的内存空间重新分配,realloc
会向堆区寻找一块合适的空间,若找到的新空间与原空间位置不相同,则将原空间的数据拷贝到新空间,释放掉之前的空间(防止内存泄漏)。
扩容扩多大?
我们一般采用的是,原数组大小的2倍。
原因:
- 小于2倍,考虑到可能要多次重复向内存申请空间,会造成申请空间的时间消耗;
- 大于2倍,考虑到开过多的内存可能会用不到,造成空间浪费
综上所述,数组要重新分配新空间一般推荐是原空间大小的2倍。
代码实现:
// 检查容量
// 如果满了就扩容
void CheckCapacity(SeqList* psl)
{
if (psl->size == psl->capacity)
{
int newCapcity = psl->capacity == 0 ? 4 : psl->capacity * 2;
SLDataType* tmp = (SLDataType*)realloc(psl->a, newCapcity*sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
psl->data = tmp;
psl->capacity = newCapcity;
}
}
/*
* 尾部插入元素
*/
void SeqListPushBack(SeqList* psl, SLDataType x)
{
assert(psl);
//检查是否要扩容
CheckCapacity(psl);
psl->data[psl->size] = x;
psl->size++;
}
中间插入
中间插入,相较于尾部插入会稍微复杂一些。由于数组每一个元素都有其固定的下标,所以需要将想要插入元素后面的元素向后移动,空出地方,再把要插入的元素放到对应的地方。
再考虑到可能会有扩容的情况,再进行移动之前,我们可以先对数组进行检查(是否需要扩容),让我们来看看代码改怎样实现吧
( •̀ ω •́ )✧
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x)
{
assert(psl);
assert(pos <= psl->size);
//检查是否需要扩容
CheckCapacity(psl);
// 挪动数据
size_t end = psl->size;
while (end > pos)
{
psl->data[end] = psl->data[end-1];
--end;
}
psl->data[pos] = x;
++psl->size;
}
任意位置插入元素
刚刚我们实现了在中间插入元素,那么我们是否可以通过调用SeqListInset
实现任意位置插入元素呢?
//头插
void SeqListPushFront(SL* psl, SLDataType x)
{
SeqListInsert(psl, 0, x);
}
//尾插
void SeqListPushBack(SL* psl, SLDataType x)
{
SeqListInsert(psl, psl->size, x);
}
4)顺序表删除元素
顺序表的删除元素操作与插入操作的过程相反,如果删除的元素位于数组中间,可以将后面的元素向前挪动1位,将前面的元素覆盖掉,最后将size
个数减1,就不妨碍插入操作了。这相比较于插入来说不用考虑到是否需要扩容,但也不要画蛇添足去实现缩容😂
来看看代码实现吧:
void SeqListErase(SeqList* psl, size_t pos)
{
assert(psl);
assert(pos < psl->size);
size_t begin = pos;
while (begin < psl->size - 1)
{
psl->data[begin] = psl->data[begin + 1];
++begin;
}
psl->size--;
}
5)查找顺序表元素
经历了上面的大关,向要实现顺序表查找就很简单了,只需要遍历整个数组,找到符合条件的值返回其下标就行了。
简单的代码实现如下:
int SeqListFind(SeqList* psl, SLDataType x)
{
assert(psl);
for (int i = 0; i < psl->size; ++i)
{
if (psl->data[i] == x)
{
return i;
}
}
return -1;
}
7)更改顺序表某个位置的元素
更改某一位置元素,可以直接用下标直接更改你想要修改的位置的值。
代码如下:
void SLModify(SeqList* psl, size_t pos, SLDataType x)
{
assert(psl);
assert(pos < psl->size);
psl->a[pos] = x;
}
3. 顺序表小结
1)时间复杂度分析
刚刚我们知道了顺序表的增删查改,那么顺序表增加一个元素、删除一个元素、查找一个元素 的时间复杂度是多少呢🤔?
通过图,我们可以简单的分析出,增删插所需要的 时间复杂度 为 O ( n ) O(n) O(n),而更改一个元素的时间复杂度为 O ( 1 ) O(1) O(1)。
2)顺序表(数组)优势
通过时间复杂度,我们可以清楚的看到,修改内部数据要的时间复杂度明显比,直接访问某一位置所需要的时间复杂度高,那么结论就很清晰了,顺序表(数组) 多适合在 读操作多,写操作少 的环境中,而这也正好与我们接下来要讲的链表恰恰相反。
三、链表
1. 链表的概念
首先我们要知道,链表在物理内存上是由很多分散的空间组成的,那么,要怎样才能知道其它元素所住的地方呢?欸,我们的链表很聪明,会在自己家里讲下一家的地址抄在小本本上,这样就方便找到下家的地址了。
所以,链表是一种在 **物理存储结构上非连续、非有顺 **的存储结构,数据元素的逻辑顺序是通过链表内的 指针链接顺序 去实现的。
而我们从宏观的角度去看链表就是下面这样的结构:
2. 单向链表的实现
现在我们知道,每一个房间里面需要一个小本本来记录下家的地址,那么我们的结构体可以这样定义:
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;//元素数据
struct SListNode* next;//下家地址
}SLTNode;
1)创建一个节点
相比较与顺序表,链表在物理空间上并不是一块连续的空间,那么就不能很好的对链表进行初始化,所以我们在只需要创建一个节点的时候,再说明指向的下家地址就好啦
设计想法:
- 传入一个
/*
* 创建一个节点,返回新节点的地址
* @program x 插入元素的值
*/
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
2)销毁链表
对比顺序表,顺序表的删除可以直接将一串连续的空间直接删除,但链表一串分散的空间,我们又应该怎样删除呢?
我们可以创建一个cur
指针,删除一个空间之前,记录下一房间地址的门牌号,再将这块空间删出,以此类推,依次向后,就可以将整串链表删除了。
那么代码该如何实现呢?
/*
* 链表销毁
* @program pphead 链表头节点地址
*/
void SListDestory(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
4)链表元素查找
想要实现在链表中查找只要从前向后依次遍历整个链表就好啦。
假设要找到元素数值为3的元素,那么只要从前向后,只要找到满足条件就返回该节点的地址,若找不到,就返回NULL
代码实现如下:
/*
* 链表查找某一元素位置
* @program phead 链表头节点
* @program x 要找的元素值
*/
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
5)链表插入元素
吃完了链表的前菜,让我们快来看看链表的正餐吧ο(=•ω<=)ρ⌒☆。
链表插入元素时,一样可以分为3种情况。
- 尾部插入
- 中间插入
- 头部插入
尾部插入
对于这样结构的单链表来说,想要实现尾部插入,可以定义一个指针遍历整个链表,直到指向最后一个节点,再像最后一个节点的next
指针存入要插入的元素即可。但是,我们需要注意一个特殊情况,当链表为空时,无需向后查找,直接连接就好了。
那么代码该如何实现呢?
/*
* 链表尾部插入
* @program pphead 链表头节点地址
* @program x 插入元素的值
*/
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
// 链表为空
if (*pphead == NULL)
{
*pphead = newnode;
}
// 链表为非空
else
{
// 找尾
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
中间插入
链表中间插入与尾部插入类似,都是遍历整个数组,知道找到合适的位置,再将新的节点插入进去。
代码实现如下:
/*
* 链表尾部插入
* @program pphead 链表头节点地址
* @program pos 链表节点地址
* @program x 插入元素的值
*/
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
SListPushFront(pphead, x);
}
else
{
SLTNode* cur = *pphead;
while (cur != pos)
{
cur = cur->next;
// 暴力检查,pos不在链表中.prev为空,还没有找到pos,说明pos传错了
assert(cur);
}
SLTNode* newnode = BuySLTNode(x);
newnode->next = cur->next;
cur->next = newnode;
}
}
头部插入
头部插入相较于前面是最简单的一种操作,只需要创建一个节点,next
指向头节点即可。
代码实现:
/*
* 链表头部插入
* @program pphead 链表头节点地址
* @program x 插入元素的值
*/
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
6)链表的节点删除
链表删除某一节点同样有3种情况:
- 尾部节点删除
- 中间节点删除
- 头部节点删除
尾部节点删除 / 中间节点删除
尾部节点删除和中间节点删除相类似,都是将当前节点删除,上一个节点的next
指向下一个节点的地址。
代码实现如下:
/*
* 链表某一节点删除
* @program pos 节点地址
*/
void SListEraseAfter(SListNode* pos)
{
assert(pos);
SListNode* next = pos->next;
assert(next);
pos->next = next->next;
free(next);
next = NULL;
}
在使用这个函数之前,可以先使用我们前面写的SListFind
(链表元素查找),传入一个已经确定要删除的地址,再将其删除 。
头部节点删除
头部删除是最简单的一种删除方式,直接将第一个链表删除,再更新一下头节点就好了。
代码如下:
/*
* 链表头节点删除
* @program pplist 头节点地址
*/
void SListPopFront(SListNode** pplist)
{
assert(pplist);
assert(*pplist != NULL);
SListNode* del = *pplist;
*pplist = (*pplist)->next;
free(del);
del = NULL;
}
3. 链表小结
1)时间复杂度分析
我们可以简单思考一下,在链表查找一个元素,修改一个元素,尾部或中间插入一个元素,所需要的时间复杂度是多少呢?若删除元素,插入元素的时候不知道插入元素的位置,那么是不是只能在一个函数内将这些功能全部实现。
单从我们今天实现的单链表接口来说,我们创建节点、插入节点、节点删除的时间复杂度是 O ( 1 ) O(1) O(1)。
但是,从总体使用的角度去看,要删除一个节点,我们首先要知道删除节点的位置,需要先查找链表,找到符合条件的节点才能得到要删除的节点位置,而查找链表所需要的时间复杂度是 O ( n ) O(n) O(n),故从使用的角度来看,删除一个链表节点我们所需要的时间复杂度是 O ( n ) O(n) O(n)
那么再来考虑链表的头插和头删呢?链表头插和头删并不需要搜索整个链表,直接在第一个删除或者增加就好了,那么他的时间复杂度是多少?头删和头插的时间复杂度是 O ( 1 ) O(1) O(1)。
2)链表的优势
从上面我们知道,链表对于插入元素和删除元素具有独特的优势,只要知道插入或删除的位置,那么时间复杂度就是 O ( 1 ) O(1) O(1),而数组想要插入或删除元素,就算知道了插入或删除的位置,一样要使用 O ( n ) O(n) O(n)的时间复杂度。
所以,链表多用于 写操作多,读操作少 的环境中。
四、数组 vs 链表
现在,我们链表的知识已经懂了,那么链表和顺序表都属于线性表的结构,用哪一个更好呢?
正确解答:数据结构没有绝对的好坏之分,数组和链表各有各的好处。
数组在内存中是连续存储的,我们可以通过[]
下标引用操作符对数组进行随机访问,而且所能达到的时间复杂度是
O
(
1
)
O(1)
O(1) ,但链表却要使用
O
(
n
)
O(n)
O(n)的时间复杂度;链表呢?链表方便在靠前的地方插入元素,若直接使用头插,链表所用的时间复杂度是
O
(
1
)
O(1)
O(1),而数组想要实现先头插,需要将所有元素向后移动,这样所用的时间复杂度是
O
(
n
)
O(n)
O(n)。
小结:链表适合头插,数组适合尾插;链表相较于数组更适合中间插入,数组相较于链表更适合随机读取。
好啦,本期的内容就到这里啦,链表和顺序表你收获了多少呢?如果绝对对你有帮助的话,还不忘三连支持一下博主,我们下期再见ο(=•ω<=)ρ⌒☆