资讯动态

数据结构与算法:链表核心原理、手写实现与面试高频题型全解

发布时间:2026/9/13 7:41:23 来源:尧图企业网站定制
我一直觉得数据结构里最“反直觉”的入门坎就是链表。数组多简单连续内存、下标随手就能用迭代走起来还快链表非得一个节点套一个指针访问第几个元素得从头一个一个走看代码时一不留神就把自己绕进去了。但无论是期末考试、考研保研还是大厂技术面试“数据结构与算法——线性表链表篇”几乎是必刷章节。这篇博客不会把教材改头换面再抄一遍而是站在实际操作的角度把单链表、双向链表、循环链表的底层机制讲明白再带手写一套完整的链表代码最后把反转、快慢指针、排序这类高频题目的思路拆开揉碎。给你一个明确的结果预期看完你能自己写一个可用的链表类能应对面试里最常见的链表面试题并且知道调试链表时那些Bug到底是怎么来的。1. 线性表的世界为什么链表是绕不开的一环1.1 线性表到底是什么线性表这个词听起来学术实际上就是一组数据排成“一条线”的结构。想象你去食堂排队队伍里的人一个挨一个每个人都有明确的前一位和后一位再想一下书架上的书一本接一本立在架子上找某一本书时从头扫到尾就行。计算机里的线性表抽象出来就是这种“一串元素按顺序排列”的逻辑模型它只关心元素之间有没有前后关系不关心背后用什么姿势存储。可一旦落到内存里就有两条完全不同的路可以走一种是顺序存储也就是数组逻辑上相邻的元素在物理内存上也挨着另一种是链式存储也就是链表逻辑上相邻的元素在内存里可能隔得很远全靠指针把它们串起来。这里就有一个很多初学者会忽略的点线性表是逻辑结构数组和链表是实现方式。同一个逻辑结构可以有多个物理实现反过来也是数组可以模拟链表的行为链表也可以实现出栈、队列这类结构理解成“数据结构逻辑结构存储结构基本操作”的组合后面学起来会顺手很多。1.2 链表相比数组的底气在哪里数组的强项是随机访问想取第5个元素直接arr[4]就结束了时间复杂度O(1)。链表的强项在插入和删除如果已经持有某个节点的指针插入一个新节点只需要改两三个指针的指向即使要遍历找到位置操作本身也非常轻量不需要像数组那样把后面的元素成片往后搬。很多人背结论时说“链表插入O(1)数组插入O(n)”这句话对但它有一个大前提你已经站在目标节点旁边。如果你要从头找第5个位置再插那还是绕不开O(n)的遍历这就是为什么链表的很多优化题本质都是在减少“定位”的代价。链表还有一个不可忽略的好处是内存分配灵活。数组需要一段连续内存开大了浪费开小了不够用扩容时要申请新的连续空间整体搬移。链表是能用多少申请多少节点可以散落在堆里各个位置哪怕是碎片化的内存也能缝缝补补用起来。这在老式嵌入式环境、内存紧张的系统里是实打实的优势。1.3 链表的经典应用场景链表在实际工程里出现得比想象中多。操作系统的进程管理经常用双向链表维护进程队列方便随时插入和移除浏览器的后退前进页面管理会用双向链表串联访问记录HashMap在Java里的链地址法解决哈希冲突时冲突位置后面挂的正是一个链表图论里稀疏图的邻接表本质上就是“顶点数组每条边用链表串起来”。除此之外多项式加减乘除、大整数运算、LRU缓存淘汰策略都是链表的经典应用。面试里常考的LRU就是哈希表加双向链表的组合所以不啃透链表后面很多高级结构也会学得磕磕绊绊。2. 链表筑基存储结构、节点设计与指针细节2.1 节点是怎么构成的链表的每一次操作归根结底是在操作“节点”和“指针”。单个节点是链表的最小单元一般包含两部分数据域用来存放这个节点携带的实际数据指针域用来存放下一个节点的地址。在C/C里我们会定义一个结构体里面一个成员是数据另一个是指向同类型结构体的指针比如template typename T struct ListNode { T data; // 数据域 ListNodeT* next; // 指针域指向下一个节点 ListNode(const T val) : data(val), next(nullptr) {} };这一段代码是后续所有链表的基石。注意这里用了模板让链表可以存放任意类型的数据。ListNodeT* next这种写法在初学者看来就是“自己指向自己”会有种循环定义的错觉实际上它只是声明一个指针指针在64位系统下固定占8字节32位下占4字节它保存的是另一个节点的内存地址所以不会造成无限嵌套。这和生活中的“排队”很像队伍里每个人都知道下一个人是谁但每个人都没有把下一个人“装进”自己口袋里。2.2 头指针不一定等于头结点这是链表里第一个容易踩的坑。很多入门资料会把“头指针”和“头结点”混着说严谨一点区分头指针是指向链表第一个节点的指针它本质上是一个变量标记着整条链表的入口头结点则是在第一个有效数据节点之前单独加的一个节点它的数据域一般不存有效数据只作为一个哨兵存在。用头结点和不用的场景各有各的写法。不用头结点时空链表状态就是head nullptr在头部插入节点时要特殊处理因为head本身是个指针变量想修改它指向必须用二级指针ListNodeT**或者引用否则函数里改了head外层还是原来的值。用头结点时即使链表是空的head也指向一个实实在在的节点头部插入和中间插入逻辑完全统一对新手非常友好。我在实际写代码时除非是面试白板题特意要求裸指针实现否则工程里更喜欢带头节点的写法可以让“空和非空”的边界逻辑统一起来减少一堆分支判断。2.3 单链表、双向链表、循环链表怎么选单链表是最简单的形态每个节点只有一个next指针只能往后走。它实现起来简单内存占用少但坑也很明显想删除当前节点的前驱或者从后往前遍历就得从头再扫一遍。这就好比单向马路想到前面的路口拐弯只能掉头重跑。双向链表每个节点多了一个prev指针既知道下一个是谁也知道上一个是谁。删除操作因为能直接拿到前驱时间复杂度变成O(1)但代价是每个节点多开一个指针的内存插入删除时要维护的指针数也多了一倍代码复杂度明显上升。工程里使用频率最高的其实是双向链表因为增删灵活各种框架的底层缓存、LRU实现、双向队列基本都是它的天下。循环链表则把链表的尾节点指向头节点头尾连成环。单循环链表和双循环链表都有经典的约瑟夫问题、操作系统的进程轮转调度、时间片轮询都是循环链表的实战场景。三种形态看着只有一点差异但思维模型完全不同。我建议入门阶段先把单链表练扎实因为双向和循环只是在单链表的基础上加针、减针单链表能顺手写出来后面两种就是体力活。3. 从零手写链表核心操作的完整实现3.1 C模板类链表的基础搭建用C写链表我的建议是从一个模板类开始既能存int又能存string后面做实验、写课程设计都会很舒服。先定义一个链表类内部封装节点结构体template typename T class LinkedList { private: ListNodeT* head; // 头指针这里带头结点 int size; // 当前节点数量方便求长度 public: LinkedList() : head(new ListNodeT(T())), size(0) {} ~LinkedList() { ListNodeT* cur head; while (cur ! nullptr) { ListNodeT* nextNode cur-next; delete cur; cur nextNode; } } // 后续操作函数... };构造时new ListNodeT(T())是创建头结点T()是类型的默认值数字就是0字符串就是空串反正头结点数据域基本不用。析构函数里必须遍历所有节点逐个删除否则每个new出来的节点都会内存泄漏。这里用nextNode先保存下一个节点再delete当前节点是链表删除里最基础的防断链手法。3.2 插入、删除、遍历的实现思路做插入操作前先弄明白一个原则插入的关键是先让新节点把“后路”接好再回头断掉旧链接。以在指定下标pos后插入为例void insertAfter(int pos, const T val) { if (pos 0 || pos size) throw std::out_of_range(Index out of range); ListNodeT* prev head; for (int i 0; i pos; i) { prev prev-next; } ListNodeT* newNode new ListNodeT(val); newNode-next prev-next; // 1. 新节点先连上后面的节点 prev-next newNode; // 2. 再让前面的节点指向新节点 size; }初始化头结点以后空表和插入第一个节点在逻辑上没有区别全是“在头结点后操作”。这就是头结点的价值所在它让“插入到头部”这种特殊场景在代码层面退化为普通的“在位置0插入”。删除操作则要先找到目标节点的前驱然后让前驱跨过目标节点直接指向它的后继最后释放目标节点void erase(int pos) { if (pos 0 || pos size) throw std::out_of_range(Index out of range); ListNodeT* prev head; for (int i 0; i pos; i) { prev prev-next; } ListNodeT* toDelete prev-next; prev-next toDelete-next; // 跳过去 delete toDelete; // 回收内存 --size; }遍历就相对简单了从head-next开始沿着next一路走到nullptrvoid print() const { ListNodeT* cur head-next; while (cur ! nullptr) { std::cout cur-data - ; cur cur-next; } std::cout nullptr std::endl; }这里的核心心法是链表里的每个节点只关心“我后面是谁”所以任何操作的关键都是控制好指针修改的顺序。把“先断后接还是先接后断”想明白代码就没有大问题。3.3 用Python写一遍体感完全不同Python写链表和C有个天壤之别没有指针语法用对象引用来模拟指针。每个节点就是一个对象next存的是下一个对象的引用访问属性时解释器自动完成解引用。class ListNode: def __init__(self, val0, next_nodeNone): self.val val self.next next_node class LinkedList: def __init__(self): self.dummy ListNode() # 哨兵节点对应C的头结点 self.size 0 def insert_after(self, pos, val): if pos 0 or pos self.size: raise IndexError(Index out of range) prev self.dummy for _ in range(pos): prev prev.next new_node ListNode(val, prev.next) prev.next new_node self.size 1 def erase(self, pos): if pos 0 or pos self.size: raise IndexError(Index out of range) prev self.dummy for _ in range(pos): prev prev.next to_delete prev.next prev.next to_delete.next self.size - 1Python没有delete一说节点不再被引用后会自动被垃圾回收省了C里最担心的内存管理问题。但Python的代价是性能上限低循环访问大量节点时比C慢不少。如果做算法题Python写链表题有个天然优势可以直接用while cur:来判空代码更简短但如果去啃底层库源码还是得读懂C/C的指针写法。4. 链表的经典算法题从平铺直叙到举一反三4.1 链表反转迭代和递归吃透链表反转是“链表面试第一题”高频中的高频。迭代法用三个指针就能搞定分别是prev、cur、next核心动作是先记住当前节点的下一个再把当前节点的next指向前一个然后整体向后平移。ListNodeT* reverse(ListNodeT* head) { ListNodeT* prev nullptr; ListNodeT* cur head; while (cur ! nullptr) { ListNodeT* nextNode cur-next; // 先保存下一站 cur-next prev; // 掉头 prev cur; // prev向后移动 cur nextNode; // cur向后移动 } return prev; // 最后prev就是新链表的头 }为什么必须先保存nextNode因为一旦执行cur-next prev原来指向下一站的“路标”就被覆盖了不提前记下来就再也走不到后面了。这也是链表题里特别常见的通病改一个指针丢一个节点。递归写法更隐蔽也更优雅。核心假设是“当前节点之后的链表已经反转好了”我只需要把当前节点接到它们后面ListNodeT* reverseRecursive(ListNodeT* head) { if (head nullptr || head-next nullptr) return head; ListNodeT* newHead reverseRecursive(head-next); head-next-next head; // 让下一个节点指回自己 head-next nullptr; // 断开自己指向下一个节点 return newHead; }这个递归确实有很多人想不明白。用一句话概括递归函数负责把“以head-next为头的链表”反转反转后返回的新头就是整条链表的新头当前节点要做的只是让原来的下一个节点反过来指向自己同时把自己原来的后继指针置空。画图比硬记代码有效得多建议在纸上模拟几次3个节点的链表递归过程。4.2 快慢指针环检测、找中点、倒数第k个快慢指针是链表题里的万能套路。它的核心逻辑是一个指针每次走一步另一个指针每次走两步如果有环快指针最终会“绕回来”和慢指针相遇如果没有环快指针会先到达尾部。判断链表是否有环的代码简洁到令人发指bool hasCycle(ListNodeT* head) { ListNodeT* slow head; ListNodeT* fast head; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; fast fast-next-next; if (slow fast) return true; } return false; }找链表中点也用快慢指针快指针到终点时慢指针正好停在中点。这种做法比“先遍历一遍求长度再走一半”少了一次完整遍历时间复杂度依然是O(n)但代码写起来特别漂亮。倒数第k个节点则是“快指针先走k步然后两个指针同步走快指针走完时慢指针就在倒数第k个位置”。这类题型的共同点是一次遍历哨兵指针空间复杂度O(1)这就是面试官最喜欢的答案形态。4.3 链表排序为什么归并排序是默认解给数组排序随便拉一个快排、堆排都行给链表排序最顺手的其实是归并排序。原因在于链表天生适合“分一半分别排好再合并”的过程找中点用快慢指针拆成两条子链表递归下去最后合并两条有序链表全程只需要改变指针指向不需要像数组那样开辟大量辅助空间。ListNodeT* mergeTwoLists(ListNodeT* l1, ListNodeT* l2) { ListNodeT* dummy new ListNodeT(T()); ListNodeT* cur dummy; while (l1 ! nullptr l2 ! nullptr) { if (l1-data l2-data) { cur-next l1; l1 l1-next; } else { cur-next l2; l2 l2-next; } cur cur-next; } cur-next (l1 ! nullptr) ? l1 : l2; return dummy-next; }mergeTwoLists本身也是面试里的常客。它的关键点是用dummy节点把合并结果串起来最后返回dummy-next避免一堆空指针判断。链表归并排序整体时间复杂度O(n log n)空间复杂度O(log n)递归栈开销相比数组归并的O(n)额外空间在链表场景下要友好很多。5. 常见问题与排查技巧实录5.1 野指针、断链和空指针访问链表调试时最经典的一句话叫“segmentation fault”。遇到这种崩溃第一反应应该是我是不是用了空指针。C里nullptr-next或者nullptr-data直接就得崩Python里则是AttributeError: NoneType object has no attribute next。排查的有效方法是检查每一个while循环的条件确保在循环结尾指针不是nullptr的情况下才去访问它的成员。断链是另一个高频事故。比如删除节点时先delete toDelete再使用toDelete-next或者插入时先修改了前驱的指针却还没保存后继节点的地址一回头发现整条链从中间断成两截后半截再也找不到了。我在快速排查时会用一个小技巧在关键操作前后打印每个节点的地址和值把链表“可视化”出来一条条对。看到0x0、(null)、None这类标记基本上就是空指针的问题。5.2 死循环最常见于循环链表和排序写循环链表的遍历时如果不加计数器或者不判断“是否回到了头节点”很容易陷入死循环。归并排序和快速排序的链表版本也会有递归不收敛的问题最常见的原因是递归终止条件写错比如应该判断head nullptr || head-next nullptr漏掉后半句就直接栈溢出。排查死循环的经验是不要只盯着代码看拿一个小规模的用例比如3个节点去手动模拟。如果手动模拟也绕晕了就在循环里加一个临界条件最多循环1000次就退出然后把当前节点的值打印出来。这个方法很土但确实能在调试时帮你快速锁定是哪一步的指针更新出了问题。5.3 内存泄漏与测试用例设计C里new了节点忘记delete程序不崩但内存悄悄泄漏跑得久了占用越来越大。尤其是析构函数没写或者析构时没有把每个节点都释放干净就会泄漏。辨别方法可以用valgrind检查或者观察程序退出前后内存变化。链表问题的测试用例设计也有门道。我的习惯是先测空链表再测只有一个节点的链表然后是两个节点最后才是多个节点因为很多bug恰恰出现在边界。接着测头部操作、尾部操作、中间操作最好再把链表逆序后测一遍。这套流程看起来繁琐但碰到“笔试现场写了代码错得一塌糊涂”的情况时非常管用。6. 几个提升代码质量的实践心得之前在网上看到有很多人被“数据结构高频核心知识点面试”这类热搜词搞得一头雾水其实大厂的链表题翻来覆去就那么几种套路反转、环、相交、合并、排序、快慢指针。只要掌握了套路背后的原理题目再怎么变形都能拆解成基本操作的组合。多写多练是唯一的捷径。我自己练链表时会把一类题放在一起横向对比比如把“判断环”“找环入口”“找中点”“找倒数第k个”放到同一天做因为它们的核心都是快慢指针。练习的时候不要背答案每道题至少手写两种解法迭代和递归、循环和哨兵。第一次写会感觉很痛苦写到第十遍以后这些指针操作的逻辑就会变成条件反射。等你能闭着眼睛把反转链表写出来链表这一章也就算真正拿下了。如果觉得单纯看文字不够直观强烈建议下载一个可视化的算法演示工具或者在纸上自己画节点和箭头手动模拟一遍插入、删除、反转的全过程。很多“一看就会一写就废”的问题本质上就是大脑里缺少这张“节点箭头图”把图画出来问题往往就迎刃而解了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价