一、前言
线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是⼀种在实际中⼴泛使⽤的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...
线性表主要有两个特征:逻辑结构与物理结构。
- 在逻辑上是线性结构,也就说是连续的一条直线(人为想象);
- 在物理结构上并不一天定是连续的, 线性表在物理上存储时,通常以数组和链式结构的形式存储。
二、顺序表
2.1 概念与结构
概念:顺序表是用一段段物理地址连续的存储单元依次存储数据元素的线性结构,⼀般情况下采用数组存储。
看到下表,我们发现顺序表的结构与数组一致,调用的方法也与数组一致。
所以顺序表的底层结构是数组,对数组的封装,实现了常用的增删改查等接口。
数据 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
因此,顺序表是包含在数组里的。
数组 | 顺序表(数组+增加数据+删除数据+修改据+查找数据+.) |
2.2 分类
在数组中,有两种数组类型:静态数组与动态数组。
静态数组 | 动态数组 |
int arr [10] | int * arr |
既然顺序表属于数组,那么顺序表也有两种类型,分别为:静态顺序表与动态顺序表。
2.2.1 静态顺序表
静态顺序表是一种使用定长数组存储元素的线性数据结构。
typedef int SLDataType;
#define N 7
typedef struct SeqList
{
SLDataType a[N]; //定长数组
int size; //有效数据个数
}SL
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|
静态顺序表底层逻辑就是一个固定长度的数组,我们定义了一个size来访问其数组元素。
这样的顺序表有个比较明显的问题:定义的空间少了有可能不够用,多了又造成内存空间的浪费。而使用动态顺序表能完美解决这个问题。
2.2.2 动态顺序表
我们在使用静态顺序表时,有时候会出现数组空间不够用的情况。而使用动态顺序表时,顺序表会动态申请内存空间,根据使用者的需要来进行数组大小的增删扩减。
typedef struct SeqList
{
SLDataType* a;
int size; //有效数据个数
int capacity; //空间内容
}SL;
动态顺序表的内存空间是时刻在变化的,所以我们还要在定义一个变量“capacity”,让它实时记录动态顺序表内存空间的大小。
2.2.3 动态顺序表的初始化
先创建一个头文件,名为"SeqList.h”。在其中,创建一个"SLInit"函数来为顺序表初始化。
#pragma once
#include<stdio.h> //定义动态顺序表的结构
typedef int SLDataType;
typedef struct seqList
{
SLDataType* arr;
int size; //有效数据个数
int capacity; //空间容量
}SL;
//typedef struct SeqList sL;
//顺序表的初始化
void SLInit(SL* ps);
接下来创建两个源文件,一个用来实现初始化,一个用来测试功能的完整性。
#include"SeqList.h"
//初始化
void SLInit(SL* ps)
{
ps->arr = NULL; //不能用“ s.”,因为在初始化时形参不能识别到。传值:形参是实参的拷贝
ps->size = ps->capacity = 0; //传地址:新参的改编影响实参。只要没看到“&”这个操作符就是传值
}
#include "SeqList.h"
void test01()
{
SL s1;
SLInit(&s1);
}
int main()
{
test01();
return 0;
}
运行发现没有异常,说明已经初始化成功了。
2.2.4 动态顺序表的插入方法
基本思想
-
动态顺序表通常通过数组实现,初始分配一定的存储空间。
-
当插入新元素时,如果当前存储空间不足,则需要动态扩展数组容量。
-
插入操作可能会导致已有元素的后移,因此需要在插入位置之后的所有元素向后移动一位,若在最后一位插入,则不需要。
1. 尾插
尾插是一种在动态顺序表中常用的插入操作,指将新元素插入到顺序表的末尾。这种操作具有较高的效率,因为它不需要像一般插入那样移动已有元素,仅需简单地将新元素添加到数组的最后位置,并更新数组的实际长度即可。
元素 | 1 | 2 | 3 | 4 | 5 | ||
地址 | 0 | 1 | 2 | 3 | 4 | size |
当我们要实现尾插时,在size的位置插入一个元素,最后再令size++即可。
优点:尾插是动态顺序表中最高效的插入方式之一,适用于需要在表末尾频繁添加元素的场景。通过动态扩容机制,尾插能够保持良好的性能表现,同时避免了因数组容量不足而引发的问题。
尾插的操作步骤
1. 判断存储空间是否足够
- 在执行尾插之前,首先需要检查当前数组是否还有足够的空闲空间容纳新元素:
- 如果空间充足,直接进行插入。
- 如果空间不足,需要扩容。
2. 扩容操作(若必要)
- 如果存储空间不足,需要动态扩展数组的容量。
3. 插入新元素
- 将新元素直接赋值给数组的最后一个位置。
- 更新数组的实际长度。
尾插的时间复杂度
- 扩容操作:扩容的时间复杂度为 O(n),其中 n 是数组当前长度。
- 插入操作:尾插只需要更新一个位置,时间复杂度为 O(1)。
- 平均时间复杂度:由于扩容不是每次插入都会发生,因此尾插操作的平均时间复杂度为 O(1)。
清楚了基本原理和步骤之后,我们来尝试实现它。
在原先初始化代码的基础上,先判断尾插元素需不需要扩容。
void SLPushBcak(SL* ps, SLDataType x)
{
//判断空间是否足够
if (ps->size == ps->capacity)
{
//增容
//如何增容:增容一般成倍数增加。不推荐每次只加一个空间,会存在频繁增容
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2; //初始容量为0时,设置为4
SLDataType* tmp = (SLDataType*)realloc(ps->arr, newCapacity * sizeof(SLDataType)); //创造一个临时指针变量接收,以防内存不够导致功能无法实现,让数据丢失
//realloc 函数的返回值类型是 void*,而 ps->arr 的类型是 SLDataType*(即 int*)。直接将 void* 赋值给 int* 会导致类型不匹配的错误。
if (tmp == NULL)
{
perror("realloc failed");
exit(EXIT_FAILURE);
}
ps->arr = tmp;
ps->capacity = newCapacity;
}
//空间足够的情况
ps->arr[ps->size++] = x;
}
计算新的容量 newCapacity
。
-
如果当前容量为
0
(如初始化时),则设置为4
(初始容量)。 -
否则,容量翻倍(
capacity * 2
)。
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
尝试重新分配内存。
-
realloc
会在原有内存块的基础上扩展(或另找新内存块复制数据)。 -
用tmp来接收的作用:
-
如果直接
ps->arr = realloc(...)
,且realloc
失败返回NULL
,会导致原数据指针丢失,内存泄漏。
-
SLDataType* tmp = (SLDataType*)realloc(ps->arr, newCapacity * sizeof(SLDataType));
-
检查内存分配是否成功。
-
如果
tmp == NULL
,说明realloc
失败(如系统内存不足)。 -
perror
输出错误信息,exit(EXIT_FAILURE)
终止程序。
-
if (tmp == NULL)
{
perror("realloc failed");
exit(EXIT_FAILURE);
}
最后在顺序表末尾插入数据 x
。
-
ps->arr[ps->size] = x
:将x
写入当前末尾位置。 -
ps->size++
:有效数据个数 +1。
ps->arr[ps->size++] = x;
功能实现完了后,我们来测试一下。
void test01()
{
SL s1;
SLInit(&s1);
SLPushBcak(&s1, 1);
SLPushBcak(&s1, 2);
SLPushBcak(&s1, 3);
SLPushBcak(&s1, 4);
SLPushBcak(&s1, 5);
}
经过调试,发现功能正常实现,说明代码无误。
三、附:
1、增容一般都成倍数增加,不推荐每次只增加一个空间,因为:
-
频繁增容导致性能下降。
-
内存利用率低。
-
推荐的扩容策略是倍增法,它能显著提高性能并减少资源浪费。
2、Malloc、Calloc、Realloc的作用:
代码 | malloc | calloc | realloc |
作用 | 申请固定大小的空间 | malloc + 初始化 | 增加空间 |