T19 删除链表的倒数第 N 个结点
题目链接:leetcode-cn.com/problems/re…
题意分析
看到题目给的示例,可能有同学会感觉很疑惑:这不就是从数组中删除倒数项么?请注意,这里题目要求的是「链表」而不是「数组」,它们是有着根本性的差别的。简单来说,链表相比于数组,没有下标的概念,因为下个节点的位置信息只能通过上个节点知晓;在查找节点时它只能通过头节点指针,从每一个节点依次往下找。因此我们不能把这题当作数组题来做。
准备工作
由于JavaScript并不自带链表(在此题中是单向表)的数据结构,我们需要自行进行模拟:
function ListNode(val = 0, next = null) {
this.val = val;
this.next = next;
}
此外,为了便于使用题目中写作数组形式的示例进行测试,我们需要编写一个转换函数:
function convertArrayToList(arr) {
if(arr.length === 0) return null;
let headNode = new ListNode(arr[0]);
let curNode = headNode;
for(let i = 1; i < arr.length; i++) {
curNode = curNode.next = new ListNode(arr[i]);
}
return headNode;
}
(tip:前述的代码我们在后续解决有关链表的习题中还会继续使用!)
投机取巧的解法
这里首先介绍一种投机取巧的做法。既然我们介绍了链表的节点相较于数组元素,无法使用下标进行访问,那么我们自然而然可以想到再重新把链表「转换」成数组的形式,而后直接通过数组下标进行处理。
代码如下:
function removeNthFromEnd(head, n) {
let curNode = head;
let arr = [];
while(curNode !== null) {
arr.push(curNode);
curNode = curNode.next;
}
let len = arr.length;
let idx1 = len - n - 1;
//处理删除第一个节点的情况
if(idx1 < 0) {
return head.next;
}
let idx2 = idx1 + 2;
let left = arr[idx1];
//需要考虑删除最后一个节点的情况
let right = idx2 >= len ? null : arr[idx2];
left.next = right;
return head;
}
当然,此种方法过于「投机取巧」,它也不是我们今天的主角。
快慢指针
下面介绍本题的正统解法「快慢指针」。
- 设指针
fast、slow,使它们都指向链表头部节点Head;- 保持指针
slow不动,移动指针fast向前走n步;- 同时移动指针
fast、slow并保持两者距离一致,直至fast指向尾部节点Tail;- 此时指针
slow必定指向欲删除的目标节点的前一个位置,对目标节点进行定位并删除即可.
请注意,如果我们希望删除链表的头部节点,那么在fast向前移动n步后会指向null,此时我们便不再需要后面的步骤了,直接删除头部节点即可。
代码如下:
function removeNthFromEnd(head, n) {
let fast = head;
while(n--) {
fast = fast.next;
}
//删除头部则直接结束程序
if(fast === null) {
return head.next;
}
let slow = head;
while(fast.next !== null) {
fast = fast.next;
slow = slow.next;
}
//删除目标节点
slow.next = slow.next.next;
return head;
}
T2 两数相加
解决了上面的这道「链表」题,下面让我们回过头来做一道仍然与链表有关的「模拟」题。
哨兵技巧
本题模拟的是人工计算竖式加法的过程,且题目所给的数据从个位开始按次序排好,这极大降低了解题的难度。
我们通过本题主要需要学会的是链表题目中的一个常用技巧——哨兵节点。
我们可以在链表的边界(一般是头部或尾部)添加一个「哨兵」节点(也叫dummy节点,即「傀儡」节点),帮助简化(甚至避免)对边界情况的处理。
如果我们是链表初学者,上面的表述的确过于抽象了!为了对TA有一个直观的理解。请我们仔细对比下面两段本题的解答代码:
使用「哨兵」节点前
function addTwoNumbers(L1, L2) {
let ans = new ListNode(); //创建输出链表的头部节点
let tempNode = ans;
let flag = true; //用于标记是不是第一次编辑链表
let carry = 0; //用于记录进位
while(L1 !== null || L2 !== null) {
flag ? (flag = false) : (tempNode = tempNode.next = new ListNode());
let x = L1?.val ?? 0;
let y = L2?.val ?? 0;
let n = x + y + carry;
carry = Math.floor(n / 10);
tempNode.val = n % 10;
L1 !== null && (L1 = L1.next);
L2 !== null && (L2 = L2.next);
}
carry !== 0 && (tempNode.next = new ListNode(carry));
return ans;
}
使用「哨兵」节点后
function addTwoNumbers(L1, L2) {
//此处的ans为我们创建的「哨兵」节点
let ans = new ListNode();
let tempNode = ans;
let carry = 0;
while(L1 !== null || L2 !== null) {
tempNode = tempNode.next = new ListNode();
let x = L1?.val ?? 0;
let y = L2?.val ?? 0;
let n = x + y + carry;
carry = Math.floor(n / 10);
tempNode.val = n % 10;
L1 !== null && (L1 = L1.next);
L2 !== null && (L2 = L2.next);
}
carry !== 0 && (tempNode.next = new ListNode(carry));
//「哨兵」节点本身是没有意义的,从它的下一个节点开始即为答案
return ans.next;
}
分析
我们知道,为了避免L1和L2的next指针都为null之后ans链表仍然会创建一个新的尾部节点,我们不得不将代码tempNode = tempNode.next = new ListNode()(下记作代码A)紧随于while(L1 !== null || L2 !== null)之后。
但是显而易见的是,如果我们直接这么做,头部节点的处理便会出现问题。在前述的第一份代码中,我们在定义ans的时候便创建了返回答案的头部节点,因而我们应该先往头部节点而不是它的next里写入数据,这与代码A便冲突了!为了解决这个问题,我们不得不引入额外的标记变量flag。
在使用了哨兵技巧后,我们将声明ans创建的节点作为「哨兵」节点,进而使得while循环中的代码完全实现了复用,避免了对头部边界进行特殊化处理!
写在文末
我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以实战锻炼我们的前端应用开发能力。
我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!