很多人在刚学数据结构的时候,都会有同一个感觉:数组还没用明白,怎么又要写链表了?其实单链表恰恰是帮你从“静态存储”走向“动态存储”的第一道坎。只要把单链表真正想通了,后面双向链表、循环链表、二叉树甚至图的邻接表,都会顺很多。
这篇文章要聊的就是单链表的实现。从底层结构设计到查找、插入、删除、逆序这些高频操作,再到实验课和面试里最容易踩的坑,我都会用自己的话来拆一遍。无论你是刚接触数据结构的大学生,还是正在准备算法面试的开发者,都能从里面捞到点能直接用的东西。
我不会把代码往屏幕上一贴就走,而是要把每段代码背后的设计思路、每个容易写错的地方都讲透。看完之后你可以不照抄我的实现,但你会知道为什么头结点有时候很重要,为什么逆序递归的代码让人头疼,以及调试链表时到底该在哪里打断点。
1. 单链表的整体设计与思路拆解
1.1 为什么单链表不是数组的“替代品”
很多人刚开始接触链表会误以为,这就是一种比数组更高级的存储方式。如果抱着这个想法去学,后面会越学越别扭。链表和数组其实是完全不同的两种存储思路,谈不上谁替代谁。
数组的特点是内存空间连续。你可以通过下标一步算出第 i 个元素的地址,所以随机访问很快,时间复杂度 O(1)。但也正因为内存连续,一旦你想在中间插入或者删除一个元素,就要把后面的元素整体搬家,最坏情况是 O(n)。另外数组扩容通常要重新申请一块更大的内存再拷贝旧数据,这在元素很多时会让人很头疼。
单链表的思路反过来。每个结点存一份数据和一个指向下一个结点的指针,物理内存上根本不需要连续。这样带来的好处是在已知前驱结点的情况下,插入和删除一个结点只需要改指针,时间复杂度 O(1)。但这些好处不是白拿的。链表失去了随机访问能力,你想找第 i 个结点必须从头开始遍历,时间复杂度 O(n)。这在某些场景下完全能接受,但在需要频繁按下标访问的场景里就很不合适。
所以我在做技术选型的时候,从来不问“数组和链表谁好”,而是问“我这批数据到底怎么用”。如果需要频繁按下标访问,优先数组;如果数据量变化大,且操作集中在头部或已知位置,单链表就很有优势。
1.2 核心存储结构:结点、头指针和尾结点的“三角关系”
单链表的存储结构如果用大白话讲,就是每个结点都由两个部分组成:一部分存数据,另一部分存下一个结点的地址。数据部分是真正承载业务逻辑的地方,指针部分则是把分散的内存块串起来的关键。
单独的结点很好定义,真正容易把人绕晕的是头指针和头结点这两个概念。简单区分一下:
- 头指针是指向链表第一个结点的指针。它不管链表是空的还是非空的,一定存在。
- 头结点是链表第一个结点前面附加的一个特殊结点,它不存放有效数据,只作为“哨兵”。
在教科书和多数工程实现里,我倾向于带头结点的写法。为什么不干脆把第一个数据结点直接当头结点用?因为不带头结点时,当你需要删除第一个结点,或者在第一个结点前面插入新结点,函数的参数就必须接收“头指针的指针”,否则函数内部修改了头指针,外部根本不知道。
这个问题在 C 语言里,我会写成 void insert(Node **head, int data),在 C++ 里可以用引用参数 void insert(Node* &head, int data)。虽然能解决,但每次调用都要记得传地址,很容易忙中出错。
带头结点的链表就不一样了。无论链表空不空,头指针始终指向那个哨兵结点,并且哨兵结点永远不被删除。这样所有插入和删除操作都变得统一,因为“第一个有数据的结点”前面也有了一个逻辑上的前驱。用 Python 写的时候虽然没有指针语法,但我们可以用 ListNode 对象之间的引用来模拟同样的结构。这也是为什么做题时经常看到类似 dummy = ListNode(-1); dummy.next = head 的写法,dummy 就是那个头结点,它存在的意义就是为了把所有插入操作都变成普通情况。
尾结点的特征也值得说清楚:next 指向 null。在 C/C++ 里就是空指针 nullptr,在 Python 里就是 None。遍历链表时的停止条件,以及判断链表是否结束,全靠这个空指针。很多程序崩溃、死循环的问题,本质上都是尾结点没有正确置空。
1.3 单链表能干什么,不能干什么
单链表在真实工程里的作用,经常被我身边不写底层代码的朋友低估。举几个我实际接触过的场景:
- LRU 缓存淘汰算法,一般会用哈希表配合双向链表。单链表虽然也能做,但双向链表在淘汰尾部元素时更方便,因为可以直接拿到前驱。
- 操作系统空闲内存管理里有种办法是空闲链表,把当前可用的内存块串起来,分配和回收都走链表操作。
- 一个进程里如果有多个任务需要定时调度,有时会用链表把任务串起来,方便任务插入和删除。
单链表不适合的场景也很明确:频繁需要反向访问数据、需要按下标随机定位的操作,这些都是它的短板。我见过有人拿单链表去存排行榜数据,结果每次要取第 10 名都得从头遍历,代码写起来非常憋屈,后来还是换成了数组和跳表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单链表核心功能与代码实现
2.1 结点定义:最简单的数据单元
先用 C++ 写一个最经典的结点定义,因为 C++ 能最直观地体现“指针把结点串起来”这一步:
cpp复制struct ListNode {
int val;
ListNode* next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode* next) : val(x), next(next) {}
};
有些人会在结点里多写一个构造函数,方便创建结点时不漏掉 next 的初始化。这是我的个人习惯,因为漏初始化 next 是个经典崩溃源,尤其在 C++ 里如果 next 是个未初始化的野指针,后面遍历时就不知道会跑到哪块内存去。
如果用 Python,代码会更简洁:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
很多刷题平台比如 LeetCode 直接把 ListNode 给你,但不告诉你具体怎么用。这里我要补充一句:在线做题时,局部变量在函数结束之后还能不能访问,取决于语言的内存管理机制,而不是链表本身的逻辑。Python 里因为对象在引用计数归零时才释放,所以只要链表的头结点还被外部引用,整条链上的结点就都不会被回收。
2.2 创建链表:头插法和尾插法到底差在哪
创建链表最常用的算法有两种,一个是头插法,一个是尾插法。
头插法的思路是每次都把新结点插入到链表的头部,也就是头结点之后。这种方式在处理逆序数据时很有优势,因为后插入的结点会跑到前面,最终链表的顺序和输入顺序相反。很多面试题里要求不申请额外空间逆序一个数组,其实可以借助头插法来实现。头插法代码写起来也比较短,因为不需要维护尾指针,只要记住当前头结点的 next 是谁就行。
尾插法就是老老老实实把新结点追加到末尾。这样做能保证最后链表元素的顺序和输入顺序一致。需要维护一个 tail 指针,每次插入时都要更新它。在很多实际需求里,我们的业务数据有顺序要求,所以尾插法用得更多。
我给出一个 C++ 尾插法的参考:
cpp复制ListNode* createByTailInsert(const vector<int>& nums) {
ListNode* dummy = new ListNode(); // 头结点
ListNode* tail = dummy;
for (int num : nums) {
ListNode* newNode = new ListNode(num);
tail->next = newNode;
tail = newNode;
}
return dummy; // 返回头指针
}
这里我用了 dummy 做头结点,所以创建链表时不需要处理“空表”的特殊情况。如果不用 dummy,就得每次判断头指针是否为空,代码会平白多出一堆分支。
在 Python 里你可以这么写:
python复制def create_by_tail_insert(nums):
dummy = ListNode(-1)
tail = dummy
for num in nums:
tail.next = ListNode(num)
tail = tail.next
return dummy.next
注意最终返回的是 dummy.next,不是 dummy。因为 Python 里很多测试框架不希望你返回带哨兵的链表,它们默认返回第一个数据结点。而在 C++ 或 C 的课程设计里,返回带头结点的头指针更常见。两种返回方式没有绝对的对错,关键是整个程序里保持一致。我见过不少同学把带头结点的链表返回给一个不带头结点的遍历函数,结果多跳过一个数据结点,排查半天才发现是约定不一致导致的。
2.3 遍历和查找:看似简单,最容易踩空指针的坑
遍历单链表的固定套路其实就一句话:从头指针出发,用 cur 指针逐步后移,直到 cur 为 null。很多初学者会把条件写成 while (cur->next != nullptr),这会导致拿到最后一个结点就停止遍历。如果只是打印还好说,如果要在最后一步做删除、逆序等操作,就容易出错。
更头疼的问题是在循环体里改变链表结构。比如你想在遍历过程中删除某些元素,如果提前把 cur 结点删了,那下面的 cur=cur->next 就是访问已释放内存,这在 C++ 里会直接导致未定义行为,程序时好时坏,非常难排查。一般做法是先用 nextNode 保存下一个结点位置,再执行删除,最后再让 cur 指向 nextNode。说白了就是一个原则:先备份,再修改。
按值查找某个结点是否存在,算法很简单,从 head 开始遍历,比对每个结点的 val,找到了就返回该结点,找不到就返回 null。需要注意如果链表里有多个相同值的结点,函数返回第一个匹配的就行。
对于带头结点的链表,遍历的起点是 head->next,如果不带头结点,起点才是 head 本身。这块逻辑不难,但确实很容易被“头结点”和“第一个数据结点”混淆。我建议在用带头结点链表时,把一开始的遍历起点固定记成 cur = head->next,千万不要养成从 head 直接开始遍历的习惯。
2.4 插入和删除:改指针顺序是关键
插入操作必须严格遵循“先接新链,再断旧链”的顺序。以在 p 结点之后插入 newNode 为例:
cpp复制newNode->next = p->next;
p->next = newNode;
这一步看着简单,但我在教学和带实习生的过程中,见过大量先写 p->next = newNode; 再写 newNode->next = p->next; 的错误写法。这样一写,p->next 已经被覆盖了,newNode 再往后指就只能指向它自己,链表从中间断成了两截。
删除 p 结点之后的那个结点 q 只需要两步:
cpp复制ListNode* q = p->next;
p->next = q->next;
delete q;
注意一个细节:如果是在头部插入,并且你用了头结点,这件事就变成了“在 dummy 结点之后插入”,代码和普通插入完全一样。如果不用头结点,就得单独判断头指针为空的情况,代码要多一个分支。这就是我前面坚持用头结点的原因。
Python 里没有手动释放内存的问题,删除结点后交给垃圾回收即可。但如果是写底层代码或者做课程设计,C++ 里一定要记得 delete 被摘除的结点,否则内存泄漏。有些在线判题系统不检测内存泄漏,程序运行完操作系统会回收,但个人实验和项目里不能养成这种坏习惯。
3. “单链表的基本操作实验”到底在考什么
3.1 基础操作背后的隐藏考点
很多学校的“单链表基本操作实验”看起来很基础,无非就是建立链表、打印、查找、插入、删除。但课程设计喜欢在里头挖坑。比如实验要求可能是“输入若干学生成绩,按输入顺序创建链表,删除成绩低于 60 分的结点,并输出剩余结果”。
这种题目真正考的不是你会不会写单个函数,而是会不会把几个基本操作组合起来,并且在删除过程中依然保持链表结构正确。如果只学了函数本身,很容易在删除循环里踩“跳过下一个结点”的坑。
我来模拟一个典型的错误实现。假设链表的成绩值存在 val 里,有个同学写了这样的循环:
cpp复制ListNode* cur = head;
while (cur) {
if (cur->next && cur->next->val < 60) {
ListNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
}
cur = cur->next;
}
读起来好像没问题,但仔细看会发现,当删除 cur 后面那个结点时,新的 cur->next 还没来得及检查,程序就移动到了 cur,于是新搬上来的结点直接被跳过了。正确做法是在删除节点时 cur 不要动,只更新 cur->next,把循环条件改成 while (cur->next) 更安全,这样每一轮都会检查当前后继。
从这可以看出,实验课往往不是为了让你抄代码,而是通过这些组合任务帮你把链表结构记在脑子里。我的习惯是每写一次涉及删除的循环,就在纸上画出 cur、前驱、后继三个结点,修改指针前先标出要断哪条边、要接哪条边。
3.2 一个既能运行又适合观察的单链表全套代码
我提供一个可以直接跑通的 Python 版本,用于课程实验或者自测。它包含建表、遍历、查找、插入、删除和长度统计。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class LinkedList:
def __init__(self):
self.head = ListNode() # 哨兵头结点
def append(self, val):
cur = self.head
while cur.next:
cur = cur.next
cur.next = ListNode(val)
def insert_after(self, pos_node, val):
if not pos_node:
return
new_node = ListNode(val)
new_node.next = pos_node.next
pos_node.next = new_node
def remove(self, val):
prev = self.head
cur = self.head.next
while cur:
if cur.val == val:
prev.next = cur.next
return True
prev = cur
cur = cur.next
return False
def search(self, val):
cur = self.head.next
idx = 0
while cur:
if cur.val == val:
return idx
cur = cur.next
idx += 1
return -1
def length(self):
cur = self.head.next
cnt = 0
while cur:
cnt += 1
cur = cur.next
return cnt
def print_list(self):
cur = self.head.next
values = []
while cur:
values.append(str(cur.val))
cur = cur.next
print(" -> ".join(values) if values else "empty")
这段代码为了可读性,把删除操作设计成只删除第一个匹配的结点。如果你要在实验中一次删除所有匹配结点,可以参考我在 3.1 里提到的思路:让 prev 保持不变,一旦 prev.next 匹配就摘除,否则再移动 prev。用哨兵节点之后,无论头结点是否被删除,算法都能统一处理。
我自己在用这段代码验证算法时,会在每个操作之后马上调用 print_list()。很多初学者喜欢等写完整段程序再一口气跑,结果出错后不知道问题出在哪个函数里。我建议实验时把每个操作作为一步,打印一行输出,这样逻辑错误能快速定位到具体操作。
3.3 实验报告里的需求分析和模块设计怎么写
有些同学在实践中功能都实现了,但实验报告结构混乱,导致分数不高。写单链表实验报告时,最重要的不是贴一大坨完整代码,而是要清晰说明你的模块划分。你可以把整个程序分成:链表结点定义、链表管理类、主函数。主函数里再按菜单流程调用功能函数。例如:
cpp复制while (true) {
cout << "1.尾插法建表" << endl;
cout << "2.打印链表" << endl;
cout << "3.删除指定元素" << endl;
cout << "0.退出" << endl;
int op;
cin >> op;
// 分支处理
}
我在指导学生时经常说,报告里的流程图不需要太复杂,核心是展示“用户输入某个选择后,程序会调用哪个函数”。最好把每个函数的功能、输入参数、返回值和边界情况都用一两句话说明白。删除函数的边界情况包括删除第一个元素、删除最后一个元素、链表为空、处理值为空等情况。
4. 单链表逆序的原理与实操
4.1 为什么要专门聊逆序
热搜词里既然有“python单链表逆序”,那我必须重点展开这个环节。逆序是单链表学习里最有代表性的一个综合操作。
数组要逆序,直接首尾交换就行,因为知道起点和终点。链表没法那样玩,因为单链表每个结点只存储后继地址,没有前驱指针。你没办法从尾往头走。所以你需要借助迭代或递归来改变指针方向。
逆序的本质,就是把每个结点的 next 从“指向下一个结点”改成“指向上一个结点”。原来的头结点变成尾结点,原来的尾结点变成新头结点。这样做之后,整个链表的遍历顺序就反过来了。
4.2 迭代逆序:三个指针跳来跳去
迭代逆序是面试和课程考试中最常见的写法。核心思路是准备三个指针:pre 指向前一个结点,cur 指向当前结点,next 用来保存下一个结点,防止链表在修改指针后断掉。每次循环干三件事:先记住 cur 的下一个结点;把 cur.next 指向 pre;然后 pre 和 cur 同时往后移动一步。
C++ 实现如下:
cpp复制ListNode* reverseList(ListNode* head) {
ListNode* pre = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* nextNode = cur->next;
cur->next = pre;
pre = cur;
cur = nextNode;
}
return pre;
}
很多人第一次看这个代码会想:为什么最后返回的是 pre 而不是 cur?因为循环结束的条件是 cur 为空,说明 cur 已经跳出链表末尾,而 pre 正好停在新链表的头结点上。如果原链表为空,循环不执行,pre 保持 null,结果也正确。
我在教这个算法时,会建议大家动手画图。画几个结点,然后用手遮住代码,每次执行循环都更新一次箭头方向。连续画个五六轮,代码的逻辑就会比较清楚。需要注意最后把原头结点的 next 置空了吗?实际上不用单独做,因为第一轮循环里 cur 就是原头结点,它的 cur->next = pre 等于赋成 null,所以它自动变为尾结点。
4.3 递归逆序:理解不了的代码可以有另一条路
递归逆序代码极短,但理解门槛确实高。我先列出主要代码:
python复制def reverse_list(head):
if head is None or head.next is None:
return head
new_head = reverse_list(head.next)
head.next.next = head
head.next = None
return new_head
这里最重要的理解方式是“不要试图追踪每一层递归的细节,而是相信函数定义”。我定义 reverse_list(head) 的作用是:把以 head 为头的链表逆序,并返回新的头结点。那么如果我把 head.next 当作另一个链表的头,调用同样的函数后,这个子链表已经被逆序好了,并且它的最后一个结点就是原来的 head.next。此时我只需要把 head 接到这个子链表的尾部,也就是执行 head.next.next = head。
递归版本对新手来说很容易陷入“栈溢出”或“绕晕”的状态。如果一遍没看懂,我建议先用迭代法。等迭代法写熟了,再回来看递归法,并配合小型链表逐步验证。比如链表是 1->2->3,你只看第二层:head=2,子链表 3 返回 3,然后 2.next.next 也就是 3.next 指向 2,2.next 置空,于是子链表 3->2 被构建出来。返回给上一层后,1.next.next 也就是 2.next 指向 1,子链表最终变成 3->2->1。
4.4 逆序问题的变体:区间逆序、K 个一组逆序
在把基本逆序掌握后,我建议再往深走一层,因为很多面试题不会直接让你逆序整条链表,而是考局部逆序、分段逆序。比如“反转从位置 left 到 right 的区间链表”就是 LeetCode 92 题。它的核心做法是:先找到 left 的前一个结点 pre,然后从 left 开始做 left-right 次迭代头插,或直接把区间内结点摘下来逆序再接回去。
“K 个一组反转链表”则是 25 题。它把链表按长度 K 分组,在每组内部做逆序,然后能把前后组接好。这类问题的代码量和边界条件都多很多。做这些题的前提是先吃透整链表逆序。如果整链表逆序还手生,建议不要直接挑战这些变体,否则很容易把边界条件绕晕。
5. 常见问题排查与性能认知
5.1 写单链表最容易碰到的运行时错误有哪些
先整理一份速查表。很多问题本质上都集中在指针失控和边界条件没考虑全。
- 空链表访问:在空链表上调用取第一个结点、删除或逆序,忘掉判断 head 是否为空。
- 野指针:创建结点后没有初始化 next,导致遍历时指针飘到未知地址。
- 指针自指:在插入操作里,先覆盖了当前 next,再用它赋值,结果新结点指向自己。
- 尾结点未置空:逆序或删除最后一个结点后没让 next 指向 null,打印链表时会出现冗余或越界。
- 死循环:循环条件里 cur 没有前进,或删除循环中 cur 被错误地固定住了。
- 内存泄漏:C++ 里删除结点后没 delete,或者把 head 指针弄丢,导致整条链没法访问。
举例说明:我在 C++ 里做逆序时,第一版代码经常写成下面这种:
cpp复制while (cur != nullptr) {
cur->next = pre;
pre = cur;
cur = cur->next; // 错误!
}
第二行 cur = cur->next 这里,cur->next 已经被上一个语句改成 pre 了,cur 根本没跑到下一个结点。正确的做法是在修改 cur->next 之前先把下一个结点保存起来。这就是迭代逆序为什么要用一个临时 nextNode 的原因。很多学习者第一次写这个循环都会栽在这里,因为经验积累不深时想不到要保存。
5.2 定位链表 bug 的三板斧
我自己调试链表的顺序几乎固定:先是“打印看形状”,再是“断点看状态”,偶尔用“额外计数器”验证长度。
打印是最直接的手段。在每个关键步骤后,打印整条链表的结点值。如果发现顺序不对,立刻能判断是插入、删除还是逆序出问题。比如我在逆序完成后习惯打印新链表,如果发现原尾结点确实在了最前面,后面每个结点也依次连接,就说明逆序成功。
断点要看的是指针本身。假如在 VS Code 或 CLion 里调试,我会给循环体第一行打断点,然后逐行看 cur、pre 的地址以及 cur->next 的变化。很多人在 C++ 里出现段错误时一脸懵,其实用调试器一看就是访问了空指针,或者访问了已经 delete 的结点。Python 里则相对安全,报错信息会直接告诉你 NoneType 没有 .next 属性。看到这种提示,一般就是遍历到了空结点。
长度计算也有验证价值。每完成一次插入或删除,我可能会用独立计数器统计长度,看和预期是否一致。有时候链表结构和打印都对,但某些算法依赖 length 字段,计数器错误会导致下标访问错位。
5.3 单链表和数组的性能对比,别把“快”理解成万金油
为了更理性地选择数据结构,我通常建议拿实际使用频次说话。需要区分插入和发生在什么位置。这里对常见操作做一个大概对比:
| 操作 | 数组 | 单链表 |
|---|---|---|
| 按下标访问第 i 个元素 | O(1) | O(n) |
| 在已知位置后面插入结点,不移动已有元素 | O(n)(因为后续元素要搬家) | O(1) |
| 删除已知位置后面的结点 | O(n) | O(1) |
| 查找某个值 | O(n) | O(n) |
| 空间连续性 | 连续内存,对 CPU 缓存友好 | 结点分散,缓存命中率低 |
表里有一点容易被忽略:链表的 O(1) 插入删除,前提是你已经站在目标位置附近。如果你还需要从头遍历去找到那个位置,那总成本还是 O(n)。所以别看到一个操作是 O(1),就以为整段代码的时间复杂度也一定低。
在真实工程里,由于 CPU 缓存的存在,很多看似数组更慢的插入操作,在数据量不大的时候反而比链表快很多。因为数组是连续的内存块,遍历时能预取到后面多个元素;链表每个结点散落在堆中,每次访问都大概率要等内存。这个问题是做性能评测时会被实际数据反复验证的。
5.4 调试样例该怎么设计才能覆盖更多坑
我在给学生讲链表实验时,一直强调不要只用一个测试用例就跑。好的测试输入应该覆盖空链表、单元素链表、操作头元素、操作尾元素、删除所有元素等情况。
例如演示删除操作,至少要试这些:
- 删除头结点,链表有多个元素。
- 删除尾结点,链表末尾元素被摘除后,新尾结点的 next 是 null。
- 删除不存在的元素,函数要返回 false 而不是崩溃。
- 连续删除所有元素,最后链表为空。
逆序操作也要分别测试空链表、单元素链表、两个元素的链表、多个元素的链表。空链表返回 null 或空节点,单元素链表返回原头即可。两个元素算最简复现场景,能有效暴露迭代逆序中 cur 移动错误问题。四个元素更适合观察整体逆序效果。
在我现在的习惯里,写链表相关代码会直接把测试用例也同时列出来。尤其是用于实验报告时,一张包含“输入”、“预期输出”、“实际输出”的表格会极大提升可读性。这不只是给老师看的,也是帮助自己下一次维护代码时快速判断有没有回归问题。
6. 从单链表延伸出去:双向链表和循环链表很快就能上手
我把单链表讲完,最后想提一句扩展方向。因为如果你学习链表是为了应付“数据结构”这门课后面的内容,单链表只是第一块基石。真正在工程和应用中出场率更高的往往还有双向链表和循环链表。
双向链表每个结点多了一个 prev 指针,可以往前回退。这解决了单链表无法高效访问前驱的问题,但也带来了更多指针维护工作。循环链表则会末尾结点的 next 指向头结点,形成环。这样你可以从链表中任何一个结点出发走遍整个链表。不过循环链表在判断结束时要特别注意避免死循环。
如果你把单链表中的头结点、哨兵概念掌握好,看双向链表时会很轻松,因为哨兵思想能帮你统一处理各种边界条件。而 Java、Python 标准库中的 LinkedList、collections.deque 在底层实现上往往也不是单链表,而是双向链表或动态数组。学单链表时的价值,更多在于培养对指针和内存布局的感受,以及对边界情况的警觉。
我自己做实际项目时,很少直接裸写一个单链表,因为有太多现成的容器库可以用。但每次处理涉及链表的问题,比如实现 LRU、合并有序链表、找链表环入口,都需要单链表层面的扎实理解。如果你现在正被单链表的作业折磨,不要慌,多画图、多打断点、多打印,这部分能力很快就能长到自己身上。
