Hello 各位编程爱好者~ 今天咱们从最基础的线性结构说起,聊聊顺序表——这个看似简单,却贯穿整个数据结构学习的“入门基石”。无论是应付面试、笔试,还是搭建后续复杂结构(比如栈、队列),顺序表的核心逻辑都必不可少。如果你是刚接触数据结构的新手,这篇文章会用最通俗的语言+可直接运行的代码,帮你彻底搞懂顺序表的来龙去脉,避免踩坑!
一、先搞懂:什么是顺序表?
在讲顺序表之前,先回忆一个生活中的场景:学校里一排连续的储物柜,每个柜子位置固定、依次排列,我们要放东西时,只能按顺序占用空闲柜子,取东西时也能直接找到对应柜子的位置。顺序表,本质就是这种“连续存储”的线性结构。
从专业角度来说,顺序表是用一段地址连续的存储单元,依次存储数据元素的线性表,逻辑上相邻的元素,物理上也一定相邻(这是它和链表最核心的区别)。简单理解:顺序表就是“动态数组”——它基于数组实现,但比普通数组更灵活,能动态调整容量,还封装了规范的操作方法,帮我们处理边界、扩容等繁琐问题。
补充一个关键知识点:线性表的本质是“元素之间只有前后前驱后继关系”,就像排队买奶茶,每个人只有一个前面的人和一个后面的人(队首没人在前面,队尾没人在后面),顺序表就是线性表的“顺序存储实现”,而链表是“链式存储实现”。
二、顺序表的两种形式:静态 vs 动态
顺序表分为两种,实际开发中动态顺序表更常用,静态顺序表仅适用于数据量固定的场景,咱们分别简单说明,重点放在动态顺序表上。
1. 静态顺序表
用固定大小的数组实现,编译时就确定容量,一旦初始化,容量无法修改。优点是实现简单,缺点是灵活性差,容易出现“容量不够用”或“容量浪费”的问题。
#define MAX_SIZE 100 // 固定容量 typedef struct { int data[MAX_SIZE]; // 存储数据的数组 int length; // 当前有效元素个数 } StaticList; // 静态顺序表结构体
2. 动态顺序表
用堆内存分配实现,运行时可以根据数据量动态扩容,解决了静态顺序表的痛点,是实际开发中的首选。核心是通过指针指向动态分配的数组,用“容量”记录总空间大小,用“有效长度”记录当前元素个数。
#define INIT_SIZE 10 // 初始容量 typedef int ELEM_TYPE; // 元素类型,便于后续修改(比如改成char、float) // 动态顺序表结构体 typedef struct SeqList { ELEM_TYPE* elem; // 指向动态数组的指针(存储数据) int length; // 当前有效元素个数 int listsize; // 当前总容量(可扩容) } SeqList, *PSeqList;
这里有个小细节:用typedef定义元素类型ELEM_TYPE,后续如果需要存储其他类型的数据(比如字符串),只需修改这一行,无需改动整个代码,这是一种规范的编程习惯哦~
三、动态顺序表核心操作(代码实现+解析)
顺序表的核心操作就5个:初始化、插入、删除、查找、销毁,每个操作都有明确的边界条件(比如插入不能越界、删除不能删空表),咱们逐个实现,每一步都加了注释,新手也能看懂。
1. 初始化:给顺序表“开辟空间”
初始化的核心是:分配内存(结构体+数据数组)、初始化参数(有效长度为0,容量为初始容量),还要注意内存分配失败的异常处理(避免程序崩溃)。
#include <stdio.h> #include <stdlib.h> #include <assert.h> // 用于断言,检查参数合法性 // 初始化顺序表 void Init_SeqList(PSeqList psl) { assert(psl != NULL); // 断言:psl不能为NULL(避免传空指针) // 分配数据数组的内存,大小为初始容量*元素大小 psl->elem = (ELEM_TYPE*)malloc(INIT_SIZE * sizeof(ELEM_TYPE)); if (psl->elem == NULL) { // 检查内存分配是否成功 exit(EXIT_FAILURE); // 分配失败,退出程序 } psl->length = 0; // 初始有效元素个数为0(空表) psl->listsize = INIT_SIZE; // 初始容量为INIT_SIZE } // 测试初始化(主函数中调用) int main() { SeqList sl; Init_SeqList(&sl); // 传入结构体地址,修改结构体内容 printf("初始化成功!初始容量:%d,当前长度:%d\n", sl.listsize, sl.length); return 0; }
2. 扩容:当顺序表“装不下”时
动态顺序表的核心优势就是扩容,当有效元素个数等于容量(sl.length == sl.listsize)时,就需要扩大容量(推荐2倍扩容,兼顾效率和空间)。扩容用realloc函数,注意扩容失败的处理。
// 扩容函数(内部调用,无需外部直接调用) bool Increase_SeqList(PSeqList psl) { assert(psl != NULL); // 新容量 = 原容量 * 2(推荐2倍扩容,避免频繁扩容) int new_size = psl->listsize * 2; // 重新分配内存,将原数据复制到新内存中 ELEM_TYPE* new_elem = (ELEM_TYPE*)realloc(psl->elem, new_size * sizeof(ELEM_TYPE)); if (new_elem == NULL) { // 扩容失败 return false; } // 更新指针和容量 psl->elem = new_elem; psl->listsize = new_size; return true; }
3. 插入操作:3种常用场景(头插、尾插、按位插)
插入的核心逻辑:先检查容量(不够则扩容),再移动元素腾出位置,最后插入新元素,更新有效长度。不同插入位置,效率不同,咱们分别实现并说明。
(1)尾插:最高效的插入(时间复杂度O(1))
尾插就是在顺序表的末尾插入元素,无需移动任何元素,直接放在有效长度的位置即可,是最优的插入方式。
// 尾插:在顺序表末尾插入元素val bool Insert_SeqList_Tail(PSeqList psl, ELEM_TYPE val) { assert(psl != NULL); // 检查容量,满了则扩容,扩容失败返回false if (psl->length == psl->listsize && !Increase_SeqList(psl)) { printf("扩容失败,尾插失败!\n"); return false; } // 直接在末尾插入元素,更新长度 psl->elem[psl->length] = val; psl->length++; return true; }
(2)头插:最耗时的插入(时间复杂度O(n))
头插是在顺序表的第一个位置插入元素,需要将所有元素向后移动1位,腾出第0个位置,元素越多,移动次数越多,效率越低。
// 头插:在顺序表开头插入元素val bool Insert_SeqList_Head(PSeqList psl, ELEM_TYPE val) { assert(psl != NULL); // 检查容量并扩容 if (psl->length == psl->listsize && !Increase_SeqList(psl)) { printf("扩容失败,头插失败!\n"); return false; } // 所有元素向后移动1位(从后往前移,避免覆盖) for (int i = psl->length - 1; i >= 0; i--) { psl->elem[i + 1] = psl->elem[i]; } // 插入新元素,更新长度 psl->elem[0] = val; psl->length++; return true; }
(3)按位插:指定位置插入(时间复杂度O(n))
按位插是在指定位置pos插入元素,pos的合法范围是0 ≤ pos ≤ psl->length(可以插在末尾,等同于尾插),需要将pos及后面的元素向后移动1位。
// 按位插:在pos位置插入元素val(pos从0开始) bool Insert_SeqList_Pos(PSeqList psl, ELEM_TYPE val, int pos) { assert(psl != NULL); // 检查pos合法性:不能小于0,不能大于当前长度(否则越界) assert(pos >= 0 && pos <= psl->length); // 检查容量并扩容 if (psl->length == psl->listsize && !Increase_SeqList(psl)) { printf("扩容失败,按位插失败!\n"); return false; } // 从pos位置开始,所有元素向后移动1位 for (int i = psl->length - 1; i >= pos; i--) { psl->elem[i + 1] = psl->elem[i]; } // 插入新元素,更新长度 psl->elem[pos] = val; psl->length++; return true; }
4. 删除操作:和插入对应(头删、尾删、按位删、按值删)
删除的核心逻辑:先检查顺序表是否为空(空表不能删),再根据删除位置移动元素(或直接修改长度),最后更新有效长度。同样,不同删除方式效率不同。
(1)尾删:最高效的删除(时间复杂度O(1))
尾删无需移动元素,直接将有效长度减1即可(相当于“舍弃”最后一个元素,后续插入会覆盖它)。
// 尾删:删除顺序表末尾元素 bool Del_SeqList_Tail(PSeqList psl) { assert(psl != NULL); // 检查顺序表是否为空 if (psl->length == 0) { printf("顺序表为空,无法尾删!\n"); return false; } // 直接减少有效长度,无需移动元素 psl->length--; return true; }
(2)头删:最耗时的删除(时间复杂度O(n))
头删需要将所有元素向前移动1位,覆盖第一个元素,元素越多,移动次数越多。
// 头删:删除顺序表开头元素 bool Del_SeqList_Head(PSeqList psl) { assert(psl != NULL); if (psl->length == 0) { printf("顺序表为空,无法头删!\n"); return false; } // 所有元素向前移动1位(从前往后移) for (int i = 1; i < psl->length; i++) { psl->elem[i - 1] = psl->elem[i]; } psl->length--; return true; }
(3)按位删+按值删
按位删:删除指定pos位置的元素,pos范围是0 ≤ pos < psl->length;按值删:删除第一个匹配目标值的元素(先找到位置,再调用按位删)。
// 按位删:删除pos位置的元素 bool Del_SeqList_Pos(PSeqList psl, int pos) { assert(psl != NULL); assert(pos >= 0 && pos < psl->length); if (psl->length == 0) { printf("顺序表为空,无法删除!\n"); return false; } // 从pos+1位置开始,所有元素向前移动1位 for (int i = pos + 1; i < psl->length; i++) { psl->elem[i - 1] = psl->elem[i]; } psl->length--; return true; } // 按值删:删除第一个值为val的元素 bool Del_SeqList_Val(PSeqList psl, ELEM_TYPE val) { assert(psl != NULL); if (psl->length == 0) { printf("顺序表为空,无法删除!\n"); return false; } // 先找到第一个值为val的元素位置 int pos = -1; for (int i = 0; i < psl->length; i++) { if (psl->elem[i] == val) { pos = i; break; } } if (pos == -1) { // 未找到该元素 printf("未找到值为%d的元素,删除失败!\n", val); return false; } // 调用按位删,删除该位置元素 return Del_SeqList_Pos(psl, pos); }
5. 查找操作:按位查(O(1))vs 按值查(O(n))
顺序表的一大优势的是“随机访问”——通过下标可以直接访问任意元素,时间复杂度O(1);而按值查找需要遍历整个顺序表,时间复杂度O(n)。
// 按位查:返回pos位置的元素(pos从0开始) ELEM_TYPE Search_SeqList_Pos(PSeqList psl, int pos) { assert(psl != NULL); assert(pos >= 0 && pos < psl->length); return psl->elem[pos]; // 直接通过下标访问,O(1)效率 } // 按值查:返回第一个值为val的元素位置,未找到返回-1 int Search_SeqList_Val(PSeqList psl, ELEM_TYPE val) { assert(psl != NULL); for (int i = 0; i < psl->length; i++) { if (psl->elem[i] == val) { return i; } } return -1; // 未找到 }
6. 销毁:释放内存,避免内存泄漏
顺序表使用完后,必须释放动态分配的内存(结构体中的elem指针指向的内存),否则会造成内存泄漏,这是新手最容易忽略的点!
// 销毁顺序表,释放内存 void Destroy_SeqList(PSeqList psl) { assert(psl != NULL); // 释放数据数组的内存 free(psl->elem); psl->elem = NULL; // 避免野指针 psl->length = 0; // 重置长度 psl->listsize = 0; // 重置容量 printf("顺序表销毁成功!\n"); }
四、顺序表的优缺点(面试高频考点)
学完实现,一定要记住顺序表的优缺点,这是面试中常考的点,也是我们选择数据结构的依据。用表格总结更清晰,同时对比链表,方便记忆:
|
特性 |
顺序表 |
链表 |
|---|---|---|
|
存储方式 |
连续存储,逻辑与物理地址一致 |
离散存储,通过指针连接节点 |
|
访问效率 |
按索引O(1)(随机访问),按值O(n) |
无论按索引还是按值,均需遍历,O(n) |
|
插入/删除效率 |
中间插入/删除需移动元素,O(n);尾插/尾删O(1) |
找到位置后,只需修改指针,O(1) |
|
内存要求 |
需预先分配连续大块内存,存储密度高(只存数据) |
无需连续内存,需额外存储指针,存储密度低 |
|
扩容 |
需重新分配内存、复制原数据,成本较高 |
动态增长,无需扩容,灵活 |
|
缓存友好 |
是(连续存储,缓存命中率高) |
否(离散存储,缓存命中率低) |
顺序表核心优缺点总结
优点:
-
随机访问高效:通过公式(地址 = 起始地址 + 下标 × 元素大小),可直接访问任意位置元素,这是它最大的优势。
-
存储密度高:只存储数据元素,不占用额外空间(不像链表需要指针域)。
-
实现简单:基于数组,逻辑清晰,上手容易。
缺点:
-
插入/删除效率低:中间位置操作需移动大量元素,数据量越大,效率越低。
-
扩容成本高:扩容时需要重新分配内存,并复制原数据,耗时耗力。
-
内存利用率可能低:预先分配的容量如果用不完,会造成内存浪费;如果预估不足,又需要频繁扩容。
五、顺序表的适用场景(实战选型指南)
没有最好的数据结构,只有最适合的场景。顺序表的适用场景的核心是“频繁访问,少插入删除”,具体如下:
-
数据量相对固定,变化不大(比如存储班级学生的固定名单)。
-
需要频繁按索引访问元素(比如查成绩排名,按学号索引查分数)。
-
对存储空间要求较高,需要高存储密度(比如嵌入式设备,内存有限)。
-
实现栈、队列等结构(栈的顺序实现就是基于顺序表,尾插尾删效率高)。
反之,如果需要频繁插入、删除,且数据量动态变化大(比如消息队列、购物车增删商品),则优先选择链表。另外要注意:只有顺序表支持高效二分查找,这是它的独特优势,也是面试常考的考点。
六、新手常见坑(避坑指南)
结合自己学习和面试的经验,总结几个新手最容易踩的坑,一定要注意!
-
忽略边界条件:插入时pos越界(比如pos大于当前长度)、删除时空表删除、按位查时pos超出范围,这些都会导致程序崩溃,一定要用断言或条件判断检查。
-
忘记扩容:插入元素时不检查容量,导致数组越界,程序崩溃。
-
内存泄漏:顺序表使用完后,不释放elem指针指向的内存,长期运行会导致内存占用越来越高。
-
头插/头删时移动元素方向错误:头插要从后往前移,头删要从前往后移,否则会覆盖元素,导致数据丢失。
-
混淆“容量”和“有效长度”:容量是总空间大小,有效长度是当前元素个数,两者不能混淆(比如容量10,有效长度3,说明还能插入7个元素)。
七、总结
顺序表是数据结构的入门基础,本质是“动态数组”,核心优势是随机访问高效、存储密度高,核心劣势是插入删除效率低、扩容成本高。
对于新手来说,重点要掌握:①顺序表的定义(静态vs动态);②核心操作的代码实现(尤其是插入、删除、扩容);③优缺点和适用场景;④常见边界条件和避坑点。
其实顺序表的逻辑并不复杂,只要多敲几遍代码,理解“连续存储”和“元素移动”的核心,就能轻松掌握。后续我们会讲解链表,通过对比学习,能更深刻地理解两种线性结构的差异,为后续学习栈、队列、树等复杂结构打下基础。
1285

被折叠的 条评论
为什么被折叠?



