对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 19. 删除链表的倒数第 N 个结点
1. 题目描述
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
示例 1:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
示例 2:
输入:head = [1], n = 2
输出:[1]
示例 3:
输入:head = [1,2], n = 1
输出:[1]
提示:
- 链表中结点的数目为
sz 1 <= sz <= 300 <= Node.val <= 1001 <= n <= sz
2. 问题分析
本题的核心挑战在于"倒数第N个"这个定位要求。单链表无法逆向回溯,因此必须通过正向遍历 来找到这个特定位置。一个关键的边界情况是删除的可能是头节点,这需要我们仔细处理。
3. 解题思路
本题主要有两种主流思路,双指针(快慢指针)法是最优解。
3.1 思路一:两次遍历法(计算链表长度)
- 第一次遍历 :获取链表的长度
L。 - 计算目标位置 :需要删除的正数位置为
L - n + 1。 - 第二次遍历 :移动到目标位置的前一个节点(即
L - n的位置),执行删除操作。 - 时间复杂度:O(L),需要遍历链表两次。
- 空间复杂度:O(1)。
3.2 思路二:双指针(快慢指针)法 【最优解】
- 设置哑节点(Dummy Node) :在头节点前创建一个虚拟节点,其
next指向head。这是处理链表删除问题的常用技巧,可以优雅地统一处理删除头节点和非头节点的情况,避免复杂的条件判断。 - 初始化快慢指针 :
fast和slow都指向哑节点。 - 快指针先行 :让
fast指针先向前移动n步。此时,fast和slow之间相隔n个节点。 - 同步移动 :同时移动
fast和slow,直到fast到达链表的末尾(fast.next为null)。此时,slow恰好指向待删除节点的前一个节点 。因为fast和slow始终保持n的间距。 - 执行删除 :
slow.next = slow.next.next。 - 返回新头节点 :返回
dummy.next。 - 时间复杂度:O(L),只遍历链表一次。
- 空间复杂度:O(1)。
为什么双指针法更优?
它不仅时间复杂度相同,而且在一次遍历中完成,逻辑清晰,代码简洁,是面试官最期望看到的解法。
4. 各思路代码实现(JavaScript)
4.1 两次遍历法实现
javascript
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} n
* @return {ListNode}
*/
var removeNthFromEnd = function(head, n) {
// 1. 第一次遍历:计算链表长度
let length = 0;
let current = head;
while (current !== null) {
length++;
current = current.next;
}
// 2. 计算要删除节点的正数位置
const targetIndex = length - n;
// 3. 处理删除头节点的特殊情况
if (targetIndex === 0) {
return head.next;
}
// 4. 第二次遍历:找到目标节点的前一个节点
current = head;
for (let i = 0; i < targetIndex - 1; i++) {
current = current.next;
}
// 5. 执行删除操作
current.next = current.next.next;
return head;
};
4.2 双指针法实现 【推荐】
javascript
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} n
* @return {ListNode}
*/
var removeNthFromEnd = function(head, n) {
// 1. 创建哑节点,其next指向原头节点
const dummy = new ListNode(0, head);
// 2. 初始化快慢指针
let fast = dummy;
let slow = dummy;
// 3. 快指针先走 n 步
for (let i = 0; i < n; i++) {
fast = fast.next;
}
// 4. 快慢指针同步前进,直到快指针到达链表末尾
while (fast.next !== null) {
fast = fast.next;
slow = slow.next;
}
// 循环结束时,slow指向待删除节点的前一个节点
// 5. 删除节点
slow.next = slow.next.next;
// 6. 返回新头节点(哑节点的next)
return dummy.next;
};
5. 复杂度与优缺点对比
| 实现方案 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 两次遍历法 | O(L), L为链表长度 | O(1) | 思路直观,易于理解和实现 | 需要遍历链表两次,效率非最优;处理头节点删除需额外判断 |
| 双指针法(哑节点) | O(L), L为链表长度 | O(1) | 只遍历一次,效率高 ;代码统一简洁,哑节点技巧避免了头节点的特殊判断;面试和工程中的首选方案 | 引入了额外的哑节点,但对空间复杂度无影响,逻辑上稍需理解 |
6. 总结与实际应用场景
6.1 总结
- 哑节点(Dummy Node)技巧:这是解决链表问题的"瑞士军刀"。它在链表头部之前创建一个虚拟节点,使得对头节点的操作(增、删)和对中间节点的操作逻辑完全一致,极大地简化了代码和逻辑判断。
- 快慢指针模式 :这是链表算法的核心模式之一,不仅用于本题,还广泛应用于检测环形链表(LeetCode 141) 、寻找链表中点(LeetCode 876) 、寻找环形链表入口(LeetCode 142) 等问题。
- 对"倒数第N个"的转化思维:通过快指针先走N步,将"倒数"问题转化为两个指针之间的"固定间隔"问题,这是一种非常巧妙的思维方式。
6.2 前端实际应用场景
- 虚拟DOM与Fiber架构:React的Fiber节点通过链表链接。在协调(Reconciliation)过程中,中断和恢复渲染的能力,本质上依赖于对这片"链表森林"的可控遍历。理解指针移动和节点操作,能帮你更深层理解React的调度机制。
- 长列表/无限滚动性能优化:在渲染成千上万条数据的列表时(如聊天记录、新闻流),通常采用"窗口化"技术,只渲染可视区域的部分节点。维护这些节点的缓存池、计算哪些节点应该进入或离开可视区,其数据结构的底层逻辑与链表操作息息相关。
- 撤销/重做(Undo/Redo)功能:编辑器的历史记录栈可以用双向链表来实现,每个节点保存一个状态。删除历史记录中的某一项,就类似于链表的删除操作。
- 路由历史记录管理:浏览器或前端路由库(如React Router)的历史记录管理,其前进、后退、替换等操作,底层都可以用链表模型来理解和设计。