简介:本资源是北京邮电大学数据结构课程首次实验的完整线性表实践报告,面向计算机类本科生及算法初学者,聚焦带头结点单链表的核心实现与深度剖析。内容涵盖存储结构原理、9大关键算法(头/尾插法、析构、按位/按值查找、插入、删除、遍历、求长、复制构造、倒置)的代码实现、时间复杂度分析及完整main()函数测试流程,辅以清晰的内存示意图与分步执行逻辑,有效支撑实验复现与概念内化。资源为1个6.3MB的Word文档(.doc),结构规范,含实验要求、程序分析、运行结果与调试总结等模块,便于直接阅读、打印或作为课程笔记参考。已有602人学习下载,适合数据结构课堂巩固、实验预习复习及链表操作能力系统训练。
1. 线性表不是“表格”,而是数据排队的底层规则:零基础也能看懂的链表入门第一课
你有没有试过在超市结账时,队伍突然从中间插进一个人?或者前面有人临时改主意不买了,整条队就得重新调整?——线性表,就是计算机里模拟这种「排队逻辑」的最基础模型。它不关心你存的是数字、名字还是照片,只管一件事:谁在谁前面、谁在谁后面,怎么插队、怎么退队、怎么找人。北邮这份数结构实验报告里的「带头结点的单链表」,就是用代码把这套排队规则写实了:每个“人”(叫结点)自己带着一张小纸条(叫指针),上面写着下一个人站哪儿。没人规定大家必须挨着站(所以内存可以零散),但靠这张纸条,就能顺着找到所有人。这不是高深算法,而是所有APP、游戏、后台系统背后最底层的“秩序感”来源。如果你刚学编程,看到Node<T>* p = front->next;就头皮发麻;如果你连new Node<T>是干啥的都拿不准;如果你试过抄代码却卡在“程序一闪而过”“报错说类型没定义”——别慌,这篇就是为你写的。它不讲大道理,只带你亲手搭出第一个能跑通、能输入、能删改、还能倒着念的排队系统。接下来,我们从“为什么非得用链表”开始,一关一关拆解,直到你亲手敲完、编译成功、看到屏幕上打出1 2 3 4 5那一刻。
2. 为什么不用数组?先搞懂链表存在的真实理由:动态扩容、插入删除快、内存不浪费
2.1 数组排队的“硬伤”:固定长度像订死的座位号
想象你去电影院买票,售票员递给你一张印着“7排12座”的票——这就是数组。好处是:你想找第5个人?直接翻到票根上写“5”的位置,秒开。但坏处也致命:
- 如果队伍突然加进来20个人,座位不够了,你得整个大厅重排,搬椅子、贴新号、通知所有人——对应代码里就是
int a[10]想变int a[30],得申请新内存、复制旧数据、释放旧空间,慢且容易出错; - 如果第3个人临时有事走了,他空出的“3号座”永远空着,后面的人不能往前挪(否则编号全乱),座位就白白浪费;
- 更糟的是:你想在第2和第3个人中间插个新人?那第3号及之后所有人得集体往后挪一个座位——1000个人的队,就得动998次,时间全耗在路上。
提示:这些不是理论假设。你在写学生管理系统时,班级人数从30变成60;写聊天软件时,用户消息一条接一条涌进来;写编辑器时,光标在文字中间疯狂删改……数组的“固定座位”立刻变成拖慢速度的枷锁。
2.2 链表排队的“活法”:每人带张导航纸条,队伍想长就长、想短就短
链表换了一种思路:不发固定座位票,而是给每个人发一张小纸条,上面只写“下一个人站哪儿”。第一个人(叫头结点)不存数据,只负责指路;后面每个人既存自己的数据(比如数字5),又写明下一个人的地址。这样:
- 加人?在队尾或队中随便找个位置,让新人站好,把他的地址写进前一个人的纸条里,再把自己的纸条填上“下一个是谁”——两步操作,跟队伍多长无关;
- 删人?让前一个人的纸条跳过他,直接指向后一个人,然后把这个人“清空回收”——也是两步;
- 找第i个人?得从头结点出发,按纸条一路问下去:“你下一个是几号?”“他下一个是几号?”……问i次才能到——慢是慢了,但胜在灵活。
这就是北邮实验里强调“带头结点的单链表”的核心价值:它用一点点“找人时间”,换来了无限的“插队自由”和“内存省心”。不是它比数组高级,而是它解决的是数组根本解决不了的问题。
2.3 一个真实场景对比:学生选课系统里的“增删查改”
假设你开发一个课程报名系统,初始有100个学生。用数组实现:
Student students[100]; // 固定容量 int count = 0; // 插入新学生:得检查count是否<100,满了就崩溃或手动扩容 // 删除某学生:得把后面所有人往前拷贝,count--,否则留空洞 // 查找学号为1001的学生:for循环遍历,O(n)用链表实现(北邮这份代码的雏形):
LinkList<Student> courseList; // 容量自动伸缩 // 插入:courseList.Insert(5, newStudent); // 在第5位插入,内部只改2个指针 // 删除:courseList.Delete(3); // 删第3个,内部只改1个指针+释放内存 // 查找:courseList.Locate(1001); // 内部遍历,但你不用操心内存够不够看到区别了吗?数组让你时刻盯着“容量红线”,链表让你专注“业务逻辑”。北邮实验没让你造火箭,它让你亲手把“排队自由”这个概念,焊进自己的肌肉记忆里。
3. 第一步:从零搭建你的第一个链表——照着做,5分钟跑通打印1 2 3 4 5
3.1 先理解两个最关键零件:结点(Node)和链表类(LinkList)
别被template<class T>吓住,它只是说:“这个东西能装任何类型的数据,数字、字符、甚至你自己定义的结构体,都行。”我们先锁定最简单的int(整数)。
结点(Node):就是排队的“每个人”。它有两个口袋:
data口袋:装数据,比如数字5;next口袋:装一张纸条,写着“下一个人的地址”,类型是Node<T>*(指向另一个Node的指针)。
代码长这样(摘自北邮源码):
template<class T> struct Node { T data; // 装数据的口袋 struct Node<T>* next; // 装“下一个人地址”的纸条 };白话解释:
struct Node<T>* next;不是“下一个Node”,而是“下一个Node住在哪栋楼、哪个房间号”。就像你朋友说“我家在朝阳区建国路8号302室”,他没把你拉过去,只给了地址。链表类(LinkList):就是管理整支队伍的“队长”。它有个秘密武器——头结点(front):一个不存数据、只负责指路的“领队”。它的
next口袋,永远指着队伍里第一个人。template<class T> class LinkList { public: LinkList() { front = new Node<T>; front->next = NULL; } // 无参构造:先建领队,让他举着“空纸条” private: Node<T>* front; // 队长,永远站在最前面 };关键细节:
front->next = NULL意思是“领队目前没看到任何人”,队伍为空。这是链表安全的起点,漏掉这句,后续所有操作都会崩。
3.2 动手!用尾插法创建含5个数字的链表并打印
北邮实验提供了两种建队方式:头插法(新人总插队到最前面)、尾插法(新人老实排在队尾)。我们选更符合直觉的尾插法,一步步敲:
第一步:准备数据
const int n = 5; int a[n] = {1, 2, 3, 4, 5}; // 要排队的5个数字第二步:创建链表对象(调用尾插法构造函数)
LinkList<int> myList(a, n); // 这行会触发下面的构造函数第三步:看懂尾插法构造函数(重点!逐行注释)
template<class T> LinkList<T>::LinkList(T a[], int n) { // 参数:数据数组a,长度n front = new Node<T>; // 1. 先建领队(头结点) front->next = NULL; // 2. 领队举着“空纸条”,队伍初始为空 Node<T>* r = front; // 3. r是“尾巴指针”,当前正指着领队(因为队空,领队就是尾巴) for (int i = 0; i < n; i++) { // 4. 对每个数字a[i],执行: Node<T>* s = new Node<T>; // 4a. 给新人s分配一个“身体”(内存) s->data = a[i]; // 4b. 把数字a[i]塞进s的data口袋 r->next = s; // 4c. 让当前尾巴r的纸条,指向新人s(s排到队尾) r = s; // 4d. 更新尾巴r,现在s成了新尾巴 } r->next = NULL; // 5. 最后,新尾巴s的纸条写“空”,表示队尾结束 }为什么需要
r指针?因为front是固定的领队,不能乱动。r就像一根“尾巴绳”,始终拴在最后一个人身上,确保新人永远准确接在队尾。
第四步:打印整个队伍(遍历)
template<class T> void LinkList<T>::PrintList() { Node<T>* p = front; // 1. p从领队开始 while (p->next != NULL) { // 2. 只要领队纸条没写“空”,说明后面有人 p = p->next; // 3. p走到下一个人(顺着纸条走) cout << p->data << " "; // 4. 打印这个人data口袋里的数字 } }注意:
p = front后,第一次p = p->next就跳过了领队,直接到第一个人。所以打印不会出现乱码。
第五步:在main函数里串起来
int main() { const int n = 5; int a[n] = {1, 2, 3, 4, 5}; LinkList<int> myList(a, n); // 创建链表 cout << "打印链表:"; myList.PrintList(); // 打印:1 2 3 4 5 cout << endl; system("pause"); // 防止窗口一闪而过(北邮实验的血泪经验) return 0; }✅ 现在,你已经完成了北邮实验要求的第一步:构造链表 + 打印。把它完整敲进Dev-C++或Code::Blocks(推荐),编译运行,屏幕上一定会跳出1 2 3 4 5。这不是魔法,是你亲手用指针纸条,搭起的第一支数字队伍。
4. 避坑指南:新手必踩的5个“程序一闪而过”“报错看不懂”陷阱
4.1 现象:程序编译成功,双击运行后黑窗口一闪就消失
原因:程序执行完main()最后一行就立刻退出,Windows系统自动关闭控制台窗口。你根本来不及看清输出结果。
解决:在main()函数末尾加一句暂停命令。北邮实验用了system("pause");,这是最简单粗暴的方法(需#include <stdlib.h>)。更规范的做法是加cin.get();(需#include <iostream>),等待你按回车才退出。
血泪经验:我第一次写的时候,反复编译十几次,以为代码错了,其实只是没看到输出。后来养成习惯:只要
main()里有cout,结尾必加system("pause");或cin.get();。
4.2 现象:编译报错error: 'LinkList' is not a template或error: missing template arguments before ...
原因:模板类LinkList必须明确告诉编译器“里面装什么类型”。比如LinkList<int>表示装整数,LinkList<string>表示装字符串。如果只写LinkList myList;,编译器一脸懵:“你让我管什么类型?”
解决:所有LinkList对象声明,必须带上尖括号类型,如LinkList<int> myList;。北邮实验里LinkList<int>example(a, n);就是正确示范。漏掉<int>是新手最高频错误。
提示:
template<class T>是定义时的“通用模具”,LinkList<int>才是用模具造出的具体“零件”。
4.3 现象:运行时报错Segmentation fault(段错误)或直接崩溃
原因:指针p指向了一个无效地址(比如NULL),你还强行用p->data去读取。常见于:
Get(i)查找第i个元素时,i超出了链表长度,p最终变成NULL,但代码没检查就直接p->data;Delete(i)删除时,i=0或i大于长度,p = Get(i-1)返回NULL,后续p->next就崩了。
解决:所有涉及p->xxx的操作前,先加安全检查:
if (p == NULL) { cout << "位置错误,超出范围" << endl; return; // 或返回默认值 } // 然后再用 p->data北邮源码里Insert和Delete函数开头都有if (p)判断,这就是防御式编程。
4.4 现象:打印链表时,开头多出一个奇怪的数字(比如0 1 2 3 4 5)
原因:误把头结点front当作了有效数据节点。头结点front的data是未初始化的垃圾值(可能是0),而PrintList()里如果写成p = front; do { cout << p->data; p = p->next; } while(p != NULL);,就会先打头结点的垃圾值。
解决:严格遵循北邮代码的遍历逻辑——p = front; while (p->next != NULL) { p = p->next; cout << p->data; }。关键在while (p->next != NULL),它保证了p每次进入循环体时,p本身已经是有效节点(因为p->next存在),绝不会访问头结点的data。
4.5 现象:插入或删除后,链表内容错乱,比如1 2 3插入99到位置2后变成1 99 99 3
原因:指针操作顺序错了。典型错误是:
// ❌ 错误:先改p->next,再改s->next,导致s的next指向了错误位置 p->next = s; s->next = p->next; // 此时p->next已是s,s->next = s,自己指向自己!解决:牢记链表操作的黄金顺序——先连新线,再断旧线。正确插入(在p后插入s):
s->next = p->next; // 1. 新人s先认清楚“下一个是原来p后面那个人” p->next = s; // 2. 再让p改纸条,写上“下一个是s”类比:你要把新人C插到A和B之间。正确做法:C先记住B的位置(
s->next = p->next),然后A把纸条从“B”改成“C”(p->next = s)。如果A先改纸条,C就找不到B了。
5. 从“能跑通”到“真理解”:动手改三个小功能,吃透链表的呼吸感
5.1 功能一:把“用户输入数组”从硬编码变成交互式(北邮实验下一步改进点)
北邮原代码用int a[n] = {1,2,3,4,5};是教学简化,实际项目必须让用户自己输。我们来升级:
修改main函数,加入动态输入:
int main() { int n; cout << "请输入学生人数:"; cin >> n; int* a = new int[n]; // 动态申请数组,大小由用户定 cout << "请依次输入" << n << "个数字:"; for (int i = 0; i < n; i++) { cin >> a[i]; } LinkList<int> myList(a, n); // 传给链表构造函数 cout << "打印链表:"; myList.PrintList(); cout << endl; delete[] a; // 重要!释放动态数组内存 system("pause"); return 0; }关键点:
int* a = new int[n]和delete[] a必须配对。不释放会导致内存泄漏——就像租房子不退钥匙,房东永远以为房子被占着。
5.2 功能二:增加“按值删除所有匹配项”,不止删第一个
北邮Delete(int i)只能按位置删,但实际需求常是“删掉所有数字5”。我们加一个新函数:
在LinkList类中添加成员函数:
template<class T> void LinkList<T>::DeleteAll(T x) { // 删除所有值为x的结点 Node<T>* p = front; // 从领队开始 while (p->next != NULL) { // 只要p后面还有人 if (p->next->data == x) { // 检查p后面那个人是不是目标 Node<T>* q = p->next; // q是待删者 p->next = q->next; // p的纸条跳过q,指向q的下一个人 delete q; // 释放q的内存 } else { p = p->next; // p往后走,继续检查下一个人 } } }在main中测试:
myList.DeleteAll(3); // 删除所有3 myList.PrintList(); // 原来1 2 3 4 3 5 → 变成1 2 4 5为什么用
p->next检查?因为要删除p->next,必须先拿到p(前驱),这是单链表的限制。双链表可以前后双向,单链表只能“借前驱之手”。
5.3 功能三:实现“合并两个有序链表”,练熟指针游走
这是面试高频题。假设有两个升序链表list1: 1 3 5,list2: 2 4 6,合并成1 2 3 4 5 6。核心思想:用两个指针p1、p2分别扫描两个链表,谁小谁先接进新链表。
写一个独立函数(不修改原类,便于理解):
template<class T> LinkList<T> Merge(LinkList<T>& list1, LinkList<T>& list2) { LinkList<T> result; // 新建空链表存结果 Node<T>* p1 = list1.front->next; // list1第一个有效结点 Node<T>* p2 = list2.front->next; // list2第一个有效结点 Node<T>* r = result.front; // r是result的尾巴指针 // 当两个链表都没走完时 while (p1 != NULL && p2 != NULL) { if (p1->data <= p2->data) { r->next = p1; // 把p1接到result尾巴 p1 = p1->next; // p1往下走 } else { r->next = p2; // 把p2接到result尾巴 p2 = p2->next; // p2往下走 } r = r->next; // result尾巴更新 } // 把剩余的链表全部接上(最多一个非空) if (p1 != NULL) r->next = p1; if (p2 != NULL) r->next = p2; return result; }测试:
LinkList<int> list1(a1, 3); // 1 3 5 LinkList<int> list2(a2, 3); // 2 4 6 LinkList<int> merged = Merge(list1, list2); merged.PrintList(); // 输出1 2 3 4 5 6这个函数没有
new和delete,因为它只是“重排指针”,不申请新内存——高效的关键。
6. 下一步学什么?一份拒绝焦虑的渐进路线图:从链表到真实项目能力
6.1 别急着跳坑:先吃透这3个“链表思维”习惯
很多新手学完链表,转身写项目还是懵,不是代码不会,而是思维没切换。我带过的A同学,第一次独立写图书管理系统,卡在“借书”功能三天,最后发现他还在用数组思维——试图给每本书编号1~100,然后维护一个bool borrowed[100]。直到我把北邮链表的Insert和Delete函数往他眼前一放:“你看,借书=从‘在库’链表删一个,加到‘已借’链表;还书=反过来。根本不用管编号!”——他当场顿悟。所以,先固化这三个本能:
- 遇到“可能增删”的数据,条件反射想链表:用户列表、消息队列、购物车商品,凡是数量不确定、频繁变动的,优先考虑链表(或现代语言的
vector/list); - 操作前必问“前驱在哪”:单链表无法直接访问前一个结点,所以删除、插入位置i,本质都是先找到位置i-1的结点(即前驱)。北邮
Delete(int i)里p = Get(i-1)就是这个逻辑; - 指针操作后必画图:哪怕再简单的
s->next = p->next; p->next = s;,也花10秒在纸上画两个方块(p和s)和箭头。我至今保留这个习惯,每次重构复杂链表逻辑,第一件事就是摊开草稿纸,画节点、标指针、涂掉旧线、画新线。图比代码诚实。
6.2 路线图:用3周时间,把链表变成你的“数据搬运工”
不要追求“学完所有数据结构”,先让链表为你干活。按周规划:
| 周次 | 目标 | 关键动作 | 北邮资源如何用 |
|---|---|---|---|
| 第1周:链表熟练工 | 能独立写出增删查改、倒置、合并,不查资料 | 每天手写1个函数(如Insert),不看代码,写完立刻画图验证;用北邮的main()测试框架,把所有函数都跑一遍 | 把北邮main()里的cin部分全改成你自己的测试数据,比如Insert(1,99)连续插5次,观察指针变化 |
| 第2周:链表调试员 | 能快速定位段错误、空指针、内存泄漏 | 故意制造错误:删掉if(p)检查,看崩溃位置;用cout << "p=" << p << endl;打印指针地址;学习用Dev-C++的调试器单步执行,观察p和p->next的值 | 北邮实验报告第4页“调试过程出现的问题”就是现成的踩坑清单,照着复现一遍,再修复 |
| 第3周:链表应用家 | 用链表解决1个真实小问题 | 选一个:① 学生成绩管理(增删学生、按分数排序);② 简易浏览器历史记录(前进/后退用双向链表);③ 文本编辑器光标移动(字符用链表存,光标是Node* cursor) | 北邮的LinkList<T>是完美骨架,你只需替换T为Student或char,重载operator<<支持打印即可 |
6.3 一个真实教训:从那以后我每次写指针,都强制走一遍“三问检查”
我第一次用链表写文件解析器,处理上万行日志时,程序随机崩溃。花了两天才发现,是在Delete函数里忘了delete q;,内存越积越多,最后爆掉。从那以后,我给自己定了铁律:
- 一问生命周期:“这个
new出来的指针,对应的delete写在哪了?如果函数提前return,会不会漏掉?” - 二问空值:“
p此刻可能是NULL吗?p->next能安全访问吗?所有->操作前,加if(p)或assert(p)。” - 三问所有权:“这段内存,是我申请的,还是别人传进来的?释放权在我这,还是该交给调用者?”
北邮实验里析构函数~LinkList()完整遍历释放,就是所有权意识的体现。希望帮到你。
本文还有配套的精品资源,点击获取