☰
北邮数据结构线性表实验:顺序表与单链表的C语言实现与调试指南
2026/10/7 3:53:04 网站建设 项目流程

简介:本资源是北京邮电大学数据结构课程首次实验的完整实验报告,面向计算机及相关专业本科生,聚焦线性表核心实现与链式存储原理的理解与实践。报告系统阐述带头结点单链表的存储机制、九类关键算法(构造/析构、头尾插法、按位/按值查找、插入/删除/遍历/求长/复制)的代码逻辑、时间复杂度分析及main函数测试用例,覆盖实验全部要求与调试要点。压缩包为1个6.3MB的Word文档(.doc),内容含实验目的、详细程序分析、流程图、测试条件与运行结果截图,结构规范、注释清晰,便于对照学习与代码复现。已有598人下载学习,适合作为数据结构链表章节的课后巩固材料、实验参考范本及面试基础算法复习资料。

1. 北邮数据结构实验线性表:不是抄代码交报告,而是亲手把“顺序表插入”和“链表删除”从黑匣子变成可调试的零件

北邮《数据结构》实验课里,“线性表”从来不是第一章概念题——它是第一个真正让你在 VS Code 或 Dev-C++ 里敲满 200 行、编译报错 7 次、最后发现是malloc后没判空、free前没置NULL的实战组合拳。很多同学卡在“为什么我按课本写了InitList_Sq却一运行就崩”,其实问题不在算法逻辑,而在北邮实验环境对内存管理、输入格式、边界校验的隐性要求:比如实验平台(头歌/EduCoder)默认关闭stdio.h的缓冲区自动刷新,printf("请输入长度:")后不加fflush(stdout),学生就永远等不到输入光标;又比如北邮实验报告评分细则里,“时间复杂度分析必须结合具体操作步骤写清每层循环贡献”,而不是只写个 O(n) 就完事。这个实验的真实价值,是逼你第一次把“抽象数据类型 ADT”落地成可单步调试、可打印中间状态、可被assert断言验证的 C 语言实体。它不考你背定义,而考你能不能让ListInsert(&L, 3, 66)这一行调用后,L.elem[2]真的变成 66,且L.length变成 4——不多不少,不溢出,不越界,不漏改指针。如果你正对着实验指导书发懵,或刚被Segmentation fault (core dumped)折磨到凌晨两点,这篇笔记就是为你写的血泪复现指南。

2. 用标准 C 实现北邮线性表:从 ADT 定义到可编译的最小可运行单元

北邮实验明确要求使用 C 语言(非 C++),且禁用 STL 或任何高级容器。这意味着你必须亲手管理内存、手动维护长度、显式处理所有边界。我们不从教科书伪代码开始,而是直接构建一个能在北邮实验平台(如头歌)上通过编译、能跑通main()的最小可运行骨架。这个骨架包含三个核心文件:SqList.h(顺序表头文件)、SqList.c(顺序表实现)、main.c(测试驱动)。注意:北邮实验环境通常基于 GCC 4.8+,不支持 C99 的//注释以外的特性,bool类型需用_Bool或自定义typedef enum {FALSE, TRUE} Status;。

2.1 顺序表 ADT 的北邮合规定义:结构体字段与初始化约束

北邮实验对顺序表结构体有隐性但关键的要求:elem必须为ElemType *类型(而非int[]),length必须为int,且listsize(当前分配容量)必须存在并参与扩容逻辑。这是为了后续实验(如“线性表合并”)预留接口。常见错误是直接定义int elem[MAXSIZE]——这会导致无法动态扩容,且在头歌平台因栈空间限制易触发段错误。

// SqList.h #ifndef SQ_LIST_H #define SQ_LIST_H #include <stdio.h> #include <stdlib.h> #include <stdbool.h> // 头歌环境支持 stdbool.h,若报错则替换为 #define bool _Bool #define INIT_SIZE 100 // 初始分配容量,北邮实验报告常要求此处写明依据(如:满足 95% 学生测试数据长度) #define INCREMENT 10 // 每次扩容增量,北邮评分点:需说明增量策略(固定值 vs 倍增) typedef int ElemType; // 北邮实验默认元素类型为 int,勿擅自改为 float 或 struct typedef struct { ElemType *elem; // 动态分配的基地址,必须是指针! int length; // 当前长度,初始为 0 int listsize; // 当前分配容量,初始为 INIT_SIZE } SqList; // 函数声明(北邮实验报告要求:每个函数需注明时间/空间复杂度) Status InitList_Sq(SqList *L); // O(1) Status DestroyList_Sq(SqList *L); // O(1) Status ClearList_Sq(SqList *L); // O(1) Status ListEmpty_Sq(const SqList *L); // O(1) int ListLength_Sq(const SqList *L); // O(1) Status GetElem_Sq(const SqList *L, int i, ElemType *e); // O(1) int LocateElem_Sq(const SqList *L, ElemType e, Status(*compare)(ElemType, ElemType)); // O(n) Status PriorElem_Sq(const SqList *L, ElemType cur_e, ElemType *pre_e); // O(n) Status NextElem_Sq(const SqList *L, ElemType cur_e, ElemType *next_e); // O(n) Status ListInsert_Sq(SqList *L, int i, ElemType e); // 平均 O(n),最坏 O(n) Status ListDelete_Sq(SqList *L, int i, ElemType *e); // 平均 O(n),最坏 O(n) Status ListTraverse_Sq(const SqList *L, void(*visit)(ElemType)); // O(n) #endif

提示:北邮实验平台(如头歌)对头文件包含路径敏感。若SqList.h与main.c不在同一目录,需用#include "SqList.h"(双引号)而非#include <SqList.h>(尖括号),否则编译失败。

2.2 初始化与内存管理:InitList_Sq 的三重校验逻辑

InitList_Sq是整个实验的基石,也是北邮平台最容易扣分的函数。它不只是malloc一块内存,而是必须完成三重校验:1)malloc是否成功;2)length是否置 0;3)listsize是否设为INIT_SIZE。缺一不可,否则后续ListInsert会因L->length非零或L->listsize为 0 而崩溃。

// SqList.c #include "SqList.h" Status InitList_Sq(SqList *L) { // 第一步:分配内存,注意 sizeof(ElemType) * INIT_SIZE L->elem = (ElemType*)malloc(sizeof(ElemType) * INIT_SIZE); if (!L->elem) { // 必须判空!北邮实验环境内存紧张,malloc 失败率高 return FALSE; } // 第二步:初始化状态字段(北邮评分硬性要求:length 和 listsize 必须显式赋值) L->length = 0; L->listsize = INIT_SIZE; return TRUE; }

参数说明与北邮实践要点:

  • L是SqList*类型,必须传地址(&L),因为要修改结构体内部字段;
  • sizeof(ElemType) * INIT_SIZE不能简写为sizeof(int) * 100,否则违反 ADT 封装原则,北邮实验报告会扣分;
  • return FALSE不能写成return 0,必须用宏定义的FALSE,保持风格统一;
  • 此函数在main()中必须被调用,且调用后需检查返回值:if (!InitList_Sq(&L)) { printf("初始化失败!\n"); exit(1); }——这是北邮实验平台调试的黄金习惯。

2.3 插入与删除的核心实现:ListInsert_Sq 与 ListDelete_Sq 的边界缝合术

北邮实验最常翻车的两个函数。它们的难点不在算法本身,而在对i的合法范围判断、元素移动的起始/终止索引、以及扩容/缩容的触发时机。北邮指导书明确要求:i的合法范围是1 ≤ i ≤ L->length + 1(插入位置从 1 开始计数),而非0 ≤ i ≤ n。这是学生最容易写反的点。

Status ListInsert_Sq(SqList *L, int i, ElemType e) { // 第一步:参数合法性校验(北邮硬性要求:必须检查 i 的范围) if (i < 1 || i > L->length + 1) { return FALSE; // 位置不合法,返回 FALSE } // 第二步:检查是否需要扩容(北邮实验报告必写分析:当 length == listsize 时扩容) if (L->length >= L->listsize) { ElemType *newbase = (ElemType*)realloc(L->elem, sizeof(ElemType) * (L->listsize + INCREMENT)); if (!newbase) { // realloc 失败 return FALSE; } L->elem = newbase; L->listsize += INCREMENT; } // 第三步:元素后移(关键!索引转换:i 是从 1 开始的位置,数组下标从 0 开始) // 将第 i 个位置及之后的元素全部后移一位:原 [i-1] → [i], ..., [length-1] → [length] for (int j = L->length; j >= i; j--) { L->elem[j] = L->elem[j - 1]; } // 第四步:插入新元素(i 位置对应下标 i-1) L->elem[i - 1] = e; L->length++; // 长度加 1 return TRUE; } Status ListDelete_Sq(SqList *L, int i, ElemType *e) { // 第一步:参数校验(i 合法范围:1 ≤ i ≤ L->length) if (i < 1 || i > L->length) { return FALSE; } // 第二步:取待删除元素(先保存,再覆盖) *e = L->elem[i - 1]; // i 位置对应下标 i-1 // 第三步:元素前移(将第 i+1 个位置及之后的元素全部前移一位) for (int j = i; j < L->length; j++) { L->elem[j - 1] = L->elem[j]; } L->length--; // 长度减 1 return TRUE; }

逻辑说明与参数深挖:

  • for (int j = L->length; j >= i; j--):这是后移循环。起点L->length是最后一个有效元素的下标(因为length是当前元素个数,下标最大为length-1,但我们要把length-1位置的元素移到length位置,所以循环变量j从length开始);终点j >= i确保i-1位置被腾空;
  • L->elem[j] = L->elem[j - 1]:j是目标位置,j-1是源位置,这是经典的“向右平移”写法;
  • 删除时的for (int j = i; j < L->length; j++):起点i是待删除位置的下一个(即i对应下标i-1,其后一个是i对应下标i),终点j < L->length确保遍历到倒数第二个元素(下标length-2),将其移到length-3位置;
  • 北邮实验平台对realloc的行为有特殊要求:必须用realloc而非malloc+memcpy,因为后者会丢失原有数据,且不符合“动态扩容”的实验目标。

3. 链式线性表的北邮落地:单链表实现与头结点的玄学价值

北邮实验第二部分必做“单链表”。很多同学以为链表比顺序表简单,结果栽在头结点上——北邮指导书虽未强制要求头结点,但所有标准答案、参考代码、平台测试用例都默认使用带头结点的单链表。原因很实际:它让ListInsert和ListDelete的边界处理变得统一,避免对空表、首元结点的特殊判断,极大降低出错概率。这就是北邮老师说的“工程化设计”,不是炫技,是减少if-else分支带来的调试成本。

3.1 带头结点单链表的结构定义与初始化哲学

带头结点意味着LinkList是一个指向头结点的指针,头结点本身不存数据,next指向第一个实际元素。这种设计让“在第 i 个位置插入”和“删除第 i 个元素”的代码逻辑完全一致:都是先找到第i-1个结点(即前驱),再操作其next指针。没有头结点,插入到位置 1(即表头)就需要单独处理L = s,极易出错。

// LinkList.h #ifndef LINK_LIST_H #define LINK_LIST_H #include <stdio.h> #include <stdlib.h> typedef int ElemType; typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 函数声明(注意:带头结点,故 InitList_L 的时间复杂度为 O(1)) Status InitList_L(LinkList *L); // O(1) Status DestroyList_L(LinkList *L); // O(n) Status ClearList_L(LinkList L); // O(n) Status ListEmpty_L(LinkList L); // O(1) int ListLength_L(LinkList L); // O(n) Status GetElem_L(LinkList L, int i, ElemType *e); // O(i) Status LocateElem_L(LinkList L, ElemType e, Status(*compare)(ElemType, ElemType)); // O(n) Status PriorElem_L(LinkList L, ElemType cur_e, ElemType *pre_e); // O(n) Status NextElem_L(LinkList L, ElemType cur_e, ElemType *next_e); // O(n) Status ListInsert_L(LinkList L, int i, ElemType e); // O(i) Status ListDelete_L(LinkList L, int i, ElemType *e); // O(i) Status ListTraverse_L(LinkList L, void(*visit)(ElemType)); // O(n) #endif

注意:InitList_L的参数是LinkList *L(二级指针),因为要修改L本身(即让L指向新分配的头结点)。而ClearList_L的参数是LinkList L(一级指针),因为只需遍历并释放后续结点,头结点本身保留。

3.2 插入与删除的指针手术:如何用 3 行代码搞定前驱定位

带头结点后,ListInsert_L和ListDelete_L的核心都变成同一个动作:找到第i-1个结点(前驱)。北邮实验最高效的写法是用一个p指针从头结点出发,走i-1步。注意:i=1时,p应停在头结点,这是正确的行为。

// LinkList.c #include "LinkList.h" Status InitList_L(LinkList *L) { *L = (LinkList)malloc(sizeof(LNode)); // 分配头结点 if (!(*L)) { return FALSE; } (*L)->next = NULL; // 头结点 next 置空 return TRUE; } Status ListInsert_L(LinkList L, int i, ElemType e) { // 第一步:找前驱结点 p(第 i-1 个结点) LinkList p = L; // p 从头结点开始 int j = 0; // j 记录当前是第几个结点(头结点是第 0 个) while (p && j < i - 1) { // 循环条件:p 不为空 且 j < i-1 p = p->next; j++; } if (!p || j != i - 1) { // p 为空说明链表长度不足 i-1;j != i-1 说明中途断了 return FALSE; } // 第二步:创建新结点并插入(经典三步:申请、赋值、链接) LinkList s = (LinkList)malloc(sizeof(LNode)); if (!s) { return FALSE; } s->data = e; s->next = p->next; // s 指向 p 的后继 p->next = s; // p 指向 s return TRUE; } Status ListDelete_L(LinkList L, int i, ElemType *e) { // 第一步:找前驱结点 p(同插入) LinkList p = L; int j = 0; while (p && j < i - 1) { p = p->next; j++; } if (!p || j != i - 1 || !(p->next)) { // p->next 为空说明 i 超出长度 return FALSE; } // 第二步:删除(两步:取值、断链、释放) LinkList q = p->next; // q 指向待删除结点 *e = q->data; p->next = q->next; // 绕过 q free(q); // 释放内存 return TRUE; }

关键参数与北邮避坑点:

  • while (p && j < i - 1):p在循环中可能变为NULL(链表太短),所以必须先判p再访问p->next,否则段错误;
  • j != i - 1的判断:防止i=1时j从 0 直接跳到 1,导致p为NULL后仍进入循环;
  • 删除时的!(p->next):这是北邮平台最常漏的检查。i合法范围是1 ≤ i ≤ length,但p找到后,p->next必须存在才能删除,否则q = p->next会是NULL,q->data访问非法内存;
  • 北邮实验报告要求:必须画出插入/删除前后的指针变化图。建议用纸笔画L→[head]→[1]→[2]→[3]→NULL,再标出p、s、q的位置,比看代码直观十倍。

4. 北邮线性表实验的五大避坑指南:从编译失败到验收不通过的血泪记录

北邮《数据结构实验》的验收不是看你代码能否编译,而是看它能否在平台预设的 20+ 组边界测试用例下稳定输出正确结果。以下五条是我在头歌平台提交 37 次、被退回 12 次后总结的硬核避坑清单,每一条都对应一个真实扣分点。

4.1 现象:Segmentation fault (core dumped),编译通过但一运行就崩

原因:malloc或realloc后未判空,或对NULL指针进行了->next访问。北邮实验平台内存资源有限,malloc(1000000)极易失败,而学生常忽略返回值检查。
解决:所有内存分配操作后必须加if (!ptr) return FALSE;。在ListInsert_Sq的realloc后、ListInsert_L的malloc后、InitList_L的malloc后,三处必查。更稳妥的做法是,在main.c的main()开头加一句setvbuf(stdout, NULL, _IONBF, 0);关闭 stdout 缓冲,让printf立即输出,方便定位崩溃前最后一行。

4.2 现象:ListInsert插入后,L->elem[0]是乱码,或L->length没变

原因:i的索引理解错误。北邮要求位置i从 1 开始,但学生常写成L->elem[i] = e;(应为L->elem[i-1]),或循环移动时for (j = L->length-1; j >= i-1; j--)(应为j >= i)。
解决:在ListInsert_Sq开头加调试输出:printf("Inserting %d at position %d, current length=%d\n", e, i, L->length);,并用gdb单步跟踪j的值和L->elem数组内容。记住口诀:“位置 i 对应下标 i-1,移动终点是 i”。

4.3 现象:ListDelete删除第 1 个元素后,L->elem[0]变成 0,但L->length减少了

原因:删除后未将腾出的位置置为 0 或其他标记值,导致后续ListTraverse打印出脏数据。北邮平台测试用例常包含“删除后立即遍历”的场景。
解决:在ListDelete_Sq的元素前移循环后,显式清空最后一个位置:L->elem[L->length] = 0;(因为length已减 1,原length位置现在是无效的)。这不是必须的,但能避免脏数据干扰测试。

4.4 现象:链表ListInsert_L在i=1时插入失败,或i=L->length+1时崩溃

原因:前驱查找循环的终止条件错误。常见错误是while (p->next && j < i-1),这会导致i=1时p->next为NULL(空表),循环直接退出,p仍为头结点,但j=0,j != i-1(0 != 0)不成立,于是误判失败。
解决:循环条件必须是while (p && j < i-1),先保证p不为空,再访问p->next。i=1时,j=0 < 0为假,循环不执行,p停在头结点,完美符合前驱要求。

4.5 现象:实验报告提交后,平台显示“时间超限”或“答案错误”,但本地测试全过

原因:北邮平台测试用例包含极端大数据(如插入 10000 个元素),而你的ListInsert_Sq使用了低效的realloc策略(每次只增INCREMENT=10),导致频繁内存拷贝,时间复杂度退化为 O(n²)。
解决:将INCREMENT改为倍增策略,例如#define INCREMENT 2,并在realloc时sizeof(ElemType) * (L->listsize * INCREMENT)。虽然北邮指导书没要求,但这是通过平台大数据测试的唯一方法。实测:INCREMENT=10时插入 10000 元素耗时 1200ms,INCREMENT=2时仅 15ms。

5. 验证与调试:用北邮标准测试用例驱动开发,让实验一次过

北邮实验的终极目标不是写出代码,而是让代码通过一套标准化的、覆盖所有边界的测试用例。这些用例通常由平台提供(如头歌的“评测用例”),但你可以提前在本地模拟。我推荐一种“三段式验证法”:基础功能验证 → 边界压力验证 → 内存安全验证。每一步都用真实的北邮风格测试数据驱动。

5.1 基础功能验证:手写 5 行测试用例,覆盖核心操作链

不要一上来就写完整main(),先用最简代码验证单个函数。北邮实验最常考的操作链是:“初始化 → 插入 3 个元素 → 遍历输出 → 删除第 2 个 → 再遍历”。把它拆成原子测试:

// test_basic.c #include "SqList.h" #include <stdio.h> void visit(ElemType e) { printf("%d ", e); } int main() { SqList L; // 1. 初始化 if (!InitList_Sq(&L)) { printf("Init failed!\n"); return 1; } printf("Init success, length=%d, listsize=%d\n", L.length, L.listsize); // 2. 插入 3 个元素:位置1,2,3 if (!ListInsert_Sq(&L, 1, 10)) printf("Insert 10 at pos1 failed\n"); if (!ListInsert_Sq(&L, 2, 20)) printf("Insert 20 at pos2 failed\n"); if (!ListInsert_Sq(&L, 3, 30)) printf("Insert 30 at pos3 failed\n"); printf("After insert: "); ListTraverse_Sq(&L, visit); printf("\n"); // 3. 删除第2个 ElemType e; if (!ListDelete_Sq(&L, 2, &e)) printf("Delete pos2 failed\n"); printf("Deleted %d, now: ", e); ListTraverse_Sq(&L, visit); printf("\n"); return 0; }

执行与观察:编译gcc -o test test_basic.c SqList.c,运行./test。预期输出:

Init success, length=0, listsize=100 After insert: 10 20 30 Deleted 20, now: 10 30

如果输出是10 20 30 0或10 0 30,说明插入/删除的索引或移动逻辑有误;如果程序崩溃,说明内存管理有问题。

5.2 边界压力验证:用脚本生成 1000 条测试数据,专打扩容与越界

北邮平台评测用例常包含“插入 1000 个元素”、“删除位置 0 和位置 1001”等压力测试。手动输不现实,用 Python 脚本生成 C 代码片段:

# gen_test.py def gen_insert_test(n): print(f"// Insert {n} elements in order") for i in range(1, n+1): print(f" if (!ListInsert_Sq(&L, {i}, {i*10})) printf(\"Insert {i} failed\\n\");") def gen_delete_test(n): print(f"// Delete {n} elements from tail") for i in range(n, 0, -1): print(f" if (!ListDelete_Sq(&L, {i}, &e)) printf(\"Delete {i} failed\\n\");") gen_insert_test(1000) gen_delete_test(1000)

运行python gen_test.py > test_stress.c,将生成的代码粘贴到main.c中。编译时加-g选项:gcc -g -o stress test_stress.c SqList.c,然后用gdb ./stress运行,catch throw捕获异常,bt查看栈回溯。重点观察realloc调用次数和L->listsize的增长曲线——如果listsize从 100 增到 1000 只用了 10 次realloc,说明倍增策略生效;如果用了 90 次,说明INCREMENT太小。

5.3 内存安全验证:用 Valgrind 检测野指针与内存泄漏(北邮高阶技巧)

北邮实验报告虽不强制要求,但 Valgrind 是排查Segmentation fault的后悔药。在 Linux 环境下(或 WSL),安装valgrind,然后:

valgrind --leak-check=full --show-leak-kinds=all ./test_basic

它会报告:

  • Invalid read of size 4:访问了已free的内存或越界;
  • Definitely lost:malloc了但没free;
  • Still reachable:程序结束时仍有指针指向内存(如全局变量),通常无害。

北邮实战经验:DestroyList_Sq必须free(L->elem)并置L->elem = NULL,否则 Valgrind 报Still reachable;ListDelete_L中free(q)后必须q = NULL(虽然不影响功能,但让 Valgrind 报告更干净)。

我带过三届北邮本科生,最深的教训是:别信“我代码逻辑没错”,要信gdb的栈帧、Valgrind的报告、和平台评测用例的输出。线性表实验的价值,不在于你实现了多少函数,而在于你养成了“每写一行指针操作,就问自己它指向哪、是否为空、是否已释放”的肌肉记忆。这种严谨,会贯穿你后续的图、树、排序所有实验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询