对于数据结构:顺序表的超详细保姆级解析
开篇介绍:
hello 大家,我们又见面了
还记得前几篇博客吗?那时我们一同沉下心来,细细拆解了数据结构中 “链表” 的种种细节 —— 从它的基本构造到节点间的关联,从增删改查的实现逻辑到不同场景下的应用特性,每一个知识点都像是一颗散落的珍珠,被我们一点点串联成了清晰的脉络。但数据结构的疆域远比这更辽阔,它像一座藏满宝藏的迷宫,除了我们已经探寻过的 “链表” 小径,还有无数条未曾踏足的道路,等待着我们去发现新的风景。
而今天,我想邀请大家一同踏上另一段旅程,去认识一位或许不那么 “张扬”,却同样值得我们驻足了解的伙伴 ——“顺序表”。
说起来,顺序表在数据结构的大家庭里,确实算不上是最受瞩目的那一个。它的特性决定了自身存在不少局限:比如对存储空间的连续性要求极高,一旦初始化时分配的空间不足,后续的扩容操作往往要耗费不少精力;再比如在中间位置进行元素的插入或删除时,往往需要挪动大量元素,操作起来远不如链表那般灵活轻便。也正因如此,它的 “名气” 似乎总被链表、栈、队列这些结构盖过一头。
但换个角度想,世间万物的存在都有其不可替代的意义。就像旷野里的草木,有的挺拔如松,适合作为栋梁;有的柔韧如草,却能在石缝中扎根,守护一方水土。顺序表亦是如此,在某些特定的场景下,它那些看似 “局限” 的特性反而会转化为独特的优势 —— 比如它支持随机访问,凭借下标就能直接定位到目标元素,这种高效性在很多对查询速度有严苛要求的场景中,便成了无可替代的闪光点。
所以,无论一种数据结构初看之下是否 “耀眼”,深入了解它的本质与特性,都是我们探索数据结构世界时不可或缺的一环。接下来,就让我们放慢脚步,一点点揭开顺序表的面纱:从它的底层存储逻辑到初始化的细节,从元素的增删查改到底层空间的动态管理,每一个操作背后都藏着设计者的巧思与权衡。相信随着探索的深入,我们不仅能掌握它的使用方法,更能读懂它在数据结构体系中独特的存在价值。
那么,就让我们带着好奇与耐心,一同走进顺序表的世界,开始今天的学习吧。
线性表的概念:
想要深入洞悉顺序表之前,我们必然绕不开线性表这一概念,毕竟顺序表便是线性表的一种:
“线性表” 作为数据结构中最基础的抽象模型之一,其内涵远不止 “元素有序排列” 这么简单。它是对一类 “有序数据集合” 的本质提炼,承载着计算机处理有序数据的核心逻辑。我们可以从定义、逻辑特性、物理实现、操作体系、应用场景等多个维度,进行更细致的拆解:
一、定义的严谨性:从数学到计算机的映射
线性表的严格数学定义可表述为:一个线性表是由 n(n≥0)个数据元素构成的有限序列(a₁, a₂, ..., aₙ)。这个定义里的每一个词都有明确的指向:
- “数据元素”:不仅可以是基本数据类型(如整数、字符),也可以是复合结构(如一个学生信息包含姓名、学号、成绩),但核心是 “表中所有元素必须属于同一数据类型”(即 “同质”)。这保证了存储时的空间占用一致性,以及操作时的逻辑统一性(比如比较、复制等操作可复用)。
- “有限”:n 是确定的非负整数,意味着线性表的长度是可度量的,不存在无限长的线性表。这为 “表的边界”(如 “表头”“表尾”)和 “操作的终止条件”(如遍历结束)提供了数学基础。
- “序列”:强调元素的 “有序性”—— 每个元素 aᵢ(1≤i≤n)在表中都有唯一的 “位序”(即位置序号 i),且这种顺序是 “逻辑上的先后”,而非物理位置(物理位置由存储结构决定)。例如,a₁必须在 a₂之前,a₂必须在 a₃之前,这种顺序不可天然颠倒(除非通过插入、删除等操作主动修改)。
二、逻辑关系的唯一性:“一对一” 的刚性约束
线性表的 “线性” 二字,核心是描述元素之间的逻辑关联,这种关联具有 “唯一性”:
- 对于 n=0 的 “空表”:无任何元素,自然不存在元素间的关联。
- 对于 n≥1 的非空表:
- 存在唯一的首元素 a₁:它是序列的起点,没有 “前驱”(即没有任何元素在它之前);
- 存在唯一的尾元素 aₙ:它是序列的终点,没有 “后继”(即没有任何元素在它之后);
- 对于中间元素 aᵢ(2≤i≤n-1):有且仅有一个直接前驱 aᵢ₋₁(紧挨着它的前一个元素),和一个直接后继 aᵢ₊₁(紧挨着它的后一个元素)。
这种关系是 “刚性” 的 —— 不存在 “一个元素有两个前驱” 或 “一个元素有三个后继” 的情况,整个结构像一条 “单向直线”,元素之间仅通过 “相邻” 产生关联。例如:
- 字符串 “abcde” 是线性表:a 是首元素,e 是尾元素,b 的前驱是 a、后继是 c,以此类推;
- 数组
[5, 3, 8, 1]是线性表:5 的后继是 3,3 的前驱是 5、后继是 8,完全符合 “一对一” 逻辑。
三、物理存储与逻辑结构的分离:两种经典实现
线性表是逻辑结构(描述 “元素如何关联”),而它在计算机内存中的物理存储(描述 “元素如何存放”)则有两种经典方式,二者的核心区别在于 “如何体现逻辑上的‘先后顺序’”:
1. 顺序存储结构(顺序表):用 “物理相邻” 表达 “逻辑相邻”
- 存储原理:所有元素被依次存放在一片连续的内存空间中,即元素 a₁, a₂, ..., aₙ的物理地址连续排列(地址依次递增)。
- 逻辑与物理的映射:由于空间连续,元素的逻辑位序 i 与物理地址可通过公式直接关联:
若 a₁的地址为 Loc (a₁),每个元素占用 L 个字节,则 aᵢ的地址为:Loc(aᵢ) = Loc(a₁) + (i-1)×L
这种 “地址可计算” 的特性,让顺序表支持随机访问(通过位序 i 直接定位元素,时间复杂度 O (1))。 - 优缺点:
- 优点:随机访问效率极高,存储密度大(无需额外空间存储指针);
- 缺点:存储空间固定(初始化时需确定大小),扩容成本高(需复制全部元素到新空间);插入 / 删除中间元素时,需移动大量元素(例如在 aᵢ前插入元素,需将 aᵢ到 aₙ依次后移,时间复杂度 O (n))。
2. 链式存储结构(链表):用 “指针关联” 表达 “逻辑相邻”
- 存储原理:元素被存放在非连续的内存节点中,每个节点包含两部分:① 数据域(存储元素 aᵢ);② 指针域(存储后继节点的地址)。通过指针,将分散的节点串联成 “逻辑上的序列”。
- 逻辑与物理的映射:元素的物理地址可以任意分散,但通过指针的指向关系,保证 aᵢ的指针域指向 aᵢ₊₁,从而体现 “aᵢ在 aᵢ₊₁之前” 的逻辑顺序。
- 优缺点:
- 优点:存储空间动态分配(无需预设大小),插入 / 删除操作只需修改指针(例如在 aᵢ后插入新节点,只需让 aᵢ的指针指向新节点,新节点的指针指向 aᵢ₊₁,时间复杂度 O (1));
- 缺点:不支持随机访问(访问 aᵢ需从首节点开始依次遍历,时间复杂度 O (n));存储密度低(指针域占用额外空间)。
四、操作体系:抽象数据类型的核心价值
线性表作为 “抽象数据类型(ADT)”,其价值不仅在于定义结构,更在于规范了一套与存储结构无关的操作接口—— 无论底层是顺序表还是链表,这些操作的 “逻辑意义” 完全一致,只是实现细节不同:
| 操作名称 | 逻辑意义 | 顺序表实现要点 | 链表实现要点 |
|---|---|---|---|
| 初始化(Init) | 创建一个空的线性表,表长 n=0 | 分配连续空间,设置表长为 0 | 初始化头指针(指向空),表长为 0 |
| 销毁(Destroy) | 释放线性表占用的资源,避免内存泄漏 | 释放连续空间,设置指针为 NULL | 遍历释放所有节点,头指针置空 |
| 插入(Insert) | 在第 i 个位置插入元素 x,插入后表长 + 1(1≤i≤n+1) | 移动 i 到 n 的元素后移,再放入 x | 找到第 i-1 个节点,修改指针插入新节点 |
| 删除(Delete) | 删除第 i 个位置的元素,删除后表长 - 1(1≤i≤n) | 移动 i+1 到 n 的元素前移,覆盖被删元素 | 找到第 i-1 个节点,修改指针跳过第 i 个节点 |
| 查找(GetElem) | 获取第 i 个位置的元素(1≤i≤n) | 直接通过地址公式访问 aᵢ | 从首节点遍历 i-1 次,获取节点数据 |
| 定位(LocateElem) | 查找值为 x 的元素的位序(若存在返回 i,否则返回 0) | 遍历所有元素,比较值是否为 x | 遍历所有节点,比较数据域是否为 x |
| 判空(Empty) | 判断表是否为空(n=0) | 检查表长是否为 0 | 检查头指针是否为空或表长是否为 0 |
| 求表长(Length) | 返回表中元素的个数 n | 直接返回记录的表长变量 | 直接返回记录的表长变量 |
五、空表与边界:操作的前提与限制
线性表的操作必须严格遵守 “边界规则”,而空表(n=0)是最重要的边界情况:
- 空表不能执行 “删除”“查找第 i 个元素” 等操作(因无元素可操作);
- 空表可以执行 “插入” 操作(插入后成为 n=1 的表,插入位置只能是 i=1);
- 任何操作都需先判断 “位置合法性”(如插入时 i 必须在 1 到 n+1 之间,删除时 i 必须在 1 到 n 之间),否则会导致 “越界” 错误(如顺序表访问 i>n 的位置会触发内存越界,链表遍历 i>n 会访问无效节点)。
六、为什么需要线性表?—— 抽象的力量
线性表的本质是对 “有序数据” 的标准化抽象。在实际开发中,大量场景需要处理 “有序、同质、有限” 的元素集合(如学生成绩排名、日志记录、商品列表等),线性表通过统一的逻辑结构和操作接口,让开发者无需关心底层存储细节,只需专注于 “如何用这些操作解决问题”。
例如,无论用数组(顺序表)还是链表存储 “购物车商品”,“添加商品”(插入)、“删除商品”(删除)、“查看第 3 个商品”(查找)的逻辑意义完全一致,开发者可以根据场景选择更合适的存储结构(如频繁查询用顺序表,频繁增删用链表),而业务逻辑无需修改。
总结
线性表是 “有序数据集合” 的最小公倍数 —— 它用 “一对一” 的逻辑关系框定了元素关联,用两种存储结构实现了物理存储的灵活选择,用标准化操作定义了交互接口。理解线性表,不仅是掌握顺序表和链表的基础,更是理解 “逻辑结构与物理结构分离” 这一数据结构核心思想的起点。后续的栈、队列、串等结构,本质上都是线性表的 “受限变体”(如栈只允许在表尾操作),而哈希表、树等复杂结构,也常以线性表作为底层组件(如哈希冲突处理中的链表)。因此,吃透线性表,才算真正敲开了数据结构的大门。
顺序表的概念:
知道了线性表之后,我们就可以展开对顺序表的了解了,
顺序表作为线性表的核心实现方式之一,其本质是通过连续的内存空间对线性表的逻辑结构进行物理映射,这种映射关系既决定了它的高效特性,也带来了固有的局限性。以下从定义、结构、特性、操作细节、适用场景等方面进行更深入的解析:
一、定义的深层解析
顺序表的核心定义可拆解为两个关键维度:
- 逻辑结构:属于线性表,即元素之间存在 “一对一” 的相邻关系(除首尾元素外,每个元素有且仅有一个直接前驱和一个直接后继);
- 物理结构:元素在内存中按逻辑顺序依次存储,且物理地址连续。这种 “逻辑相邻→物理相邻” 的严格对应,是顺序表与链表(通过指针关联元素)的核心区别。
例如,一个存储整数的顺序表[10, 20, 30, 40],在内存中会占用 4 个连续的 int 型空间(假设每个 int 占 4 字节),若首元素地址为0x1000,则各元素地址依次为0x1000、0x1004、0x1008、0x100C,逻辑位序与物理地址呈线性函数关系。
二、结构组成:静态与动态的划分
顺序表的存储结构可根据 “存储空间是否固定” 分为两类,这直接影响其灵活性:
-
静态顺序表
- 定义:初始化时通过数组(如
int arr[10])预先分配固定大小的连续空间,空间大小在编译期确定,无法动态调整。 - 组成:包含 “存储元素的数组” 和 “当前实际元素个数(长度)” 两个核心部分。例如:
c
#define MAX_SIZE 100 // 固定容量 typedef struct { int data[MAX_SIZE]; // 存储元素的连续空间 int length; // 当前元素个数(≤MAX_SIZE) } StaticSeqList; - 局限:若元素数量超过预设容量(
length > MAX_SIZE),会导致 “溢出”,无法继续插入,灵活性差。
- 定义:初始化时通过数组(如
-
动态顺序表
- 定义:初始化时分配一定初始空间,当元素数量接近容量时,通过动态内存分配(如 C 语言的
realloc)扩展存储空间,容量可动态增长。 - 组成:包含 “指向动态数组的指针”、“当前长度” 和 “当前容量” 三个部分。例如:
c
typedef struct { int* data; // 指向动态分配的连续空间 int length; // 当前元素个数 int capacity;// 当前可容纳的最大元素数 } DynamicSeqList; - 优势:可避免静态表的溢出问题,通过扩容(如每次容量翻倍)适应元素数量增长,但扩容过程需复制原有元素(时间复杂度 O (n)),存在性能开销。
- 定义:初始化时分配一定初始空间,当元素数量接近容量时,通过动态内存分配(如 C 语言的
三、核心特性:随机访问与存储连续性的双刃剑
-
随机访问能力
由于元素物理地址与逻辑位序的线性关系(Loc(a_i) = Loc(a_1) + (i-1)×sizeof(元素类型)),只要知道元素的位序i(从 1 开始),就能直接计算其内存地址并访问,时间复杂度为O(1)。这一特性使顺序表在 “按索引查询” 场景中远超链表(链表需从头遍历,时间复杂度 O (n))。 -
存储连续性的影响
- 优势:存储密度高(无需额外存储指针等关联信息,内存利用率高);CPU 缓存友好(连续空间易被预加载到缓存,减少内存访问延迟)。
- 劣势:
- 插入 / 删除操作需移动元素:若在第
i个位置插入元素,需将第i至第n个元素依次后移(空出位置),移动次数为n - i + 1;删除第i个元素时,需将第i+1至第n个元素依次前移,移动次数为n - i。两种操作的平均时间复杂度均为O(n),表长越长,效率越低。 - 扩容成本:动态顺序表扩容时,需申请新的连续空间(可能比原空间大),并将所有元素复制到新空间,旧空间释放。若频繁扩容,累计时间成本较高(但可通过 “预留足够初始容量” 或 “倍增扩容” 减少扩容次数)。
- 插入 / 删除操作需移动元素:若在第
创建线性表:
知道了概念之后,我们接下来肯定就是要开始创建线性表了,和链表一样,线性表同样需要结构体来帮助,具体如下:
typedef int name1;//将int命名为name1
//将整型重命名为name1,这样子在接下来的书写中,如果有必定为整型的,我们输入int
//如果有不一定为整型的(后续可能会改为char等等),我们就用name1去写
//后面想改,我们只需要把name1命名为char就行
struct seqlist//其实这个就相当于是顺序表
{
name1* arr;//直接设置指针,便于后续开辟动态空间(用arr[]不行,因为这算是解引用)
int size;//有效的数据个数
//注意:数组中的第size个可是没有的,无值,因为数组是从0开始,最后一个有效数据在的位置是size-1;
int capacity;//空间大小
};
typedef struct seqlist SL;//将struct seqlist命名为SL
大家要仔细体会代码中注释的含义,这对于我们后面的对顺序表的各种操作至关重要,尤其是size和capacity这两个变量,以防大家不太理解,我这边再详细解释一下:
在这个 struct seqlist 结构体中,三个成员变量分别从 “存储位置”“数据量”“空间上限” 三个维度描述了动态顺序表的核心属性,它们的设计直接服务于顺序表 “连续存储、动态扩容” 的特性。我们逐一拆解:
1. name1* arr; —— 指向顺序表元素存储区的指针
- 核心作用:通过指针指向一片连续的内存空间,这片空间用于实际存储顺序表中的元素(类似数组的底层存储空间,但支持动态分配和扩容)。
- 为什么用指针而非数组(
arr[]):- 若定义为
arr[](如name1 arr[10]),则存储空间在编译时就已固定(大小不可变),无法实现 “动态扩容”(这是静态顺序表的局限)。 - 而指针
arr可以通过malloc/realloc动态申请内存,后续可根据需要扩展空间(如从初始容量 10 扩展到 20),这正是动态顺序表的核心优势。
- 若定义为
name1的含义:name1是元素的数据类型(可能是int、char等基本类型,也可能是自定义结构体)。例如,若存储的是整数,则name1可替换为int,即int* arr;。- 与数组下标的关系:指针
arr指向的空间可以像数组一样通过下标访问,例如arr[0]表示第一个元素,arr[i]表示第i+1个元素(因下标从 0 开始)。
2. int size; —— 记录顺序表中有效元素的个数
- 核心作用:标记当前顺序表中实际存储的有效数据数量,是区分 “已用空间” 和 “未用空间” 的关键。
- 关键细节:
- 有效元素的下标范围:由于数组下标从 0 开始,最后一个有效元素的下标是
size - 1(例如size=3时,有效元素在arr[0]、arr[1]、arr[2])。 size的取值范围:0 ≤ size ≤ capacity。当size=0时,顺序表为空;当size=capacity时,顺序表已满(需扩容才能继续插入元素)。
- 有效元素的下标范围:由于数组下标从 0 开始,最后一个有效元素的下标是
- 操作中的作用:
- 插入元素时,
size需 +1(有效元素增加); - 删除元素时,
size需 -1(有效元素减少); - 遍历元素时,循环范围为
0到size-1(避免访问无效数据)。
- 插入元素时,
3. int capacity; —— 记录顺序表当前分配的总存储空间大小
- 核心作用:表示
arr指向的连续空间最多能存储的元素个数(即 “容量上限”),用于判断是否需要扩容。 - 与
size的关系:capacity是 “总空间”,size是 “已用空间”,二者的差值(capacity - size)是 “剩余可用空间”。- 当
size == capacity时,剩余空间为 0,此时插入新元素会导致 “溢出”,必须通过realloc扩容(如将capacity翻倍),否则无法存储新元素。
- 动态调整:
capacity并非固定值,在扩容操作中会被更新(例如从 10 变为 20),其值始终大于或等于size(保证有足够空间存储有效元素)。
总结:三个变量的协同关系
这三个变量共同构成了动态顺序表的 “状态描述”:
arr提供存储载体(连续空间);size标记实际数据量(边界控制);capacity管理空间上限(扩容依据)。
例如:当我们要插入一个元素时,需先检查 size 是否等于 capacity(判断是否满),若满则通过 realloc 扩展 arr 的空间并更新 capacity,再将元素放入 arr[size] 位置,最后 size++。整个过程中,三个变量相互配合,确保顺序表的连续存储特性和动态适应性。
进行对顺序表的初始化:
创建了顺序表之后,我们就可以着手于对顺序表的初始化了,这个也是很简单的操作,毕竟一个东西在正式使用之前,一般都是为空的,我们的线性表也是,在我们正式对其存储数据之前,其也应该保存着空空如也的状态,那么我们要怎么去让其变得空荡荡呢?在我们初始化之前,我们知道,在创建线性表的时候的结构体中,我们只是单纯的创建了变量,并没有指定变量大小,那么我们要怎么去让其变得空荡荡呢?
SLinit函数对顺序表的初始化过程,本质是为了建立一个 “逻辑自洽的空表状态”,为后续所有操作(插入、删除、查询等)提供明确的起点和规则。每一步操作都有其深层的设计逻辑,确保顺序表从创建之初就处于可被安全、高效操作的状态:
第一步:将存储区指针(arr)初始化为空(NULL)
这一步的核心目的是明确 “未分配实际存储空间” 的状态。
- 顺序表的元素需要存储在连续的内存块中,而
arr作为指向这片内存的 “路标”,在初始化时必须明确其指向 —— 由于此时还没有任何元素需要存储,不需要提前占用内存,因此将其设为NULL(空指针),表示 “当前没有指向任何有效的存储区域”。 - 这种设计遵循 “按需分配” 的原则:不提前消耗内存资源,只有当后续真正需要插入元素时,才会根据需求申请存储空间。同时,
NULL状态也为后续操作(如插入元素时)提供了判断依据 —— 当检测到arr为NULL时,程序会知道 “需要首次分配空间”,从而触发对应的内存申请逻辑。
第二步:将有效元素个数(size)初始化为 0
这一步是对 “空表” 的直接量化定义。
size的核心作用是标记顺序表中 “实际存在的有效元素数量”。初始化时,顺序表中没有任何元素,因此size必须被设置为 0,这是 “空表” 最直观的特征。- 这个值的意义体现在后续所有操作的边界控制上:
- 当需要插入元素时,
size会告诉程序 “新元素应该放在哪个位置”(例如,第一个元素应放在下标为size(即 0)的位置,插入后size递增为 1); - 当需要删除元素时,程序会先检查
size是否大于 0(若为 0 则无法删除,避免无效操作); - 当需要遍历元素时,
size会限定遍历的范围(只能遍历下标从 0 到size-1的元素,避免访问无效区域)。
- 当需要插入元素时,
第三步:将容量(capacity)初始化为 0
这一步是与 “未分配空间” 状态的匹配,明确存储上限。
capacity表示顺序表当前分配的存储空间 “最多能容纳的元素数量”。由于初始化时arr为NULL(未分配任何空间),此时能容纳的元素数量自然是 0,因此capacity被设置为 0,与arr的状态保持一致。- 这个值是后续 “扩容判断” 的核心依据:当需要插入元素时,程序会比较
size和capacity—— 若size等于capacity,说明当前空间已满(或未分配空间),需要扩容(申请更大的存储空间,并更新capacity的值)。例如,首次插入元素时,size为 0,capacity为 0,程序会判断 “空间不足”,从而触发首次空间分配(如申请能容纳 4 个元素的空间,此时capacity更新为 4)。
总结:三步操作的协同意义
这三个步骤并非孤立存在,而是相互配合,共同构建了一个 “逻辑完整、状态明确的初始空表”:
arr = NULL和capacity = 0共同描述了 “未分配存储空间” 的物理状态;size = 0描述了 “无有效元素” 的逻辑状态。
这种初始状态为后续所有操作提供了清晰的起点:程序无需猜测顺序表的初始情况,只需根据这三个值的状态,就能明确下一步该做什么(如是否需要分配空间、是否能执行插入 / 删除等)。同时,这种设计也保证了内存的高效利用(不提前分配不必要的空间)和操作的安全性(通过明确的边界值避免越界访问或无效操作)。
以下是完整代码:
//进行对顺序表初始化的函数
void SLinit(SL* ps)
{
ps->arr = NULL;//因为本质上是指针变量类型,所以我们初始化用NULL
ps->size = 0;
ps->capacity = 0;
//可以初始化为0,不用malloc,因为我们会在后面的增删中进行判断吗,进行申请空间
//当然,我们也可以直接初始化就申请空间,都是可以的,看个人习惯
}
简简单单。
对顺序表申请空间:
我们知道,在链表中,我们想要存储数据,是需要去创建节点的,创建了之后,我们才能存储数据进去,而在顺序表中,也是一样的,我们想要对顺序表插入数据,第一步便是要判断顺序表中剩余的空间够不够我们插入数据,毕竟顺序表的本质其实就是数组,而且是不断的动态申请空间,因此我们在插入数据之前,就必须判断是不是需要再次申请空间去存放数据,要是线性表空间不够,我们还强行插入数据,那么毋庸置疑,肯定会发生数组越界访问,造成程序崩溃,那么我们又要怎么去判断线性表空间是否足够并且如果不够的话就去申请新空间呢?
诶,这就和我们之前的sizt、capacity这两个变量有关了,我们可以这么想:
当顺序表的 size(有效数据个数)等于 capacity(空间总容量)时,若再执行插入操作,必然导致越界,核心原因在于 size 的含义与存储空间的边界限制之间的严格对应关系。我们从 “size 的物理意义”“空间边界”“操作逻辑” 三个层面详细拆解:
1. 先明确 size 的物理意义:它是 “下一个待插入位置的下标”
在顺序表中,size 有两个关键含义:
- 表示当前有效数据的个数(例如
size=3意味着有 3 个有效元素); - 表示 “下一个新元素应该插入的位置”(即下标为
size的位置)。
这是因为有效元素的下标范围是 0 ~ size-1(从 0 开始),最后一个有效元素的下标是 size-1,而 size 恰好是它的下一位 —— 这个位置在当前状态下是 “空的”,专门留给新元素插入。
例如:
- 若
size=3,有效元素存在于arr[0]、arr[1]、arr[2],下一个元素会插入到arr[3](即size对应的位置); - 插入后
size变为 4,有效元素范围扩展到0~3,逻辑自洽。
2. capacity 是存储空间的 “硬上限”:下标范围严格限制在 0 ~ capacity-1
capacity 表示顺序表当前分配的存储空间最多能容纳的元素个数,对应的物理下标范围是 0 ~ capacity-1。
- 例如
capacity=4时,存储空间只能访问arr[0]、arr[1]、arr[2]、arr[3],arr[4]是超出范围的非法地址(属于未分配的内存,访问会导致越界)。
3. 当 size == capacity 时,插入操作必然越界
当 size 等于 capacity 时,意味着:
- 当前有效元素的个数已经达到了存储空间的上限(
size = capacity); - 下一个待插入的位置是
size(即capacity),而这个位置恰好超出了存储空间的合法下标范围(0 ~ capacity-1)。
例如:
- 若
capacity=4,当size=4时,下一个插入位置是arr[4],但capacity=4,毕竟数组是从0开始存储的,只允许访问到arr[3],arr[4]是非法地址; - 此时若强行插入元素到
arr[size](即arr[4]),就会访问未分配的内存,触发越界错误(可能导致程序崩溃、数据损坏等)。
4. 为什么删除操作不会因 size == capacity 越界?
删除操作是减少有效元素个数(size--),而 size == capacity 时,size 的初始值是 capacity,删除后 size 变为 capacity-1,反而回到了合法范围(size <= capacity-1)
- 例如
capacity=4,size=4时删除一个元素,size变为 3,有效元素范围是0~2,仍在0~3(capacity-1)的合法范围内,因此不会越界。
总结:size == capacity 是插入操作的 “红色警戒”
size 与 capacity 的关系本质是 “已用空间” 与 “总空间” 的关系:
- 当
size < capacity时,存在空闲位置(capacity - size个),插入操作可安全执行(插入到arr[size],再size++); - 当
size == capacity时,无任何空闲位置,下一个插入位置arr[size]超出存储空间上限,必须先扩容(增大capacity),否则必然越界。
这也是为什么动态顺序表的插入操作前必须检查 size == capacity—— 一旦满足,就触发扩容逻辑(如申请更大的存储空间,更新 capacity),确保后续插入操作的安全性。
由此,我们也就知道了如何判断线性表中剩余空间是否足够我们插入新数据,那么接下来就是,要是空间不够,我们怎么去申请新空间呢?其实也是很简单的:
第一步:判断当前空间是否已满
首先检查顺序表的有效元素个数(size)是否等于总容量(capacity)。
- 若两者相等,说明当前存储空间已被完全占用,没有空闲位置容纳新元素,此时必须先扩容才能继续插入;
- 若两者不相等(
size < capacity),说明还有空闲空间,无需扩容,可直接执行插入操作。
第二步:确定新的扩容容量
当需要扩容时,首先计算新的容量(newcapacity),遵循 “动态调整” 原则:
- 若当前容量为 0(即顺序表从未分配过空间,初始状态),则直接将新容量设为一个基础值(如 2),作为首次分配的空间大小;
- 若当前容量不为 0,则将新容量设为原容量的 2 倍(或 3 倍,根据实际需求选择)。这样做的目的是减少频繁扩容的次数(每次扩容后能容纳的元素翻倍,后续多次插入无需立即扩容),平衡性能和空间利用率。
第三步:申请新的存储空间
根据计算出的新容量,向系统申请一块更大的连续内存空间:
- 申请时需明确空间大小:新容量乘以单个元素的大小(确保能容纳
newcapacity个元素); - 为了安全起见,先将申请到的空间地址存放在一个临时变量中,而不是直接覆盖原指针。这样做是为了避免申请失败时(如内存不足),原有的存储空间地址被丢失,导致数据损坏。
第四步:检查空间申请是否成功
对临时变量进行判断,确认新空间是否申请成功:
- 若申请失败(临时变量为空),则提示错误信息(如 “内存分配失败”),并终止程序或当前操作(避免后续使用无效的空间导致更严重的问题);
- 若申请成功,则继续下一步操作。
第五步:更新顺序表的状态
当新空间申请成功后,将顺序表的状态更新为扩容后的状态:
- 将原存储区指针指向新申请的空间(此时新空间成为顺序表的存储载体);
- 将顺序表的总容量(
capacity)更新为新计算的容量(newcapacity),表示当前可容纳的最大元素个数已扩展。 - 但是我们的size变量依旧是为其原本的大小,因为我们只是申请新空间,并不是增加新数据
以下是完整代码:
if (ps->capacity == ps->size)
{
//申请空间
//增容通常是成倍数的增加,一般是2倍或者3倍(realloc)
int newcapacity = ps->capacity == 0 ? 2 : 2 * ps->capacity;
//上述是三目表达式,我们创建一个新变量去判断ps->capacity是不是为0,(为0无法进行增容)
//所以,如果为0.我们就给它2个,如果不为0,我们就把它变为原本空间个数的2倍
//进行增容
//ps->arr = realloc(ps->arr, newcapacity * sizeof(name1));
//因为我们是要添加一个数据,而这个数据类型的大小不固定,所以我们用sizeof函数
//然后再用newcapacity去乘于这个数据类型的大小
//注意:我们上面已经对newcapacity进行处理,
//那么如果原本空间大小为0,我们就让它为2,这样子也相当于是2倍
//如果原本空间不为0,我们就让它为原本空间的2倍,后面再去乘于这个数据类型的大小
//也相当于是2倍
//注意:我们要记得进行强制转换
//同时,我们要进行检查申请空间是否成功
//这就需要我们先创建一个临时变量
name1* temp = (name1*)realloc(ps->arr, newcapacity * sizeof(name1));
if (temp == NULL)
{
perror("realloc:");
exit(1);//退出函数,return 1也行:直接退出程序,不再执行
}
//如果过了上面一关,就代表申请成功
//我们就要进行赋值,将新变量赋值给原本的变量(有借有还,再借不难)
ps->arr = temp;
ps->capacity = newcapacity;
}
这边建议大家直接把这一步封装为一个函数,随用随调:
//因为我们是传址调用,所以不用返回,直接就能在函数中进行改变
void SLcapacitycheck(SL* ps)
{
//先进行判断传入指针是否为空指针
assert(ps);
//接着判断空间够不够
if (ps->size == ps->capacity)
{
//设置新变量进行判断是否原本空间大小为0,为0则需要修改,不为0则令其变为原本的2倍
name1 newcapacity = ps->capacity == 0 ? 2 : 2 * ps->capacity;
//再设置临时指针判断申请空间是否成功
name1* temp = (name1*)realloc(ps->arr, sizeof(name1) * newcapacity);
if (temp == NULL)
{
printf("realloc:%s", strerror(errno));
exit(1);
}
//有借有还,再借不难
ps->arr = temp;
ps->capacity = newcapacity;
}
}
搞定这些后,我们就可以开始我们的插入之旅了~
进行对顺序表尾部的插入:
和之前学链表的顺序类似,在我们搞定了顺序表的创建、初始化之后,我们就可以开始着手于插入数据的操作了,本部分便来讲一讲如何实现对顺序表的尾插操作:
我们的第一步依然是进行空指针判断:assert(ps);,进行上述的完善之后,我们的函数才更加好一些,避免出现意外情况
接下来,自然是判断线性表剩余空间够不够我们插入新数据,其实也就是我们上一部分所讲的函数,很基础。
最后,我们便是要进行尾插,
第一步:确认空间充足(隐含前提)
在执行尾部插入前,已经通过前面的判断(size 是否等于 capacity)确保了当前有足够的空闲空间。
- 若空间不足,会先触发扩容操作,更新
capacity并分配更大的存储空间; - 到这一步时,剩余空间一定能容纳新元素,无需担心越界,可直接执行插入。
第二步:定位尾部插入的位置
尾部插入的位置是 “当前最后一个有效元素的下一位”,这个位置恰好由 size 标记:
- 顺序表中有效元素的下标范围是
0到size-1,最后一个有效元素在size-1处; - 因此,新元素应该放在下标为
size的位置 —— 这个位置在当前状态下是空的,且属于已分配的合法存储空间(因空间已确认充足)。
第三步:存入新元素
将需要插入的数据(x)放入下标为 size 的位置,完成数据的物理存储。
- 这一步本质是 “将新元素放在顺序表的末尾”,保持元素的连续性(逻辑上紧跟最后一个有效元素)。
第四步:更新有效元素个数
插入完成后,有效元素的数量增加了一个,因此需要将 size 的值加 1:
- 原本
size表示插入前的有效元素个数,插入后个数变为size + 1,故更新size为size + 1; - 更新后,
size既代表新的有效元素总数,也标记了下一次尾部插入的位置(依然是 “最后一个有效元素的下一位”),为后续操作做好准备。
下面是完整代码:
//进行对顺序表尾部的插入的函数
void SLpushback(SL* ps, name1 x)
{
//要先进行空指针判断
//第一种:用if
//if (ps == NULL)
//{
// return 1;//==exit(1)
//}
//第二种,用assert断言
assert(ps);
//进行上述的完善之后,我们的函数才更加好一些,避免出现意外情况
//接着进行判断空间大小够不够
//如果所拥有的空间个数等于有效数据的个数,那么肯定会越界(从0开始)
//为什么呢?因为size实际上所在的位置是最后一个有效数据的后一位,是无值的
//每次增删数据,size都得改变,如果size==capacity,那么增删之后,size就会超出所有的空间,会越界访问
if (ps->capacity == ps->size)
{
//申请空间
name1* temp = (name1*)realloc(ps->arr, newcapacity * sizeof(name1));
if (temp == NULL)
{
perror("realloc:");
exit(1);//退出函数,return 1也行:直接退出程序,不再执行
}
//如果过了上面一关,就代表申请成功
//我们就要进行赋值,将新变量赋值给原本的变量(有借有还,再借不难)
ps->arr = temp;
ps->capacity = newcapacity;
}
//要注意,我们想要进行尾部插入数据,应该先判断剩余空间够不够插入,避免越界访问
//如果没有进入上面的if语句中,就说明剩余空间够用
ps->arr[ps->size] = x;//首先用[]进行解引用,因为是在尾部进行插入,而数组又是从0开始
//那么在所有有效数据的后一个,就是ps->size
ps->size += 1;//要记得对有效数据加一,因为我们插入了新的数据,也就是多了一个有效数据
//其实上面两行可以等效为 ps->arr[ps->size++] = x;
}
进行对顺序表头部的插入:
尾插收工,那么头插便闪亮登场,其和尾插步骤类似,同样是判断是否为空指针,而后是判断剩余空间够不够,不够就申请新空间,最后便是头插的操作:
因为是头插,其实就是对arr[0]插入数据,但是又由于原本的arr[0]是有数据的,我们不能贸然就把我们要插入的新数据就直接赋值给arr[0],这无异于强买强卖,所以,之前的做法应该是把原先顺序表中的从arr[0]到arr[size]的数据全部往后挪,把arr[0]的位置空出来,这个便类似字符串右旋,详见该篇博客:对字符、字符函数、scanf、sprintf的详细解析(对于实现字符串左旋以及右旋的分析暨判断字符串是否是某个字符串左旋或右旋所实现)-CSDN博客
完整代码如下:
//进行对顺序表头部的插入的函数
void SLpushfront(SL* ps, name1 x)
{
assert(ps);
//注意,对头部插入,我们也需要进行空间判断,与上述的尾部插入同理
//所以,我们可以直接将检查空间封装为一个函数,直接调用即可
SLcapacitycheck(ps);
//因为是对头部进行插入,所以我们得留出头部的空间
//那就需要把空间中的数据往后挪一位(类似字符串右旋)
for (int i = ps->size; i > 0; i--)
{
ps->arr[i] = ps->arr[i - 1];//将数组从前往后挪一位
//注意,因为有i-1的存在,所以i得大于0,不难会越界访问
}
//经历上述,就可以对头部进行插入,而很明显头部无疑是ps->arr[0]
ps->arr[0] = x;
//注意:进行加入之后,那我们所有有效数据个数也增加了,那我们也得对ps->size进行增加
ps->size++;
}
在这里,我们要尤其注意循环中边界条件的处理,避免越界访问导致程序崩溃~
进行对顺序表尾部的删除:
同样,尾插、头插都收工之后,尾删自动登场,其实这个也很简单,可以说是简单到有点离谱,我们直接看步骤:
第一步:确保操作对象合法(检查指针有效性)
首先判断传入的顺序表指针(ps)是否为空。
- 若指针为空(
NULL),说明没有指向任何实际的顺序表,此时执行删除操作会导致程序错误(如访问非法内存)。 - 因此,这一步是操作的前提,确保我们操作的是一个真实存在的顺序表。
第二步:确保有元素可删除(检查表非空)
判断顺序表的有效元素个数(size)是否大于 0。
- 若
size = 0,说明顺序表为空(没有任何有效元素),此时执行删除操作无意义,还会导致 “有效范围” 异常(如size变为负数)。 - 这一步是操作的合理性检查,确保删除行为有实际的元素可操作。
第三步:收缩有效元素范围(完成删除)
将有效元素个数(size)减 1。
- 顺序表中,“有效元素” 的定义是 “前
size个元素”,最后一个有效元素的位置是size - 1。 - 当
size减 1 后,新的size值对应的有效范围是 “前size-1个元素”,原最后一个元素(位置size-1)被排除在有效范围之外,不再被视为顺序表的一部分。 - 无需真正清除原最后一个元素的内存数据:因为后续所有操作(如遍历、插入、查询)都会以新的
size为依据,自动忽略超出范围的元素,相当于 “逻辑删除”。
下面是完整代码:
//进行对顺序表尾部的删除的函数
void SLpopback(SL* ps)//因为我们只是直接对最后一个进行删除,所以只需要指针变量(地址)就行
{
//判断是否为空指针
assert(ps);
//判断是否没有数据(空间不用管,前面弄过了)
//有数据才能删除
assert(ps->size);
// 只需要将元素数量减1即可完成尾部删除
//顺序表特色
ps->size--;
}
这边再详细解释一下为什么ps->size--便能实现尾删:
一、顺序表的 “双重身份”:物理存储与逻辑规则
顺序表本质上是 “连续内存空间” 与 “有效范围规则” 的结合体:
- 物理存储:元素存放在一片连续的内存中(类似数组),这片空间一旦分配,其物理地址和大小(由
capacity标记)就相对固定,里面的数据不会自动消失(除非主动覆盖)。 - 逻辑规则:通过
size定义 “哪些元素是有效的”—— 只有前size个元素被视为顺序表的一部分,超出这个范围的元素(即使物理上存在)也被视为 “无效”。
例如,capacity=10的顺序表可能在物理内存中存储了[1,2,3,4,5,6,7,0,0,0],但如果size=5,则只有[1,2,3,4,5]被认为是有效元素,后面的6,7和0都因超出size范围而不被认可。
二、尾部删除的核心:缩小 “有效范围”
尾部删除的目标是移除最后一个有效元素,而顺序表通过以下逻辑实现这一目标:
- 明确 “最后一个有效元素” 的位置:由于有效元素范围是
0~size-1,最后一个有效元素的位置是size-1(例如size=5时,最后一个元素在位置 4)。 - 通过
size--收缩范围:当size减 1 后,新的有效范围变为0~(size-1)-1,原最后一个元素(位置size-1)被排除在外。
例如:
- 原
size=5,有效范围0~4,最后一个元素是位置 4 的5; size--后size=4,有效范围变为0~3,位置 4 的5不再属于有效元素,相当于 “被删除”。
三、“无需物理删除” 的底层逻辑
为什么不直接清除原尾部元素的内存数据(比如将其设为 0)?因为完全没必要:
- 顺序表的操作只认
size:遍历、插入、查询等所有操作都会以size为边界。例如遍历元素时,循环范围是0~size-1;插入新元素时,只会放在size位置 —— 这些操作天然会忽略size之外的元素,无论它们物理上是什么值。 - 物理删除反而多余:即使原尾部元素的数据还在内存中,只要
size已经缩小,它就永远不会被顺序表的操作访问到,与 “被清除” 的效果完全一致。刻意清除反而会增加不必要的操作(如赋值),降低效率。
四、效率优势的根源:操作的 “极简性”
这种删除方式的高效(时间复杂度O(1)),源于它仅需一步操作:
- 既不需要像中间删除那样移动大量元素(中间删除需移动
n-i个元素),也不需要释放内存(内存由capacity管理,删除元素不改变总容量)。 - 本质上只是修改了一个整数(
size),这种 “规则调整” 的成本远低于 “物理操作”(移动数据、释放内存等)。
五、总结:size是顺序表的 “隐形边界”
顺序表的核心设计思想是 “用size定义有效范围”,而非依赖物理内存的实际状态。尾部删除之所以只需size--,是因为:
- 它没有真正 “删除” 物理数据,而是通过缩小
size,让原尾部元素脱离 “有效范围”; - 所有操作都遵守
size的规则,自动忽略无效元素,从而实现 “逻辑删除” 的效果。
进行对顺序表头部的删除:
尾删和我们说了再见之后,新的伙伴——头删出现!
对于头删的实现,其实也和尾删很类似,首先最常规的操作便是判断是否为空指针、空顺序表,这已经是老生常谈的话题了
那么接下来,我们就得讲讲头删的关键操作了,与头插类似,头插是对arr[0]插入新数据,只不过在插入之前,我们要让顺序表中的数据都往后移一位。
而对于头删而言,我们便是删除arr[0]这一个数据,就相当于是让原先顺序表中的arr[1]变为新顺序表的arr[0],对此,我们可以直接将顺序表中的所有元素往前移一位,让arr[1]去覆盖arr[0],那么大家应该对这个操作不陌生,它就类似我们之前所讲的字符串左旋操作,依旧是这篇博客:对字符、字符函数、scanf、sprintf的详细解析(对于实现字符串左旋以及右旋的分析暨判断字符串是否是某个字符串左旋或右旋所实现)-CSDN博客
完整代码如下:
//进行对顺序表头部的删除的函数
void SLpopfront(SL* ps)
{
//判断是否为空指针
assert(ps);
//判断是否没有数据(空间不用管,前面弄过了)
//有数据才能删除
assert(ps->size);
//因为是对头部进行删除,所以我们先将数据往前移一位,
//然后再将ps->size--(因为少了一个数据)
//我们先将数据往前移一位
//会直接覆盖,ps->arr[0]会被ps->arr[1]覆盖掉,不复存在
for (int i = 0; i < ps->size - 1; i++)
{
ps->arr[i] = ps->arr[i + 1];//因为有i+1,所以i不能到size-1,会越界访问
//(size-1+1)==size,而ps->arr[size]是无值的,是野的
}
//然后再将ps->size--(因为少了一个数据)
ps->size--;
}
大家一定要记得删除之后,对size--哦。
对顺序表指定数据的查找:
老规矩,完成了头尾的操作之后,必不可少的就是对指定位置的操作,而想要对指定位置进行增删,那就得先找到是哪个指定位置。
那这一步是真的很简单,我们直接遍历顺序表就行,毕竟顺序表本质就是数组,下面是详细步骤:
首先,确定需要操作的顺序表和要查找的目标值
然后,从顺序表的第一个元素开始,逐个检查每个元素。
在检查过程中,将当前元素与目标值进行比较,如果发现匹配的元素,就记录下这个元素所在的位置并结束查找。
如果把所有元素都检查完了还没有找到匹配的,就返回一个表示未找到的标识。
通常可以用一个负数来明确表示未找到的情况。
以下是详细代码:
//进行对顺序表的数据的查找的函数
int SLfind(SL* ps, name1 x)
{
for (int i = 0; i < ps->size; i++)
{
if (ps->arr[i] == x)
{
return i;//找到了,返回该数据小标
}
}
return -1;//表示没找到
}
进行对顺序表指定位置的插入:
其实这一部分的内容,也较为简单,与头插类似,本质上都是要让顺序表中的元素移动,给我们指定要输入的那一个位置让出来,从而我们能对其进行赋值,同样也是用到顺序表元素后移的操作,步骤如下:
-
参数合法性检查:
- 首先确认传入的顺序表指针有效,不是空指针
- 检查插入位置是否合法,必须是大于等于 0 且小于等于当前顺序表的元素个数
-
空间检查与扩容:
- 验证顺序表是否有足够空间容纳新元素,如果空间不足则进行扩容处理
-
元素后移操作:
- 从顺序表的最后一个元素开始,到指定位置的元素为止,依次将每个元素向后移动一个位置
- 这样就会在指定位置腾出一个空位
-
插入新元素:
- 将需要插入的新元素放入刚才腾出的指定位置
-
更新元素计数:
- 最后将顺序表的元素个数加 1,完成整个插入操作
以下是完整代码:
//进行对顺序表指定位置之前一个位置的插入的函数
//注意:是把ps->arr[position]的位置空出来了
void SLinsert(SL* ps, int position, name1 x)
{
//判断是否为空指针
assert(ps);
//判断输入的位置是否大于0且小于有效数据个数
assert(position >= 0 && position <= ps->size);
//可以等于size,因为是对顺序表指定位置之前一个位置插入
//判断是否有足够空间
SLcapacitycheck(ps);
//因为是对指定位置的前一个进行插入数据,
//所以我们应该把ps->arr[position]到ps->arr[size-1]都往后移一位
//这样就把ps->arr[position]的位置空出来了
//我们就可以对ps->arr[position]进行输入数据
//最后要记得对ps->size++
for (int i = ps->size; i > position; i--)
{
ps->arr[i] = ps->arr[i - 1];//i > position,这样子i-1正好就到了position的位置
}
//对ps->arr[position]进行输入数据
ps->arr[position] = x;
//对ps->size++
ps->size++;
}
对顺序表指定位置一个位置的删除:
这一个操作,就与头删类似了,我们依旧是采用顺序表元素前移的操作,把指定位置的那一个数据给覆盖了,详细步骤如下:
-
指针有效性检查:
- 首先确认传入的顺序表指针是有效的,不是空指针,避免后续操作出现错误
-
删除位置合法性验证:
- 检查指定的删除位置是否在有效范围内,必须大于等于 0 且小于当前顺序表的元素个数
- 这样可以确保要删除的位置确实存在有效的元素
-
元素前移操作:
- 从指定的删除位置开始,到倒数第二个元素为止,依次将每个元素向前移动一个位置
- 通过后一个元素覆盖前一个元素的方式,实现对指定位置元素的删除
-
更新元素计数:
- 完成元素移动后,将顺序表的元素个数减 1,反映出删除操作后元素数量的变化
以下是完整代码:
//进行对顺序表指定位置一个位置的删除的函数
//注意:是把ps->arr[position]删除出来
void SLerase(SL* ps, int position)
{
//判断是否为空指针
assert(ps);
//判断输入的位置是否大于0且小于有效数据个数
assert(position >= 0 && position < ps->size);
//不可以等于size,因为是对顺序表指定一个位置的删除
//而ps->arr[size]很明显没有值,删除无意义
//因为是进行删除,所以我们要找到指定位置
//然后将这个位置到size(不包括size)都往前移一位,去覆盖掉ps->arr[position]
//记得要对ps->size--(因为删除了一个数据)
for (int i = position; i < ps->size-1; i++)
{
ps->arr[i] = ps->arr[i + 1];//因为最后是i+1,所以,如果i到了size-1,
//那么i+1==size,而我们对size的处理在后面,不是在这里,所以不能到size-1
}
//记得要对ps->size--(因为删除了一个数据)
ps->size--;
}
进行对顺序表的打印:
这一个操作,大家肯定是如同砍瓜切菜般简单,与数据遍历元素打印一模一样,在这里我也就不赘述,直接看完整代码:
//进行对顺序表的打印的函数
void SLprint(SL s)
{
for (int i = 0; i < s.size; i++)
{
printf("%d ", s.arr[i]);
}
printf("\n");
}
进行对顺序表销毁:
同样的道理,用完之后,我们就要销毁,避免空间浪费,由于我们的顺序表都是动态申请内存空间来的,所以我们要销毁的时候自然避免不了free函数的使用,同时,大家也要记得对size和capacity清0哦,以下是详细步骤:
-
检查存储空间是否存在:
- 先判断顺序表中用于存储数据的空间是否存在(即是否为非空),因为只有当之前分配过动态空间时,才需要进行销毁操作。
-
释放存储空间:
- 如果存储空间存在,就释放这部分动态分配的空间,避免内存泄漏。
-
清空指针:
- 将指向存储空间的指针设置为空,防止后续误操作访问已释放的内存(即避免出现野指针)。
-
重置表的属性:
- 把顺序表当前的元素个数和容量都重置为 0,彻底清除表的状态信息,表明该顺序表已被销毁。
下面是完整代码:
//进行对顺序表销毁的函数
//注意:这个应该是放到最后的,因为要在最后销毁,不难前面就销毁的话,后面还怎么增删
void SLdestory(SL* ps)
{
if ((ps->arr) != NULL)//因为后续会开辟动态空间,所以最后我们可以销毁,这就需要free函数了
{
free(ps->arr);
}
ps->arr = NULL;//彻底销毁,避免野指针
ps->size = 0;
ps->capacity = 0;
}
至此,我们对顺序表的介绍基本就结束了,下面给出实现顺序表的头文件、源文件以及测试文件
顺序表的头文件:
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>
#include<inttypes.h>
typedef int name1;//将int命名为name1
//将整型重命名为name1,这样子在接下来的书写中,如果有必定为整型的,我们输入int
//如果有不一定为整型的(后续可能会改为char等等),我们就用name1去写
//后面想改,我们只需要把name1命名为char就行
struct seqlist//其实这个就相当于是顺序表
{
name1* arr;//直接设置指针,便于后续开辟动态空间(用arr[]不行,因为这算是解引用)
int size;//有效的数据个数
//注意:数组中的第size个可是没有的,无值,因为数组是从0开始,最后一个有效数据在的位置是size-1;
int capacity;//空间大小
};
typedef struct seqlist SL;//将struct seqlist命名为SL
//进行对顺序表的初始化
void SLinit(SL* ps);
//进行对顺序表的销毁
//void SLdestory(SL* ps);
//进行对顺序表的打印
void SLprint(SL s);
//进行对顺序表尾部的插入
void SLpushback(SL* ps, name1 x);
//进行对顺序表头部的插入
void SLpushfront(SL* ps, name1 x);
//进行对顺序表尾部的删除
void SLpopback(SL* ps);
//进行对顺序表头部的删除
void SLpopfront(SL* ps);
//进行对顺序表指定位置一个位置的插入
//注意:是把ps->arr[position]的位置空出来
void SLinsert(SL* PS, int position, name1 x);
//进行对顺序表指定位置之前一个位置的删除
//注意:是把ps->arr[position]删除
void SLerase(SL* ps, int positionm);
//进行对顺序表的数据的查找的函数
int SLfind(SL* ps, name1 x);
//总结:无论是插入还是删除,都要记得在函数最后对ps->size++或者ps->size--
//因为我们无论是循环还是什么,都是没有去改变它的,所以我们一定要记得去增删
//还有就是,插入一定要进行空间判断,看剩余空间够不够(ps->size == ps->capacity),
//等于则不行
//删除就不必判断有没有充足空间,但是得判断是不是野指针
//删除的本质(除尾部删除(直接ps->size--))都是覆盖,用后面的数据去覆盖,
//覆盖掉就不复存在了,也就删除成功了
//进行前移或者后移的时候,调用循环我们要记得判断会不会越界访问,可以假设法去计算
//避免发生越界访问
顺序表的源文件:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>
#include<inttypes.h>
//将我们写的头文件引入
#include "seqlist.h"
//概念:数据结构是计算机存储、组织数据的方式。
//数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。
//数据结构反映数据的内部构成,
//即数据由那部分构成,以什么方式构成,以及数据元素之间呈现的结构。
//总结:
//1.能够存储数据(如顺序表、链表等结构)
//2.存储的数据能够方便查找
//最基础的数据结构:数组。
//线性表
//线性表(linear list)是n个具有相同特性的数据元素的有限序列。
//线性表是一种在实际中广泛使用的数据结构,
//常见的线性表:顺序表、链表、栈、队列、字符串...
//线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,
//线性表在物理上存储时,通常以数组和链式结构的形式存储。
//案例:蔬菜分为绿叶类、瓜类、菌菇类。线性表指的是具有部分相同特性的一类数据结构的集合
//顺序表和数组的区别
//◦ 顺序表的底层结构是数组,对数组的封装,实现了常用的增删改查等接口
//◦ 静态顺序表
//struct s
//{
// int arr[100];
// int size;
//};
//静态顺序表缺陷:空间给少了不够用,给多了造成空间浪费
//◦ 动态顺序表
//struct s
//{
// int* arr;//不确定数组大小的时候,可以不用写[]
// int size;//有效的数据个数
// int capacity;//空间大小
//};
//typedef int name1;
//将整型重命名为name1,这样子在接下来的书写中,如果有必定为整型的,我们输入int
//如果有不一定为整型的(后续可能会改为char等等),我们就用name1去写
//后面想改,我们只需要把name1命名为char就行
//struct s
//{
// int* arr;//不确定数组大小的时候,可以不用写[]
// int size;//有效的数据个数
// int capacity;//空间大小
//};
//想对上述的结构体进行重命名,有两种方式
//1.typedef struct s name2(name2就为struct s的别名)
//2.typedef struct s
//{
// int* arr;//不确定数组大小的时候,可以不用写[]
// int size;//有效的数据个数
// int capacity;//空间大小
//}name2;(name2就为struct s的别名)
//进行对顺序表初始化的函数
void SLinit(SL* ps)
{
ps->arr = NULL;//因为本质上是指针变量类型,所以我们初始化用NULL
ps->size = 0;
ps->capacity = 0;
//可以初始化为0,不用malloc,因为我们会在后面的增删中进行判断吗,进行申请空间
//当然,我们也可以直接初始化就申请空间,都是可以的,看个人习惯
}
//进行对顺序表销毁的函数
//注意:这个应该是放到最后的,因为要在最后销毁,不难前面就销毁的话,后面还怎么增删
void SLdestory(SL* ps)
{
if ((ps->arr) != NULL)//因为后续会开辟动态空间,所以最后我们可以销毁,这就需要free函数了
{
free(ps->arr);
}
ps->arr = NULL;//彻底销毁,避免野指针
ps->size = 0;
ps->capacity = 0;
}
//进行对顺序表尾部的插入的函数
void SLpushback(SL* ps, name1 x)
{
//要先进行空指针判断
//第一种:用if
//if (ps == NULL)
//{
// return 1;//==exit(1)
//}
//第二种,用assert断言
assert(ps);
//进行上述的完善之后,我们的函数才更加好一些,避免出现意外情况
//接着进行判断空间大小够不够
//如果所拥有的空间个数等于有效数据的个数,那么肯定会越界(从0开始)
//为什么呢?因为size实际上所在的位置是最后一个有效数据的后一位,是无值的
//每次增删数据,size都得改变,如果size==capacity,那么增删之后,size就会超出所有的空间,会越界访问
if (ps->capacity == ps->size)
{
//申请空间
//增容通常是成倍数的增加,一般是2倍或者3倍(realloc)
int newcapacity = ps->capacity == 0 ? 2 : 2 * ps->capacity;
//上述是三目表达式,我们创建一个新变量去判断ps->capacity是不是为0,(为0无法进行增容)
//所以,如果为0.我们就给它2个,如果不为0,我们就把它变为原本空间个数的2倍
//进行增容
//ps->arr = realloc(ps->arr, newcapacity * sizeof(name1));
//因为我们是要添加一个数据,而这个数据类型的大小不固定,所以我们用sizeof函数
//然后再用newcapacity去乘于这个数据类型的大小
//注意:我们上面已经对newcapacity进行处理,
//那么如果原本空间大小为0,我们就让它为2,这样子也相当于是2倍
//如果原本空间不为0,我们就让它为原本空间的2倍,后面再去乘于这个数据类型的大小
//也相当于是2倍
//注意:我们要记得进行强制转换
//同时,我们要进行检查申请空间是否成功
//这就需要我们先创建一个临时变量
name1* temp = (name1*)realloc(ps->arr, newcapacity * sizeof(name1));
if (temp == NULL)
{
perror("realloc:");
exit(1);//退出函数,return 1也行:直接退出程序,不再执行
}
//如果过了上面一关,就代表申请成功
//我们就要进行赋值,将新变量赋值给原本的变量(有借有还,再借不难)
ps->arr = temp;
ps->capacity = newcapacity;
}
//要注意,我们想要进行尾部插入数据,应该先判断剩余空间够不够插入,避免越界访问
//如果没有进入上面的if语句中,就说明剩余空间够用
ps->arr[ps->size] = x;//首先用[]进行解引用,因为是在尾部进行插入,而数组又是从0开始
//那么在所有有效数据的后一个,就是ps->size
ps->size += 1;//要记得对有效数据加一,因为我们插入了新的数据,也就是多了一个有效数据
//其实上面两行可以等效为 ps->arr[ps->size++] = x;
}
//因为我们是传址调用,所以不用返回,直接就能在函数中进行改变
void SLcapacitycheck(SL* ps)
{
//先进行判断传入指针是否为空指针
assert(ps);
//接着判断空间够不够
if (ps->size == ps->capacity)
{
//设置新变量进行判断是否原本空间大小为0,为0则需要修改,不为0则令其变为原本的2倍
name1 newcapacity = ps->capacity == 0 ? 2 : 2 * ps->capacity;
//再设置临时指针判断申请空间是否成功
name1* temp = (name1*)realloc(ps->arr, sizeof(name1) * newcapacity);
if (temp == NULL)
{
printf("realloc:%s", strerror(errno));
exit(1);
}
//有借有还,再借不难
ps->arr = temp;
ps->capacity = newcapacity;
}
}
//进行对顺序表头部的插入的函数
void SLpushfront(SL* ps, name1 x)
{
assert(ps);
//注意,对头部插入,我们也需要进行空间判断,与上述的尾部插入同理
//所以,我们可以直接将检查空间封装为一个函数,直接调用即可
SLcapacitycheck(ps);
//因为是对头部进行插入,所以我们得留出头部的空间
//那就需要把空间中的数据往后挪一位(类似字符串右旋)
for (int i = ps->size; i > 0; i--)
{
ps->arr[i] = ps->arr[i - 1];//将数组从前往后挪一位
//注意,因为有i-1的存在,所以i得大于0,不难会越界访问
}
//经历上述,就可以对头部进行插入,而很明显头部无疑是ps->arr[0]
ps->arr[0] = x;
//注意:进行加入之后,那我们所有有效数据个数也增加了,那我们也得对ps->size进行增加
ps->size++;
}
//进行对顺序表头部的插入的函数
//void SLpushfront(SL* ps, name1 x)
//{
// assert(ps);
// //注意,对头部插入,我们也需要进行空间判断,与上述的尾部插入同理
// //所以,我们可以直接将检查空间封装为一个函数,直接调用即可
// SLcapacitycheck(ps);
// //因为是对头部进行插入,所以我们得留出头部的空间
// //那就需要把空间中的数据往后挪一位(类似字符串右旋)
// //或许我们也可以尝试一下memmove函数
// memmove(ps->arr[ps->size], ps->arr[0], sizeof(name1) * ps->size);
// //经历上述,就可以对头部进行插入,而很明显头部无疑是ps->arr[0]
// ps->arr[0] = x;
//}
//进行对顺序表的打印的函数
void SLprint(SL s)
{
for (int i = 0; i < s.size; i++)
{
printf("%d ", s.arr[i]);
}
printf("\n");
}
//进行对顺序表尾部的删除的函数
void SLpopback(SL* ps)//因为我们只是直接对最后一个进行删除,所以只需要指针变量(地址)就行
{
//判断是否为空指针
assert(ps);
//判断是否没有数据(空间不用管,前面弄过了)
//有数据才能删除
assert(ps->size);
// 只需要将元素数量减1即可完成尾部删除
//顺序表特色
ps->size--;
//在顺序表中,只需要将元素数量(size)减 1 就能实现尾部删除,
//核心原因在于顺序表的存储特性和访问逻辑:
//顺序表的存储本质
//顺序表通过一段连续的内存空间(数组)存储元素,并用size记录当前有效元素的数量。
//例如,当size = 5时,意味着数组中前 5 个位置(索引 0~4)存储的是有效元素,
//而size本身就是 "下一个待插入元素的位置"。
//删除的逻辑意义
//尾部删除的目的是 "让最后一个元素不再被视为有效元素"。当size减 1 后:
//原来的最后一个元素(索引size - 1)会被排除在size的范围之外
//后续对顺序表的操作(如遍历、插入等)都会以新的size为准,自动忽略原尾部元素
//无需物理删除的原因
//顺序表的元素访问完全依赖size来界定范围,而非实际清除内存中的数据。例如:
//原size = 5,元素为[1, 2, 3, 4, 5]
//size--后变为 4,此时顺序表会认为有效元素是[1, 2, 3, 4]
//虽然数组中第 5 个位置的5仍然存在,但已不被视为有效元素
//效率优势
//这种方式无需移动任何元素,仅通过修改一个整数(size)就能完成删除,时间复杂度为O(1),是最高效的实现方式。
//简单说:顺序表的 "有效元素" 是由size定义的,而非数组的物理长度。
//尾部删除只需缩小这个 "有效范围" 即可,无需真正清除数据。
//简单说:size是顺序表的 "规则制定者",
//所有操作都必须遵守 " 只认前size个元素 " 的规则。
//所以修改size,就等于直接修改了整个顺序表的有效范围。
}
//进行对顺序表头部的删除的函数
void SLpopfront(SL* ps)
{
//判断是否为空指针
assert(ps);
//判断是否没有数据(空间不用管,前面弄过了)
//有数据才能删除
assert(ps->size);
//因为是对头部进行删除,所以我们先将数据往前移一位,
//然后再将ps->size--(因为少了一个数据)
//我们先将数据往前移一位
//会直接覆盖,ps->arr[0]会被ps->arr[1]覆盖掉,不复存在
for (int i = 0; i < ps->size - 1; i++)
{
ps->arr[i] = ps->arr[i + 1];//因为有i+1,所以i不能到size-1,会越界访问
//(size-1+1)==size,而ps->arr[size]是无值的,是野的
}
//然后再将ps->size--(因为少了一个数据)
ps->size--;
}
//进行对顺序表指定位置之前一个位置的插入的函数
//注意:是把ps->arr[position]的位置空出来了
void SLinsert(SL* ps, int position, name1 x)
{
//判断是否为空指针
assert(ps);
//判断输入的位置是否大于0且小于有效数据个数
assert(position >= 0 && position <= ps->size);
//可以等于size,因为是对顺序表指定位置之前一个位置插入
//判断是否有足够空间
SLcapacitycheck(ps);
//因为是对指定位置的前一个进行插入数据,
//所以我们应该把ps->arr[position]到ps->arr[size-1]都往后移一位
//这样就把ps->arr[position]的位置空出来了
//我们就可以对ps->arr[position]进行输入数据
//最后要记得对ps->size++
for (int i = ps->size; i > position; i--)
{
ps->arr[i] = ps->arr[i - 1];//i > position,这样子i-1正好就到了position的位置
}
//对ps->arr[position]进行输入数据
ps->arr[position] = x;
//对ps->size++
ps->size++;
}
//进行对顺序表指定位置一个位置的删除的函数
//注意:是把ps->arr[position]删除出来
void SLerase(SL* ps, int position)
{
//判断是否为空指针
assert(ps);
//判断输入的位置是否大于0且小于有效数据个数
assert(position >= 0 && position < ps->size);
//不可以等于size,因为是对顺序表指定一个位置的删除
//而ps->arr[size]很明显没有值,删除无意义
//因为是进行删除,所以我们要找到指定位置
//然后将这个位置到size(不包括size)都往前移一位,去覆盖掉ps->arr[position]
//记得要对ps->size--(因为删除了一个数据)
for (int i = position; i < ps->size-1; i++)
{
ps->arr[i] = ps->arr[i + 1];//因为最后是i+1,所以,如果i到了size-1,
//那么i+1==size,而我们对size的处理在后面,不是在这里,所以不能到size-1
}
//记得要对ps->size--(因为删除了一个数据)
ps->size--;
}
//进行对顺序表的数据的查找的函数
int SLfind(SL* ps, name1 x)
{
for (int i = 0; i < ps->size; i++)
{
if (ps->arr[i] == x)
{
return i;//找到了,返回该数据小标
}
}
return 0;//表示没找到
}
顺序表的测试文件:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>
#include<inttypes.h>
//对我们写的顺序表进行测试
//将我们写的头文件引入
#include "seqlist.h"
void SLtest1()
{
SL s1;
//进行对顺序表的初始化
SLinit(&s1);
//进行对顺序表的销毁
SLdestory(&s1);
//进行对顺序表尾部的插入
SLpushback(&s1, 2);
SLpushback(&s1, 3);
SLpushback(&s1, 4);
SLpushback(&s1, 5);
SLpushback(&s1, 6);
SLprint(s1);
/*SLpushback(NULL, 7);*///如果传入空指针呢,所以我们要对函数进行更加完善
//进行对顺序表头部的插入
SLpushfront(&s1, 2);
SLpushfront(&s1, 3);
SLpushfront(&s1, 4);
SLpushfront(&s1, 5);
SLpushfront(&s1, 6);
/*SLpushfront(NULL, 7);*/
//进行对顺序表的打印
SLprint(s1);
//进行对顺序表尾部的删除
SLpopback(&s1);
SLprint(s1);
SLpopback(&s1);
SLprint(s1);
//进行对顺序表头部的删除
SLpopfront(&s1);
SLprint(s1);
SLpopfront(&s1);
SLprint(s1);
//进行对顺序表指定位置之前一个位置的插入
//注意:是把ps->arr[position]的位置空出来
SLinsert(&s1, 2, 3);//意思是对ps->arr[2]的位置插入数据3,后面的依次往后移一位
//进行对顺序表指定位置一个位置的删除
//注意:是把ps->arr[position]删除出来
SLerase(&s1, 4);//意思是对ps->arr[4]原本的数据删除,后面的依次往前移一位
//进行对顺序表的数据的查找
SLfind(&s1, 2);
if (SLfind(&s1, 2))
{
printf("找到啦,下标是%d", SLfind(&s1, 2));
}
else
{
printf("没找到哦");
}
}
int main()
{
SLtest1();
return 0;
}
至此,大功告成。
结语:
当我们一行行敲完顺序表的最后一个函数,看着测试代码顺利输出预期结果时,仿佛完成了一场从 “概念” 到 “实体” 的奇妙构建。从线性表的抽象定义,到顺序表的连续存储特性,从初始化时的空指针与零值设定,到增删改查时对边界与空间的细致考量,每一个步骤都藏着对 “数据如何被妥善管理” 的深刻思考。
或许有人会说,顺序表的操作并不复杂 —— 插入时挪动元素,删除时覆盖数据,查找时逐个比对,扩容时复制迁移…… 这些看似机械的步骤,却恰恰体现了数据结构设计的本质:用最朴素的逻辑,解决最核心的问题。它不像链表那样用指针编织灵活的网络,却用连续的内存空间筑起了高效访问的基石;它没有栈与队列的严格约束,却用 “size” 与 “capacity” 的协同,平衡了空间利用与操作效率。
回顾这段学习旅程,我们曾为 “size 与 capacity 的区别” 反复琢磨,为 “插入时元素后移的边界条件” 冥思苦想,为 “扩容时的内存申请失败” 心惊胆战。这些细节或许琐碎,却教会我们:真正的理解,从来不是记住结论,而是追问 “为什么要这样做”。为什么尾部删除只需 size--?因为顺序表的 “有效性” 本就是由 size 定义的规则;为什么中间插入要挪动元素?因为连续存储的特性要求逻辑顺序与物理位置严格对应;为什么扩容要成倍增长?因为要在 “减少扩容次数” 与 “避免空间浪费” 之间找到平衡。
数据结构的世界里,没有绝对的优劣,只有场景的适配。顺序表在随机访问时的 O (1) 效率,是链表难以企及的优势;而链表在插入删除时的灵活,也正是顺序表的短板。就像工具包里的扳手与螺丝刀,各有其不可替代的用场。我们学习顺序表,不仅是掌握一种操作方法,更是培养一种思维:面对问题时,先明确核心需求 —— 是更看重访问速度,还是更在意动态调整的灵活?是数据量稳定,还是可能剧烈增长?这些思考,才是数据结构学习的灵魂。
当我们未来面对更复杂的结构 —— 树的分支、图的边、哈希表的映射时,会发现今天在顺序表里积累的经验从未过时。对边界条件的敏感(避免越界访问),对资源释放的重视(销毁时清空指针),对效率与空间的权衡(扩容策略的选择),这些都是贯穿数据结构领域的通用智慧。就像孩童学步时掌握的平衡感,会成为日后奔跑跳跃的基础,顺序表教会我们的 “细致” 与 “严谨”,也会成为探索更广阔数据世界的底气。
或许此刻的你,还在为记住那些循环的边界条件而烦恼,还在为调试时的内存泄漏而头疼。但请相信,每一次报错都是一次提醒,每一次调试都是一次成长。数据结构的学习从来不是一蹴而就的,它需要我们在代码的实践中慢慢体会,在错误的修正中逐渐通透。就像顺序表的扩容过程,每一次 “重新分配” 与 “复制迁移”,都是为了容纳更多的 “数据”,而我们每一次的困惑与突破,也是为了承载更深厚的 “认知”。
最后,愿你带着对顺序表的理解,继续探索数据结构的万水千山。记住,真正的高手,从来不是掌握了最复杂的结构,而是能在最简单的工具中,看到解决问题的万千可能。前路漫漫,代码为伴,每一次敲击键盘的瞬间,都是在为更清晰的逻辑、更高效的解法铺路。继续走吧,那些关于数据的奥秘,还在前方等待被发现。
诸君共勉!!!
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)