很多人在学数据结构的时候,第一关就是线性表。这门课学得顺不顺手,很多时候就看线性表这一章有没有真正学明白。它不只是一张表那么简单,后面几乎所有数据结构(栈、队列、串、树、图)都建立在“把一组数据组织成一个序列”或者“反过来对序列做增删改查”这套思维方式上。甚至可以说,如果你把线性表吃透了,再学后面的内容会顺畅得多。
这篇文章主要面向三类读者:正在上数据结构课的学生,要考408或者自主命题考研的同学,以及自学数据结构准备面试的开发者。我会用C语言来讲解核心代码,因为C语言在考研、面试和实验报告里都是主流语言,另外还会穿插一些我在实际写代码和带人过程中积累的经验。只要你能耐心读完并且跟着敲一遍代码,线性表这一章基本就没问题了。
1. 线性表到底是个什么东西
1.1 一句话解释:逻辑上排队的元素
先别去背定义。线性表本质上就是一群元素排成一条队伍。队伍里每一个人除了第一个没有前一个,最后一个没有后一个,其他人都恰好有唯一的前一个和唯一的后一个。这句话包含了线性表的三个关键信息:
- 元素之间是一对一的关系,不存在“一个元素有两个直接后继”的情况;
- 每个元素有位置,通常叫位序,从1开始(不是从0开始,C语言数组下标才是从0开始,这个区别很多人会搞混);
- 操作都是针对这个序列做的,比如在第i个位置插入一个元素、删除第i个元素、查找某个值。
我的建议是,学的时候先把“逻辑结构”和“物理结构”分开。逻辑结构描述的是数据元素之间的关系,线性表就是一种逻辑结构;物理结构描述的是这些数据在内存里到底怎么放的。同样的逻辑结构,可以有不同的物理实现方式,这就引出了接下来的两种主力实现:顺序表和链表。
1.2 两种物理存储思路:顺序存储与链式存储
顺序存储很好理解:找一块连续的内存空间,把元素一个个挨着放进去,就像一排连号座位,你坐1号座,旁边就是2号座。链式存储就不一样了:每个元素除了存数据,还要额外存一个“指向下一个元素的地址”,就像寻宝游戏,你拿着线索找到下一个人,那个人再告诉你下一个线索在哪。这两种方式没有绝对的好坏,只有适不适合。顺序存储适合随机访问,链表适合频繁插入删除——这个结论背后的原因,后面我会展开讲。
结论先放在这里:线性表是“逻辑上排队的数据”,顺序表和链表是它的两种物理实现。理解了这一层,下面学代码才有框架感,不然纯粹是在背代码。
2. 顺序表:一个数组搞定一切
2.1 结构定义与初始化
顺序表在C语言里常用的实现方式是动态数组。为什么不用静态数组?因为静态数组的大小编译时就得定死,你没法预知实际要存多少数据,开大了浪费内存,开小了不够用。动态分配就灵活很多,不够了可以扩容。
先看结构体定义:
#define INIT_CAPACITY 10 typedef struct { int *data; // 指向动态分配数组的指针 int length; // 当前实际元素个数 int capacity; // 当前容量 } SeqList;初始化的时候,先分配一块初始容量的内存,然后把length置为0。这里有一个我踩过很多次坑的地方:如果你在初始化函数里用malloc分配了内存,那么使用完这个顺序表之后,一定要free(data),否则就是内存泄漏。写实验报告的同学尤其容易忽略这一点,因为程序运行一次就退出了,泄漏了也看不出来。LeetCode刷题多了,内存泄漏不报错,但大型项目中这是很严重的问题。
void initSeqList(SeqList *L) { L->data = (int *)malloc(INIT_CAPACITY * sizeof(int)); L->length = 0; L->capacity = INIT_CAPACITY; }2.2 插入和删除:为什么必须移动元素
顺序表最核心的操作就是插入和删除,几乎所有面试题和考试题都围绕这两个操作展开。先看插入的逻辑:想把元素e插到第i个位置,首先得判断i是否合法(既不能小于1,也不能大于当前length+1),然后从最后一个元素开始,依次往后挪一个位置,给第i个位置腾出空位,最后写入e,length加1。
int insertSeqList(SeqList *L, int i, int e) { if (i < 1 || i > L->length + 1) { return 0; // 位置不合法 } if (L->length >= L->capacity) { // 扩容:重新分配更大的内存,或用realloc L->capacity *= 2; L->data = (int *)realloc(L->data, L->capacity * sizeof(int)); } for (int j = L->length; j >= i; j--) { L->data[j] = L->data[j - 1]; } L->data[i - 1] = e; L->length++; return 1; }注意这里的for循环方向:从后往前移动,而不是从前往后。如果你从前往后移动,前面的元素就会覆盖后面的元素,数据就乱了。我自己刚学的时候犯过这个错误,把整个数组搞成一串重复的数字。这个细节也是很多数据机构实验课老师喜欢问的考点:“为什么插入时要倒着移动?”
删除操作逻辑类似:第i个元素后面的所有元素都往前挪一个位置,然后length减1。理论上被覆盖的最后一个位置不需要清理,因为后续插入会覆盖它;但养成良好的习惯,可以手动把data[length]置0,方便调试时观察内存状态。
int deleteSeqList(SeqList *L, int i) { if (i < 1 || i > L->length) { return 0; } for (int j = i; j < L->length; j++) { L->data[j - 1] = L->data[j]; } L->length--; return 1; }删除的时间复杂度是O(n),因为在最坏情况下(删除第一个元素),所有元素都得往前挪。平均也是O(n)。插入同理。为什么顺序表插入删除慢?答案就是“挪元素”本身成了瓶颈。这也是后面选择用链表来优化这个场景的直接原因。
2.3 顺序表的优缺点和适用场景
优点很明确:随机访问快。你只要知道下标,直接O(1)就能拿到元素,不需要从头找。这个特性在需要频繁“按位置查数据”的场景里是压倒性优势。另一个容易被忽略的优点是对缓存友好。数组在内存里是连续存储的,CPU访问一次内存会把相邻的一段数据都加载到高速缓存里,下次访问相邻元素直接命中缓存,速度非常快。相比之下,链表结点分散在内存各处,每一次跳转都可能发生缓存未命中。
缺点也很明显:插入删除要搬大量元素;扩容可能涉及整块内存的拷贝(realloc可能重新分配并把旧数据复制过去)。所以如果你提前就知道元素个数基本固定,或者需要频繁随机访问,顺序表是最佳选择。
3. 链表:不连续也能线性
3.1 单链表的结构与结点定义
单链表是链表家族最基础的一种。它的结点包含两个部分:数据域存数据,指针域存下一个结点的地址。C语言里用结构体表示:
typedef struct LNode { int data; struct LNode *next; } LNode;每个结点都散落在内存各个角落,通过指针串成一条链。这种“物理上不连续,逻辑上连续”的思想,经常让初次接触的人觉得抽象。我的经验是,链表的操作中,你要时刻记住一句话:永远不要在你需要用到指针之前把它弄丢。很多链表bug都是因为指针指向的位置在你不知情的情况下变了,结果后续操作全乱了。
3.2 头插法和尾插法:如何构造一个链表
构造链表有两种最常见的方法:头插法和尾插法。
头插法:每次把新结点插到链表的头部。代码非常短,插入顺序和结果顺序相反。也就是说,你按1、2、3的顺序插入,最终链表里存的是3、2、1。这个特性经常被拿来面试考察——链表反转的经典做法之一就是用头插法重新构造链表。
LNode *createByHead(int arr[], int n) { LNode *head = NULL; // 头指针,初始为空 for (int i = 0; i < n; i++) { LNode *newNode = (LNode *)malloc(sizeof(LNode)); newNode->data = arr[i]; newNode->next = head; // 新结点指向旧的第一个结点 head = newNode; // 头指针指向新结点 } return head; }尾插法:每次把新结点插到链表尾部。这个更符合直觉:插入顺序和结果顺序一致。但注意,尾插法需要维护一个尾指针,否则每次插入都要遍历到链表结尾,复杂度就变成O(n²)了。
LNode *createByTail(int arr[], int n) { LNode *head = NULL, *tail = NULL; for (int i = 0; i < n; i++) { LNode *newNode = (LNode *)malloc(sizeof(LNode)); newNode->data = arr[i]; newNode->next = NULL; if (head == NULL) { head = newNode; // 第一个结点既是头也是尾 } else { tail->next = newNode; } tail = newNode; } return head; }关于头结点:很多教材(尤其严蔚敏版)会用到头结点。头结点是一个不存实际数据的结点,它存在于链表的头部,目的是统一空表和非空表的操作逻辑。有了头结点,在头部插入/删除的时候就不需要单独修改头指针。但408统考和很多面试题里面,一般默认链表不带头结点,或者会明确问你“带头结点还是不带头结点”。写代码前一定要先确认清楚,否则你的操作逻辑可能完全不一样。我自己在带学生的时候就发现,很多人没搞清头指针和头结点这两个概念,导致写“删除第一个元素”这种基本操作都会报错。
3.3 链表的插入删除:修改指针的“三步走”原则
单链表的插入删除也是高频操作。插入一个结点的核心是:找到前驱结点p,然后修改指针。
// 在p结点之后插入一个新结点s s->next = p->next; p->next = s;注意这两条语句的顺序不能颠倒。如果你先执行p->next = s,那么原来的p->next(也就是s后面的那个结点)就找不到了,s的新链就断了。所以先接后断:先让s指向p原本的后继,再让p指向s。同样的道理也适用于删除:删除p的后继结点,只需要让p->next = p->next->next,然后free掉被删的结点。
为什么操作这么简单?因为单链表删除的本质就是“绕过被删除的结点”,不需要像顺序表那样搬动大量元素。这也是链表插入删除快的根本原因。但要注意,链表的插入删除虽然只需要O(1)时间,前提是你已经知道前驱结点的位置。如果需要先找到前驱,查找本身又要O(n),那就没有想象中那么香了。
3.4 链表查找:时间换空间的典型
链表的随机访问很弱,想找第i个元素必须从头指针开始一个个跳过去,时间复杂度O(n)。很多人刚学链表的时候总觉得链表很高级、很灵活,但真正写代码才发现它麻烦:不能直接取下标,遍历才能访问,内存还要每结点多花一个指针的空间。这个认知很有必要纠正一下——链表并不是顺序表的全面升级版,它是在某些特定场景下才更合适的替代方案。
链表还有一个特点:它对内存的要求很低,数据可以分散存放。顺序表必须找一块大且连续的存储区,链表只需要一个一个的小空间。所以如果你面对的是碎片化内存、插入删除频繁、数量不确定的场景,链表会更有优势。但现实工程里,顺序表(数组)仍然是绝对的主流,原因后面详细说。
4. 顺序表 vs 链表:选型不是拍脑袋
4.1 从时间复杂度、空间开销、缓存友好三个维度对比
很多学生问我:到底什么时候用顺序表,什么时候用链表?这个问题的标准回答不应该是“看情况”,而是一套清晰的判断标准。我习惯从三个维度来比较:
| 维度 | 顺序表 | 链表 |
|---|---|---|
| 随机访问 | O(1),直接下标 | O(n),需要遍历 |
| 头部插入/删除 | O(n),所有元素都得挪 | O(1),只需改指针 |
| 尾部插入(已知尾指针) | O(1),直接写到末尾 | O(1),但需要维护尾指针 |
| 中间插入 | O(n),挪元素 | O(n)查找+O(1)插入,合计O(n) |
| 额外空间 | 基本无 | 每个结点多存一个指针 |
| 缓存友好性 | 高(连续存储) | 低(离散存储) |
注意上面表格里的中间插入,很多人以为链表中间插入是O(1),这是个经典错误。链表只是“指针修改”是O(1),但你要先找到插入位置,这个查找过程是O(n)。所以如果你要在一大堆数据里反复做中间插入,链表并不比顺序表快多少。
4.2 结合实际场景的选型判断标准
我总结了一套简单的选型逻辑,拿去就直接用:
- 如果你主要操作是按位置访问(比如第n个元素是谁),无脑选顺序表;
- 如果你主要操作是按值查找,顺序表和链表都要O(n),但顺序表常数项更小,优先顺序表;
- 如果你主要操作是头部插入删除且数据量很大,链表占优;
- 如果你无法预估数据量上限,链表更灵活,因为顺序表扩容有代价;
- 如果内存碎片严重、大块连续内存分配不出来,只能链表。
这里额外插一句:实际工程中数组(顺序表)的使用频率远高于链表。原因除了缓存友好之外,还有一个容易被忽视的点——链表每访问一个结点都要做一次指针跳转,而每一次跳转都可能触发内存访问延迟。在现代CPU架构下,访问连续内存比随机散布的内存要快得多,这种性能差异在数据量大时会变得非常明显。所以不要一听“链表插入删除快”就觉得它更高级。这个误解几乎每个初学者都经历过,包括当年的我。
4.3 关于“链表比数组厉害”的一个澄清
再展开说一句这个误解。链表在面试里经常出现,给人的感觉是“考得多的东西更难更有用”。实际上,链表之所以常被考查,是因为它本身容易出边界问题(空指针、头结点、指针修改顺序等),适合用来考察你有没有真正理解指针和内存,而不是因为它在实际工程里更强。真正的大规模数据存储和检索,靠的是数组、哈希表、跳表、B+树这些。链表更多是作为这些结构的底层组件出现。理解了这层,你就不会把时间浪费在“到底哪个好”的口水仗上了。
5. 实验报告/考试常见问题与避坑指南
5.1 写实验报告时最容易忽略的几个点
写数据结构实验报告的同学,我每次批改都会发现几类共性问题,这里统一说下:
第一,初始化函数里的参数传递问题。如果你用void initSeqList(SeqList L),在函数内部修改L的data和length,是不会影响到外面的L的。因为C语言的函数参数是值传递,形参是实参的一份拷贝。正确做法是传指针:void initSeqList(SeqList *L)。很多同学写完了报错,但检查半天找不出原因,就是因为这个基础问题没搞懂。
第二,野指针和空指针。链表删除最后一个结点之后,那个结点的指针如果不置NULL,后面再次遍历时会访问一个已经free掉的内存区域,这种行为是未定义的。写代码的时候不要以为free了就万事大吉,建议被free的指针手动赋NULL。
第三,逻辑边界没考虑。比如插入位置的判断,i=0或者i=length+2,这两种越界情况都要排除。很多同学的代码在“正常情况”下能运行,但一测边界就挂,因为压根没做过边界检查。
5.2 操作中必踩的坑
我在自己写代码和带人的过程中总结了几个经典坑,几乎是必踩的:
- 插入时忘记检查位置合法。不检查i的范围,直接操作数组或者链表,会写出越界内存,轻则数据错乱,重则程序崩溃。
- 删除时没有释放内存。C语言不像Java那样有垃圾回收,你不free,内存就一直占着。考试题一般不查这个,但实验报告里老师会看。
- 链表指针修改顺序错了。这个前面强调过,先接后断。写错了你会发现链表断成了两截,后半截找不回来。
- 遍历时指针跑到NULL。比如想遍历到倒数第二个结点来删除最后一个结点,循环结束条件写错了,指针变成NULL,下一轮还想访问它的next,直接段错误。
- **参数想改头结点却传了LNode *而不是LNode****。这也是链表头插法经常遇到的问题。*其实是在说:你想在函数里改变外部head的值,必须传head的地址,即
LNode **head。这是一个非常高发的错误,我在带人时反复强调过。
5.3 考研/面试高频考点速查
如果你在准备408或者面试,这里给你一个高频考点速查表,比漫无目的地翻书效率高很多:
| 考点 | 判断要点 |
|---|---|
| 线性表的抽象类型定义 | 能说出基本操作集合及含义(InitList、ListInsert、ListDelete、LocateElem) |
| 顺序表插入/删除最坏情况 | O(n),平均移动n/2个元素 |
| 顺序表扩容 | realloc可能搬移整块数据,最坏O(n) |
| 单链表头插法特点 | 结果顺序与输入顺序相反,可用于实现链表反转 |
| 链表删除结点 | 修改前驱的next指针;删除最后一个结点需要找到倒数第二个结点 |
| 双链表插入 | 需要修改前后两个方向共4个指针,顺序同样关键 |
| 循环链表判空条件 | 头结点的next是否指向它自己(带头结点时) |
| 链表找中间结点 | 快慢指针法,快指针到末尾时慢指针指向中间结点 |
| 合并两个有序链表 | 归并思想,递归或迭代都行,注意空链表边界 |
表格里的每一项,对照你自己的理解,如果都能说出“是什么、为什么、怎么写”,线性表这章就没问题了。如果某些术语陌生,赶紧回去翻课本针对性补。
6. 实操心得:从“背代码”到“会写”的转变
6.1 画图!画图!画图!
学链表的正确姿势不是盯着代码看,而是先在纸上画图。你先画一个方框代表结点,方框分成两半,左半写数据,右半画一个箭头指向下一个结点。然后在纸上模拟插入、删除操作:把一个箭头拆掉,接上新的箭头。每一步都画出来,你会发现链表操作其实是一套非常机械的规则。一旦在纸上画通了,写代码就是翻译的过程,不会卡壳。
我记得自己大二学链表的时候也是云里雾里,后来在纸上画了一整页的箭头和方框,突然就开窍了。后来我带学弟学妹,推荐这个方法,反馈基本都是“画完就懂了,代码照着画写就对了”。真的建议所有觉得链表难的人都试一下,这个方法不花一分钱,但效果比看十段视频都强。
6.2 亲手写一遍、调试一遍,比看十遍视频有用
不少初学者把视频看了一遍又一遍,觉得“老师讲的我都听懂了”,一动手就废。原因很简单:听只是输入,写才是输出。线性表的代码真的不多,你完全可以自己从零开始把顺序表和单链表各写一遍,不要抄,写完再对比标准答案。写错的地方就是你没有理解的地方,调试器里看变量变化,比任何讲解都直观。
对于时间和精力有限的考研党,我的建议是重点掌握顺序表的插入删除、单链表的头插法建表、尾插法建表、按值查找、按位查找、插入删除这几个核心函数,能用C语言默写出来。这是很多408考生总结出来的基础纸面功夫。
6.3 一点给考研同学的考前复习建议
最后给考研的朋友加个餐。线性表这一章,在408数据结构科目里属于选择题和算法题的基础内容。选择题喜欢考复杂度分析和细微概念,大题经常把线性表和其他结构(栈、队列、树)结合来出,比如用栈实现括号匹配、树的孩子兄弟表示法等。所以复习的时候不要只盯着代码,要把“复杂度的计算过程”和“结构的转换关系”也讲给自己听。
按我个人的经验,考前一个星期把线性表的代码拿出来重新默写一遍,尤其是插入删除和链表反转,手不能生。数据结构这门课不像文科,只要熟悉就能拿分,它必须靠写。多花30分钟在代码上,比多背30分钟理论强很多。建议拿一支笔,一张纸,对着题目直接写代码,写完再对着教材核对自己哪里漏了、哪里错了。这个方法我亲测有效,带过的学生里面反馈也很好。