
先问一个我在实际教学和面试中反复遇到的情况不少同学刷题时链表题手到擒来但让他从零手写一个完整的 C 单链表——包括结构体定义、头插尾插、删除、反转、析构——立刻就露怯了。要么忘记初始化 next 指针要么删除一个节点后整条链都断了要么析构函数写了个寂寞导致内存泄漏。如果你也有同感那今天这篇正适合你。我会把单链表的完整源码、背后的设计思路、每一段关键代码为什么要这么写以及我在调试链表时踩过的典型坑全部拆开讲清楚。源码可以直接复制运行适合正在学 C 的初学者、准备面试需要手撕链表的新人以及想复习数据结构和内存管理的朋友。哪怕你之前完全没写过链表照着这篇文章一步步走也能亲手实现一个可用的单链表。1. 为什么每个学 C 的人都应该亲手写一遍单链表1.1 数组和链表两种完全不同的内存组织方式很多人学链表时的困惑是明明数组已经够用了为什么还要发明一个链表要理解这个问题得先看清它们的本质差异。数组在内存中是一段连续空间你声明一个int arr[10]那么 10 个 int 在物理上就是挨着放的。因为连续所以按下标访问arr[3]是编译器直接算地址偏移复杂度是 O(1)。但代价也在这里如果你想在中间插入一个元素后面的所有元素都得往后挪最坏情况是 O(n)。链表则完全不同它的每个节点都是独立 new 出来的在堆上可能分布在天南海北。节点之间靠指针串起来就像探宝游戏里每张纸条只写着下一个线索的位置。这样一来插入和删除只需要改指针不需要搬动数据但同时也失去了随机访问能力想找第 n 个节点必须从头开始顺着指针走复杂度是 O(n)。用生活化的类比就是数组是电影院一排固定座位你买 3 号座就只能坐 3 号座但查座位特别快链表是密室逃脱的线索链每张纸条只告诉你下一张在哪找线索得一张张走但你想在中间加一条新线索只需要改一张纸条上的字不需要重排整条线索。1.2 手写一遍的价值指针和内存管理的肌肉记忆有人会问STL 里明明有std::list直接用不就行了如果你工作几年只做业务开发确实很少需要手写链表。但在以下几个场景里手写单链表的能力是硬通货。第一C 面试几乎必考链表题很多公司直接让你在白板上手写单链表的插入和反转甚至追问边界条件。第二单链表是理解指针、动态内存、对象生命周期最干净的训练场——整个类一共就几个成员函数但每一个都在和地址、堆内存打交道。第三在嵌入式系统、游戏引擎、部分库开发场景里STL 可能不可用或者有性能限制你需要能自己实现一个小型链表。更重要的是链表背后是链式结构的思维。哈希表的拉链法、LRU 缓存、内存池的空闲块链表、内核任务队列……底层全是链式结构。你把单链表吃透了后面学双向链表、二叉树、跳表都会顺很多因为它们核心的指针操作逻辑是同一套。1.3 单链表的实际应用场景单链表在实际工程中很少单独作为主力容器出现但它的变体和思想无处不在。最常见的应用是操作系统内核的任务队列——新任务插入队尾调度器从队头取任务这种 FIFO 操作天然适合链表。哈希表解决冲突时用的链地址法就是每个桶挂一条单链表。内存池和文件系统的空闲块管理也经常用一条链表串起所有空闲区块。还有一个很典型的例子是浏览器或编辑器的撤销重做历史记录你每一步操作就是一个节点新操作插入链表尾部撤销时往回走一步。所以说到底单链表不是让你在生产环境里死磕这个数据结构本身而是让你理解通过指针把零散内存组织起来这个核心思想。这一关过了后面看 std::list、看各种链式结构源码的时候你会觉得亲切很多。2. 核心设计怎么把节点和指针变成一个类2.1 Node 结构体数据与地址的合成体链表的最小单元是节点。每个节点至少要有两个东西一份数据一个指向下一个节点的指针。写成代码就是这个样子template typename T struct Node { T data; NodeT* next; explicit Node(const T value) : data(value), next(nullptr) {} };这里用struct而不是class是因为 Node 里只有公有数据和构造函数没有任何封装需求用 struct 最直白。T data存实际数据NodeT* next存下一个节点的地址。构造函数里的next(nullptr)是这条命脉——如果 new 出一个节点后不把 next 置空它就是一个野指针后面所有while (cur-next ! nullptr)的判断都会出问题。养成习惯链表节点的 next 必须初始化。我面试时见过不少这种错误结构体写好了构造函数也写了但忘了初始化 next。结果链表的倒数第二个节点一直指向一片随机的内存打印链表时就会无限循环或者读到垃圾值。所以别嫌next(nullptr)这行多余它值一条命。2.2 为什么用模板而不是写死成 int很多人初学链表时喜欢写一个struct Node { int data; Node* next; }然后整个类都基于 int。这样写是能跑但一换类型就得把整份代码复制一遍改成string data再改成double data维护成本立刻上来了。用模板的思路是把链表操作逻辑和数据类型解耦。我写一份LinkedListT使用的时候LinkedListint、LinkedListstd::string、LinkedListStudent全都是同一套代码。编译器会在你实例化LinkedListint时自动生成一份基于 int 的类实例化LinkedListstd::string时再生成一份基于 string 的类。相当于你只写了一遍逻辑编译器帮你复制粘贴了很多份。这里有一个必须提醒的点模板类的声明和实现不能像普通类那样分开写在 .h 和 .cpp 里。普通类在编译时已经生成了完整代码链接时能找到模板只有在被实例化的那一刻才真正生成代码而实例化发生在包含它的那个编译单元里。如果你把实现写在 list.cpp 里main.cpp 只 include 了 list.h链接时就会报无法解析的外部符号或undefined reference。解决方法就是本篇文章的做法模板实现直接写在类内或者全部写进一个 .hpp 头文件。2.3 头指针、尾指针、计数器的取舍单链表类里的最小配置是两个成员头指针head_和计数器count_。template typename T class LinkedList { public: LinkedList() : head_(nullptr), count_(0) {} private: NodeT* head_; int count_; };head_是整个链表的入口只要还有节点head_ 就不为空。count_让size()变成 O(1) 的操作不用每次遍历链表数一遍。有人可能会问要不要再加一个尾指针tail_加了之后尾插可以从 O(n) 变成 O(1)确实是个优化。但代价是维护麻烦删除尾节点时要判断删除的是不是最后一个节点、prev 是不是 null清空链表、反转链表时也要同步更新 tail_。每一处边界都不一样初学者很容易漏。我这个版本为了保持代码清晰故意只保留 head_ 和 count_。尾插就老老实实遍历到尾部O(n) 对于学习用途完全够用。面试时如果你能主动说出如果尾插是热点操作我还可以加一个 tail_ 指针优化成 O(1)这反而是加分项——说明你不仅会写代码还知道性能瓶颈在哪。另外注意我的成员变量名带下划线head_、count_。这是为了避免和函数参数、局部变量重名时产生混淆是 C 工程里很常见的命名习惯。比如insertAtHead(const T value)里没有参数叫 head但很多方法里会有cur head_这种读取下划线让成员变量和局部变量一眼可辨代码审查时省很多麻烦。3. 逐个实现增删改查关键代码拆开讲3.1 头插和尾插指针操作的顺序不能反头插是链表操作里最基础也最不容易出错的但很多人第一次写还是会翻车template typename T void LinkedListT::insertAtHead(const T value) { NodeT* newNode new NodeT(value); newNode-next head_; head_ newNode; count_; }看一下这两行赋值顺序先让新节点的 next 指向旧链表的头节点再把 head_ 更新为新节点。顺序一旦反了也就是先head_ newNode;再newNode-next head_;那么新节点的 next 指向的是它自己旧链表整个丢了内存也泄漏了。用排队来类比插队时必须先让新人拉住排在最前面那个人的手再让大家重新认领新人为队首。你要是先换队首标识后面的人已经被挤出队伍了那还拉谁的手。尾插比头插稍微复杂因为单向链表没有尾指针你得先找到真正的尾巴template typename T void LinkedListT::insertAtTail(const T value) { NodeT* newNode new NodeT(value); if (head_ nullptr) { head_ newNode; } else { NodeT* cur head_; while (cur-next ! nullptr) { cur cur-next; } cur-next newNode; } count_; }注意 while 的判断条件是cur-next ! nullptr不是cur ! nullptr。前者找到的是最后一个节点后者会一直走到空指针到头来反而没法把新节点接回去。很多初学者在这里写成while (cur ! nullptr)遍历完 cur 变成 nullptr 后还想cur-next newNode直接就是空指针访问。另外还要单拎出空链表的情况head_ 是 nullptr此时直接把 head_ 指向新节点不需要遍历否则head_-next就是空指针解引用。3.2 删除节点找到 prev 是一切的前提单向链表删除的核心困难在于每个节点只有指向后面的指针没有指向前面的指针。你要摘掉某个节点必须知道它前一个节点是谁。也就是说删除的本质是让前一个节点的 next 跳过当前节点指向当前节点的下一个节点同时释放当前节点的内存。template typename T bool LinkedListT::remove(const T value) { NodeT* prev nullptr; NodeT* cur head_; while (cur ! nullptr) { if (cur-data value) { if (prev ! nullptr) { prev-next cur-next; } else { head_ cur-next; } delete cur; --count_; return true; } prev cur; cur cur-next; } return false; }这段代码需要重点理解两个地方。第一prev的更新时机在 cur 向后移动之前先把当前 cur 记到 prev 里。这样到下一个节点时prev 正好就是它的前驱。第二删除头节点时的特判如果 cur 就是 head_那么 prev 为空不能写prev-next cur-next直接让 head_ 指向 cur-next 即可。这个删除逻辑还有一个提升空间。如果你不想写 if-else 判断删除的是不是头节点可以使用二级指针技巧NodeT** indirect head_; while (*indirect ! nullptr (*indirect)-data ! value) { indirect ((*indirect)-next); } if (*indirect ! nullptr) { NodeT* toDelete *indirect; *indirect toDelete-next; delete toDelete; --count_; return true; }二级指针版本的思路是始终记录指向当前节点的那个 next 字段的地址这样无论要删的是头节点还是中间节点操作都是*indirect toDelete-next不需要特判。我第一次看到这个写法时觉得非常巧妙但也提醒一句理解二级指针需要一点指针功底如果你现在还不太熟先把 if-else 版本写对二级指针等以后回过头来再看也不迟。3.3 反转链表经典三指针法的本质反转是链表面试题里出现频率最高的一道同时也是最容易写崩的操作。逻辑其实很简单从前往后走逐个把每个节点的 next 指向前一个节点。但问题是一旦你改了 cur-next当前节点后面的链表就断了。所以必须在改之前用临时指针把原来的后继存下来。template typename T void LinkedListT::reverse() { NodeT* prev nullptr; NodeT* cur head_; while (cur ! nullptr) { NodeT* nextNode cur-next; // 先保存原后继 cur-next prev; // 箭头掉头 prev cur; // prev 前进 cur nextNode; // cur 前进 } head_ prev; }这个三指针变量就是 prev、cur、nextNode。每一步循环做的事情就四行保存后继、掉头、移动 prev、移动 cur。少了任何一行都会出问题。尤其是NodeT* nextNode cur-next;这行看起来是临时变量实际上是整段代码的命根子。没有它cur-next prev 之后后面所有节点就再也找不回来了链表从中间断成两截还会内存泄漏。我用一个更直观的方式来理解想象一个两人组沿着链表的箭头方向走prev 永远走在 cur 前面一格cur 一直在回头把箭头指向前面的 prev等走到链表末尾prev 就是新链表的头。等你自己画一遍这个过程的指针变化图就永远不会忘。3.4 析构与内存释放为什么不能直接 delete head_链表的每个节点都是 new 出来的new 出来的内存如果不手动释放程序退出后依然会泄漏。作为类的设计者析构函数就是擦屁股的地方。很多人写析构时只有一个直觉delete head_。这会漏掉链表里除头节点外的所有节点。template typename T void LinkedListT::clear() { NodeT* cur head_; while (cur ! nullptr) { NodeT* next cur-next; delete cur; cur next; } head_ nullptr; count_ 0; }这段代码同样遵循先存后继再删除当前的原则。delete 掉 cur 之后cur 指向的内存已经归属于系统再去读 cur-next 就是访问已释放内存属于未定义行为可能崩溃也可能偶尔没事但不该赌这个运气。所以先执行NodeT* next cur-next;把后继地址保存到局部变量再安心 delete。还有一个很多初学者完全没意识到的问题拷贝构造与拷贝赋值。C 在没有显式定义时编译器会帮你生成默认的拷贝构造函数和拷贝赋值运算符它们做的是逐成员拷贝也就是浅拷贝。对于链表来说浅拷贝意味着新的 list 对象和旧 list 对象的 head_ 指向同一串节点。当这两个对象离开作用域时它们的析构函数都会去 delete 同一批节点——这就是著名的 double free程序大概率直接崩溃。我在这个版本里直接把它们禁掉LinkedList(const LinkedList) delete; LinkedList operator(const LinkedList) delete;这是我最想强调的工程习惯如果类管理了裸指针资源要么实现深拷贝要么明确禁止拷贝。禁止拷贝只需要两行 delete把隐患从编译层面掐死。等你有经验了再回头实现深拷贝版本也不迟。4. 完整源码、编译与运行4.1 可直接复制运行的完整源码下面这一段是完整的单链表实现包含了节点定义、所有成员函数和测试用的 main 函数。我已经把所有模板实现都写在类内因此不需要拆头文件复制到任意 .cpp 文件里就能直接编译运行。#include iostream template typename T struct Node { T data; NodeT* next; explicit Node(const T value) : data(value), next(nullptr) {} }; template typename T class LinkedList { public: LinkedList() : head_(nullptr), count_(0) {} LinkedList(const LinkedList) delete; LinkedList operator(const LinkedList) delete; ~LinkedList() { clear(); } bool isEmpty() const { return head_ nullptr; } int size() const { return count_; } void insertAtHead(const T value) { NodeT* newNode new NodeT(value); newNode-next head_; head_ newNode; count_; } void insertAtTail(const T value) { NodeT* newNode new NodeT(value); if (head_ nullptr) { head_ newNode; } else { NodeT* cur head_; while (cur-next ! nullptr) { cur cur-next; } cur-next newNode; } count_; } bool remove(const T value) { NodeT* prev nullptr; NodeT* cur head_; while (cur ! nullptr) { if (cur-data value) { if (prev ! nullptr) { prev-next cur-next; } else { head_ cur-next; } delete cur; --count_; return true; } prev cur; cur cur-next; } return false; } NodeT* find(const T value) const { NodeT* cur head_; while (cur ! nullptr) { if (cur-data value) { return cur; } cur cur-next; } return nullptr; } void reverse() { NodeT* prev nullptr; NodeT* cur head_; while (cur ! nullptr) { NodeT* nextNode cur-next; cur-next prev; prev cur; cur nextNode; } head_ prev; } void print() const { NodeT* cur head_; while (cur ! nullptr) { std::cout cur-data; if (cur-next ! nullptr) { std::cout - ; } cur cur-next; } std::cout std::endl; } private: NodeT* head_; int count_; void clear() { NodeT* cur head_; while (cur ! nullptr) { NodeT* next cur-next; delete cur; cur next; } head_ nullptr; count_ 0; } }; int main() { LinkedListint list; list.insertAtHead(3); list.insertAtHead(2); list.insertAtHead(1); list.insertAtTail(4); list.print(); list.remove(2); list.print(); list.reverse(); list.print(); Nodeint* node list.find(3); if (node ! nullptr) { std::cout found: node-data std::endl; } std::cout size list.size() std::endl; return 0; }这段代码里的find方法返回的是裸指针NodeT*这意味着调用方拿到了节点地址后可以直接修改节点内容。对于学习场景这没太大问题甚至很方便直观但如果做工程库建议返回const NodeT*或者干脆返回索引避免内部结构被外部随意修改。这是封装性上的取舍知道就行。4.2 编译运行与输出验证把上面的代码保存为main.cpp在终端里执行g -stdc11 -Wall main.cpp -o linked_list ./linked_listLinux 和 macOS 上如果提示没有 g改成clang也一样。Windows 上装的 MinGW-w64 环境也是同样的命令只是生成的可执行文件名是linked_list.exe。-stdc11必须加因为 delete、explicit和模板特性依赖 C11 标准不加编译器就直接报语法错误。程序运行后的输出应该严格是这五行1 - 2 - 3 - 4 1 - 3 - 4 4 - 3 - 1 found: 3 size 3我们来逐行验证一下 main 里的操作。先头插 3、2、1此时链表结构是 1 - 2 - 3头插的结果是倒序的这符合头插的特性。再尾插 4变成 1 - 2 - 3 - 4。然后删除 2prev 指针指向节点 1cur 指向节点 2匹配后让 1 的 next 直接指向 3所以剩下 1 - 3 - 4。反转后变成 4 - 3 - 1。最后 find(3) 能找到节点size 是 3。逻辑链完全闭合。4.3 VSCode MinGW 环境配置速通很多刚入门 C 的同学用的是 VSCode常在环境配置上卡很久。这里给一个快速可行的方案以 Windows 为例。第一步安装 MinGW-w64。注意下载时选择 x86_64 架构、posix 线程模型、seh 异常模型。很多人选成 win32 线程模型当时用没啥感觉后面用到 std::thread 才发现编译不了还得重新装。装好之后把 bin 目录加入系统 PATH在终端里输入g --version能看到版本号就算成功。第二步在 VSCode 里装好 C/C 扩展。写代码、智能提示、断点调试都靠它。编译器路径它会自动从 PATH 里找。第三步创建.vscode/tasks.json配置编译任务{ version: 2.0.0, tasks: [ { label: build, type: shell, command: g, args: [ -stdc11, -g, -o, linked_list, main.cpp ], group: { kind: build, isDefault: true } } ] }配置好后按 CtrlShiftB 就能编译终端里运行./linked_list查看输出。调试的话再创建.vscode/launch.json把程序路径填成生成的 linked_list 可执行文件就能在代码里打断点观察每个节点的指针变化我个人觉得调试链表时打断点看指针地址非常直观比单纯看输出有用得多。5. 踩坑实录与排查方法5.1 高频报错对照表我把自己和学员在实现单链表过程中遇到过的问题整理成了速查表按现象 → 原因 → 解决方法的方式列出来碰到问题直接对照。现象大概率原因解决方法编译报错 undefined reference / 无法解析的外部符号模板声明和实现拆分在 .h 和 .cpp把模板实现移进类内或 .hpp 文件编译报错 explicit 不认识编译标准低于 C11加上-stdc11一运行就段错误 / 访问冲突节点 next 未初始化或空链表直接取 next构造函数里next(nullptr)遍历前判空打印链表时无限循环链表成环通常是反转或头插顺序错了检查是否有指针被重复赋值程序结束时崩溃浅拷贝导致 double free或析构释放逻辑错误禁拷贝函数析构时先存 next 再 delete删除节点后输出乱跳删除时没有正确更新 prev 的 next检查删除分支的 prev-next cur-nextsize 一直不对插入或删除时忘了维护 count_每个操作里同步修改 count_这些错误里最隐蔽的是链表成环。我记得有个学员反转头插了半小时没找到问题后来在关键节点打了几个地址发现有一个节点的 next 指向了自己。这种问题靠肉眼看代码很难发现但构造一个遍历 100 次还没结束就终止的测试函数或者直接把指针地址打印出来一下就暴露了。5.2 段错误调试三板斧段错误是链表程序最常见的崩溃方式本质就是访问了不该访问的内存。遇到段错误不要慌按我的三板斧来。第一板斧二分定位。把 main 里的操作注释掉一半看剩下一半还会不会崩。如果注释掉删除操作就不崩了问题就在 remove 里如果只剩 insertAtTail 还崩就往 insertAtTail 里查。一次注释一半范围快速缩小通常两三轮就能定位到具体函数。第二板斧打印指针。在怀疑的位置打印当前节点的地址和 next 指针的地址。比如std::cout cur cur-next std::endl;。如果输出里出现 0x0说明你访问了空节点如果地址值是 0x7fxxx 这种看着正常的地址但它指向的内容不是预期的数据那大概率是野指针或者越界访问。指针地址就像生活里的快递单号对不上号就知道是哪一环出了问题。第三板斧上工具。GCC 和 Clang 都支持 AddressSanitizer编译命令加上一行就能自动检测内存错误g -fsanitizeaddress -g main.cpp -o test ./test这个工具比人眼可靠得多。比如你 delete 之后又访问了节点的 next它会直接输出类似 heap-use-after-free 的报错并且告诉你发生在哪个文件哪一行、调用栈是什么样的。我第一次用它定位一个悬空指针问题十分钟就解决了换成自己盯着代码看可能要看一下午。5.3 内存泄漏怎么验证链表跑起来正确并不代表没有内存泄漏。new 出来的节点如果析构函数没有全部释放程序看起来也能运行但内存一直在悄悄涨。Linux 下最常用的检查工具是 valgrindvalgrind --leak-checkfull ./linked_list正常的结果会输出 definitely lost: 0 bytes in 0 blocks。如果少写一个 delete就会出现类似 16 bytes in 1 blocks are definitely lost 的提示并且告诉你泄漏的内存是在哪个代码路径里分配出来的。Windows 下没有 valgrind可以打开任务管理器观察程序运行前后的内存变化或者使用 Visual Studio 的诊断工具直接抓取快照对比。还有一个我在调试时常用的土办法给链表类加一个静态计数器构造函数里增加析构函数里减少。程序结束时打印计数器的值如果是 0 就说明创建和销毁的节点数对得上。这个办法在简单项目里比 valgrind 还直观因为它直接反应了你这个链表对象的生命周期管理是否正确。当然正规检查最终还是以 valgrind 或 ASan 为准计数器只能作为辅助参考。6. 扩展方向与个人经验6.1 带头节点、循环链表、双向链表单链表只是链式结构的起点想再进一步可以从三个方向练手。带头节点的链表是最常见的优化版本。它额外维护一个 dummy 节点作为头节点dummy 本身不存有效数据但它的 next 指向真正的第一个节点。好处是头插头删不再需要特判是否为空链表所有操作统一成同样的代码。坏处是遍历输出时要记得跳过 dummy 节点。面试题里很多删除某个节点插到第 k 个位置的题目用 dummy 节点能省掉大量 if-else。循环链表则是把最后一个节点的 next 从 nullptr 改成指向头节点形成一个环。它非常适合约瑟夫环、时间片轮转这类需要循环遍历的场景。判断一个链表是否成环的经典方法是快慢指针一个指针每次走两步一个走一步如果两个指针能相遇那必然有环。这个思路我在面试里见过很多次建议手推一遍。双向链表就更直接了每个节点加一个 prev 指针可以往前走也可以往后走。代价是插入和删除时操作的指针从两个变成四个出错概率翻倍但换来的是双向遍历能力。STL 的 std::list 底层就是双向链表。我的建议是先把单链表写熟再花一晚上独立实现一遍双向链表对比一下代码复杂度的变化这对理解链式结构的全貌非常有帮助。6.2 说说 std::list 和手写链表的差距有人可能会想既然标准库有 std::list我练了这个还有什么用我的看法是标准库的 list 和手写的单链表不在一个复杂度层级但它们存在紧密联系。std::list 是带头节点的循环双向链表它支持双向迭代器还有 splice、merge 这种链表特有的操作。它的节点可能和手写一样由管理类维护但封装度更高分配器可以定制size 也做了缓存。换句话说标准库是把你可能想到的所有优化、所有边界处理都做完了你直接调它就行。但问题在于如果你没亲手写过链表直接用 std::list 的时候很难真正理解它的底层行为。比如为什么 std::list 不支持operator[]因为它无法 O(1) 随机访问。为什么 splice 操作可以高效地把另一个链表的节点转移过来因为本质只是改几个指针。为什么迭代器在 insert 后依然有效因为节点的内存地址没有变化你没这个视野就只能把 std::list 当一个黑盒在用出了问题也很难排查。所以我一直认为手写单链表和用 std::list 不是二选一而是先后关系。先手写一遍拿到底层视野再回来看标准库的接口就全是老朋友。6.3 我的建议把单链表当作 C 内存管理的第一课最后说点我自己的经验。我教过不少来咨询 C 学习路径的同学发现一个规律链表写得干净利落的人指针和内存管理基本没有死角后面学二叉树、跳表、红黑树都很快。而链表写不清楚的人问题往往归结于同一个原点——对指针没有建立起形象化的直觉。所以我建议你拿到这份源码后不要只复制运行一遍就完事。亲手敲一遍把每个函数的边界情况单独测试一下空链表头插、空链表删除、反转已经反转过的链表、删除链表中不存在的值。然后做三个改动练习第一给链表加一个 tail_ 指针把尾插优化成 O(1)第二改成带头节点的版本体会 dummy 节点带来的代码简化第三把new/delete换成std::unique_ptr管理节点看看智能指针怎么替你做内存回收。我写这个版本的时候最开始也踩过不少坑。印象最深的一次是实习时写一个任务队列反转函数一跑就死循环排查了半天才发现是我少存了 nextNode导致链表后半截断开后又接成了一个环。从那以后我就形成了一个习惯任何涉及修改 next 指针的操作先把下一个节点的地址存在局部变量里再动指针。这个习惯让我后来写双向链表、跳表时少踩了很多坑可以说是在链表上亏出来的经验。希望这份源码和思路解析能帮你少走这些弯路二十多分钟敲完一遍换回来的不只是链表本身而是对 C 指针和内存管理更深一层的掌控感。