ARTICLE DETAIL

资讯详情

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

线性表怎么学?顺序表与链表的原理、代码与避坑指南

线性表怎么学?顺序表与链表的原理、代码与避坑指南 如果你刚开始接触数据结构不管是在校生啃教材还是准备考研408、算法笔试线性表大概率都是你绕不开的第一个正式知识点。说实话很多人第一反应是线性表不就是数组和链表吗有什么好学的结果真到动手写代码的时候一个空指针就能让整个程序崩溃一道反转链表的题目能把人绕晕三小时。作为常年和数据打交道的开发者也带过不少实习生我太清楚这个最基础也最容易被轻视的坎到底通向哪里了。这篇文章我会把线性表当成一个完整的项目来拆不照抄教材定义而是从我写代码、调bug、改作业的真实视角出发把三件事讲透线性表这个逻辑结构到底规定了什么顺序表和链表在物理实现上为什么差别巨大以及手写增删改查时那些教科书不会告诉你的坑在哪里。如果你正在学数据结构、准备考研或笔试面试或者学完就忘想回头补底子这篇内容应该能帮你省不少时间。1. 线性表到底是什么——先把逻辑结构立起来1.1 线性表的定义和基本操作我第一次听线性表这个词的时候脑子里浮现的是一张Excel表后来发现这个理解不算错但不够准确。线性表是由n个相同类型数据元素组成的有限序列这句话拆开就是三件事元素类型要一致、数量要有限、排列要有先后顺序。排队的顾客、手机播放列表里的歌曲、通讯录里的联系人都能抽象成线性表。关键在于顺序二字。线性表里的顺序不是指数值从大到小而是指位置上的前后关系。除了第一个元素每个元素都有且只有一个直接前驱除了最后一个元素每个元素都有且只有一个直接后继。这种一对一的相邻关系把线性表和后面的树一对多、图多对多区分开来也决定了它的操作方式。线性表对外提供的基本操作不多无非是初始化、判空、求长度、按位置取值、按值查找、插入、删除、清空。听起来很简单但不同物理实现下同样一组操作背后的成本是完全不同的。这些操作也是后面所有数据结构的公共接口底座栈和队列本质上就是加了约束的线性表只是把插入和删除的位置锁死了。1.2 为什么说学数据结构的人绕不开它我说线性表是地基中的地基真不是夸张。大学数据结构课程或者考研408的复习顺序通常是线性结构再到树和图而线性结构里最典型的就是线性表。地基没打牢的话后面学栈、队列、串、广义表甚至看动态规划里的线性DP都会觉得吃力。从考试角度来说线性表是高频出题区域尤其是单链表的各种操作题王道408里关于链表的大题几乎年年都有变体。从面试角度来说反转链表、合并有序链表、找链表中间节点这些问题出现的频率高到可以单独列一个专题。更重要的是线性表涉及的数组、指针、内存操作几乎是所有高级语法特性迭代器、容器、垃圾回收背后的底层原理。你写C的vector、Java的ArrayList本质上都是在用一个封装好的顺序表写LinkedList就是在用一个封装好的双向链表。把底层结构搞明白再回头用这些容器思路会清晰很多。一句话总结线性表看着简单但它把逻辑抽象和物理实现这两条主线同时拉到你面前这是数据结构最核心的思维训练。2. 顺序表与链表同一种逻辑两种完全不同的物理方案2.1 顺序表连续内存带来的访问优势与搬移代价顺序表的物理实现就是动态数组。所有元素在内存里连续存放下标i对应的元素地址就是起始地址加i乘上元素大小所以想取第k个元素直接算地址就行时间复杂度是O(1)这就是随机存取能力。数组按顺序遍历的时候有很好的缓存局部性CPU把附近内存读进缓存遍历速度相当快。但是顺序表的痛点在插入和删除。假设一个顺序表里有100个元素你要在下标50的位置插入一个新元素那从第50个元素到第99个元素总共50个元素要集体往后挪一位。删除同理后面的元素要集体往前挪。平均来看插入和删除要移动约一半的元素时间复杂度是O(n)。顺序表还有一个问题容量。一开始你给顺序表分配了一小块空间用着用着发现不够了得扩容。扩容可不是简单的在同一块内存后面接一段而是要去申请一块更大的内存把老元素全部搬过去这个操作复杂度是O(n)而且频繁扩容会带来不小的内存开销。所以实际设计动态数组时扩容策略通常不是一次加一个而是按倍数扩比如每次扩大到原来的1.5倍或2倍这样平摊下来一次插入的代价仍然可以看作O(1)。2.2 链表用指针换来操作灵活性链表的物理实现方式完全不同。每个元素是一个节点节点里除了存数据还要存一个指针指向下一个节点的地址。这样内存就散着放不需要整块连续空间每个节点独立存在谁和谁是邻居全靠指针维系。链表的优势在插入和删除。只要你知道某个节点的位置想要在它后面插入一个新节点只需要把指针改两下时间复杂度是O(1)。删除节点同理把前一个节点的指针绕过自己直指下一个节点再把这个节点释放掉。有得必有失。链表失去了随机存取能力因为内存是散的你没法用起始地址加偏移量算出第k个节点在哪只能从头节点开始一个next一个next地往后跳所以按位置查找是O(n)。更隐蔽的代价有两处一是每个节点多了一个指针的开销你别小看这8字节64位系统下一个指针占8字节数据量大的时候额外浪费很多内存二是节点在内存里通常是乱序分布的链表遍历时的缓存命中率很差在数据量大、遍历操作频繁的场景下链表速度往往比数组慢一个量级这点在LeetCode上跑大数据集的时候非常明显。链表本身还有细分单链表、双向链表、循环链表。单链表只能从前往后走双向链表每个节点有前驱和后继两个指针删除节点更方便但内存开销更大循环链表把最后一个节点的指针指向头节点适合表示环形结构比如约瑟夫环问题。2.3 怎么选一张表加四个判断标准既然两种方案各有优劣实际开发里怎么选我一般给一个朴素决策框架按以下四个问题来判断操作密集点是读还是写读多写少优先顺序表因为随机访问快、缓存友好。插入删除的位置是否集中在中间如果频繁在头部或中间插入删除链表更合适如果只操作尾部顺序表并不吃亏。数据总量是否未知且变化剧烈完全不可预测的动态增长链表省心一点但顺序表用倍增扩容也能解决。节点本身是否很大如果每个元素都是几百字节的大结构体复制成本高链表切换指针比搬元素划算。再看复杂度对比这样更直观操作顺序表单链表按位置访问O(1)O(n)按值查找O(n)O(n)尾部插入O(1) 均摊O(1) 维护尾指针中间插入O(n)O(n) 查找 O(1) 插入头部插入O(n)O(1)删除O(n)O(n) 查找 O(1) 删除内存使用连续利用率高每节点多指针散乱缓存友好性好差很多教材会笼统地说链表插入删除快这个表述其实有误导性。链表的O(1)插入是建立在你已经拿到了目标位置的前驱节点这个前提上的。如果你只知道要插在第k个位置那你还是得从头遍历找到第k-1个节点这一步就是O(n)。这个误区在笔试和面试里被反复考必须记清楚。3. 手写顺序表和单链表核心代码逐行细抠3.1 顺序表实现与往哪个方向挪的问题我见过太多同学在写顺序表插入时纠结移位方向这个细节确实是一步错步步错的经典场景。下面给出一份可供参考的C语言顺序表实现重点看插入和删除。#define INIT_CAPACITY 4 typedef struct { int *data; int length; // 当前元素个数 int capacity; // 当前容量 } SeqList; void init(SeqList *list) { list-data (int *)malloc(sizeof(int) * INIT_CAPACITY); list-length 0; list-capacity INIT_CAPACITY; } // 在下标 pos 处插入元素 value合法范围是 0 pos length int insert(SeqList *list, int pos, int value) { if (pos 0 || pos list-length) return 0; if (list-length list-capacity) { int new_cap list-capacity * 2; int *new_data (int *)realloc(list-data, sizeof(int) * new_cap); if (new_data NULL) return 0; list-data new_data; list-capacity new_cap; } // 关键从最后一个元素开始往后移动腾出 pos 位置 for (int i list-length; i pos; i--) { list-data[i] list-data[i - 1]; } list-data[pos] value; list-length; return 1; } // 删除下标 pos 处元素 int delete(SeqList *list, int pos) { if (pos 0 || pos list-length) return 0; // 关键从 pos 开始把后面的元素逐个往前覆盖 for (int i pos; i list-length - 1; i) { list-data[i] list-data[i 1]; } list-length--; return 1; }插入时为什么从尾部往前移因为如果从前往后移前一个元素先覆盖后一个位置后面还没移的元素就被破坏了。顺序表删除时反过来从删除点开始向前覆盖后一个覆盖前一个直到表尾最后把length减一逻辑上认为最后一个元素不存在了不用真的清理数据。这里有个隐性知识点值得留意顺序表插入和删除的时间复杂度虽然都是O(n)但插入是普遍移动n/2个元素删除也是普遍移动n/2个元素两者并没有谁更优。真正的优化思路有两种一是如果要批量插入先整体把区间往后挪再逐个填值能把多次O(n)合并成少数几次O(n)二是允许表内空洞或延迟整理牺牲部分有序性换取写入速度很多性能敏感的日志系统就是这么干的。3.2 单链表实现与指针操作的三个易错点单链表的核心操作我更建议写带虚拟头节点的版本。虚拟头节点dummy node是个不存实际数据的节点它的next指向真正的第一个元素。这样做的最大好处是在头部插入或删除第一个元素时不需要单独修改头指针本身代码逻辑统一了。typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *dummy; // 虚拟头节点dummy-next 是真正的表头 Node *tail; int length; } LinkedList; // 在尾部插入 void insertTail(LinkedList *list, int value) { Node *node (Node *)malloc(sizeof(Node)); node-data value; node-next NULL; list-tail-next node; list-tail node; list-length; } // 在指定节点 p 后面插入 value void insertAfter(Node *p, int value) { Node *node (Node *)malloc(sizeof(Node)); node-data value; node-next p-next; p-next node; } // 删除 p 后面的节点 void deleteAfter(Node *p) { if (p-next NULL) return; Node *tmp p-next; p-next tmp-next; free(tmp); }这段代码里藏着三个新手最容易踩的坑挨个说第一个坑插入新节点时到底是先改新节点的next还是先改前驱的next正确顺序一定是先把新节点接到老链表上node-next p-next再把前驱挂到新节点p-next node。如果反过来先把p-next指向新节点那老的后继节点就找不到了链表直接从中间断掉。第二个坑删除节点后要先用临时变量保存被删节点的指针然后再free。很多教材的示意代码看起来简单真动手时容易写成p-next p-next-next后没保存旧节点结果内存泄漏删掉的节点变成一块无法回收的垃圾。更危险的是如果在free之后还去访问被释放节点的data或next这是典型的悬垂指针程序什么时候崩溃全看运气。第三个坑尾节点删除后tail指针要记得更新。上面的删除函数删的是p后面的节点但如果p是倒数第二个节点删除后tail还指向那个已经被free掉的节点下一次往尾部插入时你往一块已经释放的内存上写next必崩。3.3 三个扩展操作检验你到底有没有真正理解链表基础增删改查写完我建议再用三个经典操作自测一下。反转单链表。这是面试题最常考的基础。核心思路是遍历一遍把每个节点的next从指后改成指前。因为一改next原来的下一个节点就找不到了所以必须先用临时变量保存原后继。三指针法如下Node* reverse(Node *head) { Node *prev NULL; Node *cur head; while (cur ! NULL) { Node *next cur-next; // 先保存原后继 cur-next prev; // 反转 prev cur; // 前驱后移 cur next; // 当前节点后移 } return prev; // 此时 prev 是新的头节点 }如果你能独立写出来并且在一分钟内解释清楚为什么需要三个指针那链表基础基本过关。找中间节点。用快慢指针快指针每次走两步慢指针每次走一步快指针到底时慢指针正好在中间。这个技巧不只在链表题里有很多环形结构的问题都靠快慢指针解决。需要注意链表长度是偶数时慢指针会停在中间偏右的位置到底符不符合需求要看题目约定。合并两个有序链表。用递归写很优雅但面试官更喜欢看非递归版本。核心思路是维护一个虚拟头节点和一个尾指针每次比较两个链表头部节点把小的接在尾部。边界情况是两个链表长度不一致一个已经空了另一个还没空这时直接把剩余的整体接上就行。Node* merge(Node *l1, Node *l2) { Node dummy; Node *tail dummy; dummy.next NULL; while (l1 ! NULL l2 ! NULL) { if (l1-data l2-data) { tail-next l1; l1 l1-next; } else { tail-next l2; l2 l2-next; } tail tail-next; } if (l1 ! NULL) tail-next l1; if (l2 ! NULL) tail-next l2; return dummy.next; }这三道题做完线性表的手写能力算是基本达标了。不要觉得它们是背模板每次写的时候脑子里都要过一遍指针指向关系写烦了自然就内化了。4. 充分踩坑之后我总结的排查清单4.1 边界条件空表、表尾、下标转换程序崩溃的高发区永远是边界。线性表的边界问题集中在三点空表、表尾、下标起止。空表的情况最容易忽略。对空表调用删除、按位置查找都会直接越界对空表反转链表按理应该返回NULL但很多初写的代码会直接段错误。写实现前先问自己三个问题空表时这个函数应当返回什么只有一个元素时还能不能正常运行函数内有没有对size为0的显式处理表尾的处理是另一个经典问题。向尾部插入时循环条件到底是i length还是i length删除最后一个元素后length应该减到多少这类问题统一答案就是回到定义上去检查区间合法性插入位置pos的范围是0到length闭区间删除位置pos的范围是0到length-1闭区间。我给学生检查时最常用的方法是让他们把9个测试例列出来覆盖空表、一个元素、满容量、插入开头、插入中间、插入末尾、删除开头、删除末尾、删除唯一元素跑过一遍后边界问题基本就清理干净了。4.2 指针事故野指针、悬垂指针、内存泄漏C/C写链表某种意义上就是在和指针事故作斗争。三种事故我个人都踩过分得清清楚楚。野指针是定义后没有初始化就拿来用。写成Node *p; p-next NULL;p不知道指向哪里赋值直接崩溃。解决办法很粗暴声明指针时立刻初始化一律置空。悬垂指针是内存已经释放但指针还留着。free(p)之后再访问p-data能跑出来什么全看运气。解决这个问题的好习惯是free之后马上把指针置空这样后续无意中的使用会以空指针的形式暴露出来比访问已释放内存好排查得多。内存泄漏是删除了节点但忘了free或者每次插入都new了空间但整个过程从未释放。链表写了删除和销毁函数却不调用测试跑十遍内存就飞涨。有经验的开发者会在写完结构后立刻补一个destroy(LinkedList *list)函数把所有节点都free掉再在测试代码里调用它。用Valgrind或者ASan跑一遍确认没有泄漏再算真正完成。4.3 扩容与动态数组realloc的两个隐藏陷阱顺序表的动态扩容我用的是realloc但这个函数有两个坑特别隐蔽。第一个坑是**realloc失败**。它失败时返回NULL但原来的内存块并没有被释放。如果直接写list-data realloc(list-data, new_size)失败后原来的指针就丢了既拿不到新空间也丢了老数据。正确写法是像上面代码那样先用临时变量接收返回值判断非空后再赋给list-data。第二个坑是扩容后是否要初始化新空间。realloc只保证能访问到的区间变大了新增的数组元素内容是未定义的。对顺序表来说这通常无所谓因为有效范围由length控制新元素直接在插入时赋值。但如果你的顺序表在某些操作里会读取未赋值区域就可能读到垃圾数据。所以复杂一点的设计会在扩容后把新增区间全部置零。还有一个经常被问到的细节为什么扩容按倍数而不是按固定大小因为固定大小扩容比如每次加10个在最坏情况下会造成频繁搬家插入n个元素的总代价会到O(n²)。按2倍扩容则像银行存钱每次扩容的搬移成本被后续多次插入平摊掉均摊下来插入还是O(1)。这个平摊分析的思路后面分析哈希表rehash时还会用到建议现在就吃透。4.4 面试和考研里经常出现的链表题目这个清单不算长但几乎囊括了我见过的80%的链表面试题。题目核心思路易错点反转链表三指针迭代/递归忘记保存原后继检测链表是否有环快慢指针空表、单节点找环的入口快慢指针相遇后慢指针从头再走数学推导理解不透找倒数第k个节点快指针先走k步再两步同速k大于链表长度合并两个有序链表虚拟头节点尾指针剩余链的处理删除链表中的节点值虚拟头节点让逻辑统一删除头节点时忘更头指针相交链表找交点双指针走完全程差额不相交时死循环我特别想说一下环检测这道题。快慢指针相遇后怎么找入口经典做法是让一个指针从头节点出发另一个从相遇点出发每次都走一步它们最终会在入口处相遇。表面看像是背结论但这个结论背后的数学是从头到入口的距离等于相遇点到入口的距离加上若干个环长。理解了这个推导遇到变体题问你相遇时慢指针走了多少步才能从容应对。考研408的链表大题风格不太一样它通常不要求你写完整可编译代码而是考察思路比如设计算法删除链表中的最小值、原地逆置单链表、把链表按奇偶位置重排。这类题的解法万变不离其宗要么是顺序扫描维护最值指针要么是多指针配合单次遍历要么是反转加合并的组合操作。多练几道经典题之后你会发现逻辑套路就那么几个。5. 线性表并不是终点而是理解数据结构的地基5.1 从线性表到栈、队列再到真实系统线性表搞明白以后后面很多内容都是线性表 约束的变体。栈就是只允许在一端插入删除的线性表所以实现栈可以直接复用顺序表或链表的代码只暴露push/pop接口。队列是只允许一端插入、另一端删除的线性表链表实现时还会用到我们提过的尾指针。很多地方把栈和队列单独讲成两种数据结构其实它们的底层就是线性表只是操作的边界条件不同。往更远看线性表在真实系统里无处不在。操作系统里的IO缓冲区本质上是个队列函数调用的调用栈本质上是个栈浏览器的后退按钮也是栈的行为。文件系统的目录结构虽然是树形但绝大多数存储设备上的数据最终都转化为顺序字节流也就是线性排列。数据库里的索引结构虽然用B树但底层页内数据大都是按顺序排列的。这些看起来高级的系统底层里到处都是线性表的身影。从学习路径来说我的建议是先把C语言版线性表手写一遍再学C的STL容器或Java集合框架里的对应类对比它们的设计取舍。比如C的vector越界检查、扩容策略Java的ArrayList和LinkedList的区别看一眼源码再回头想我们上面的复杂度分析理解会更深。5.2 给学习者的几条实操建议写代码一定要动手这是老生常谈但线性表这个知识点特别适合纸上写一遍 机上跑一遍的双重验证。因为算法题常常要求在纸上或者白板上写完整代码不给你编译运行的机会这个时候对边界的把握就显得格外重要。第二我推荐把漏掉头节点判空当作一种技能训练每次写完函数先自己问一遍头指针为NULL时能不能跑。这个动作养成了后面写树、写图时会省很多事。第三建议做一个小的复习表把顺序表和链表的所有操作复杂度列出来隔几天默写一遍。这看起来是笨办法但数据结构这种课的根基就是这些复杂度结论地基熟了后面学哈希表、排序算法都会更快。我自己的经验是每带一批新人都会让他们用三天时间把顺序表和单链表各实现至少三遍。第一遍照着书写第二遍合上书写第三遍不看任何参考资料。三遍之后指针和下标的问题基本就长进肌肉记忆里了。这个速度看起来慢但后续学栈、队列、树的时候会把扯平的进度加倍补回来。
返回列表