☰
数据结构入门:顺序表与链表的原理、实现与C语言实操
2026/10/10 3:20:22 网站建设 项目流程

学习数据结构,顺序表和链表是几乎所有人绕不开的第一道坎。这两个概念听起来简单,但真正上手写代码时,很多人都栽过跟头。我之前帮一位初学者调试链表程序,他对着屏幕盯了一晚上,逻辑怎么看都对,程序一跑就段错误,最后发现问题出在遍历时不小心把头指针给改了。这次经历让我印象很深,也让我觉得顺序表和链表绝对不能当“背概念”来处理,它们是需要动手验证、静下心来推演的工程问题。

这篇文章就围绕顺序表和链表展开,覆盖两类线性表的定义、结构设计、核心操作和常见错误,穿插完整的C语言实现代码,最后用一个“合并两个有序单链表”的实操案例带大家走一遍完整流程。适合正在上数据结构课、准备期末复习或者考研的同学,也适合自学编程、想补基础的爱好者。读完你会发现,很多所谓“不会写代码”的卡点,本质上只是对指针和内存布局的理解不到位。

1. 为什么顺序表和链表总是成对出现

1.1 它们解决的是同一类问题:线性表

数据结构里有个基础概念叫线性表,通俗讲就是“一串元素排成一队”,每个元素有前驱和后继。排队这个场景到处都有:食堂打饭的队伍、播放器里的歌单、手机通讯录的联系人列表,本质都是线性结构。线性表要解决的核心问题就是:怎么把这串元素存下来,并且支持查找、插入、删除这些操作。

顺序表和链表就是存储线性表的两种典型方案。一个用连续的内存空间挨个存放,一个用分散的内存空间靠指针串联。很多教材把两者放在一起讲,是因为它们天然构成了一组对比:连续 vs 分散,随机访问 vs 顺序访问,搬移代价 vs 查找代价。你只有同时理解这两者,才能真正理解“时间换空间”“空间换时间”这些权衡到底在说什么。

我见过不少同学只背结论,比如“顺序表适合查、链表适合增删”,但问他为什么,他就说不清了。其实原因就藏在内存布局里,这也是本文想带着你从底层逻辑走一遍的原因。

1.2 内存布局的差异决定了操作方式的差异

顺序表对应的是数组那套存储方式,元素在内存里紧挨着放,就像一栋楼里连续编号的房间。你要找第5个房间,不需要从第1间走到第4间,直接根据编号算出位置就能到。这就是为什么顺序表支持O(1)的随机访问,访问第103个元素和访问第2个元素耗时基本一样。

链表则是另一套玩法。每个节点相当于一个独立的集装箱,集装箱里既装数据,也装一个写着“下一个箱子在哪”的地址纸条。要找第103个节点,必须从第1个节点出发,按纸条指引逐个找过去,前面102个都得经过,所以随机访问的时间复杂度是O(n)。这是链表最吃亏的地方,也是很多算法题喜欢围绕它出文章的原因。

但是链表也有绝活。在中间插入或者删除一个节点,链表只需要改几个指针,成本O(1)。顺序表就不一样了,插入或删除往往要把后面所有元素往前挪或往后挪,一次操作可能搬移n个元素。一个省时间,一个费时间,选哪个取决于你的业务场景更侧重哪类操作。

2. 顺序表:看似简单,处处都是细节

2.1 动态数组的结构定义与扩容策略

顺序表最常用的实现方式就是动态数组。C语言里用一个结构体记录三样东西:底层数组的起始地址、当前已存元素个数、当前数组容量。

#define INIT_CAPACITY 4 typedef struct { int *data; int size; // 当前元素个数 int capacity; // 当前容量 } SeqList;

初始化的时候,先分配一块初始容量大小的内存,把size设为0,capacity设为初始值。接下来每一次插入都要做个判断:如果size等于capacity,说明装满了,需要扩容。扩容的策略通常是“翻倍扩容”,也就是新的capacity = 旧的capacity × 2,一般是2倍,有些实现会用1.5倍。

为什么要翻倍而不是每次加1?因为扩容意味着要重新申请一块更大的内存,再把旧数据搬过去,这是一次O(n)操作。如果每次插入都扩容一次,那么连续插入n个元素的总成本是1+2+3+…+n,也就是O(n²),数据量一大就会卡到怀疑人生。翻倍扩容则不同,虽然某一次扩容很贵,但均摊到每次插入上,成本变成O(1)。这个“均摊分析”的思想后面会多次用到。

我建议自己实现动态数组时,把初始容量设得小一点,比如4或者8,这样扩容路径能被完整触发,方便测试边界。但要注意,真实项目里如果提前知道数据规模,直接按需分配一个够大的容量,避免频繁扩容,性能会更好。

2.2 插入与删除的搬移逻辑

顺序表的插入分三步走:检查下标是否合法,判断是否需要扩容,然后从尾部开始往前搬移元素,腾出目标位置,最后把新元素放进去。

以下代码是向指定位置插入一个元素:

int insertAt(SeqList *list, int index, int value) { if (list == NULL) return -1; if (index < 0 || index > list->size) return -1; // index可以等于size,表示尾部插入 if (list->size == list->capacity) { if (!resize(list)) return -1; } for (int i = list->size; i > index; i--) { list->data[i] = list->data[i - 1]; } list->data[index] = value; list->size++; return 0; }

注意循环里的方向。这里必须从后往前搬移,如果从前往后搬,后面的元素会被覆盖掉,数据就乱了。删除操作正好反过来,需要从删除位置开始,把后面的元素一个个往前覆盖,最后size减一。

这里有个常见的边界陷阱:向顺序表尾部插入元素时,index等于size,循环一次都不会执行,直接写入data[size],逻辑依然成立。删除最后一个元素时,index为size-1,循环体不会执行,只是size减一。理解这些边界,比背代码重要得多。

2.3 顺序表实现中的坑:越界、扩容与缩容

先说越界。动态数组虽然能扩容,但不代表你可以随便访问下标。访问data[size]或更靠后的位置,程序不会立刻报错,但很可能修改到内存里相邻变量的值,造成极其隐蔽的bug。我调试过一个排序程序,数组多写了一个位置,结果把另一个变量的值改了,排序结果死活不对。这种问题用调试器看也未必能一眼看出,所以写代码时一定要严格约束下标。

再说扩容。扩容不是简单把data换个大数组就行,旧数据必须拷贝过去,然后释放旧空间。如果忘记释放旧内存,程序跑着跑着内存占用越来越大,就是所谓的内存泄漏。释放时机也要注意,顺序很重要:先拷贝,后释放,否则数据就没了。

缩容的情况相对少,但值得一提。如果大量删除元素后,数组容量一直保持巨大,内存会被白白占着。常见的做法是:当size降到容量的四分之一时,把容量缩到一半。这种“延迟缩容”策略能避免频繁扩容缩容带来的性能抖动。实际项目里很多容器类实现都这么做,典型的就是动态数组类。

3. 链表:指针操作是核心,穿针引线要谨慎

3.1 单链表的基础结构与头指针

单链表是最基础的链表形态,每个节点只有两个成员:数据和指向下一个节点的指针。

typedef struct Node { int data; struct Node *next; } Node;

链表本身通常只保存一个头指针head,它指向链表里的第一个节点。你可能会疑惑:为什么链表要有头结点,甚至很多教材里称它为“哨兵节点”?原因很简单:空链表和非空链表的操作逻辑可以统一。

不带哨兵节点的链表,当链表为空时head是NULL,此时向头部插入节点,要特殊处理“head = newNode”这种分支;删除头节点时也要特殊处理。代码里到处是if判断,很容易出错。引入一个哨兵节点后,空链表时哨兵节点的next是NULL,插入删除操作就不再需要划分“空表还是非空表”两种情况,所有节点都有“前驱”,代码统一又安全。

实际操作时,我强烈建议在练习阶段就用带哨兵节点的写法。表面上多占用了一个节点空间,但换来的代码简洁性和正确率绝对划算。

3.2 头插、尾插与按值删除

头插法是指新节点插到链表最前面,也就是让新节点的next指向原来的首节点,然后更新head指向新节点。如果带了哨兵节点,链表的“首节点”实际是哨兵节点的next。

void insertHead(Node *dummy, int value) { Node *node = (Node *)malloc(sizeof(Node)); node->data = value; node->next = dummy->next; dummy->next = node; }

很多初学者会写成先更新dummy->next,再让node->next = dummy->next,结果就变成node指向自己了,链表断掉。正确顺序一定是先把“旧的一环”接好,再“解锁”新的链接,顺序不能反。

尾插法需要先遍历到链表末尾,找到next为NULL的节点,再让它的next指向新节点。如果链表很长,尾插要O(n),所以很多实现会额外维护一个tail指针指向末尾节点,插入时直接用tail,效率变成O(1)。这也是一个很经典的“空间换时间”思路。

按值删除稍微复杂一点,需要找到目标节点的前驱,然后让前驱的next跳过目标节点,指向目标节点的下一个节点,最后释放目标节点。很多人容易漏掉“前驱指针要随遍历一起更新”这一点,或者找到了目标节点却拿不到它的前驱。有一种技巧是使用“双指针遍历”,一个指针cur负责找目标,另一个指针prev始终跟在后面记录前驱。记住这张小套路,删除逻辑会稳很多。

3.3 链表的变体:双链表与循环链表

单链表一个明显的缺陷是只能从前往后走,想找某个节点的前驱,只能重新遍历。双链表在节点里同时保存prev和next两个指针,既往前能走,往后也能走。代价是每个节点多了一个指针的存储空间,并且插入删除时要多处理一个方向的指针,稍不留神就会漏掉某个指针的更新。

比如在双链表中插入一个节点,需要四步:新节点先接好前后两个方向,然后修改前驱节点的next、修改后继节点的prev。我在给别人讲的时候喜欢用“系鞋带”来类比:必须先把左右两边都搭好,再收紧,不然整个结构就会松散甚至断开。

循环链表则是把链表的尾部重新接回头部,形成闭环。单链表尾节点的next不再是NULL,而是指向head。这种结构适合表示循环队列、循环播放列表这种需要“绕圈”的场景。需要注意的坑是:遍历循环链表的退出条件不能再用“next是否为NULL”来判断,而要用“是否回到起点”来判断,否则会死循环。很多笔试和机试都爱考这个细节,识别出“循环链表”后,遍历条件必须跟着改。

3.4 链表的真实场景选择

我接触过不少开发者,一听到链表就觉得“太底层了,用不到”,其实很多基础组件里都有链表的身影:哈希表解决冲突用的分离链接法、内存分配器的空闲块管理、操作系统的任务队列,底层都可能是链表或者链表的变体。理解链表,并不仅仅是应付考试,更是为了看懂这些系统设计。

选顺序表还是选链表,本质上是在回答三个问题:你需要随机访问吗?你的插入删除发生在头部或中间吗?你对内存连续性敏感吗?如果查询多、增删少,顺序表几乎是标准答案;如果增删多、查询少,链表更合适。没有哪种结构绝对优越,只有是否匹配场景。

4. 实操:用完整代码实现“合并两个有序单链表”

4.1 题目分析与思路拆解

前面把基础知识过了一遍,现在来看一个非常经典、也特别适合练手的案例:合并两个有序升序单链表,要求合并后依然有序。这个题几乎是链表操作的综合考验,够新手的程度,但也足够让你把哨兵节点、指针移动、边界条件这些知识点全部用上。

分析一下,两个链表本身有序,所以可以用类似“归并”的思路:同时遍历两个链表,每次从当前节点中挑比较小的那一个,接到结果链表的末尾。该思路最需要注意的边界是:其中一个链表可能先走完,剩下的那部分直接全部接到result尾部即可,不需要再逐个比较。

这个题考察的核心主要有三块:哨兵节点的使用、尾插法思想、对空链表的处理。一旦你理解这三块,代码写出来会非常短。

4.2 完整代码实现与关键讲解

我用C语言实现,为了方便查看,节点定义和合并函数都写在下面:

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; Node* createNode(int value) { Node *node = (Node *)malloc(sizeof(Node)); node->data = value; node->next = NULL; return node; } Node* mergeLists(Node *head1, Node *head2) { Node dummy; // 栈上哑节点,不需要malloc dummy.next = NULL; Node *tail = &dummy; // tail始终指向结果链表的最后一个节点 while (head1 != NULL && head2 != NULL) { if (head1->data <= head2->data) { tail->next = head1; head1 = head1->next; } else { tail->next = head2; head2 = head2->next; } tail = tail->next; } // 哪条链表剩余,直接拼接 if (head1 != NULL) tail->next = head1; if (head2 != NULL) tail->next = head2; return dummy.next; }

这里用了一个很实用的技巧:在栈上定义一个局部哑节点dummy,不需要手动分配和释放内存,最后返回dummy.next就是合并后的链表头。你可能会问,为什么不直接把tail初始化为NULL?因为那样的话,第一轮循环里让tail->next指向某个节点时,暗示“tail”的next要能被访问,而NULL是没法解引用的。如果你不用哑节点,就得在循环里单独判断“tail是否为空”来决定要不要给tail->next赋值,代码分支会多一套。

还有一点值得注意:合并过程中,我直接复用了原链表节点,新链表并没有复制节点,只是调整了指针指向。这样做空间复杂度是O(1),很高效。如果你希望新链表独立于原链表,才需要在新节点上拷贝数据。

4.3 测试用例与边界验证

写完代码必须验证边界。我通常会把测试分为几组:两个链表都为空、一个为空另一个非空、两个链表长度不同、两个链表元素完全相同、存在重复值的情况。

void printList(Node *head) { while (head != NULL) { printf("%d -> ", head->data); head = head->next; } printf("NULL\n"); } int main() { // 构建第一个链表: 1 -> 3 -> 5 Node *head1 = createNode(1); head1->next = createNode(3); head1->next->next = createNode(5); // 构建第二个链表: 2 -> 4 -> 6 Node *head2 = createNode(2); head2->next = createNode(4); head2->next->next = createNode(6); Node *merged = mergeLists(head1, head2); printList(merged); // 预期输出: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> NULL return 0; }

测试时我特别建议你打印每个节点值的同时打印节点地址。为什么要打印地址?因为这样能直观看到节点指针是否被正确连接,比如输出1@0x1000,2@0x2000这种,一眼就能看出先后顺序有没有对。如果节点顺序接错了,地址序列会非常直观地暴露问题。

在测试一个链表为空的情况时,合并函数必须能直接返回另一个链表的头指针,而且不能对空指针做解引用。上面代码里循环条件写了head1 != NULL && head2 != NULL,同时处理了这种情况,逻辑上是安全的。你还应该测一下两个链表都为空的情况,此时dummy.next是NULL,返回NULL是正确行为,打印就输出NULL。

5. 常见错误与调试技巧实录

5.1 指针操作最经典的三个错误

链表调试起来比顺序表痛苦得多,因为问题往往不会立刻显现,而是在某个遥远的遍历或打印中突然爆发。结合我自己的经验,最高频的错误是这三个。

第一个是“断链”。插入节点时先更新了前驱节点的next,导致旧链表丢失了一部分节点。典型场景是头插法写成“先改新节点,再把旧链表接上”的顺序颠倒,最终某个节点谁都不指向它。这时候内存里那个节点还存在着,但你从链表中找不到它,数据就算丢了。

第二个是“成环”。比如尾插时让新节点指向了自己,遍历会在某个节点原地转圈,程序看起来像卡死,其实是无限循环。还有循环链表的遍历退出条件写错,也会导致类似问题。

第三个是“空指针解引用”。常见于未判断链表为空就直接访问head->data,或者遍历到NULL后循环还没停,接着访问NULL->next。C语言里这种操作会直接触发段错误,程序崩溃,这是目前人脸识别度最高的错误。

我用的一招很土但很有效:起步阶段在每个操作前先画一下指针图,把“谁指向谁”“先改哪个指针”标清楚,再落到代码上。别看这个习惯简单,它救了我无数次。

5.2 用调试器和打印快速定位链表问题

面对链表问题,最直接的调试工具是gdb这类调试器。但链表问题用打印排查常常更快,因为打印能一次性呈现整个链表的状态。

我习惯写一个printList函数,循环遍历所有节点,打印每个节点的值和地址。如果不放心,可以把前驱的地址也打印出来,甚至可以打印“这个节点的next指向谁”。这样一旦出现断链,打印输出里一定会出现地址“跳变”的情况,定位就很快。

如果你想用gdb,有个技巧值得记:不要只在main里设断点,直接在可能出错的函数里设断点,然后配合print list->data这样的命令查看节点数据。单步执行时,重启关注next的值,如果某一步next突然指向了一个奇怪的地址,说明指针被错误赋值了。单步调试链表确实繁琐,但能把指针一步步的变化看清楚。

5.3 性能陷阱与内存管理提醒

链表有两大性能隐患必须注意。第一是内存碎片。每个节点由malloc单独分配,尤其节点很小时,内存分配器会产生不少碎片,导致实际内存占用比数据本身大得多。测试数据小时没感觉,数据量大时你会发现链表占用内存是顺序表的好几倍。第二是缓存不友好。链表节点在内存里分散存储,遍历时会频繁跳转地址,CPU缓存的命中率低,访问性能远不如顺序表。这也是为什么很多高性能场景宁可选择动态数组,也不愿用链表。

内存管理方面,C语言用链表时最容易发生的就是节点释放遗漏,也就是内存泄漏。要想避免,可以给链表设计一个destroyList函数,从head开始逐个节点free,释放前先把next保存下来,否则释放当前节点后你就拿不到下一个节点了。很多人会在这里写错:free(cur)之后再cur = cur->next,这已经是访问已释放内存。正确写法是:

void destroyList(Node *head) { while (head != NULL) { Node *next = head->next; free(head); head = next; } }

这个细节虽然小,但配置率的作家很怀疑。

6. 期末与考研视角:这些知识点怎么变成题目

6.1 高频题型与常见考法

数据结构期末考试和考研里,顺序表和链表是必考内容,而且考法相对固定。最常见的就是让你手动模拟插入或删除过程,给你一串节点,画出删除后链表的连接关系。这种题不需要写代码,但要求你把指针变化的过程一笔一笔画清楚。很多同学觉得简单,实际就是在这里丢分,因为画图时漏画了某个节点的next。我的建议是,平时练习就养成画图的习惯,画完再对照代码验证,养成习惯以后模拟就不容易出错。

另一类高频题是算法设计题,比如单链表反转、合并有序链表、查找倒数第k个节点、判断链表是否有环。这些题的解题套路相对有限,但边界处理很考验基本功。以单链表反转为例,很多人写完后尝试运行就出错,原因往往是忘记把原有头指针指向NULL,导致链表成环。答案就藏在指针更新的顺序里。

6.2 时间复杂度与空间复杂度的判断方法

考试除了让你写代码,还会让你分析时间复杂度。顺序表插入平均要搬移约n/2个元素,所以时间复杂度是O(n);链表插入只需要修改指针,时间复杂度是O(1)。这里的“O(1)”必须限定在已经找到目标位置的前提下,否则查找位置本身要O(n)。很多教材在时间复杂度表里直接写链表插入O(1),省略了前提条件,导致初学者理解偏差。考试时如果题目问你“已知指向某节点的指针,在其后插入新节点的时间复杂度”,那么答案就是O(1)。这恰恰是链表容易出分的地方。

分析时间复杂度时还有一个常用的均摊思想,前面扩容策略已提到过。在分析动态数组连续插入n个元素的总复杂度时,如果每次扩容都按常数倍扩容,总成本O(n),均摊到每次插入也是O(1)。期末复习的时候把这个例子弄懂,很多关于动态数组和哈希表演化的问题也都能顺下来,因为它们都受益于同一个均摊分析逻辑。

6.3 刷题时的练习顺序建议

如果是为了期末或者考研机试,我建议按难度阶梯来做练习。第一步先把顺序表和链表的增删改查全部用C语言自己实现一遍,不参考任何现成代码,写完再对比教材或开源实现,找出自己代码里的问题。第二步做经典算法题,重点练单链表反转、合并有序链表、删除倒数第k个节点、判断是否有环这几个类型。第三步尝试把链表和双指针技巧结合起来,比如找链表中点。

语言方面,很多同学纠结该用C语言还是Python。我的看法是,练原理和数据结构时,C语言是最好的工具,因为它能逼你理解指针和内存;但刷算法题为了快捷,Python也非常合适,它的链表结构虽然不显式暴露指针,但核心逻辑和C语言完全一致。两种都试一遍,理解层面的互补性会很不错。

实习之后的一点私房话

顺序表和链表是许多人数据结构的第一课,也是让很多人第一次感受到“代码写不出来”这种挫败感的地方。我自己当年也是给链表调bug调到怀疑人生,菜鸟和熟练工之间的距离并没有想象中那么大,差别只在于:熟练工拿到一道链表题,会先动笔画图,再动手写;新手往往连图都没画清,就直接开始敲代码。

最后想分享一个笨而有效的习惯:每次写完链表相关的代码,都把自己当成计算机,用纸笔把指针一个个指过去,每改动一个指针就重新画一次。坚持一段时间,你会发现自己对“指针操作”的恐惧会慢慢消失,转而变成一种肌肉记忆。如果你也在学习这块,或者正在为一道链表题头疼,不妨按文章里的操作自己动手做一遍,遇到具体问题再来对照着排查,会顺手很多。

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

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

立即咨询