ARTICLE DETAIL

资讯详情

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

线性结构全解析:顺序表、链表、栈与队列的底层实现与工程取舍

线性结构全解析:顺序表、链表、栈与队列的底层实现与工程取舍 讲到数据结构很多人第一反应是那些让人头疼的树和图但真正的地基其实是线性结构。线性结构不是什么高深理念它就是一支排好队的队伍每个元素最多只有一个前驱和一个后继。数组、链表、栈、队列这些你在刷题、写业务代码时天天打交道的家伙全都是线性结构的经典形态。这篇文章想把线性结构一次讲透包括它的定义、两种存储方式的取舍、栈和队列的实现细节以及我实际手写代码时踩过的坑。不管是准备期末考的学生还是面试前临时抱佛脚的开发者都可以拿这篇文章当复习提纲。1. 线性结构到底是什么先搞懂逻辑结构与存储结构1.1 线性结构的定义一张排好队的队伍线性结构在教科书上的标准定义是存在唯一的开始结点和唯一的终端结点其余每个结点都有且仅有一个前驱结点和一个后继结点。翻译成大白话就是数据元素像一支队伍一样一个挨一个排着每个人都有且只有一个前一个一个后一个。站在最前面的那个没有前驱站在最后面的那个没有后继。这个定义听起来像废话但它其实是在描述一种逻辑关系。排队买饭的队伍、电影院一排连号的座位、糖葫芦上串着的山楂都是线性结构的生活化原型。生活中你绝不会排着排着队就多出一个分支也不会有人同时排在两个不同位置这就是线性结构“一个前驱一个后继”约束的直观体现。在计算机里这种约束意味着数据元素之间的逻辑关系是一条链链断了结构就会被破坏链多了同样也会出错。这里有一个特别重要的理解角度结构是站在逻辑层面说的它不关心数据在物理内存里到底怎么放。你可以在纸上把数据画成一条直线但物理上它们可能放在内存里连续的格子中也可能分散在各个角落用指针串起来。逻辑结构描述的是“数据元素怎么互相联系”存储结构描述的是“联系如何在硬件上落地”这是两个维度的事。1.2 容易混淆的概念线性结构不等于顺序存储我见过太多初学者把“线性结构”和“顺序存储”画等号一看到线性就条件反射想到数组一看到链表就觉得它不是线性结构。这其实是把逻辑结构和存储结构两个概念搅在了一起。顺序存储的典型形态是数组它要求元素在物理内存里地址连续逻辑相邻的两个元素在物理上也紧挨着。链式存储的典型形态是链表它不要求物理连续每个结点除了存数据还存一个指向下一个结点的指针。数组和链表是物理或者说存储层面的两种实现方案而线性结构是逻辑层面的抽象。线性结构的逻辑关系完全可以塞进链表里树、图这类非线性结构同样可以强制塞进一维数组里比如堆排序里用数组存的完全二叉树。所以一个更准确的说法是线性结构是一种逻辑分类顺序表和链表是它的两种常用物理实现。我见过有人在博客里问“线性结构和顺序表有什么区别”其实这个问题本身就有点拧巴正确的关系是线性表是最典型的线性结构顺序表是线性表在连续内存里的实现链表是线性表在指针串联下的实现。后面展开讲的顺序表和链表都是给线性结构配上具体存储方案后的产物。1.3 线性结构的家族图谱线性结构的核心成员一共有四类线性表、栈、队列、串。线性表是最普通的形态任何位置都能插入删除栈把插入和删除限制在表的一端队列限制在一端插入、另一端删除串是数据元素限定为字符的线性表。这种家族关系非常重要它意味着你只要吃透线性表栈和队列只是给它加了操作规则串只是换了元素类型一切都能串起来。搞明白这个层级关系再看任何一本数据结构教材都会轻松很多。很多人在堆栈和队列这里因为结构定义而蒙圈其实就是没意识到它们是“被限制操作位置的线性表”是兄妹关系不是完全独立的新结构。2. 线性表顺序存储与链式存储的实战对比线性表是线性结构的课代表。它具备的每一个操作——插入、删除、查找、修改都是后面栈和队列操作的原型。这一节我从实现细节和源码粒度出发把顺序表和链表掰开揉碎讲清楚。2.1 顺序表用数组实现的线性表顺序表就是拿一段连续内存装线性表里的数据实现上就是数组加一个记录长度的变量。C语言里面通常这样定义#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int length; } SeqList;访问第i个元素非常直接就是data[i-1]时间复杂度是O(1)因为数组的下标本身就是内存偏移量计算机算一下地址就能直接拿到数据。这是顺序表最大的优势随机存取想到哪个元素一秒钟直达。但代价在插入和删除上。在位置i插入一个新元素需要把原第i个到第n个元素整体往后挪一格删除同理要把后面的元素往前挪一格。这个“挪”的代价就是O(n)的时间复杂度。更细节的是平均要挪多少个元素假设插入位置在1到n1之间等概率可选平均要移动n/2个元素删除位置在1到n之间等概率同样是平均(n-1)/2个。这个n/2是个非常重要的直觉数组中间插入删除就要动一半的数据。插入操作的代码有一个关键细节必须从后往前移动元素不能从前往后。写错方向会直接把数组后半部分覆盖掉int insertElem(SeqList *L, int pos, int e) { if (pos 1 || pos L-length 1 || L-length MAXSIZE) return -1; for (int i L-length; i pos; i--) { L-data[i] L-data[i - 1]; } L-data[pos - 1] e; L-length; return 0; }从i L-length开始把data[i-1]赋给data[i]这样每个元素都是先往后腾出位置再被后面的赋值动作覆盖不会丢数据。如果反着从pos开始往后遍历赋值第pos个元素还没腾地方就被新值覆盖了原来的数据直接毁掉。这个边界条件是手写顺序表最常见的错误点面试和考试里也经常拿这个当考点。2.2 链表用指针把结点串起来链表抛弃了“内存连续”这个约束每个结点单独申请内存里面存一个指向下一个结点的指针。单链表的结点定义长这样typedef struct Node { int data; struct Node *next; } Node, *LinkList;链表有带头结点和不带头结点两种形态有人说这是约定俗成但我更愿意说这是工程实践逼出来的设计。带头结点头节点存数据或存链表长度等元信息next指向真正的第一个数据结点的最大好处是空表和非空表、在第一个位置插入和在其他位置插入代码逻辑完全统一不用写一堆if判断“我到底是不是在改头指针”。举个例子不带头结点的链表在头部插入时要写L newNode这种修改头指针的语句带头结点之后头部插入跟中间插入没有区别都是在某个结点的后面插入新结点操作完全模板化。我在面试里看过太多人在这个if判断上栽跟头强烈建议所有手写链表默认带头结点。链表的插入操作核心就两行但顺序绝对不能换newNode-next cur-next; cur-next newNode;必须先把新节点接到后继上再修改前驱的next。因为一旦先执行cur-next newNodecur原来的后继结点就找不到了整条链从cur处断掉后面那半条链表等于从内存里“丢”了。这种断链错误是链表操作里最高频的bug没有之一。删除操作同样要小心先用临时指针保存待删除结点改完链再free不然free早了同样会导致悬空指针Node *tmp cur-next; cur-next tmp-next; free(tmp);链表的访问代价是O(n)因为想拿到第i个结点必须从head开始一个个next跳过去不能随机存取。但插入删除只要找到位置改指针本身是O(1)的。这听起来很美实际上“找到位置”的过程往往还是O(n)的所以单向链表在真实工程里并没有那么万能。2.3 顺序表和链表的取舍一张表看懂它们俩没有绝对的好坏只看场景。我把工程里真正影响决策的几个维度列成一张对比表维度顺序表数组链表随机访问第i个元素O(1)直接下标计算O(n)必须从头遍历尾部插入O(1)偶尔扩容O(1)需额外维护尾指针中间插入/删除O(n)要成片移动数据O(n)找位置O(1)改指针内存分配一次性要一整块连续空间一个个结点独立malloc空间开销几乎无额外开销每个结点多一个next指针CPU缓存友好度高连续内存预读友好低指针跳跃导致缓存命中率差扩容成本高可能要整体搬迁天然可扩展不用搬迁这个表里大多数人容易漏掉的是最后两行。现代CPU是缓存友好的访问连续内存时硬件会一次性把一段数据加载进高速缓存遍历数组时几乎每步都命中缓存但遍历链表时每跳一个结点就可能是一次缓存未命中差距可能达到好几倍。这也是为什么Java里的LinkedList在真实项目中经常打不过ArrayList工程实践已经反复证明了这一点。Redis里的quicklist为什么是“双向链表ziplist压缩”的组合就是因为纯链表在缓存友好性上太吃亏把一段连续小数据打包压缩存储能显著提升性能。这个例子很能说明问题工程上的选型从来不迷信某个抽象概念而是看真实硬件环境里的表现。3. 栈和队列两个被“限了位”的线性表栈和队列的底层都是线性表区别在于操作被限制在了特定位置。栈是只能在栈顶插入删除的线性表后进先出队列是只能在一端插入另一端删除的线性表先进先出。这种限制看起来像是“阉割”实际上是精准投放很多场景只需要线性表的部分操作限制反而能规避风险。3.1 栈的先进后出到底怎么实现栈的所有操作都发生在栈顶。数组实现顺序栈两个关键点是栈顶指针的语义和它的初始值。这里有个极其常见的细节坑栈顶指针top到底指向栈顶元素本身还是指向栈顶元素的下一个空位两种语义都能工作但代码完全不同。如果top指向栈顶元素的下一个空位初始top0入栈先赋值再移动指针出栈先移动指针再取值如果top指向栈顶元素本身初始top-1入栈先增加top再赋值出栈先取值再减少top。最怕的是写代码时一会儿用第一种语义一会儿用第二种结果就是明显的越界和错位。#define STACK_MAX 100 typedef struct { int data[STACK_MAX]; int top; // 这里采用 top 指向栈顶元素的下一个空位 } SeqStack; void push(SeqStack *s, int e) { if (s-top STACK_MAX) return; // 栈满 s-data[s-top] e; } int pop(SeqStack *s) { if (s-top 0) return -1; // 栈空 return s-data[--s-top]; }栈满判断是top STACK_MAX栈空判断是top 0这个边界要和top语义一一对应不能死记。我见过有人背代码背得很熟面试默写结果面试官换个top语义当场就懵了。理解比背诵重要得多。顺序栈还有一个扩展设计叫共享栈用一个数组两个栈底分别在数组两端top各自向对方方向生长。两个栈共享同一块内存最坏情况是相遇时栈满好处是空间利用率高一个栈没填满时另一个栈可以用它那边的空间。这个知识点在操作系统相关的面试题里偶尔会考到比如程序运行时函数调用栈和堆从两头往中间生长就是共享栈思想的直接应用。3.2 队列的环形设计为什么非要“绕圈”队列如果用普通的顺序数组来实现会遇到一个经典问题——假溢出。假设数组长度是5rear指针一直往后加加到数组末尾时前面明明空出来了几个位置但rear已经指向最大下标的下一个位置无法继续入队了。这就是“队伍没空但新队员无法入场”的尴尬局面。头插法式的解决策略是用环形队列逻辑上把数组首尾相接rear走到末尾后通过取模运算重新绕回开头。它的核心逻辑就是把入队出队操作里的index改成index (index 1) % MAXSIZE。这样数组虽然还是线性的一段地址但在逻辑上变成了一个圈。环形队列实现里最容易被问住的是判空和判满。如果让front指向队头元素rear指向队尾元素的下一个位置那么队列空时front等于rear队列满时front也等于rear两个状态直接冲突无法区分。解决方式有三种加size计数器记录元素个数、加tag标记最后一次操作是入队还是出队、或者牺牲一个存储单元让rear再走到front前一个位置时就认为队列已满。第三种方式最常用代码也最干净#define QUEUE_MAX 100 typedef struct { int data[QUEUE_MAX]; int front; // 队头下标 int rear; // 队尾下标指向下一个入队位置 } SqQueue; int isFull(SqQueue *q) { return (q-rear 1) % QUEUE_MAX q-front; } int isEmpty(SqQueue *q) { return q-front q-rear; } void enQueue(SqQueue *q, int e) { if (isFull(q)) return; q-data[q-rear] e; q-rear (q-rear 1) % QUEUE_MAX; } int deQueue(SqQueue *q) { if (isEmpty(q)) return -1; int e q-data[q-front]; q-front (q-front 1) % QUEUE_MAX; return e; }牺牲一个存储单元意味着这个环形队列最多存MAXSIZE-1个元素这是用极小的浪费换来判空判满的绝对干净判断。如果你实在舍不得那个空间就用size计数器入队时size出队时size--判空判满直接看size同样可行。工程上tag和size两种方案都有见但考试里“牺牲一个单元”的出镜率最高必须掌握。链式队列则完全没有这个问题它天然不限定长度入队就是往rear后面挂新结点出队就是搬走front后面的第一个结点。写法上和单链表的尾插法/头删除法完全一致链表会了链式队列基本就是白送的分。4. 线性结构的经典应用场景从编辑器的撤销到浏览器的后退很多人学数据结构时最大的困惑是“这东西到底有什么用”。线性结构看起来基础得过分以至于让人觉得它只能在考试里出现。但实际上只要是涉及“后进先出”和“先进先出”的场景背后站着的都是栈和队列。4.1 栈在系统底层无处不在函数调用是栈最经典的舞台。每次调用一个函数系统会把返回地址、参数、局部变量打包成一个栈帧压入调用栈函数返回时栈帧出栈执行权交还给上一个函数。递归为什么能一层层正确嵌套又一层层正确返出靠的就是调用栈这种天然的后进先出结构。递归深度过大导致的栈溢出Stack Overflow也是这个原因——你无限制地往调用栈里压栈帧但栈空间是有限的。浏览器的后退功能也是栈。你访问网页的路径是一条记录点“后退”就是从栈顶弹出最近访问的页面。编辑器的撤销操作同样是栈每次修改都把改动压栈撤销一次弹一次。表达式求值里的运算符优先级比如在编译原理里整表达式转后缀表达式再边读边算用的还是栈。你打字的输入法选词缓存、你在控制台里执行的每条命令历史细看都是栈在背后兜底。这些场景共享同一种需求近期发生的事要先处理早先的事反而后处理。这就是后进先出的直觉价值。4.2 队列在多任务调度上大显身手队列的先进先出语义天然匹配“公平排队”这类场景。操作系统里的进程调度、打印机的任务队列、银行叫号系统全都是队列。你做异步编程时经常打交道的消息队列本质上就是一个分布式的大队列生产者把消息丢进队列消费者按先进先出顺序取出消息这个模式天然地解耦了生产方和消费方的节奏。广度优先搜索BFS也是依赖队列来工作的。在二叉树里做层序遍历在图中求最短路径算法核心都是“把当前的邻居节点依次加入队列再按进队顺序依次取出处理”。没有队列BFS无从谈起。Redis里list结构的底层是一个quicklist它在同一时刻既支持从头操作也支持从尾操作所以它既能当栈用也能当队列用。这也是线性结构在真实基础设施里最常见的存在形态结构不复杂但是被放在最关键的位置上提供性能基础。4.3 刷题背后其实是同一个套路一线大厂面试里那些经典的算法题反转链表、判断链表是否有环、有效的括号、用两个栈实现队列、用队列实现栈、单调栈求最大矩形面积考来考去本质上都是在考线性结构的基础操作。反转链表考的是链表指针的修改顺序判断环考的是快慢指针的追赶思想有效的括号考的是栈的匹配用两个栈实现队列考的是栈和队列操作特性的互换。如果你在纸上能把栈和队列的操作特征理清楚在看这些题时会觉得它们只是换了一层业务包装底层骨架没变。这也是我建议所有初学者把线性结构的每个基本操作手写五遍以上的原因——基础熟练度决定了你在面试时是当场翻车还是如鱼得水。5. 手写代码常见问题与排查经验手写线性结构的代码尤其是链表相关代码几乎是每个人都要经历“反复Debug”的阶段。我把自己踩过的坑和面试辅导中高频出现的问题集中整理成速查表方便你自查。5.1 链表操作最常见的错误丢链和空指针链表操作里第一高频bug是操作顺序写反导致断链。插入操作必须先让新节点指向后继再修改前驱的next删除操作必须先用临时变量保存待删节点再改链。这个顺序我在前面已经强调过一次但它真的是一个值得每次写代码前反问自己的问题“我的新节点已经接上后继了吗前驱的next还能找到原来的链吗”第二高频bug是空指针异常。对一个值为NULL的节点取next、取data程序直接崩溃。为什么容易出现因为循环遍历链表时很多人习惯判断p-next ! NULL却不判断p ! NULL一旦p走到链表尾部变成NULL下一轮循环直接爆。正确的思路是在访问p-data之前先确认p本身还存在两件事顺序不能错。排查上我的习惯是写一个极短的printList函数每做一步操作就打印一次整条链表观察节点顺序有没有异常。链表的结构和数组不同数组错了打一眼就能看到越界链表错了必须看节点间的箭头关系。在纸上画清楚每个操作前后指针的指向再对照代码看几乎能解决90%的链表问题。5.2 栈和队列里那些边界问题真凶栈最容易出错的是选择了栈顶指针加语义之后自己中途又换了一种写法。这种事情我只在初学者代码里见过无数次push用top执行了先加再赋值pop却用top指向下一个空位的写法去先取值再加加最后数据错乱。应对方法只有一个确定了top语义后入栈出栈始终和它保持一致并且写清注释让代码自解释。环形队列最容易出错的则是判满条件里的取模。(rear 1) % MaxSize这个公式本身不难但它依赖你是否正确理解了“牺牲一个单元”的约定。我把这个公式抄在我的代码模板里每次直接调用不再现场推演因为现场推演在时间压力下特别容易翻车。队列初始化时front和rear都设置为0这个和判空条件是一体两面的如果初始化写成了front0, rear1整个队列的判定体系都会跟着错。内存泄漏也是手写链式队列和链栈时容易被忽略的问题。出栈、出队时弹出的节点必须free掉不free的话程序轻则内存膨胀重则长时间运行后内存耗尽。你要是拿这个代码去做大数据的题目比如处理百万级数据泄漏问题会放大得非常明显。5.3 一个适合新手的练习路径我建议所有准备系统学数据结构的人用最笨但最扎实的方式自己动手做一轮“从零手写线性结构”练习先不参考任何代码凭空写一个顺序表要求支持插入、删除、查找、打印。写完后用随机数据测一遍重点检查边界在头部插入、在尾部插入、插入到已满的表、删除空表里的元素。然后把顺序表改成单链表要求带头节点同样实现所有操作手动模拟断链场景观察代码能不能正确应对。再实现顺序栈和链栈、环形队列和链式队列。每个实现跑够20组左右测试数据确认内存无泄漏。这一轮做完你会突然发现自己对线性结构的理解产生了质的飞跃。因为代码能跑通只是很浅层的标准真正理解是指针逻辑、边界条件、内存管理这些背后的问题都能在脑子里被画成完整链路。之后再去刷题你会发现LeetCode上大多数链表题和用栈/队列模拟的题写起来速度惊人因为你不是在写新代码而是在复用自己的肌肉记忆。我个人手写链表的体会是链表不是玄学而是一张图。每次改动指针先问自己一句“我改了谁的next原来的链还是完整的吗”把这句口诀刻进脑子基本可以告别丢链和空指针的坑。学线性结构最忌讳的就是死记代码一定拿笔在纸上一次次画出箭头图图会了代码就是照着图打字而已。这个学习方法我从学生时代一直用到现在依然觉得是应对数据结构最有效的一条路。
返回列表