ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

线性表详解:顺序表与链表的存储结构、操作复杂度及实战应用

线性表详解:顺序表与链表的存储结构、操作复杂度及实战应用 1. 从生活场景理解线性表排队、书架与磁带我先说个最简单的类比。你去看电影排队队伍里每个人都是首尾相连的你要找排在第15位的人从头数过去就是了这就是线性表最朴素的样子一串数据一个接一个除了第一个和最后一个每个元素都有且仅有一个直接前驱和一个直接后继。这个唯一前驱、唯一后继的约束很关键它是区分线性结构和非线性结构的试金石。像树里面的节点可以有多个孩子图里面的顶点可以连接任意多个顶点那都不是线性表。而线性表就是一条线串起来的。在实际开发里线性表的影子到处都是音乐播放器的播放列表、编辑器的撤销历史、操作系统的任务队列、内存中连续存放的数组……哪怕是再看不上基础的人也绕不开这东西。因为考研、期末考、面试八股线性表都是数据结构的第一道关也是后面栈、队列、串、数组、广义表的地基。实现线性表有两种存储结构顺序存储和链式存储。前者对应顺序表物理上连续像书架上一排紧挨着的书后者对应链表逻辑上连续、物理上未必连续像一条珍珠项链珠子之间靠线连着。这两种结构没有绝对的好坏只有合适不合适。顺序表随机访问快、空间利用率高但插入删除要搬运元素链表插入删除灵活、内存按需分配但访问某个位置的节点得从头走。理解它们各自的取舍是这一章的核心目标。下面我从结构定义、基本操作、复杂度分析、实战代码到面试题逐一拆开讲。2. 顺序表连续内存下的高效与代价2.1 顺序表的逻辑结构数组不是顺序表很多人把顺序表和数组画等号严格说不对。数组只是顺序表底层的承载工具。顺序表应该是一个数组 长度记录的封装提供插入、删除、查找、修改这些操作接口。为什么必须记录长度因为数组一旦定义长度就固定了而逻辑上顺序表里有多少个元素是动态变化的。你只开一个数组没有size字段别人根本不知道哪些格子是有数据的哪些是空着的。所以在C语言里定义顺序表标准姿势是这样的#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int length; // 当前表长不是数组容量 } SeqList;有的教材会把data写成指针配合动态内存分配那是为了后面扩容用。静态数组版逻辑简单适合理解原理和应付实验报告动态版更贴近工程实践。顺序表最本质的特征就一条逻辑上相邻的元素物理存储位置也相邻。这就意味着你知道首地址就可以用下标直接算出任何元素的地址——LOC(a_i) LOC(a_0) i * sizeof(ElemType)。这个公式就是随机存取的来源任何位置的访问都是O(1)时间。代价是一旦要插入或删除为了维持物理相邻这个特性你不得不搬运一堆元素。2.2 顺序表插入从尾部开始挪插入操作的核心逻辑是先把插入位置及其后面的所有元素整体后移一位腾出空位再放新元素。这里有几个容易翻车的细节插入位置i的取值范围是1 i length 1可以插在队尾length 1处但不能越界。后移必须从最后一个元素开始依次向后挪。你要是从头开始挪前面的元素会把后面的覆盖掉数据就乱了。移动方式是data[j] data[j - 1]j从length一路减到i。移动完成后length别忘了维护表长。int SeqListInsert(SeqList *L, int pos, int e) { if (pos 1 || pos L-length 1) { return 0; // 位置非法 } if (L-length MAX_SIZE) { return 0; // 表满 } for (int j L-length; j pos; j--) { L-data[j] L-data[j - 1]; } L-data[pos - 1] e; L-length; return 1; }插入的时间复杂度得看位置。平均意义下插入到任何一个合法位置的概率相等移动元素的平均次数是n/2所以是O(n)。这个O(n)不是访问慢是搬运慢你要是追求访问快顺序表是王者你要是频繁往中间塞数据顺序表就是灾难现场。2.3 顺序表删除反向思维省一半移动删除的逻辑和插入对称从删除位置的下一个元素开始整体前移一位覆盖掉被删元素。int SeqListDelete(SeqList *L, int pos) { if (pos 1 || pos L-length) { return 0; } for (int j pos; j L-length; j) { L-data[j - 1] L-data[j]; } L-length--; return 1; }删除的细节和插入不同挪动方向是从前往后data[j - 1] data[j]j从pos到length-1。被删元素其实还残留在数组里但我们根本不鸟它因为length已经减一了越界那格是无效数据下次写入直接覆盖。这里顺便说一个面试官爱问的点顺序表删除的平均移动次数也是(n - 1) / 2同样是O(n)。插入和删除的O(n)来源是一样的——维护物理相邻。2.4 查找与修改顺序表唯一的绝对优势区按值查找int SeqListFind(SeqList *L, int e) { for (int i 0; i L-length; i) { if (L-data[i] e) { return i 1; // 返回位序从1开始 } } return 0; // 没找到 }按值查找的平均比较次数是(n 1) / 2O(n)但按下标随机访问比如L.data[7]那是O(1)。很多初学者混淆查找和访问访问是给我第k个元素查找是找值为x的元素在哪。前者顺序表封神后者顺序表和链表都是O(n)。顺带一提如果顺序表是有序的查找可以用二分直接降到O(log n)这是链表永远给不了你的。2.5 动态扩容倍增策略背后的数学静态数组版的顺序表写起来简单但容量写死有硬伤。工程里一般用动态版本typedef struct { int *data; int length; int capacity; } DynList; int InitDynList(DynList *L, int cap) { L-data (int *)malloc(cap * sizeof(int)); if (!L-data) return 0; L-length 0; L-capacity cap; return 1; } int ExpandDynList(DynList *L) { int newCap L-capacity * 2; int *newData (int *)realloc(L-data, newCap * sizeof(int)); if (!newData) return 0; L-data newData; L-capacity newCap; return 1; }扩容为什么用倍数而不是每次加固定大小这里有个摊还分析的思想如果每次插入都扩容一个固定大小比如每次加10个空间那第1次扩容拷贝10个元素第2次拷贝20个第3次拷贝30个累计拷贝量是10 20 30 ...多次插入后的平均代价是O(n)。而采用倍增策略扩容的拷贝代价是1 2 4 8 ... n等比数列求和结果是2n - 1平摊到每一次插入上妥妥的O(1)。同样的扩容逻辑你以后理解Java的ArrayList、C的vector就完全无障碍了。3. 链表拆散重组的自由与指针的舞蹈3.1 为什么需要链表连续空间的三个痛点顺序表在生产环境中会撞上三个墙物理连续的限制你要开一个10万的大数组内存不一定有连续10万个单元但10万个零散单元肯定找得齐。链表吃的就是零散内存。插入删除的搬运成本在你维护一个有序列表的中间频繁插入数据顺序表每次都是O(n)搬运链表只需要改指针。容量静态化顺序表扩容需要整体搬家realloc而链表天然动态要几个节点就造几个节点。链表的基本单位是节点包含数据域和指针域。C语言里这样定义typedef struct Node { int data; struct Node *next; } LNode;这里next必须写成struct Node *不能偷懒写成Node *因为Node这个别名是在结构体定义完之后才生效的在结构体内部你只能struct Node *这是C语言里出了名的坑。3.2 单链表操作的逻辑链条与代码实现单链表操作就一句话要找到前驱节点才能改它的next指针。这是单链表所有操作的灵魂。你想删除第i个节点你真正要动的是第i-1个节点的next而不是第i个节点自己。先看头插法和尾插法创建链表// 头插法新节点永远插在头节点之后最终顺序是输入的反序 void HeadInsert(LNode *head, int e) { LNode *newNode (LNode *)malloc(sizeof(LNode)); newNode-data e; newNode-next head-next; head-next newNode; } // 尾插法维护一个尾指针保持输入顺序 void TailInsert(LNode *head, int e) { LNode *newNode (LNode *)malloc(sizeof(LNode)); newNode-data e; newNode-next NULL; LNode *p head; while (p-next ! NULL) { p p-next; } p-next newNode; }我特别强调一下头节点这个概念。这里的head不是第一个数据节点它是一个哨兵data字段闲置只用来给链表一个统一的入口。好处是插入和删除第一个数据节点和操作其他节点逻辑完全一致不用写特判。这是C语言链表里最优雅的一个设计。你要是不带头节点在头部插入删除每次都要考虑我改的是head指针本身还是我改的是某个节点的next代码瞬间多出一堆if。按位置插入和删除我用代码说话// 在第pos个位置插入节点pos从1开始 int ListInsert(LNode *head, int pos, int e) { LNode *p head; int j 0; while (p ! NULL j pos - 1) { p p-next; j; } if (p NULL) return 0; // 位置非法 LNode *newNode (LNode *)malloc(sizeof(LNode)); newNode-data e; newNode-next p-next; p-next newNode; return 1; } // 删除第pos个节点并把值带回 int ListDelete(LNode *head, int pos, int *e) { LNode *p head; int j 0; while (p-next ! NULL j pos - 1) { p p-next; j; } if (p-next NULL) return 0; LNode *temp p-next; *e temp-data; p-next temp-next; free(temp); return 1; }删除时务必先temp p-next保存待删节点再p-next temp-next最后free(temp)。三个步骤一个不能错先备份、再重链、最后释放。很多新手写着写着把中间那步省了直接p-next p-next-next然后free(p-next)结果释放的就是重链后的后继节点原节点成了内存泄漏孤儿——这种bug排查起来极其痛苦。单链表按位置查找第i个节点时间复杂度和顺序表完全不同LNode *GetNode(LNode *head, int i) { LNode *p head-next; int j 1; while (p ! NULL j i) { p p-next; j; } return p; }单链表没有随机访问找第i个节点只能从头一个个走过去O(n)。这就是顺序存取和随机存取的本质区别。3.3 双向链表与循环链表各有所长的进阶形态单链表的痛点很明显我只能往后走不能往回走。删除一个节点时必须从头找它的前驱。如果业务上频繁需要逆向遍历单链表就难受了。双向链表节点多了一个prior指针典型定义typedef struct DNode { int data; struct DNode *prior; struct DNode *next; } DNode, *DLinkList;双向链表在插入和删除时有一步需要特别小心——修改前驱的next和修改后继的prior是两条线顺序错了就会断链。以插入为例newNode-prior p; newNode-next p-next; if (p-next ! NULL) { p-next-prior newNode; } p-next newNode;如果忘了判断p-next ! NULL在链表尾部插入时就可能对NULL解引用直接崩。这种边界条件笔试面试最爱考。循环链表则是把尾节点的next指回头节点让整个链表形成一个环。单循环链表可以让你从任意节点出发遍历完整条链表解决给一个尾部节点如何快速访问头部的问题。循环双链表是两者的合体用空表时头节点的prior和next都指向自己判空条件改成head-next head。实际应用里嵌入式系统经常用循环链表管理定时器任务从左到右扫一遍注册的回调函数操作系统的进程调度也用循环队列思想双向链表则是LRU缓存淘汰算法的底层结构——每次访问一个页面就把它移到链表头部淘汰时直接删尾节点。3.4 链表的内存细节malloc与free的对称性链表每个节点都是malloc出来的释放链表时必须逐个free。很多初学C语言的同学写链表创建时开开心心malloc销毁时忘了free写一个跑一个内存泄漏积累到程序崩溃都不知道怎么回事。销毁函数的标准写法void DestroyList(LNode *head) { LNode *p head; while (p ! NULL) { LNode *temp p; p p-next; free(temp); } }注意这里同样是先保存下一个节点再释放当前节点。你要是一上来就free(p)那p-next就没法访问了后面的节点全成孤儿。malloc和free必须成对出现这是C语言链表练习里最重要的内存纪律也是从能跑到懂工程的分水岭。4. 顺序表与链表的巅峰对决复杂度、缓存与真实场景4.1 复杂度对比与选用原则操作顺序表单链表随机访问第i个O(1)直接算地址O(n)从头走头部插入O(n)全部后移O(1)改一个指针尾部插入O(1)有length直接写O(n)得遍历找到尾节点中间插入O(n)平均移动n/2O(n)找前驱O(n)捅针O(1)删除第i个O(n)前移O(n)找前驱O(n)拆指针O(1)按值查找O(n)有序时O(log n)二分O(n)只能线性扫空间开销一次性分配可能浪费按需分配但每个节点多一个指针缓存友好性极高元素连续局部性好很差节点零散跳跃式访问一个很有意思的点单链表在已知前驱指针的前提下插入和删除是O(1)。比如你遍历到某个节点想在它后面插一个只需要改两个指针。顺序表做不到这一点因为就算你知道位置也得搬运后面所有元素。这也是为什么LRU链表、内核链表都用链表而不是数组——它们的操作模式就是找到了就马上插/删。4.2 缓存与内存分配为什么工程上数组仍然打不过复杂度分析是理论但现代CPU的缓存行为会让你重新思考。顺序表连续存储遍历时CPU按顺序预取数据到缓存行几乎都是cache hit链表节点零散分布每次p p-next可能都是cache miss要重新从内存拖数据。假设缓存miss一次要100个时钟周期那么链表遍历的常数因子可能比顺序表大一两个数量级。O(n)相同常数不同这就是工程里数组常常更快的原因。内存分配上顺序表一次性malloc一大块分配次数少且完整释放容易链表每个节点都malloc一次分配器要频繁管理小内存块容易产生碎片释放还要一个个来。如果需要频繁遍历、数据规模不大、插入删除集中在尾部顺序表往往是更好的选择。链表真正的用武之地是节点数动态变化大、需要频繁在头部或已知位置插入删除、数据规模大到必须用零散内存。4.3 为什么说两者是互补不是替代很多初学者喜欢问顺序表和链表哪个更好这是个假问题。真实项目里往往是混用的哈希表的拉链法一个数组加链表图的邻接表数组存储顶点链表存储边操作系统的进程表和调度队列也有数组和链表各司其职。读多写少、需要随机访问选顺序表写多读少、节点动态增减选链表。能把这两条准则在具体场景里灵活运用比背任何复杂的算法都值钱。5. 实战代码演示综合运用顺序表与链表完成集合差集这一节我们来点硬货。标题底下那串热词里有个基于链表的两个集合的差集这种题非常典型既练遍历又练指针操作还涉及动态内存管理。差集定义A - B 在A中但不在B中的元素。例如A {1, 2, 3, 4}B {3, 4, 5}则A - B {1, 2}。我用链表实现思路很直白遍历A的每个节点去B中查找是否相同如果找不到就加入结果链表。复杂度是O(nm)n和m分别是大小的数量级nm级的嵌套遍历对面试分析来说已经足够了。更高效的做法是先排序再用双指针归并以后讲排序和双指针时会再展开。#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } LNode; void InitList(LNode **head) { *head (LNode *)malloc(sizeof(LNode)); (*head)-next NULL; } void InsertTail(LNode *head, int e) { LNode *newNode (LNode *)malloc(sizeof(LNode)); newNode-data e; newNode-next NULL; LNode *p head; while (p-next ! NULL) p p-next; p-next newNode; } int Find(LNode *head, int e) { LNode *p head-next; while (p ! NULL) { if (p-data e) return 1; p p-next; } return 0; } LNode *SetDifference(LNode *A, LNode *B) { LNode *result; InitList(result); LNode *p A-next; while (p ! NULL) { if (!Find(B, p-data)) { InsertTail(result, p-data); } p p-next; } return result; } void PrintList(LNode *head) { LNode *p head-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } int main() { LNode *A, *B; InitList(A); InitList(B); int aArr[] {1, 2, 3, 4}; int bArr[] {3, 4, 5}; for (int i 0; i 4; i) InsertTail(A, aArr[i]); for (int i 0; i 3; i) InsertTail(B, bArr[i]); LNode *C SetDifference(A, B); PrintList(A); PrintList(B); PrintList(C); return 0; }代码跑出来的结果是1 2符合期望。这里我要说的实操经验有三个尾插法的效率问题我这个InsertTail每次从头部遍历到尾节点插入m个节点就是O(m²)。做题无所谓但如果你在真实项目里频繁尾部插入建议维护一个尾指针或者直接头插法最后再反转链表。InitList(head)的二级指针为什么传二级指针因为你想在函数里修改调用方的head指针本尊。如果你传一级指针函数里只改了形参外面一直是NULL。这是C语言里最容易出错的地方每个链表练习都会踩一遍。结果链表的去重集合本来就不允许重复元素所以我没做去重。但如果你处理的是差集结果中不能有重复的数组则需要在InsertTail前加一次对result的查找。6. 面试高频题链表逆序、环检测与合并每次面试数据结构链表部分逃不掉这么几道题我把核心思路和代码骨架摆在这里它们本质上是同一个能力熟练操作指针。单链表逆序最经典的迭代解法定义三个指针pre、cur、next依次把cur的next指向前驱然后整体后移LNode *ReverseList(LNode *head) { LNode *pre NULL; LNode *cur head; while (cur ! NULL) { LNode *next cur-next; cur-next pre; pre cur; cur next; } return pre; }这个next cur-next和前面删除时先保存再操作的思路一脉相承指针操作第一原则动next之前先把next的下一个存下来。环检测用快慢指针龟兔赛跑fast每次走两步slow每次走一步如果有环它们终将相遇。临界坑是while (fast ! NULL fast-next ! NULL)不判断fast-next是空则下一步对NULL解引用直接崩溃。合并两个有序链表可以用一个哑节点作为头谁小谁往后接代码逻辑极其整齐LNode *MergeTwoLists(LNode *A, LNode *B) { LNode *dummy (LNode *)malloc(sizeof(LNode)); LNode *tail dummy; LNode *p A, *q B; while (p ! NULL q ! NULL) { if (p-data q-data) { tail-next p; p p-next; } else { tail-next q; q q-next; } tail tail-next; } tail-next (p ! NULL) ? p : q; return dummy-next; }哑节点的作用和头节点一样——省掉头部的特殊处理。合并算法里你不需要判断谁是第一个这种问题统一接到dummy后面就行最后返回dummy-next。还有一个找倒数第K个节点同样是双指针先让快指针走K步然后快慢同步走快指针到NULL时慢指针恰好指向倒数第K个。这和快慢指针判环是同一套思想通过步差制造位移。诚实地说这些题你只背代码是没用的。真正要去理解的是三件事什么时候需要用临时指针保存next、什么时候需要考虑NULL边界、什么时候哑节点能帮你省掉分支。把这三个问题想透了链表的题再变花样你也不慌。7. 常见错误排查与笔试避坑手册我把我见过的、自己踩过的错误做成了一张速查表。你写完链表代码跑出奇怪的bug先对照这张表查一遍大概率能省半小时。症状原因解决程序一跑就Segmentation fault对NULL解引用或访问野指针每个解引用前检查指针是否非空malloc后检查返回值插入后链表丢失、数据错乱链表断链新节点的next设置顺序错了先接新节点的next再改前驱的next删除后链表丢了一半释放节点前没有保存next先temp p-next; p-next temp-next; free(temp)链表销毁后再次使用use-after-free释放后把指针置NULL链表尾插效率极低每次都从头遍历维护尾指针或改用带头节点的循环链表内存泄漏malloc和free不成对每个malloc配一个free路径销毁链表逐个free位置参数从0还是从1开始混淆位序和数组下标概念没分开统一约定函数头部注释写明位序从1开始、下标从0开始无限循环循环链表里忘记终止条件检查循环条件判空条件用head-next head笔试手写代码时我建议你养成几个好习惯先处理边界条件空表、只有1个元素、操作位置在首尾再写主体逻辑优先使用带头节点的链表让空表和非空表逻辑统一不确定指针顺序时先在草稿纸上画出节点和箭头的状态再写代码。这三点看上去简单但真的能帮你避免考场上大面积涂改的惨剧。8. 学习路径与实操建议如果你是在准备期末考试或者考研我的建议是按这个顺序推进先拿C语言实现顺序表的插入、删除、查找、扩容四件套跑通并printf每一步的数组状态然后再实现单链表的创建头插尾插、插入、删除、销毁同样打印每一步接着练习双向链表和循环链表的插入删除最后才是环形链表检测、链表反转、有序合并这些经典题。写一个简单测试函数来验证每个操作的输出比如建一个空表依次插入1、2、3、4然后删除2打印当前链表你会直接观察next指针的指向变化比背书高效十倍。还有些工具层面的建议。gdb的break和print能让你在段错误现场看指针值用valgrind检查内存泄漏它会精确定位到底是哪一行malloc没配free。调试链表问题时画图比看日志直观得多纸上画一遍往往秒懂。如果你用的是Java或Python思路完全一样只是换成引用/对象免了手动管理内存但这不代表你可以不关心指针语义——Python里node.next node.next.next照样会断开链表Java里对象引用赋来赋去照样会出现逻辑断链。最后分享一个个人心得。我当年学链表死活搞不懂为什么头插法的顺序是反着的直到自己在草稿纸上画了十几个方框和箭头一步步模拟指针移动才恍然大悟。数据结构这东西读十遍不如写一遍写十遍不如画一遍。你现在觉得难是无感的等你在调试器里盯着一个NULL指针看了半小时突然发现有一步没保存next那一刻你会真正理解链表。这门课没有捷径但走完这一遍后面栈、队列、树、图都会顺很多。
返回列表