简介:面向正在完成头歌平台顺序表实训的学生,这份资源覆盖顺序表六大基本操作:插入、删除、按序号查找、按值查找、逆置与两个有序表合并。资源包内含1个docx文档,大小约54KB,按关卡拆分布局;每关提供可直接运行或补全的C++代码,并附操作要点、边界条件分析与易错点提示,适合对照调试、查错与快速通关。顺序表是线性结构的基础,掌握其增删查改、逆置归并,对后续学习链表、栈与队列很有帮助;文档对下标移动、参数合法性判断和双指针合并等关键点都有清晰呈现。目前已有16045人学习下载,既可作为实训前的预习材料,也可作为闯关过程中的参考笔记。对于需要稳过头歌顺序表1-6关、想提升代码实现能力的同学,这是一份实用的通关代码合集。
1. 数据结构顺序表的基本操作1-6关:从数组到可操作的数据结构
数据结构顺序表的基本操作1-6关,是很多人在数据结构课程里碰到的第一组实训。它不涉及指针绕圈,也不涉及递归回溯,核心只有一件事:把数组包装成一个能插入、删除、查找的结构体。但恰恰是这份"简单",让它在在线实训平台上稳居翻车率前列——判满、判空、位置合法性、数组搬移方向,任何一个边界条件写错,输出都会少一个元素。
这个实训一共6关,从初始化一路走到销毁,覆盖线性表顺序存储的全部基础操作。如果你正在刷这6关,或者已经刷到一半被某个用例卡住,这篇文章会按关卡推进的顺序,把每段代码怎么来的、参数怎么设的、最容易在哪一步翻车讲清楚。内容对新手友好,但不止于给代码——看懂执行过程和边界条件,比抄对一份实现更有用。刷完之后你会发现,后面学链表、栈、队列时,很多报错思路都能回到这里。
2. 从存储结构到初始化:顺序表第一关的代码骨架
2.1 结构体定义:定长数组和动态分配的选型
顺序表本质是用一段连续内存存放同类型元素,数组天然满足这个要求。实训里最常见的定义是定长数组加长度计数器:
#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; // 数组存元素 int length; // 当前有效元素个数 } SeqList;代码说明:data是定长数组,length是当前表里实际存了多少个元素,不是数组容量。MAXSIZE是最大容量,100只是常见值,有的模板会给200或1000,以题目给的宏为准。定义结构体时,长度计数器必须和数组放在一起,这样才能通过一个SeqList变量同时拿到数据和长度。
另一类模板用动态分配:
typedef struct { int *data; // 堆内存首地址,用malloc分配 int length; // 有效元素个数 int listsize; // 当前分配容量 } SeqList;和定长版本的差别是多了一个listsize字段,数据区从栈上的数组变成了堆上的一块连续内存。选型理由很简单:定长版本代码量小、不用考虑内存释放,适合练操作逻辑;动态版本更接近真实工程里"容量不够就扩容"的用法,但初始化、销毁都要自己管理内存。绝大多数实训关卡用定长版本就够,我一般先看模板里结构体有没有listsize,有就走动态路线,没有就按定长写。
这里容易踩的第一个坑是元素类型。模板如果提前写了typedef int ElemType,那么所有出现int的地方都应该用ElemType,包括数组声明。如果你把data写成int,题目又用ElemType统一做类型检查,编译会报不匹配的错。养成先看头部typedef的习惯,后面几关能省很多事。
2.2 初始化与销毁:length置0、malloc/free和传址边界
初始化是所有关卡的地基,最常见的版本是:
void InitList(SeqList *L) { L->length = 0; }逻辑说明:定长数组在结构体定义时就已经在栈上分配好空间,初始化不需要做任何内存分配,只需要把length置0。注意形参必须是指针:如果写成void InitList(SeqList L),函数里L.length = 0改的是实参的一份拷贝,主调函数里的L.length还是原来的随机值,这是C语言值传递的老问题。所以调用时也要带上取地址符,写成InitList(&L)。
动态版本多两步内存分配:
void InitList(SeqList *L) { L->data = (int *)malloc(MAXSIZE * sizeof(int)); if (L->data == NULL) { exit(0); // 分配失败直接退出 } L->length = 0; }参数说明:malloc的参数是字节数,不是元素个数。MAXSIZE * sizeof(int)才是正确的字节数,写成malloc(MAXSIZE)意味着只为MAXSIZE个字节分配空间,而每个int占4字节,后面写入超过1/4的元素时就会越界。分配后检查NULL是严谨习惯,在判卷环境里分配失败几乎不会出现,但代码风格能看出基本功。
销毁函数和初始化成对出现。定长版本:
void DestroyList(SeqList *L) { L->length = 0; }动态版本:
void DestroyList(SeqList *L) { free(L->data); // 释放堆内存 L->data = NULL; // 置空,防止野指针 L->length = 0; }特别注意:定长版本里data是数组名,不是指针,不能写free(L->data),那是给动态版本用的。反过来,动态版本如果不free,多跑几次初始化再销毁就会内存泄漏。实训判卷可能检测不到泄漏,但如果你把这份代码挪到本地工程里,用内存检测工具一跑就会报警。
初始化和销毁还有一个容易被忽略的顺序问题:如果题目要求先创建再插入,创建前必须确认已经调过InitList。有些同学创建函数里直接写L->length = 0,跳过InitList,在单测用例里能过,因为length还没被用过;一旦上一关的残留数据存在,跳过初始化就会带着脏数据跑完全程。我一般把InitList当作每个用例的起点,后面无论做哪个操作都先初始化一次。
2.3 判空和求长度:返回值与传参的隐藏考点
这两个函数在初看时简单到没有存在感,但关卡里出现的频率非常高,删除前的判空、查找前的范围检查都要用到它们。
bool ListEmpty(SeqList L) { return L.length == 0; // 空表返回true } int ListLength(SeqList L) { return L.length; }逻辑说明:两个函数都不修改表,所以形参传值就可以,传指针也不会错但没必要。ListEmpty的返回值是bool类型,判断条件是length == 0,不是length <= 0,后者虽然也能工作但语义不清晰。ListLength直接返回length字段,不要去遍历data数组——如果data[i]==0被认为是"没有元素",那存了0值的表就会在查找和遍历时出大问题。
有的模板把返回值的约定改掉了,比如判空用int返回,约定0表示空、1表示非空;求长度用指针带出,写成void ListLength(SeqList L, intlen)。遇到这种签名,函数体跟着改就行:ListEmpty里return !L.length;,ListLength里len = L.length;。我在实际做题时遇到过类似关卡,最容易挂的地方是照抄了网上定长版的代码,没注意形参列表变了,编译直接报错。所以每关开始前花十秒看函数原型,比写完之后反复试错划算。
3. 插入与删除:顺序表操作最核心的一段代码
3.1 插入操作:位置合法性、判满和从后往前搬移
插入是顺序表第一个需要搬大量数据的操作,也是6关里最容易写反循环方向的关卡。标准实现如下:
bool ListInsert(SeqList *L, int i, int e) { // i是位序,从1开始 if (i < 1 || i > L->length + 1) return false; // 位置非法 if (L->length >= MAXSIZE) return false; // 表满 for (int j = L->length; j >= i; j--) { L->data[j] = L->data[j - 1]; // 从后往前搬移 } L->data[i - 1] = e; // 新元素落到下标i-1 L->length++; // 长度加1 return true; }逻辑说明:插入前先做两个检查。位置i的取值范围是[1, length+1],因为可以插到表尾之后(i等于length+1),所以上限是length+1而不是length。表满判断用length >= MAXSIZE,防止数组越界。搬移方向必须从最后一个元素开始,逐个往后挪,腾出第i个位置。如果从前往后搬,data[i-1]先被覆盖,后面的元素还没挪走就已经丢了。
参数说明:第一个参数是SeqList *L,因为插入会修改length和data,必须传址。第二参数i表示位序,"第1个元素"对应i=1,而不是数组下标0。第三参数e是要插入的值,用int还是ElemType看模板。返回值bool表示插入是否成功,主调函数可以据此打印不同提示信息。
比较tricky的一个变体:有的题目给的是"删除之后插入"的复合操作,比如"把第i个元素删除,再在原位置插入e"。如果不注意插入位置的更新,会把新数据插到错的位序。遇到复合操作时,我习惯先画一个长度为3的小数组,手动推一遍删除、插入后的下标变化,推完再写代码。
插入的时间复杂度是O(n):最坏情况插到表头,需要搬移所有元素;最好情况插到表尾,搬移0个元素。这个结论在实训里不直接考,但后面学链表时会拿出来对比,先留个印象。
3.2 删除操作:先带出元素再覆盖,最后length--
删除的代码量比插入少,但有一个很经典的翻车点:循环起点多写1。
bool ListDelete(SeqList *L, int i, int *e) { if (i < 1 || i > L->length) return false; // 删除位置必须在现有范围内 *e = L->data[i - 1]; // 把被删元素带回主调函数 for (int j = i; j < L->length; j++) { L->data[j - 1] = L->data[j]; // 从被删位置开始覆盖 } L->length--; // 有效长度减1 return true; }逻辑说明:删除位置i的取值范围是[1, length],和插入不一样,这里没有length+1,因为删除的必须是已存在的元素。先从data[i-1]取出被删元素,通过e指针带出去。然后从j=i开始,把data[j]赋给data[j-1],一直覆盖到最后一个元素。注意循环是"从前往后"覆盖的,这和插入相反,因为删除时数据源在后面,覆盖前面的位置不会破坏还没搬的数据。
如果循环从j=i+1开始,data[i-1]不被覆盖,被删元素残留在原地;到最后一个元素时,又因为length已经减1,尾元素变成了永远访问不到的脏数据,打印整表时会看到最后一个值重复。这个小偏差在输出样例里很容易被忽略。
参数说明:e是指针,作用是"带出"被删元素的值。有的模板不用指针,而是用返回值同时表示成功和元素值,但那样只能处理元素值非负的情况,所以更多模板选择用bool返回值加int *e的组合。删除完成后length必须减1,放在最后执行。如果放在循环之前,循环条件j < L->length会比应该的少一趟,最后一个元素被漏搬。
还有一个和插入对应的复杂度结论:删除表头要搬移n-1个元素,O(n);删除表尾搬移0个,O(1)。顺序表不适合频繁在头部操作,这个特性后面会和链表形成鲜明对比。
3.3 整表创建与参数对照:该用插入还是直接读入
实训里常有一关是"输入n个元素建表"。两种常见写法,效果和性能差别不小。第一种是复用插入函数:
void CreateList(SeqList *L) { int n, x; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &x); ListInsert(L, L->length + 1, x); // 每次插到表尾 } }第二种是直接读入数组:
void CreateList(SeqList *L) { int n; scanf("%d", &n); L->length = 0; for (int i = 0; i < n; i++) { scanf("%d", &L->data[L->length]); L->length++; } }逻辑说明:第一种写法好处是复用ListInsert,代码更"数据结构"一些,但每次插入表尾虽然不用搬移,却要执行一遍位置检查和函数调用开销。第二种直接定位到数组下标写入,O(n)完成建表,没有多余的检查,更适合作为建表动作。
建表时有一个容易被判卷抓住的细节:第一种写法里,如果n超过了MAXSIZE,ListInsert会在中间某次返回false,循环却不知道,继续读数据就会越界。稳妥的做法是循环条件加一个长度判断,写成i < n && L->length < MAXSIZE。第二种写法同理,读入前检查L->length < MAXSIZE。
插入和删除的参数对比,总结成表方便记忆:
| 操作 | 位置i范围 | 合法位置含义 | 搬移方向 | 长度变化 |
|---|---|---|---|---|
| 插入 | [1, length+1] | 插到第i位之前 | 从后往前 | length+1 |
| 删除 | [1, length] | 删除第i个元素 | 从前往后 | length-1 |
这张表在我刷题时贴在屏幕旁边,每次写循环前先对照一下,基本不会再弄混方向。位置范围是最容易出错的点:插入多一个length+1,删除没有。记不住的话就画一个只有3个元素的顺序表,分别试一下插入到第4个位置和删除第4个位置,哪个合法、哪个报错,一眼就清楚。
4. 查找与修改:定位、回写和隐藏的边界约定
4.1 按位查找GetElem:O(1)背后的下标与位序换算
按位置查找是顺序存储最舒服的操作,不需要遍历,直接通过下标计算定位:
int GetElem(SeqList L, int i) { if (i < 1 || i > L.length) return -1; // 位置非法 return L.data[i - 1]; }逻辑说明:位序i从1开始,数组下标从0开始,所以第i个元素存放在data[i-1]。这是"位序"和"下标"之间最基础的换算,插入、删除、修改里反复用到。时间复杂度O(1),因为数组是随机存取结构,知道下标就能直接拿到数据,这种特性是顺序表区别于链表的核心卖点。
如果模板把返回值设计成两个通道,比如bool GetElem(SeqList L, int i, int *e),实现要改成:
bool GetElem(SeqList L, int i, int *e) { if (i < 1 || i > L.length) return false; *e = L.data[i - 1]; return true; }参数说明:第三个参数e是指针,用来把查到的值传回主调。见过有同学把这个版本写成*e = L.data[i];,测试用例里查第1个元素,返回的是第2个元素的值,这种错位往往要跑三个用例才能发现。记住:位序转下标统一减1。
4.2 按值查找与批量处理:返回位序、0值元素和重复值
按值查找是隐藏坑最多的一关,因为"找到"的定义和返回值约定在不同模板里差别很大:
int LocateElem(SeqList L, int e) { for (int i = 0; i < L.length; i++) { if (L.data[i] == e) return i + 1; // 返回位序,不是下标 } return 0; // 约定:找不到返回0 }逻辑说明:遍历方向从下标0开始,逐个比较元素值。找到第一个相等的元素时,返回i+1而不是i。如果返回i,第1个元素被找到时返回0,而0在多数模板里又代表"未找到",两个含义冲突,判卷一定挂。查找失败返回0还是-1完全看模板约定:主调代码里用if (pos == 0)判断失败,就返回0;用if (pos < 0)判断,就返回-1。我一般先看主调代码怎么写,再决定函数最后一行。
围绕按值查找有一个高频进阶题:删除所有值为e的元素。直接套LocateElem加ListDelete会出错,因为删除后元素位置变了。比如表[1,2,3,2],删除第一个2之后,表变成[1,3,2],原来最后一个2的位置从4变成了3,如果还用for循环按原下标遍历会跳过一个元素。稳妥方案是反复用LocateElem找第一个等于e的位置,找到就删,直到返回0:
void DeleteAll(SeqList *L, int e) { int pos = LocateElem(*L, e); while (pos != 0) { int tmp; ListDelete(L, pos, &tmp); pos = LocateElem(*L, e); } }逻辑说明:每次删除后重新查找,保证了不会因为位置移位漏删。时间复杂度O(n^2),在实训数据量下完全够用。如果数据量大,可以改成一趟遍历加两个下标边扫边覆盖,但那种写法在6关阶段不要求,知道有这个优化空间即可。
0值元素的坑再次出现:如果表里存了多个0,而查找条件写成L.data[i] != 0才比较,等于0的元素永远不会被找到。反过来,查找成功返回0的约定也会和"找到第1个元素"混淆。所以判断"是否找到"只用函数返回值,不要在循环里对元素值做非零判断。
4.3 修改操作:传址回写与"删掉指定值元素"的组合用法
修改第i个元素的实现是直接赋值:
bool UpdateElem(SeqList *L, int i, int e) { if (i < 1 || i > L->length) return false; L->data[i - 1] = e; return true; }逻辑说明:修改本质是data[i-1] = e这一行。注意形参是SeqList *L,因为要改动结构体内部的数据。如果误写成传值版本,函数内部改了data[i-1],但主调的那份表完全不变,后面打印验证时看到的还是旧值,这就是典型的"改不动"问题。
把修改和删除组合在一起,能解决一类常见题目:"把第i个元素替换成e,删除新表里所有等于x的元素"。先UpdateElem修改,再用4.2里的DeleteAll删除,两步就完成:
bool ReplaceAndDelete(SeqList *L, int i, int e, int x) { if (!UpdateElem(L, i, e)) return false; DeleteAll(L, x); return true; }这种组合题考察的其实是基础操作的复用能力。前面关卡的每个函数都是独立写的,到这里的题目开始要求你按需拼装。如果前面的实现里有隐藏bug——比如ListDelete的e参数传了NULL——拼装时会直接段错误。这里建议所有删除函数的调用统一传一个临时变量的地址,不要偷懒传NULL,因为不少模板的实现里会解引用e。
5. 顺序表常见问题排查:5个经典翻车点与修法
5.1 翻车点1:插入位置判定把第一位排除
现象:插入数据到位置1,函数返回false,主函数打印"插入失败";或者插入成功后,原来的第一个元素消失了。
原因:位置判定写成了if (i == 1 || i > L->length + 1),用了等于关系把i=1排除在合法范围外。更隐蔽的是把条件写成i <= 0,虽然i=1能过,但位置为0时也放进了非法逻辑里,容易在后续循环中产生负数下标。
解决:合法范围统一写i < 1 || i > L->length + 1。是小于1,不是等于1。搬移方向写反的问题也常出现在这关:for (int j = 0; j < L->length; j++)会让data[0]被data[1]覆盖,第一个元素丢失。遇到插入后首元素神秘消失,先检查循环方向是不是从后往前。
5.2 翻车点2:删除循环起点写错,尾元素残留
现象:删除一个元素后,打印整表发现最后一个元素重复出现两次。
原因:删除循环写成for (int j = i + 1; j < L->length; j++),起点多1。这导致data[i-1]没有被覆盖,被删元素的值残留在表内;同时最末尾的元素又因为length减1变得不可达,肉眼看到的是"末尾值重复"。
解决:循环起点必须是j = i。删除的第i个元素存放在data[i-1],要让后面的元素覆盖它,必须从data[i]开始往前搬,也就是j从i起步。可以用一个三元素表[1,2,3]删除第2个元素手动走一遍,三分钟就能验证:j从2开始,data[1]=data[2]=3,length变成2,表变成[1,3],正确;j从3开始,这个循环根本进不去,表还是[1,2,3],length变2,打印[1,2],少了一个3。
5.3 翻车点3:空表删除导致length变负数
现象:连续对一个空表调用多次删除,length变成负数,后续遍历打印时for循环跑不完或者数组越界。
原因:删除函数只检查了位置i是否在[1, length]内,但length为0时这个区间是空的,理论上i<1||i>0都会触发return false。问题是很多同学在删除函数里忘了做范围检查,直接进入循环搬移,length--后变成-1。
解决:不管位置怎么传,删除函数第一行必须是范围检查。空表和位置检查一起做:if (L->length == 0 || i < 1 || i > L->length) return false;。有些模板只写了位置检查,但空表时i=1进来,其实也是非法操作;把length == 0单独作为前置条件压在最前面,逻辑最清晰。
5.4 翻车点4:查找失败返回0还是-1约定混乱
现象:同一个查找函数,在一个用例里返回0表示找不到,另一个用例里主调却用pos == -1判断失败,导致明明没找到,代码却走了"找到"的分支。
原因:模板对查找失败的约定不统一。我遇到过返回0的版本,也遇到过返回-1的版本。如果直接抄别的关卡的代码,返回值和主调判断条件配对不上,输出全错。
解决:写查找函数前,先看主调函数怎么判断。只要看到if (pos == 0)或者if (!pos),函数末尾return 0;看到if (pos < 0),函数末尾return -1。还有一种变体是返回值通过指针带出,函数本身只返回bool:bool LocateElem(SeqList L, int e, intpos)。这种就按指针写,找到时pos = i + 1,找不到时*pos = 0。
5.5 翻车点5:传值传址不配对,改不动外层结构体
现象:初始化之后打印length,发现还是初始值;插入后length没有变大。
原因:函数形参是SeqList L(传值),函数体里L.length++改的是形参拷贝;或者形参是SeqList *L,但调用时传了L而不是&L。两者有一个出错,外层的结构体都收不到改动。
解决:检查三个地方。函数定义处形参有没有*;函数体内访问字段用L->还是L.(传指针用->,传值用.);调用处有没有&。规则很简单:要改结构体就整个链条都用指针,只读结构体就都用值。最容易出现的错误是定义用了指针、调用忘了取地址,编译器会直接报"expected 'SeqList *' but argument is of type 'SeqList'"。看到这个报错,第一反应就是去补&。
6. 把6关代码改造成可复用:动态扩容与自检测试
6.1 从定长数组到动态扩容:realloc的正确用法
6关做完后,定长版本的代码只能处理MAXSIZE以内的数据。如果你想把它用在更大的数据规模或后续实验里,第一件事是让容量可增长。动态版本的插入判满要从if (L->length >= MAXSIZE)改成if (L->length >= L->listsize)然后扩容。扩容函数常见写法是:
bool ExpandList(SeqList *L) { int newSize = L->listsize * 2; int *tmp = (int *)realloc(L->data, newSize * sizeof(int)); if (tmp == NULL) return false; L->data = tmp; L->listsize = newSize; return true; }参数说明:newSize按两倍扩容,这是避免频繁realloc的常见策略;用空间换时间,平均插入代价摊下来还是O(1)。realloc的返回值必须先用临时变量接着,因为realloc失败时会返回NULL但原来的内存仍然有效;如果直接用L->data = (int *)realloc(...),失败时L->data被覆盖成NULL,原数据指针丢失,既没法继续用也没法free。先判断tmp是否为NULL,成功后再赋给L->data,这个顺序不能颠倒。
在这个改造过程中,你会重新理解为什么定长模板要单独留一个length字段:没有length,扩容后无法知道哪些数据需要一起搬过去。线性表的核心从来不只是数组,而是数组加上对有效长度的管理。
6.2 用边界用例测试6关代码,交作业前多花一分钟
我刷这类实训的最后一个习惯,是把6个基础操作放进一个main函数里做回归测试。每次改完某个函数,就跑一遍:
int main() { SeqList L; InitList(&L); CreateList(&L); // 输入或构造测试数据 int e; ListDelete(&L, 2, &e); // 删除第2个元素 int pos = LocateElem(L, 5); if (pos != 0) { UpdateElem(&L, pos, 6); } for (int i = 0; i < L.length; i++) { printf("%d ", L.data[i]); } DestroyList(&L); return 0; }测试数据的选取比代码本身更考验经验。我常用的五组边界用例是:空表删除、表头插入、表尾删除、重复值查找、包含0元素的表。空表删除验证判空逻辑;表头插入验证搬移方向;表尾删除验证length减1后不会访问越界;重复值查找验证返回第一个命中的约定;包含0元素的表验证没有用0当结束标志。这五组跑完,大部分隐藏用例的坑都能提前暴露。
交作业之前多看一分钟测试输出,比提交后反复试错更节省时间。顺序表的6关做完之后,建议把这份代码留着:后面写栈、写队列、写多项式的顺序存储实现时,插入删除的搬移思想完全一致,改改字段就能复用。希望这篇能帮到你。
本文还有配套的精品资源,点击获取