
1. 线性结构数据世界里的排队规则我在几年前带新人时发现一个很有意思的现象大多数刚接触数据结构的初学者都能很快背出线性结构的定义——数据元素之间是一对一的线性关系。但当你追问一句这种关系到底意味着什么为什么排队这种形式是最基础的数据组织方式的时候很多人反而卡住了。先绕开教材用生活中最直观的场景来理解。你去银行办事取号排队窗口一次只服务一个人所有人都必须按照先来后到的顺序依次往前挪。这样的排队有一个非常明确的特性除了队伍最前面和最后面的两个人其他每个人都有且仅有一个前面的人和一个后面的人。这就是线性结构最本质的特征——一对一关系。无论这个队伍有多长你都能从这个特性出发从队头一路数到队尾一个不多一个不少。对应到数据结构里线性结构指的就是这样一类逻辑结构数据元素之间按某种顺序排列每个元素除首尾外都有唯一的直接前驱和唯一的直接后继。常见的有四种线性表、栈、队列和字符串。其中线性表是基础中的基础栈和队列本质上可以看作操作受限的线性表而字符串可以看作元素类型为字符的线性表。所以搞懂线性表后面学栈、队列、串都会轻松很多。还有一个容易忽略的点线性结构强调的是逻辑关系而不是物理存储。你可以用连续的一排内存空间来存储它们这叫顺序存储也可以用指针把分散的内存节点一个个串起来这叫链式存储。逻辑上相邻物理上未必相邻。很多同学在这里栽跟头就是把这层关系搞混了——看到一个链表觉得指针指来指去不是线性吧但其实它的逻辑关系依然是线性的只是物理存储方式是离散的。2. 线性表从定义到抽象数据类型的完整画像2.1 线性表的严格定义线性表Linear List是由 nn ≥ 0个数据元素组成的有限序列。当 n 0 时称为空表当 n 0 时除了第一个元素和最后一个元素其余每个元素都有唯一的直接前驱和直接后继。这里的数据元素是一个很宽泛的概念可以是一个整数、一个浮点数也可以是一个结构体、一个对象。比如一个班级的花名册每个学生的完整信息学号、姓名、成绩构成一个数据元素整张花名册就是一个线性表。定义中有两个关键词值得玩味有序性和有限性。有序不是说元素值的大小有顺序而是说元素之间存在位置顺序。a1 在 a2 前面a2 在 a3 前面这是一种逻辑上的先后关系。同样是三个学生张三、李四、王五换一种排列顺序王五、李四、张三在逻辑上是两个不同的线性表尽管元素集合完全一样。这个点在实际开发中很关键——很多业务场景下顺序本身就是数据的一部分比如播放列表、时间线顺序错了业务就错了。有限则强调了线性表的数据元素个数是有限的。你不可能有一个无限长的线性表这在物理世界和计算机世界里都不现实。2.2 核心术语前驱、后继与位置感线性表里有几个必须刻进DNA的术语表头元素第一个元素 a1没有直接前驱。表尾元素最后一个元素 an没有直接后继。前驱/后继对于第 i 个元素 aia(i-1) 是它的直接前驱a(i1) 是它的直接后继。这里需要特别注意线性表的下标是从 1 开始编号的a1 到 an而 C/C/Java 等编程语言的数组下标是从 0 开始的a[0] 到 a[n-1]。这个差异让无数初学者在写遍历循环时一个边界条件搞错直接数组越界。后面我讲算法实现时会专门回到这个1 和 0 的转换问题。2.3 线性表的抽象数据类型从抽象数据类型ADT的角度看线性表应该提供一组对外的操作接口用户不需要关心内部是怎么存储的。经典的操作集合包括InitList(L) // 初始化构造一个空的线性表 DestroyList(L) // 销毁线性表释放空间 ListEmpty(L) // 判断线性表是否为空 ListLength(L) // 返回线性表的元素个数 GetElem(L, i, e) // 获取第 i 个位置的元素值保存在 e 中 LocateElem(L, e) // 查找第一个值与 e 相同的元素位置 ListInsert(L, i, e) // 在线性表第 i 个位置插入元素 e ListDelete(L, i, e) // 删除第 i 个位置的元素用 e 返回被删元素这套接口的重要性在于它定义了一种契约。无论底层用顺序表还是链表实现上层业务调用这些接口的方式是一致的。这也是为什么在真正工程中很多抽象类或接口比如 Java 里的List接口设计思路和这个 ADT 一脉相承——实现类可以换接口定在那里调用方不用改动。如果暂时不理解 ADT 这个概念你可以把它想象成餐厅的菜单顾客调用方只需要对着菜单点菜调用接口不用关心后厨实现层是用煤气灶还是电磁炉做出来的。菜单不变换后厨设备不影响顾客点菜。3. 顺序存储一栋连续房号的公寓3.1 存储原理与地址计算顺序存储是线性表最直观的存法用一段地址连续的内存单元依次存放线性表的各个数据元素。你可以把它想象成一栋门牌号连续的公寓——101 室、102 室、103 室……第 i 位住户就住在固定的房间里。假设每个数据元素占用 L 个字节起始地址也就是第一个元素的存储位置是 LOC(a1)那么第 i 个元素的存储位置可以通过一个非常简单的公式计算出来LOC(ai) LOC(a1) (i - 1) × L这个公式是随机存取能力的基础。只要知道了首地址和元素大小想读第 100 个元素直接套公式算一下地址就能取到数据不需要像链表那样从头一个个找过去。这个过程的时间复杂度是 O(1)也就是常量时间。这是顺序存储最大的先天优势。3.2 插入操作的搬砖过程但是有得必有失。顺序存储的插入、删除操作代价就有点大了。设想你要在公寓 103 和 104 之间新住进一个住户。公寓的房间是固定的你要么只能把 104 到顶楼的住户全部往上挪一层。也就是说在顺序表中第 i 个位置插入一个新元素时需要把第 i 到第 n 个元素全部向后移动一个位置。删除操作正好反过来把第 i 个元素拿走之后后面所有的元素都要向前挪一位把空出来的位置填上。这两个操作的时间复杂度都是 O(n)。更具体地说如果插入位置在表尾i n1不需要移动任何元素是最好的情况 O(1)如果插入在表头i 1需要移动全部 n 个元素是最坏的情况 O(n)。平均而言每个位置被选中的概率均等插入一个元素平均要移动 n/2 个元素。所以在数据量大的场景下频繁在中间位置插入删除用顺序存储是要付出明显代价的。3.3 动态扩容顺序表在实际工程里的真实形态教材上讲顺序表通常假定分配一块固定大小的数组空间——这就是静态顺序表表满了就不能再插入了。但在真实开发中几乎不会这样用。实际用得最多的是动态顺序表一开始分配一个初始容量比如 4 或 8当插入元素导致容量不足时申请一块更大的新内存通常是原来容量的 2 倍然后把旧数据拷贝过去释放旧空间。这段扩容逻辑用 C 语言写出来大概是这样的#define INIT_SIZE 8 #define GROWTH_FACTOR 2 typedef struct { int *data; int length; // 当前元素个数 int capacity; // 当前容量 } SeqList; void ensure_capacity(SeqList *list) { if (list-length list-capacity) { return; } int new_capacity list-capacity * GROWTH_FACTOR; int *new_data (int *)malloc(sizeof(int) * new_capacity); if (!new_data) { // 内存分配失败一般在这里抛出异常或返回错误码 return; } for (int i 0; i list-length; i) { new_data[i] list-data[i]; } free(list-data); list-data new_data; list-capacity new_capacity; }注意这里的扩容因子选择 2 而不是固定增加几个位置背后是有讲究的。每次扩容的代价是 O(n)但总平均下来每个元素被搬运的次数级数是收敛的所以均摊时间复杂度还是 O(1)。如果每次只扩一个位置插入 n 个元素就要搬家 n 次总代价直接变成 O(n²)那就没法用了。这是均摊分析思想最开始冒头的地方后面学动态数组的底层实现比如 Java 的 ArrayList、C 的 vector时还会再遇到。3.4 顺序表适合什么场景顺序表真正闪光的地方在于读多写少的场景。比如一个城市的户籍名单大部分操作是查询第 i 个人的信息偶尔才会新增或删除又比如排行榜快照、配置列表一旦初始化基本就不变了随机访问的 O(1) 速度让顺序表成为最优解。顺带说一个容易踩的坑很多人以为顺序表就是数组这个理解其实不够准确。数组是编程语言层面的语法概念int a[100]顺序表是数据结构层面的逻辑结构。你可以用数组来实现顺序表也可以用指针动态分配内存实现还可以用 vector 实现。概念上分层来看会清晰很多。4. 链式存储用指针把节点一个个串起来4.1 单链表的结构与节点定义理解了顺序存储就理解了链式存储的对立面。链式存储不要求物理连续它由一个个节点组成每个节点包含两部分数据域存数据和指针域存下一个节点的地址。像一串手链珠子节点之间用绳子指针串在一起。单链表的节点在 C 语言里定义如下typedef struct Node { int data; // 数据域 struct Node *next; // 指针域指向下一个节点 } Node;因为数据和指针是打包在一起的结构体所以单链表在内存中是离散分布的。好处是插入删除不需要搬移大量数据坏处是无法随机访问——你想找第 5 个节点只能从第一个节点开始顺着 next 指针一个个往下跳。4.2 头指针、头节点和首元节点的区分这是初学者最容易混淆的一组概念一定要搞清楚概念说明是否必须头指针指向链表第一个节点的指针变量是访问链表的入口必须头节点在首元节点之前额外添加的一个节点data 域为空或存表长信息next 指向首元节点可选首元节点链表中真正存储第一个数据元素的节点数据结构本身需要加头节点dummy node这一招在写链表算法时能省非常多麻烦。举个最简单的例子在第一个位置插入节点如果没有头节点需要单独处理改变头指针的逻辑而有头节点之后插在首元节点前的操作和插在中间的写法完全统一了。这也是为什么刷算法题时经常看到有人先创建一个虚拟头节点dummyHead——不是炫技是真的能减少边界分支。4.3 单链表的插入与删除改指针的先后顺序在单链表中在第 i 个位置插入节点 p核心操作只有两步p-next prev-next; // 1. 先把 p 指向原第 i 个节点 prev-next p; // 2. 再把前驱节点的 next 指向 p这两行的顺序绝对不能反。如果先执行prev-next p原第 i 个节点的地址就丢掉了下面的p-next不知道该指向谁链表就从中间断掉了。这个细节在面试里被考查的概率极高实际写代码时也经常因为手滑而翻车。删除第 i 个节点同样用一个临时工来完成Node *tmp prev-next; // 先记下要删的节点 prev-next tmp-next; // 让前驱跳过 tmp直接指向后一个节点 free(tmp); // 释放被删节点的内存整个过程只动了有限个指针不涉及大量数据搬移所以时间代价是 O(1)前提是你已经知道前驱节点的位置。查找第 i-1 个节点倒是需要从头部开始遍历所以完整的插入/删除操作在单链表上的时间复杂度依然是 O(n)但 O(n) 的系数比顺序表小很多而且它不会引起大块数据拷贝。4.4 双链表与循环链表针对痛点的手术刀单链表的痛点主要有两个只能单向遍历以及找前驱节点必须从头开始。于是有了两个变体双向链表Doubly Linked List在节点里多了一个prev指针指向直接前驱。找前驱的时间从 O(n) 降到 O(1)代价是每个节点多占用一个指针的存储空间64 位系统下是 8 字节。Java 的LinkedList、Redis 的 list 结构底层都用到了双向链表。循环链表Circular Linked List让尾节点的 next 指回头节点形成一个环。它的价值在于某些场景下绕圈访问非常自然——比如操作系统的任务调度时间片轮转的时候进程就是在一个环形链表里轮流被切进来执行的。面试里还有一种高频变形题判断链表是否有环、找到环的入口。这类问题考察的其实是对链表结构绳子关系的直觉而不是单纯的背算法模板。4.5 动态内存带来的隐患别让链表变野指针黑洞链表用的是动态分配的空间每一个节点都是malloc出来的用完必须free。C 语言写链表时常见的坑包括删除最后一个节点后没有把链表的尾指针置空、释放节点后忘记让前驱的 next 指向 NULL、遍历时提前移动了工作指针导致后续节点丢失。我见过不少线上事故归根结底就是链表操作时指针悬空导致的段错误。如果你用的是 Java、Go 这类带自动垃圾回收的语言虽然不用手动 free但持有无用引用同样会造成内存泄漏。比如删掉一个节点却不清理它对外部对象的引用那个对象就永远无法被回收——Go 语言里这叫做内存泄漏的常见姿势。链表虽然基础但内存意识必须从一开始就建立。5. 顺序表还是链表工程选型的博弈5.1 一张经典的对比表学完两种存储结构最终要能回答一个非常实战的问题我的业务到底该用顺序表还是链表对比维度顺序表链表随机存取O(1)算地址直接取O(n)必须从头遍历插入/删除平均移动 n/2 个元素修改指针即可但需先找到位置空间效率需要预分配可能浪费按需分配但每个节点多存一个指针CPU 缓存友好度连续内存缓存命中率高节点散布缓存命中率低扩容/缩容需要重新分配内存并搬移动态增删节点天然灵活这个表里最后一行CPU 缓存友好度是很多人容易忽略的。现代计算机访问内存时会把相邻的一段数据同时加载进缓存。顺序表因为元素物理连续遍历时可以充分利用这一特性访问第 i 个元素时第 i1、i2 个元素很可能也已经在缓存里了速度飞快。链表因为节点可能分散在不同的内存页每次跳转都可能触发缓存缺失cache miss实际遍历性能往往比理论分析更差。所以哪怕链表插入删除看起来算法复杂度不差在大量读多写少的业务中还是打不过顺序表。5.2 数据规模小、操作频率极端时的实际经验我个人的经验做工程选型时如果你拿不准优先考虑顺序表动态数组——因为它对缓存友好实现简单debug 容易。链表最大的价值在于两类特定场景一是需要频繁在头部/中间插入删除且数据量较大二是你无法预估数据总量且数据的生命周期差异很大产生和销毁不均衡。LRU 缓存就是一个很典型的场景触发器会大量淘汰旧数据、插入新数据。如果只用数组每次淘汰都要搬移大量元素代价不可接受。这时候双向链表 哈希表的组合即 LRU 的标准实现才真正能发挥链表的威力。反过来如果你只是保存一堆订单数据然后按编号查详情动态数组从性能到代码可读性都碾压链表。5.3 静态链表没有指针时代的智慧值得一提的是高级语言里我们用指针实现链表但早期的数据结构教材里还有一种静态链表——用一串数组来模拟链表通过数组下标充当指针。数组元素除了存数据还要存下一个元素的下标。这种方案在没有指针概念的高级语言比如早期的 BASIC时代很有意义现在则主要出现在考研题和面试题中作为你是否真正理解了链表本质的考察点。理解了它你会发现链表的本质不是指针而是显式存储下一个元素的位置信息。6. 线性表几大必踩的坑与我的学习建议6.1 边界条件从1 到 n还是0 到 n-1这是所有数据结构初学者的第一道坎。线性表定义里第 1 个元素、第 n 个元素位置从 1 开始但到了代码里数组下标通常从 0 开始。于是插入位置 i 和数组下标 i-1 之间隔着一层翻译。我见过太多人写删除函数时写for (int j i - 1; j length; j)忘记限制 j 的范围导致在链尾删除时数组越界。写任何涉及线性表的算法之前先想清楚三件事空表怎么办删除最后一个元素怎么办插入到表尾怎么办这三个边界条件处理好了八成以上的 bug 都能提前消灭。另外很多函数库比如 STL里用的迭代器风格是左闭右开[first, last)与教材里的从 1 开始的位置编号又不一致。刚接触时不适应非常正常多踩两次坑就习惯成自然了。6.2 关于指针本身的几个盲区序号和地址全对上了指针操作又是一个大坑。具体来说第一不要试图对 NULL 解引用。写链表遍历时务必先判断p ! NULL再访问p-data否则一个空链表就足以让程序段错误。第二修改链表的指针时必须保留后路。比如删除当前节点如果直接用free(p)那你还没取到下一个节点的地址呢正确做法是先用临时变量记下next再释放当前节点。第三面试手写链表时画图比写代码更能避免失误。在纸上画出插入前、指针调整第一笔、调整第二笔、完成后的四种状态比闷头写十几行代码稳妥得多。这个习惯我一直保留到工作中——代码 Review 时遇到复杂的链表操作我也是先在白板上画清楚再动手。6.3 从概念到实战的进阶路线如果这篇文章你看到了这里说明你是想真正学懂线性表的。我建议按这个顺序推进第一阶段在纸上手写一个单链表的插入、删除、查找过程画图理解每一根指针的指向变化。第二阶段用 C 或 Java 自己动手实现顺序表和单链表实现全表遍历、按位置插入、按值删除并处理空表、表尾等边界。第三阶段用学到的知识刷几道经典题——反转链表、合并两个有序链表、判断链表是否有环、删除倒数第 K 个节点。这些题目把线性表的核心操作全揉进去了刷明白之后后面栈和队列对你来说就是砍瓜切菜。第四阶段回头深入理解 STL/Java 集合框架中vector、ArrayList、LinkedList的实现源码把数据结构知识点和工程代码对接起来。很多人在这一步跳过前三个阶段直接去刷题结果一遇到变量的指针改动就懵。我教过的学生里凡是在第一阶段老老实实画图的后面学二叉树和图都会顺利不少。因为数据结构的核心能力就是在脑子里维护一个抽象世界的清晰画面线性表是训练这个能力的第一站。最后说一个我自己的体会线性表和顺序表、链表这些概念看起来是期末考试前背一背就能过的基础知识但它真正的作用是帮你在做任何涉及数据组织的设计时形成一套自动化的权衡思考——要不要随机访问插入删除频繁吗数据规模多大缓存感知重要吗这一套思维模型比记住任何一段代码都能走得远。