ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

双向链表从原理到实战:核心操作、性能分析与面试高频题

双向链表从原理到实战:核心操作、性能分析与面试高频题 从事数据结构教学和底层开发这些年我越来越发现一个规律很多初学者能把单链表背得滚瓜烂熟但一碰到双向链表就发怵。原因也很简单单链表只需要管好一个next指针双向链表却要同时维护prev和next两根线稍不留神就把链给断了。但恰恰是这个看起来更复杂的结构在实际工程里出场率极高像浏览器的前进后退、文本编辑器的撤销重做、Redis的列表对象、LRU缓存淘汰底层全是双向链表的影子。如果你正在准备数据结构期末考试、考研408或者刚入行想搞懂链表到底怎么玩这篇文章应该能帮你把双向链表彻底吃透不光是会背定义还能真正写出能跑的代码。我最早在课程设计里维护一个学生成绩表需求很简单按学号排序支持插入、删除、输出。当时用单链表写删除一个节点得从头遍历找前驱代码写出来又长又绕调了几小时bug最后发现是删除时少记了一个pre指针。后来换成双向链表一切豁然开朗——删除节点时直接通过prev就能找到前驱双向遍历也顺手了。今天这篇就把我这些年用双向链表的经验全部摊开来讲包括设计思路、手写代码、性能分析、面试高频题以及我自己踩过的一些坑。1. 为什么会需要双向链表——从单向链表的痛点说起1.1 单向链表的“回不去”困境先想一个问题单链表里每个节点只存了一个指向后继的指针。这意味着你从表头出发可以一路顺着next走到结尾但想回头找上一个节点对不起没路。除非你从头再遍历一遍。这个“回不去”的问题在某些场景下是致命的。举个最典型的例子你维护了一个有序链表现在要删除某个值为x的节点。单链表的做法是遍历到目标节点的前一个节点然后修改它的next指针跳过目标节点。也就是说你明明要删的是节点p但真正要操作的是p的前驱。如果链表是单向的你就得用一个pre指针始终跟着当前节点走遍历结束后拿到的其实是前驱。代码丑不说逻辑还容易出错。好不容易写完了过几天需求又变了“我要倒序输出这个链表”。单链表这时候只能先反转或者递归输出两种方式都不够优雅。你会发现单链表一旦涉及到“向前回溯”就处处受制。1.2 双向链表怎么解决每个节点多一根“回头路”双向链表的改进非常直接每个节点不再只存一个指针而是存两个指针。一个叫prev前驱指针指向前一个节点一个叫next后继指针指向后一个节点。头节点的prev指向空或指向尾节点看是否循环尾节点的next指向空或指向头节点。用生活的话说单链表就像一条单行道你只能顺着一个方向开双向链表是双向车道既能往前也能往后。别小看这个改动它意味着你拿到了任何一个节点都能以O(1)的时间复杂度访问它的前驱和后继这在很多算法设计里是质的飞跃。比如删除操作双向链表拿到待删除节点p之后直接p-prev-next p-next再p-next-prev p-prev两步搞定根本不需要找前驱。再比如要在某个已知节点p的前面插入一个节点s双向链表同样可以直接找到p-prev调整指针完成插入。1.3 双向链表的适用场景需要频繁在已知节点前后做插入、删除操作的场景比如LRU缓存淘汰、内存管理中的空闲块回收。需要双向遍历的场景比如浏览器的前进/后退历史记录、文本编辑器的撤销/重做堆栈。实现某些高级数据结构的基础部件比如Linux内核的list_head、Redis的quicklist都用到了双向链表的思想。2. 双向链表的核心结构设计与底层原理2.1 节点结构三个字段的职责划分在C语言里双向链表的节点定义大概是这样的typedef struct DNode { int data; // 数据域存实际数据 struct DNode *prev; // 前驱指针指向前一个节点 struct DNode *next; // 后继指针指向后一个节点 } DNode, *DLinkList;有些教材里你会看到这个结构体里还顺便存了数据长度、或者额外加了mutex锁之类的字段那是工程化改造不必纠结。核心就三个字段data、prev、next。data类型根据实际需求来可以是int、char也可以是一个结构体。2.2 带头结点还是不带头结点初学链表时候最纠结的一个问题就是到底要不要头结点我的看法很简单学习阶段和考试答题带头结点永远是更稳妥的选择。理由有两个带头结点可以统一空表和非空表的操作逻辑。空表时头结点的prev和next都指向NULL插入第一个节点时不需要对头指针做特殊处理如果不带头结点空表插入要改头指针本身很多新手就是在这一步忘了给头指针重新赋值导致链直接丢了。带头结点时头指针永远指向头结点即使链表为空头指针也不会是NULL这样遍历、判断空表的代码会简单很多。当然不带头结点也能写但需要处理的操作分支会多一些。实际工程里两者都有但我强烈建议你先在带头结点的写法上把逻辑练熟再去理解无头结点的变体。2.3 循环双向链表把首尾接成一个环如果让尾节点的next指向头结点同时让头结点的prev指向尾节点双向链表就升级成了循环双向链表。这个结构在实际使用中特别常见因为它解决了两个问题从任意节点出发可以访问到链表中的所有节点不需要一定从头开始。头结点和尾节点之间形成闭环正序遍历和逆序遍历都非常方便。循坏双向链表的判断空表条件也变了不再是head-next NULL而是head-next head即尾节点的next又指回头结点如果整个表只有头结点自己那next和prev都指向头结点自己。3. 双向链表核心操作拆解与C语言实现3.1 初始化与创建链表先搞定“地基”初始化带头结点的空双向链表DLinkList initList() { DLinkList head (DLinkList)malloc(sizeof(DNode)); if (head NULL) { exit(1); // 内存分配失败果断退出别硬撑 } head-prev NULL; head-next NULL; return head; }注意这里头结点的prev和next都要置空否则就是一个野指针后面遍历或者判断空表的时候会出大问题。面试或者考试时很多人初始化只给了nextNULL忘了prev这是非常典型的低级错误。接下来创建链表常用两种方式头插法和尾插法。头插法建出来的链表和数据输入顺序相反尾插法则保持一致。写双向链表的插入逻辑之前我强烈建议你先在纸上画一个三节点的链表图标清楚指针再对照着写代码能少踩一半的坑。3.2 插入操作四步指针修改顺序不能乱在节点p后面插入新节点s核心代码是四步s-next p-next; // 1. 新节点先连接后继 s-prev p; // 2. 新节点连接前驱 if (p-next ! NULL) { p-next-prev s; // 3. 原后继的prev指向新节点注意判空 } p-next s; // 4. 前驱的next指向新节点很多人会问这四步的顺序能不能换可以换但有一条铁律在把p-next指向s之前必须先保存好原来的p-next否则原后继就找不到了。上面的四步里第1步先让s-next指向原后继就相当于把退路都安排好了接下来再怎么改p-next都不慌。这里有个小细节很容易被人忽略如果p是尾节点那么p-next本身就是NULL第3步前要先判断p-next是否为NULL。带头结点的写法里因为尾节点next为空直接影响就是p-next-prev s这行代码会空指针崩溃。所以我在给学员讲的时候一定强调先画图、再写码。如果要在p节点之前插入s呢在不额外遍历的情况下可以利用p-prevs-prev p-prev; s-next p; if (p-prev ! NULL) { p-prev-next s; // 同样要判空 } p-prev s;本质上还是四步只是把方向反过来。掌握了“后插”前插就是后插的镜像操作。3.3 删除操作两行代码搞定前提是链要接对删除节点p的核心逻辑if (p-prev ! NULL) { p-prev-next p-next; } if (p-next ! NULL) { p-next-prev p-prev; } free(p);如果p是头结点千万别删头结点删了整条链就没了。如果p是尾节点那么p-next为NULL第二个if不执行。如果链表只有一个有效节点两个if同时成立删除后头结点的next和prev都变为NULL链表回到空表状态完全符合预期。很多资料喜欢把删除写成“p-prev-next p-next; p-next-prev p-prev;”两行但这个写法在p是头结点或尾节点时会出问题。所以实际工程里我习惯加判空宁可多写两行也不愿意半夜被线上故障叫醒。3.4 遍历和查找正着走、反着走都随意正向遍历void printListForward(DLinkList head) { DNode *cur head-next; while (cur ! NULL) { printf(%d , cur-data); cur cur-next; } printf(\n); }反向遍历void printListBackward(DLinkList head) { DNode *cur head; // 先走到尾节点 while (cur-next ! NULL) { cur cur-next; } // 从尾节点往前走到头结点 while (cur ! head) { printf(%d , cur-data); cur cur-prev; } printf(\n); }查找逻辑没什么特殊的就是从头开始逐个比较时间复杂度O(n)这一点不管单双链表都一样。3.5 完整示例一个极简的成绩管理下面给一个完整的、可跑的C语言示例功能是创建学生成绩链表、按学号顺序插入、按学号删除、输出成绩单。代码我刻意保持简洁方便你直接拿去跑。#include stdio.h #include stdlib.h typedef struct Student { int id; // 学号 int score; // 成绩 struct Student *prev; struct Student *next; } Student, *StuList; StuList initList() { StuList head (StuList)malloc(sizeof(Student)); if (head NULL) { exit(1); } head-id 0; head-score 0; head-prev NULL; head-next NULL; return head; } // 按学号顺序插入升序 void insertByOrder(StuList head, int id, int score) { Student *s (Student *)malloc(sizeof(Student)); s-id id; s-score score; Student *cur head-next; // 找到第一个学号大于等于id的节点插在它前面 while (cur ! NULL cur-id id) { cur cur-next; } if (cur NULL) { // 插到尾部 Student *tail head; while (tail-next ! NULL) { tail tail-next; } tail-next s; s-prev tail; s-next NULL; } else { // 插到cur前面 s-next cur; s-prev cur-prev; if (cur-prev ! NULL) { cur-prev-next s; } cur-prev s; } } // 按学号删除 int deleteById(StuList head, int id) { Student *cur head-next; while (cur ! NULL cur-id ! id) { cur cur-next; } if (cur NULL) { return 0; // 没找到 } if (cur-prev ! NULL) { cur-prev-next cur-next; } if (cur-next ! NULL) { cur-next-prev cur-prev; } free(cur); return 1; } void printForward(StuList head) { Student *cur head-next; while (cur ! NULL) { printf((id%d, score%d) , cur-id, cur-score); cur cur-next; } printf(\n); } int main() { StuList head initList(); insertByOrder(head, 102, 90); insertByOrder(head, 101, 88); insertByOrder(head, 103, 95); printForward(head); // 输出: (id101, score88) (id102, score90) (id103, score95) deleteById(head, 102); printForward(head); // 输出: (id101, score88) (id103, score95) return 0; }这段代码我特意把“插到尾部”和“插到中间”分开处理方便你理解边界情况。运行结果符合预期插入自动排序删除后顺序依然保持。4. 性能分析双向链表到底快在哪、慢在哪4.1 时间复杂度对照不能只看“删除O(1)”很多帖子张口就是“双向链表删除节点是O(1)”这个说法其实是有前提的你得已经拿到了目标节点的指针。如果只知道值要按值删除那还是得先O(n)遍历找到目标节点删除本身确实是O(1)但查找的O(n)省不掉。操作单链表双向链表头插O(1)O(1)已知节点后插O(1)O(1)已知节点前插O(n)需要找前驱O(1)直接用prev已知节点删除O(n)需要找前驱O(1)直接用prev按值查找O(n)O(n)获取表长O(n)O(n)一目了然双向链表真正的优势在于“已知节点周围的操作”这些操作不需要额外遍历代价就是每个节点多存一个指针字段。4.2 空间开销多一个指针带来的真实成本64位系统里一个指针占8个字节。假设数据域是4字节的int单链表有效数据占4字节8字节指针12字节双向链表是4字节8字节8字节20字节。也就是说双向链表比单链表每个节点多出约67%的指针开销。如果数据量是千万级这个差距就是实打实的内存成本嵌入式场景里甚至可能直接导致分配失败。所以选型的时候要权衡如果业务里绝大多数操作都是遍历、很少在已知位置插入删除双向链表并不划算如果频繁需要在中间插入删除、又需要双向回溯那么多花的指针空间完全值得。4.3 缓存友好性链表的“隐藏短板”这里补一个很多人忽略的点链表是节点分散在堆内存里的遍历时CPU缓存命中率远低于连续内存的数组。在数据量小的时候差别不明显一旦链表节点过万频繁的指针跳转会带来显著的访存开销。这也是为什么现代工程里纯链表的应用场景在减少很多库都倾向于用“数组索引”或者“混合结构”。比如Redis在3.2版本之后list对象的底层就升级成了quicklist本质上是用双向链表把多个连续数组串起来兼顾了链表灵活插入和数组缓存友好的优点。这说明什么说明理解双向链表的价值不仅要会手写还要知道它的短板在哪才能在真实场景里做出合理选型。5. 常见错误、面试高频考点与避坑实录5.1 我自己踩过的坑五个让人抓狂的bug第一个坑插入时只连一半。我早期写双向链表插入经常写完s-next和s-prev忘了更新原节点的prev或next结果链表断成两截。后来我养成了一个习惯写完插入代码先完整走一遍逻辑——s的四个指针都指向谁、前后节点的两个指针都指向谁全部核对一遍再编译。第二个坑忘掉头结点的prev初始化。初始化时只写了head-next NULL忘了head-prev NULL。这个问题在插入删除时不一定立刻暴露但一旦你写“从尾到头遍历”程序直接就崩了。所以初始化那两行我用注释标清楚绝不偷懒。第三个坑删除指针后继续使用。free(p)之后p变成悬空指针如果后面还试图访问p-data结果不可预知。这种bug最可恶因为不一定每次崩溃有时候数据恰好还能读出来掩盖了问题换个数据规模才炸。第四个坑边界判断少写一个if。删除尾节点时不判断p-next是否为NULL直接p-next-prev p-prev一执行就是一个空指针异常。这个坑在链表操作里出现频率极高属于典型的新手失误。第五个坑循环链表里死循环。把双向链表改成循环链表后遍历的终止条件如果你还写cur ! NULL那就永远退不出来。循环列表必须判断cur是否回到头结点或某个标记节点。5.2 高频面试题这些题几乎必考第一题在不给定头指针的情况下怎么删除一个已知节点p这不是常规的“把前驱的next指向后继”因为你拿不到前驱指针正常的双向链表里你是可以拿到prev的但如果题目限定是单链表呢经典解法是“值覆盖”把p-next的数据复制到p然后删除p-next。注意p是尾节点时需要做特殊处理因为你没法找p-next一般会先判断如果p是尾节点只能从头遍历找前驱了。这道题考察的就是你对链表的理解深度而不是死记代码。第二题手写LRU缓存淘汰。LRU最近最少使用是面试高频中的高频经典实现就是“哈希表双向链表”。哈希表保证O(1)查找双向链表保证O(1)插入删除。每次访问一个key如果命中就把它移到链表头部一旦缓存超过容量就删除链表尾部的节点。用单链表行不行理论上能凑合但删除尾节点需要找前驱就退化成了O(n)。此刻双向链表的价值体现得淋漓尽致。第三题为什么Redis的list不直接用数组因为list需要频繁在两端做push/pop操作数组在头部操作要O(n)搬移数据。而双向链表天然支持头尾O(1)操作。后来Redis改用quicklist也是吸取了双向链表内存分散、指针开销大的教训做了工程上的折中。5.3 学习建议怎么把双向链表练到肌肉记忆我一直跟人讲一句话链表这玩意光看书等于白看必须上手写最好写出bug来再一个个修。给你一个自检清单用一张纸画出带头结点双向链表的空表、一个节点、两个节点三种状态标清楚所有prev和next。手写初始化、尾插、头插、指定位置插入、指定位置删除、正序和逆序打印。把所有边界条件想清楚空表插入、尾部插入、尾部删除、删除唯一节点。把带头结点改成不带头结点对比差异在哪。把双向链表改成循环双向链表再跑一遍之前的测试用例。如果你能把这五步走完笔试环节遇到链表题基本就是送分题。5.4 工程选型心得什么时候别用双向链表最后聊点选型的心得。很多人学了双向链表之后写什么都想用这其实是个误区。给你几个我的经验如果主要操作是遍历读取很少在中间插入删除优先用数组或ArrayList内存连续、遍历快。如果主要操作是头尾插入删除可以用双端队列deque底层可能是分块数组性能更稳。如果确定需要频繁在已知节点前后插入删除双向链表才是不二选择。如果项目对内存极其敏感比如单片机开发多一个指针字段可能都是负担能用单链表和数组解决的绝不硬上双向链表。我自己在实际项目里的体会是双向链表就像一把好用的扳手但你不能拿扳手去拧螺丝。选型之前先把场景看清楚再决定用不用它这才是工程思维。最后再分享一个小技巧你可以在带头结点的循环双向链表基础上用一个“头结点长度”字段封装一个通用List这样不管头插、尾插、还是按位置插全是O(1)级别的操作代码复用率极高。我在课程设计里就是这么干的后来做项目也一直沿用这套思路。如果你正在学数据结构不妨也试试自己封装一个跑通之后你会发现自己对指针的理解深了一个层次。
返回列表