大家好,今天我们来聊聊数据结构中最基础也最容易踩坑的单链表实现。很多同学第一次写链表操作时,总会被 "空链表插入""删除头节点" 这类边界问题搞得焦头烂额,要么空指针报错,要么逻辑写得又臭又长。今天我就用「虚拟头节点」这个神器,带大家一次性搞定单链表的所有核心操作,写出优雅又健壮的代码。
一、先搞懂题目要求:我们要实现什么?
先来看这道经典的链表设计题,它要求我们实现一个支持以下操作的单链表:
get(index):获取链表中第index个节点的值,索引无效则返回 -1addAtHead(val):在链表头部插入节点addAtTail(val):在链表尾部追加节点addAtIndex(index, val):在第index个节点前插入节点,支持头部、尾部和中间插入deleteAtIndex(index):删除第index个节点
链表操作的核心难点,其实都集中在边界处理上:空链表怎么插入?删除头节点时怎么更新头指针?插入到尾部时怎么找到尾节点?而「虚拟头节点」就是解决这些问题的万能钥匙。
二、为什么要用虚拟头节点?
传统的单链表实现中,头节点是链表的第一个有效节点,这就导致:
- 空链表时,头指针为
null,插入头节点需要特殊判断 - 删除头节点时,需要单独处理头指针的更新
- 所有插入 / 删除操作,都要区分 "是否在头部" 的情况,代码冗余且容易出错
而虚拟头节点(Dummy Head)的思路,就是给链表添加一个不存储有效数据的哑节点,让它作为链表的固定起点。这样一来:
- 空链表时,
dummyHead.next为null,不需要特殊处理 - 所有节点(包括头节点)的插入 / 删除操作,都可以用统一的逻辑处理
- 再也不用单独写 "头部操作" 的特殊分支了
就像下图展示的那样,不管是插入还是删除节点,我们都可以通过虚拟头节点找到目标节点的前一个节点,然后直接修改指针即可,完美规避了空指针和边界问题。
三、代码逐行解析:从基础到完整实现
接下来我们就用 JavaScript 实现这个链表,核心代码就是你提供的版本,我会逐行拆解每个操作的逻辑和细节。
1. 初始化链表
javascript
运行
ini
var MyLinkedList = function() {
// 虚拟头节点,处理边界情况
this.dummyHead = {val: 0, next: null};
// 链表长度,方便判断索引是否有效
this.size = 0;
};
这里我们定义了两个核心属性:
dummyHead:虚拟头节点,值设为 0(无实际意义),初始next为nullsize:记录链表长度,避免每次操作都遍历链表统计长度,提升效率
2. get (index):获取指定索引的节点值
javascript
运行
ini
MyLinkedList.prototype.get = function(index) {
// 索引无效直接返回 -1
if (index < 0 || index >= this.size) return -1;
// 从虚拟头节点的下一个节点(第一个有效节点)开始遍历
let cur = this.dummyHead.next;
for (let i = 0; i < index; i++) {
cur = cur.next;
}
return cur.val;
};
- 首先判断索引合法性,避免越界访问
- 因为虚拟头节点不是有效节点,所以从
dummyHead.next开始遍历index次,就能找到目标节点
3. addAtHead (val):头部插入节点
javascript
运行
kotlin
MyLinkedList.prototype.addAtHead = function(val) {
// 新节点的 next 指向原来的第一个有效节点
const newNode = {val, next: this.dummyHead.next};
// 虚拟头节点的 next 指向新节点,完成插入
this.dummyHead.next = newNode;
this.size++;
};
有了虚拟头节点,头部插入和中间插入的逻辑完全一致:
- 新节点的
next指向原来的第一个节点(dummyHead.next) - 虚拟头节点的
next更新为新节点,新节点就成了第一个有效节点 - 链表长度 +1
4. addAtTail (val):尾部追加节点
javascript
运行
ini
MyLinkedList.prototype.addAtTail = function(val) {
const newNode = {val, next: null};
// 从虚拟头节点开始遍历,找到最后一个节点
let cur = this.dummyHead;
while (cur.next !== null) {
cur = cur.next;
}
// 最后一个节点的 next 指向新节点
cur.next = newNode;
this.size++;
};
- 因为单链表只能从前往后遍历,所以我们从虚拟头节点开始,一直走到
cur.next为null的节点,这就是链表的尾节点 - 尾节点的
next指向新节点,新节点的next设为null,完成尾部插入
5. addAtIndex (index, val):指定位置插入节点
javascript
运行
ini
MyLinkedList.prototype.addAtIndex = function(index, val) {
// 索引大于链表长度,直接返回,不插入
if (index > this.size) return;
// 索引小于 0,默认插入到头部
if (index < 0) index = 0;
const newNode = {val, next: null};
// 找到第 index 个节点的前一个节点(虚拟头节点开始遍历 index 次)
let cur = this.dummyHead;
for (let i = 0; i < index; i++) {
cur = cur.next;
}
// 插入节点:先让新节点的 next 指向 cur 的下一个节点,再更新 cur.next
newNode.next = cur.next;
cur.next = newNode;
this.size++;
};
这是最核心的插入操作,逻辑非常通用:
- 先处理索引的边界情况:
index > size不插入,index < 0设为 0(头部插入) - 找到目标位置的前一个节点
cur(比如要插入到索引 2,就找到索引 1 的节点) - 先让新节点的
next指向cur.next,再让cur.next指向新节点,完成插入(注意顺序不能反!) - 链表长度 +1
6. deleteAtIndex (index):删除指定索引的节点
javascript
运行
ini
MyLinkedList.prototype.deleteAtIndex = function(index) {
// 索引无效直接返回
if (index < 0 || index >= this.size) return;
// 找到要删除节点的前一个节点
let cur = this.dummyHead;
for (let i = 0; i < index; i++) {
cur = cur.next;
}
// 让前一个节点的 next 跳过目标节点,指向它的下一个节点
cur.next = cur.next.next;
this.size--;
};
删除操作和插入操作逻辑对称:
- 先判断索引合法性,避免越界删除
- 找到要删除节点的前一个节点
cur - 让
cur.next直接指向cur.next.next,目标节点就从链表中脱离了(JS 会自动回收内存) - 链表长度 -1
四、虚拟头节点的优势总结
看完代码,我们再回头看看虚拟头节点到底帮我们解决了哪些问题:
- 消除特殊分支 :头部插入、删除头节点的逻辑和中间节点完全一致,不用写
if (index === 0)这种分支判断 - 简化边界处理 :空链表时,
dummyHead.next为null,插入节点的逻辑依然成立,不用额外判断空链表 - 代码复用性高 :
addAtIndex可以直接实现addAtHead和addAtTail,比如addAtHead等价于addAtIndex(0, val),addAtTail等价于addAtIndex(size, val)
很多同学觉得链表难,其实就是没找到统一的处理方式,虚拟头节点就是那个能让所有操作逻辑统一起来的 "万能模板"。
五、结语:链表学习的小建议
单链表是数据结构的基础,也是面试中的高频考点。这道题覆盖了链表的所有核心操作,把它吃透,你就能理解链表操作的核心逻辑:所有插入和删除操作,本质上都是修改节点的指针,而关键就是找到目标节点的前一个节点。
虚拟头节点这个技巧,在很多链表题中都能用到,比如反转链表、合并链表、删除倒数第 N 个节点等,学会它能帮你少踩很多坑。
如果觉得这篇文章对你有帮助,欢迎点赞收藏,也可以在评论区聊聊你写链表时踩过的坑~