顺序表(顺序存储)和链表(链式存储)是C语言数据结构里最先出现的两个结构,也是很多人从“会写C语法”到“能写程序”的分水岭。数组用得很溜不代表顺序表能写得利索,结构体加指针背得熟也不代表链表能一遍跑通。我自己带过不少初学者,也见过不少候选人在白板上写链表操作时卡壳,回头看基本都是对“存储方式差异 + 指针操作细节”这两层理解不够透。
这篇博文不打算只贴代码,而是把顺序表和链表的整体设计思路、关键操作的时间代价、代码实现细节,以及我自己踩过和帮别人排查过的一堆坑逐一拆开讲。无论你是正在学数据结构的学生,还是准备面试想快速复习基础,都能从里面拿到可以动手复现的代码结构、选型判断方法和避坑清单。
1. 先弄清楚一个问题:顺序表和链表各自解决了什么
1.1 存储方式决定了算法风格
很多人把顺序表和链表割裂开学,一个当作“数组”,一个当成“指针练习”。但实际上它们本质上是同一个抽象接口的两种物理实现。顺序表在内存里申请一块连续的地址空间,靠“基地址 + 下标偏移”访问元素;链表在内存里把节点散落在各处,靠指针字段把相邻逻辑关系的节点串起来。
这里有一个很直观的类比:顺序表像是电影院里的连排座位,观众挨个坐好,你按座位号就能直接找到第几排第几座;链表则像一队人玩“报数接龙”,每个人记住下一个人是谁,从头开始一个个问过去才能数到队尾。这个差异直接决定了两种结构在“按位置访问”和“插入删除”上的表现完全不同。
现实需求里,你既会遇到“频繁按下标读数据”的场景,也会遇到“不断在头部插入新数据”的场景。没有哪一种结构能同时把这两种操作做到最优,所以必须先理解各自的存储代价,才能谈到选型。
1.2 先定接口,再选实现
我见过不少同学的坏习惯:一上来就写结构体、写函数,写到一半发现功能对不上,又回头改接口。数据结构正确的学习姿势是先定义好“这个抽象类型对外提供哪些操作”,再来考虑用顺序还是链式来实现。
以线性表为例,最基础的操作无非这几样:初始化、判空、取长度、按位置取元素、查找元素、在指定位置插入、删除指定位置元素、清空、销毁。很多新手会忽略“销毁”这一步,导致后面排查内存问题时无从下手。
接口定完之后,你会发现顺序表和链表都能实现这套接口,只不过每个操作的代码难度和时间复杂度不一样。这就是“抽象”的意义:使用方只需要调Insert、Delete,不需要关心底层到底用的是数组还是指针。
2. 顺序表实现:动态数组的完整拆解
2.1 结构体定义是第一个决定成败的地方
顺序表最本质的要求是“一段连续的存储空间”,所以在C语言里实现它的核心就是一个数组指针加两个长度信息。我推荐的写法长这样:
typedef struct { int* data; // 指向堆上动态分配的数组 int size; // 当前已存元素个数 int capacity; // 当前容量,即最多能存多少个 } SeqList;这里有三个关键决定:
第一,data用int*,而不是直接定一个定长数组。虽然用定长数组int data[100]写起来更省事,但它把容量写死了,一旦数据量超过100就原地爆炸,而且无法在运行时根据需要扩大。动态分配在堆上,配合后面的扩容函数,才能让顺序表真正“用多少长多少”。
第二,size和capacity必须分开。size是真实元素个数,capacity是当前分配好的空间大小。这两个概念一旦混淆,插入时判满、遍历时控制循环次数都会出问题。我在帮人debug时最常见的一种错,就是分配空间后直接拿capacity当实际长度用,导致后面的输出多出一堆垃圾值。
第三,初始化要成对写。有init,就要有destroy;有malloc,就要有free。很多同学考试写完就扔,但实际工程里内存泄漏是会被骂的。下面的初始化函数把data先置NULL、size和capacity都归零,是一个好习惯:
void init(SeqList* list) { list->data = NULL; list->size = 0; list->capacity = 0; }2.2 插入和删除:元素搬移才是真正的代价
顺序表插入一个元素时,为了把新元素放进指定位置,从该位置到末尾的所有元素都得往后挪一格。删除则相反,后面的元素要往前补。这个“搬移”操作就是顺序表的核心成本,平均情况下要移动n/2个元素,时间复杂度是O(n)。
插入的完整逻辑分成三步:先检查位置合法性,再检查容量是否够用,最后搬移元素并放入新值。我写的最简版本如下:
int insert(SeqList* list, int pos, int val) { if (pos < 0 || pos > list->size) return -1; // 位置非法 if (list->size >= list->capacity) { if (resize(list) != 0) return -1; // 扩容失败 } for (int i = list->size; i > pos; i--) { list->data[i] = list->data[i - 1]; // 从尾部开始往后挪 } list->data[pos] = val; list->size++; return 0; }注意循环是从size开始往前倒着搬,而不是从pos开始正着搬。正着搬会先把还没挪走的值覆盖掉,这是新手最常见的低级错误之一。另外一个容易忽略的细节:插入的位置允许等于size,也就是在末尾追加,这时候循环一次都不会执行,等于一个O(1)的尾插。
删除的代码逻辑更简单,但判断条件容易写错。合法的删除位置是0到size-1,删完之后从pos+1开始往前移动。删除同样要记得把最后一个位置的旧值清掉,虽然不影响后续逻辑,但如果不小心多打印一位,就会看到残留数据。
2.3 扩容策略:为什么大家都喜欢2倍
顺序表插入前发现容量不够时,就要重新申请一块更大的空间,把旧数据搬过去。这个操作叫扩容,最常见的方式是用realloc配合临时指针做一次“搬家”。
int resize(SeqList* list) { int newCap = list->capacity == 0 ? 4 : list->capacity * 2; int* tmp = (int*)realloc(list->data, newCap * sizeof(int)); if (tmp == NULL) return -1; list->data = tmp; list->capacity = newCap; return 0; }这里有个重要细节:realloc的返回值不要直接赋给list->data,一定要用临时指针先接一下。realloc失败时会返回NULL,如果直接覆盖原指针,原来的内存就找不到了,既丢数据又泄漏内存。先接到tmp里,判断成功后再更新指针,这是业内标准的防御写法。
至于为什么扩容倍数选2,而不是“每次加一点”或者“翻10倍”,背后有摊还分析。如果每次只增加固定大小,例如每次多4个,那么连续插入n个元素要扩容n/4次,每次都要搬运已有元素,总代价是O(n^2)。而按倍数扩容,虽然单次扩容搬得很多,但扩容频率指数下降,摊还下来每次插入只有O(1)的平均代价。翻倍是简单且性能不错的策略;某些标准库实现会用1.5倍,主要目的是为了更充分地利用malloc分配的对齐内存块,减少多次扩容后的内存碎片。对于学习阶段的顺序表,2倍完全够用。
3. 链表实现:指针操作的完整拆解
3.1 带头结点是好文明
链表的核心是节点结构,每个节点存一个数据域和一个指向下一个节点的指针域:
typedef struct Node { int data; struct Node* next; } Node; typedef struct { Node* head; // 指向头结点(哨兵) int size; } LinkedList;这里我用了“带头结点”的写法。头结点也叫哨兵节点,它不是真正的第一个数据节点,data可以闲置,next指向第一个数据节点。为什么要多这个节点?因为有了它,在空表和非空表上执行插入、删除操作时,代码逻辑就统一了,不需要为“删的是第一个节点”单独写一个分支。
对比一下就很明显:不带头结点的链表,删除第一个节点时需要把head指向第二个节点,和删除中间节点时“改前一个节点的next”是两套逻辑;而带头结点后,所有删除操作都统一成“找到前一个节点,改它的next”。这个设计让代码的边界情况大大减少,强烈建议考试和工程上都用带头结点。
初始化时,给头结点分配一个节点,让head->next为NULL,size为0。注意头结点本身不是数据,算size、遍历链表时都要从head->next开始。
3.2 头插法还是尾插法:取决于业务需求
链表的插入方式里,最容易让初学者迷惑的就是头插法、尾插法和按位置插入到底怎么选。
头插法是把新节点放到头结点后面,也就是位置0。它的天然优势是快,因为不需要遍历,直接两步指针操作就能完成,时间复杂度O(1)。缺点也很明显:插入顺序和存储顺序相反。如果你依次插入1、2、3,用头插法最后得到的链表是3->2->1。很多题目里需要把链表反转,本质上就是用一个新链表不断头插。
尾插法要顺序一致,但如果链表没有维护尾指针,每次尾插都要从头遍历到尾部,复杂度O(n)。比较常见的优化方式是在链表结构体里再加一个tail指针,尾部插入就变成了O(1)。
按位置插入则先用循环找到目标位置的前一个节点,再做插入。查找的过程是O(n),实际插入动作本身还是一样的两步指针操作。很多人误以为“链表插入是O(1)”,严格来说,说的是“在已知前驱节点的情况下插入是O(1)”,而“按值/位置查找后插入”整体是O(n)。面试时把这个区别讲清楚,是很加分的细节。
核心的插入动作代码如下:
Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = val; newNode->next = prev->next; // 先连后面 prev->next = newNode; // 再接前面顺序不能反,必须先改newNode的next指向后继,再改prev的next指向新节点。两步顺序搞反的话,原来的后继就丢了。
3.3 删除节点:指针断了再free,顺序错必出事
删除操作的总体思路是找到待删节点的前驱,然后把前驱的next改成待删节点的后继,最后释放待删节点。
int deleteNode(LinkedList* list, int pos) { if (pos < 0 || pos >= list->size) return -1; Node* prev = list->head; for (int i = 0; i < pos; i++) prev = prev->next; Node* del = prev->next; prev->next = del->next; free(del); list->size--; return 0; }这个代码里最值得注意的就是先改指针连接、再free节点这个顺序。如果先free(del),你再去访问del->next就是典型的野指针操作,程序行为完全不可预期。另外,free仅仅释放内存,不会自动把del里的指针字段置空,所以free之后别再碰这个指针。
还有一个很多人忽略的点:在循环删除链表时,不要用待删除节点自己作为遍历变量。正确做法是先临时保存next,再free当前节点,然后让遍历指针等于保存好的next。我自己在答疑时见过一个经典错误——在while循环里free之后还去取p->next,结果直接段错误。这属于链表问题里的高发区,下面专门开一节讲排查方法。
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(n) |
| 额外空间 | 可能有空闲容量浪费 | 每个节点多一个指针域 |
看完这张表,最反直觉的一点其实是:除了表头插入删除差距明显,中间插入删除两者都是O(n)。顺序表是“查找O(1)但搬移O(n)”,链表是“查找O(n)但改指针O(1)”,拼一起都是O(n)。所以面试时不要张口就说“链表插入比数组快”,要分位置、分前提。
4.2 为什么顺序表在实际跑起来常常更快
从复杂度上看,链表在插入删除上似乎全面占优,但真跑代码你会发现,在很多场景下顺序表反而更快。这里有个常被忽略的物理原因:缓存局部性。
顺序表的数据在内存里连续存放,CPU加载数据时按缓存行(通常64字节)成块搬入。遍历顺序表时,第一次访问某个元素,附近的后续元素大概率已经被一起加载进缓存了,后续访问几乎是免费。链表节点散落在堆的不同位置,访问每个节点都可能触发一次缓存缺失,要从主存搬数据过来,延迟远高于缓存命中。
有个话说得挺形象:顺序表像是连续翻开一叠扑克牌,手顺着就翻完了;链表像是按线索在房间里东找一张西找一张。实际维护成本不可低估。
所以,在做“大量按下标访问”的算法题时(比如二分、快排的分区、堆排序),顺序表几乎总是比链表性能更好。链表的优势场景更多是“大量在头部/尾部插入删除”加上“不需要随机访问”,比如队列、栈、LRU淘汰链等。
4.3 选型要结合真实业务,不要背口诀
我给你一套可以直接用的判断流程:先问自己三个问题——(1)操作内容主要是读还是写?(2)插入删除集中在头部、尾部还是任意位置?(3)数据量级有多大,是否需要预知上限?
如果读多写少、按下标访问频繁,选顺序表。如果频繁在头部插入、且整体规模不大,选链表。如果既有频繁尾部追加又有频繁头部读取,选顺序表实现环形队列会更简单高效。如果确实需要在任意位置频繁插入删除且数据量很大,链表更合适,但一般这样的场景更应该考虑用其他高级结构,比如平衡树。
我个人的实际经验是:90%的业务场景里,顺序表足够好用。链表更多是作为“理解指针的基础结构”来练习,同时是各类算法题的常客。不要迷信任何一种结构,也不要背“链表万能”这种结论,每次根据场景算一笔复杂度账再做决定。
5. 常见问题与排查技巧实录
5.1 内存管理:野指针和泄漏是链表两座大山
链表代码里最典型的内存问题有两个。
第一个是访问已释放的节点。删除节点后,如果后续代码还用del->next或者打印del->data,轻则得到垃圾数据,重则直接段错误。因为free只是把内存还给堆管理器,里面的内容不会被清空,可能还是旧值,也可能已经被别的malloc重新分配走了。
第二个是忘记释放节点导致内存泄漏。常见于循环创建节点后没有配套的销毁函数,或者删除链表时只移动了head指针而没有逐个free节点。一个合格的destroy逻辑应该是:遍历所有节点,先保存next,再free当前节点,最后free头结点本身。
我这里给个诚实建议:写链表练习的时候,养成写完配上destroy的习惯。等以后做工程,你可以用内存检测工具来辅助验证,但原理还是“有malloc就要有free”。调试的时候逐步打印、逐步验证,比空想要快得多。
5.2 段错误问题:三板斧定位法
链表和指针代码的报错几乎都是段错误,新手一看到就慌。我的排查套路固定三斧头。
第一斧,看backtrace。用调试器打断点或者运行崩溃后执行bt命令,看栈帧里最后一次调用到哪一行。90%的情况下崩在哪一行一目了然。崩在空指针访问、野指针访问或者free非法地址,都能从调用栈里找到线索。
第二斧,加打印看关键节点。在插入、删除的关键位置打印prev、del、head等指针的地址值,用%p格式输出十六进制地址,逐步对照逻辑。比如打印prev的前后地址,能立刻发现prev是否走到了空节点、指针连接是否接反。
第三斧,缩小范围做最小复现。把代码里的循环、递归一层层注释掉,只留最简路径,看问题是否还能复现。如果问题消失,说明它大概率就藏在注释掉的那部分逻辑里。这个方法几乎适用于所有疑难杂症。
5.3 几个必练的经典操作
针对链表,我建议你在掌握基本增删改查后,把下面三个经典操作自己手写一遍,写通了才算真正入门。
反转链表。迭代版思路是维护prev、cur、next三个指针,每次先把cur的next保存下来,再将cur指向prev,然后三个指针整体后移。注意初始条件(prev为NULL、cur为head->next)和终止条件(cur走到NULL)的理解是重点。
查找倒数第K个节点。快慢指针法:让快指针先走K步,然后两个指针同步前进,快指针到尾部时慢指针正好在倒数第K个节点。编码时先考虑K大于链表长度的边界。
合并两个有序链表。递归版本代码极短,但能把递归理解透;迭代版本用哨兵节点统一插入逻辑。两个版本都建议练一练。用哨兵节点时,注意最后返回的是sentinel->next,而不是sentinel本身。
这三个操作覆盖了链表里最常用的两类技巧:指针步进与哨兵设计。能独立写出来,说明你对链表的理解就不是背代码,而是真正建立了指针操作的思维模型。
我在实际带人的过程中,见过的最大的学习误区就是把数据结构当成“背代码题”,而不是当成“设计空间与时间的权衡”。顺序表和链表的学习,真正应该带走的东西是两套思维方式:连续存储带来的高效随机访问,链接存储带来的灵活动态插入。它们不是竞争对手,而是互补的两个工具箱。
最后再分享一个小技巧:学完这两种结构以后,不要急着往下学,先把它们各自实现一遍,再用同一套测试数据去跑,打印每一步的状态。如果你能在不查资料的情况下,把带头结点的链表插入、删除、销毁都写对,面试和期末考这部分基本上就稳了。