☰
链表详解:从单链表到双向循环链表的核心操作与经典面试题
2026/10/10 12:38:53 网站建设 项目流程

很多人学数据结构时,最先接触的线性表有两种实现方式:顺序表和链表。顺序表就是数组,好理解,也好用;链表则让不少人一开始很懵——又是节点又是指针,画图的时候明明白白,一写代码就各种报错。我这些年不管是自己写代码还是带新人、看面试候选人,发现一个规律:真正把链表搞清楚的人,后面学树、图、哈希表都会轻松很多,因为链表教会你的不是"怎么存数据",而是"怎么用指针操作内存结构"。

这篇博文就把线性表中的链表(List)从头到尾捋一遍。会讲清楚它解决了什么问题、单链表/双向链表/循环链表各自的脾气、插入删除反转这些核心操作背后的逻辑,以及面试和考试里几乎必考的经典题目。内容按数据结构课的顺序走,但我会把我实际写代码、讲题时积累的经验和容易踩的坑一并放进去,尽量让你看完就能上手写、能应付考试和面试。

1. 为什么学了顺序表还要搞链表:存储方式的博弈

1.1 顺序表的三宗罪

顺序表在内存里是一段连续的地址空间,也就是数组。它最大的优点是随机访问——我要取第 i 个元素,直接array[i]就行,时间复杂度 O(1)。但它的三个缺点在工程里非常致命:

第一,扩容成本高。数组的容量是固定的,满了就得重新找一块更大的连续内存,把旧数据整体搬过去。这个搬家的开销是 O(n),而且搬运的时候旧内存和新内存必须同时存在,对内存的瞬时占用很夸张。我见过不少新手在写动态数组时反复触发扩容,结果程序越跑越慢,就是因为忽略了搬家的开销。

第二,插入和删除要挪元素。在数组中间插入一个元素,后面的所有元素都得往后让位;删除则是往前补位。这个操作平均要移动 n/2 个元素,时间复杂度 O(n)。数据量小还好说,数据量大了之后,大量的挪动操作会让程序卡得非常明显。

第三,存储空间是"整块"的。系统得在内存里找到一整段连续的空闲区域来容纳它。内存碎片化严重的时候,明明总空闲空间够用,但就是找不到一块足够大的连续区域,导致分配失败——"内存充足却装不下"的尴尬场景,熟悉 C 语言的人应该都撞上过。

1.2 链表的解法:用指针换灵活性

链表的思路完全不同:不要求存储空间连续,每个元素是一个独立的节点,节点之间用指针串起来。每个节点包含两部分——数据域(存值)和指针域(存下一个节点的地址)。因为不需要连续空间,所以链表实现了"有多少数据占多少内存"的动态性,插入和删除也只需要改指针的指向,不再需要移动海量元素。

代价是什么呢?代价有两个。第一,随机访问能力没了。想找第 i 个节点,只能从第一个节点开始逐个跳,时间复杂度 O(n)。第二,多了指针的存储开销。每个节点除了存数据还要存指针,对于像 int 这种小数据来说,指针域的开销甚至可能比数据本身还大。这是典型的空间换时间、灵活换访问速度的权衡。

我用一个生活化的类比来帮初学者理解:顺序表就像电影院的固定座位,一个人坐一个位子,大家坐得整整齐齐,但有人要中途换座位的话,整排人都得跟着动;链表就像玩"传纸条",每个人手里都攥着下一张纸条的线索,想给队伍中间加一个人,只需要改前后两张纸条上的线索就行,其他人完全不用动。

1.3 链表家族族谱:单链表、双链表、循环链表

顺序表基本就一种形态,链表却有多个变种,教材里最常见的就三种:

  • 单链表:每个节点只存一个指向后继节点的指针。特点是只能从头往尾走,想找前驱节点没办法,只能重头再来一遍。
  • 双向链表:每个节点有两个指针,一个指向前驱,一个指向后继。代价是每个节点多一个指针域,换来的是两边都能走。Java 的LinkedList、Redis 的列表对象底层就在用这个结构。
  • 循环链表:把单链表或双向链表的尾节点指针指回头节点,形成一个环。它的好处是从任意一个节点出发都能遍历整个链表,特别适合约瑟夫问题这类"转圈圈"场景。

这三个变种的存储逻辑完全一样,区别只在于指针的数量和指向。先吃透单链表,后面两个基本是加量不加价。

2. 单链表核心操作拆解:从建表到反转的每一步

2.1 节点定义与头节点的设计逻辑

先看最基础的节点定义,这是 C 语言教材的标准写法:

typedef struct Node { int data; // 数据域 struct Node *next; // 指针域,指向后继节点 } Node; // 创建一个新节点 Node* createNode(int data) { Node* node = (Node*)malloc(sizeof(Node)); if (node == NULL) { printf("内存分配失败\n"); exit(1); } node->data = data; node->next = NULL; return node; }

这里有个很多初学者会困惑的点:为什么很多实现里搞一个"头节点",而不是让头指针直接指向第一个元素节点?

我可以明确告诉你,头节点(也叫哑节点、dummy node)的核心价值在于统一操作逻辑。举个例子,假设链表带头节点,那么插入操作无论插入在链表的什么位置——头部、中间、尾部——代码逻辑完全一样,都是"找到前驱节点,然后修改前后两个指针"。但如果没有头节点,插入在头部和插入在其他位置就要分成两套逻辑:头部插入要修改头指针本身,中间插入要修改前驱节点的 next。删除操作同理,删除首节点和其他节点也要分开处理。

实际工程里我基本都会带头节点。它多占一个节点的内存,但换来的是代码大幅简化,出错概率直线下降。这个取舍非常划算,尤其是链表很长、操作频繁的时候。

2.2 插入和删除:为什么O(1)背后还有个隐藏条件

链表插入和删除的时间复杂度常被说成 O(1),但这是有前提的——你必须已经拿到了目标位置前驱节点的指针。实际情况是,你得先从头部遍历到指定位置才能拿到这个指针,遍历本身是 O(n) 的。所以"插入"这个动作本身 O(1),"找到插入点"是 O(n),合起来才是一般意义上的插入操作复杂度。

插入的核心步骤,以在节点prev之后插入新节点newNode为例:

  1. 新节点指向后继:newNode->next = prev->next;
  2. 前驱节点指向新节点:prev->next = newNode;

两步顺序不能反。如果先执行prev->next = newNode,那prev原来的后继节点就丢失了,新的节点没法链上去。这个错误我几乎每年都能在新手代码里反复看到,写代码时养成先保存再修改的习惯会少踩很多坑。

删除节点del则是让前驱直接跳过它:

prev->next = del->next; free(del); // 记得释放内存

这里要特别提醒一句:free 之后指针一定要置空。free(del)只是把内存还给系统,但del这个指针变量里存的地址还在,如果不手动置为NULL,后面万一不小心再访问它,就会碰到"悬空指针"问题,读到的可能是已经被系统重新分配给其他用途的内存数据,这叫未定义行为,排查起来极其痛苦。

2.3 反转链表的两种思路

反转链表是数据结构笔试面试里出现频率最高的问题,没有之一。它考察的就是你对指针操作手不手熟。

先说迭代做法。核心思想是逐个把节点的 next 指向前一个节点,但要注意,当你把 next 改成前驱之后,原来后面的链表就断了,所以必须先保存好下一个节点的指针,否则就遍历不下去了。

Node* reverseList(Node* head) { Node *prev = NULL; Node *curr = head; Node *next = NULL; while (curr != NULL) { next = curr->next; // 先保存后继 curr->next = prev; // 反转指向 prev = curr; // 前驱指针前移 curr = next; // 当前节点前移 } return prev; // 遍历完,prev 指向新头节点 }

这段代码的逻辑可以记忆为"三指针轮换":prev 是上一个,curr 是当前,next 是下一个。循环体里的四行代码就是四个动作——保存后继、反转指向、三个指针集体平移。笔试的时候如果能默写出这个,基本是稳的。

递归写法更简洁但不太容易理解:

Node* reverseListRecursive(Node* head) { if (head == NULL || head->next == NULL) return head; Node* newHead = reverseListRecursive(head->next); head->next->next = head; // 让后继节点指回自己 head->next = NULL; return newHead; }

递归的思路是:先假设"去掉当前节点之后的子链表已经反转好了",这时head->next正好是子链表原来的尾节点,也就是新子链表的头,让它的 next 指向 head 即可。理解递归的关键是不要顺着递归一层层往下想,而是假设子问题已经解决,只考虑当前层怎么处理。

2.4 遍历与倒序输出

遍历链表的代码很基础,但有个细节容易踩坑:判断条件用p != NULL还是p->next != NULL?

  • 用p != NULL:循环结束后 p 是 NULL,最后一次处理的是最后一个节点。
  • 用p->next != NULL:循环结束后 p 是尾节点,适合"处理到倒数第二个就停止"的情况。

倒序输出链表,如果要保持原链表不变,有个小技巧是利用递归的调用栈:

void printReverse(Node* head) { if (head == NULL) return; printReverse(head->next); printf("%d ", head->data); }

先在递归中一路走到链表末尾,然后在回溯回来的路上打印,天然就是逆序。这个方法空间复杂度是 O(n)(递归栈),但胜在代码极简,考试时能快速表达思路。需要我写非递归版本的话,通常会用一个栈或者把链表反转后打印,面试时如果先用递归给出思路,再被追问"栈帧太深怎么办",再给出栈的做法,会显得有层次。

3. 双向链表和循环链表:什么时候值得多花一个指针

3.1 双向链表:找前驱不用再从头跑

单链表最大的尴尬在于:想在某个节点前插入一个元素,或者想删除某个节点但手上只有这个节点的指针,这时候你必须从头遍历才能找到它的前驱。如果链表很长,这个成本就非常可观。

双向链表直接解决了这个问题。它的节点定义多了一个指针prev:

typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;

在某个节点p的前面插入新节点s,按下面顺序操作:

s->prev = p->prev; s->next = p; s->prev->next = s; p->prev = s;

这里依然要注意操作顺序。先断谁、后断谁很有讲究,如果先执行s->prev->next = s或者p->prev = s,后面的步骤可能就无法正确完成了。实际操作时我建议先把新节点的两个指针都接好,再动原来链上节点的指针,这样每一步都安全。

双向链表多付出的代价是每个节点的内存又大了一截,而且插入和删除时指针赋值多了。如果你的程序经常需要"倒着走"——比如实现双向队列、LRU 缓存、文本编辑器里的光标前后移动——双向链表是合理选择;如果只需要单向遍历,别为了"听起来高级"而用双链表。

3.2 循环链表与约瑟夫环

循环链表就是把尾节点的next指回头节点,形成一个闭环。很多人觉得它是个奇技淫巧,其实不是。操作系统进程调度、实现"环形缓冲区"、音视频播放列表循环、以及著名的约瑟夫问题,都用得着它。

约瑟夫环问题描述很简单:n 个人围成一圈,从第一个人开始报数,报到 m 的人出列,然后从下一个人重新开始报数,直到所有人出列。如果用一个普通单链表模拟"围成一圈",每次走到尾节点还得手动跳回头节点,代码会多不少 if 判断;用循环链表就非常贴合这个场景,尾节点next自动指向头节点,遍历"转圈"的代码不用做任何特殊处理。

核心循环逻辑大概是:

while (p->next != p) { // 只剩一个节点时停止 for (int i = 1; i < m - 1; i++) { p = p->next; // 找到报数 m 的前驱 } Node* del = p->next; p->next = del->next; // 跳过出列节点 printf("出列: %d\n", del->data); free(del); p = p->next; // 从下一个节点重新报数 } // 最后剩下的就是幸存者

约瑟夫问题在考研数据结构里也常考,不一定让你写完整代码,但经常让你手工模拟几轮。我的建议是画一张环形图,标好编号和报数方向,每一步把"指针指向谁"标清楚,模拟起来又快又准。

3.3 快速判断链表是否有环

判断单链表有没有坏掉(有环)是另一个热门题。经典解法是快慢指针:快指针每次走两步,慢指针每次走一步,如果链表有环,两个指针最终会在环里相遇;如果无环,快指针会先一步跑到 NULL。

为什么快指针两步、慢指针一步一定能相遇?因为每次移动后,快指针相对于慢指针的"步差"是 1,也就是说每走一轮,快指针会追上慢指针一步,若干轮后必然追上。如果步差不是 1(比如快指针走三步),反而可能跳过慢指针永远追不上,所以这个"步差 1"是数学上保证正确性的关键。

我遇到过同学问:为什么不能每次让快指针走三步、四步,更快一点?答案就是上面说的,一旦步差大于 1 就不保证相遇了。这个题还有进阶版本——判断环的入口节点,用的是"相遇后一个指针从头出发,另一个从相遇点出发,再次相遇处就是环入口"的结论,推导过程依赖步差关系,这里不展开,但值得你有空自己推一遍,印象会深很多。

4. 链表经典考题盘一盘:合并、相交、找中间节点

4.1 合并两个有序链表

这道题在 LeetCode 上是 21 题,考研和面试也常出现。它考的核心是归并排序的链表版本——两个有序链表从头比较,谁小就挂到结果链上,最后把剩余的链条直接拼接。

用递归写非常直观:

Node* mergeTwoLists(Node* l1, Node* l2) { if (l1 == NULL) return l2; if (l2 == NULL) return l1; if (l1->data < l2->data) { l1->next = mergeTwoLists(l1->next, l2); return l1; } else { l2->next = mergeTwoLists(l1, l2->next); return l2; } }

这里同样要记住"先假设子问题已解决"这个递归套路。迭代版本的代码稍微长一些,但思路更直观,我建议两个版本都练一遍。还有一道几乎同源的题叫"合并 k 个有序链表",是它的加强版,做法是每轮两两合并,复杂度是 O(kN),如果追求速度可以用优先队列,那是后话。

4.2 求链表中间节点与倒数第k个节点

求中间节点也是快慢指针的典型应用:快指针每次两步、慢指针每次一步,快指针到达末尾时,慢指针正好在中间。

求倒数第 k 个节点,思路稍变一下:先让快指针走 k 步,然后快慢指针一起每次走一步,当快指针到达 NULL 时,慢指针指向的就是倒数第 k 个节点。原理就是让两个指针之间保持 k 的距离,像卷尺一样从末尾量回来。

这两道题在笔试里看起来不难,但很容易在边界条件上翻车。比如链表长度为奇数或偶数时,快指针跳两步之后会不会越界?倒数第 k 个节点的 k 大于链表长度时怎么办?我的经验是:先把边界条件想清楚再动手,写完代码之后务必手动模拟一遍空链表、单节点、两个节点、奇偶长度这几种情况,这部分功夫省不得。

4.3 判断两个链表是否相交

两个链表相交的意思是它们从某个节点开始共享同一段内存节点,形成一个"Y"字形。判断相交不能只比较值,要比较地址——两个节点地址相同才算相交。

一个比较巧的解法是:先分别遍历两个链表得到长度,长链表先走差值步,然后两个指针同步前进,第一次遇到地址相同的节点就是交点。还有一种更简洁的双指针法:一个指针走完链表 A 后跳到链表 B 的开头,另一个走完链表 B 后跳到 A 的开头,两个指针如果相交必然会在交点相遇,如果不相交就同时走到 NULL。

这道题的价值不仅在于题目本身,更在于它提醒你:链表操作经常需要"对齐起点"才能比较。这类"双指针对齐"的思路,后面做链表相关的很多难题都用得上。

5. 工程里的链表:哪些说法是误解,哪些坑必须躲

5.1 链表和数组的真实性能差异

很多教科书和博客会告诉你"链表插入删除快、数组访问快",这句话对,但非常容易被误解为"链表比数组好"。我在实际工程里见过的真实情况是:绝大多数场景下,数组的性能表现远优于链表。

原因在于计算机的内存模型。数组是连续存储的,遍历的时候 CPU 的缓存机制会把相邻元素一次性加载进高速缓存,后续访问几乎全部命中缓存,速度很快。链表节点分散在内存各处,遍历每个节点时大概率都要去内存里重新取,而内存访问比缓存访问慢一两个数量级。换句话说,链表的 O(1) 插入换来的代价是较差的缓存局部性。链表操作复杂度虽然一样,但常数因子常常大得多。

所以数据量不大时,直接用数组;频繁在中间插入删除且数据量大时,才考虑链表。如果你不确定数据规模,很多语言里的vector或者动态数组通常就是更省心的选择。

5.2 语言差异:Python的list到底是不是链表

有件事值得单独拿来说,因为太多人在初级互转时搞混:Python 的list是动态数组,不是链表。它内部其实是元素指针的连续数组,支持高效的索引访问,但list.insert(0, x)这类操作是 O(n) 的,因为要整体右移。Java 里ArrayList类似,而LinkedList才是真正的双向链表。

如果你在 Python 里真的需要用链表结构,官方collections模块里没有现成链表,但deque(双端队列)在底层用块状链表实现,支持两端 O(1) 的插入删除,比list更适合频繁队首队尾操作的场景。C++ 的std::list是双向链表,std::vector是数组,名字上就能看出作者的用意。

我在带项目时遇到过一个实际案例:业务代码里要维护一个高频队列,频繁在列表头部插入,最初用 Python 的list,一压测就爆炸,换deque之后性能立竿见影。如果你也有类似"在头部频繁插入/弹出"的需求,先查一下你所用语言里对应的数据结构实现,再决定用数组还是链表,而不是凭感觉写insert(0, item)。

5.3 真实项目的用武之地

一句话总结链表的工程价值:它很适合实现"不确定大小 + 频繁中间插入删除 + 结构本身需要动态变化"的数据结构。

最典型的例子是 LRU 缓存淘汰算法。LRU 需要快速知道"哪个数据最久没被访问",同时每次访问都要把该数据移到最前面,这正好是"在中间位置删除 + 在头部插入"的操作用武之地,所以经典的 LRU 实现就是哈希表 + 双向链表:哈希表负责 O(1) 定位节点,双向链表负责 O(1) 调整顺序。Java 的LinkedHashMap和 Redis 的缓存淘汰都依赖这个结构。

操作系统内核、文件系统和网络协议的实现里,链表也随处可见。文件系统要维护一系列空闲块链表,内核要维护各种进程队列、定时器链表,"节点数量动态增长、需要快速删除任意元素"这类的场景下,链表几乎是无法替代的。

我给你的建议是:数据结构课上学链表,重点学的是"指针操作的思维方式"和"复杂度的严谨分析",这些能力后面学树、图甚至设计算法的时候都会反复用到。但在工程选型时,永远要结合"数据规模、访问模式、缓存友好性"来综合判断,不要被"链表更高级"这类直觉带偏。

最后分享一个我自己的习惯:写链表代码之前,先花一分钟在白纸上把链表的形态画出来,标好每个指针的指向,在要动指针的地方打上圈。画图看似慢,实际上省掉的是反复调试的时间。这个习惯从我刚学数据结构用到现在,写树、写图的时候也一直在用,算是我对链表最有感触的一课。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询