删除链表倒数第 n 个结点
前言
在对链表进行操作时,一种常用的技巧是添加一个哑节点(dummy node),它的 next\textit{next}next 指针指向链表的头节点。这样一来,我们就不需要对头节点进行特殊的判断了。
例如,在本题中,如果我们要删除节点 yyy,我们需要知道节点 yyy 的前驱节点 xxx,并将 xxx 的指针指向 yyy 的后继节点。但由于头节点不存在前驱节点,因此我们需要在删除头节点时进行特殊判断。但如果我们添加了哑节点,那么头节点的前驱节点就是哑节点本身,此时我们就只需要考虑通用的情况即可。
特别地,在某些语言中,由于需要自行对内存进行管理。因此在实际的面试中,对于「是否需要释放被删除节点对应的空间」这一问题,我们需要和面试官进行积极的沟通以达成一致。下面的代码中默认不释放空间。
第一种方式:
思路与算法
一种容易想到的方法是,我们首先从头节点开始对链表进行一次遍历,得到链表的长度 LLL。随后我们再从头节点开始对链表进行一次遍历,当遍历到第 L−n+1个节点时,它就是我们需要删除的节点。
为了与题目中的 n保持一致,节点的编号从 1 开始,头节点为编号 1的节点。
为了方便删除操作,我们可以从哑节点开始遍历 L−n+1个节点。当遍历到第 L−n+1 个节点时,它的下一个节点就是我们需要删除的节点,这样我们只需要修改一次指针,就能完成删除操作。
总结:
首先遍历到链表的长度,然后再遍历一次到length-n+1的长度停下
刚好是删除当前结点的前驱结点,找到前驱结点,就可以执行cur.next = cur.next.next来实现删除倒数第n个结点。
复杂度分析
- 时间复杂度:O(L),其中 L 是链表的长度。
- 空间复杂度:O(1)。
var getLength = (head)=>{
let length = 0;
while(head){
length++;
head = head.next;
}
return length;
}
var removeNthFromEnd = function(head, n) {
let dummy = new ListNode(0, head);
let length = getLength(head);
let cur = dummy;
for(let i = 1; i < length - n + 1; ++i){
cur = cur.next;
}
cur.next = cur.next.next;
let res = dummy.next;
return res;
};
第二种方式:
思路与算法
我们也可以在遍历链表的同时将所有节点依次入栈。根据栈「先进后出」的原则,我们弹出栈的第 n 个节点就是需要删除的节点,并且目前栈顶的节点就是待删除节点的前驱节点。这样一来,删除操作就变得十分方便了。
总结:
用队列的方式,把所有链表数据push进去,然后再把所有的队列出队,出个第n+1个的时候,在数组中如何表示n+1个结点呢?就是用pop去弹出结点。 stack[stack.length - 1]来拿到顶层结点。就是当前结点的前驱结点,用pre.next = pre.next.next把倒数第n个结点删除。
复杂度分析
时间复杂度:O(L),其中 L 是链表的长度。
空间复杂度:O(L),其中 L 是链表的长度。主要为栈的开销。
function removeNthFromEnd(head, n) {
let dummy = new ListNode(0, head);
let stack = [];
let cur = dummy;
while (cur) {
// 这里每次push进去的都是一个当前链表的引用,都可以找到后面的结点
stack.push(cur);
cur = cur.next;
}
for (let i = 0; i < n; i++) {
stack.pop();
}
// 这里也是直接拿到pre的引用,就可以做删除操作
let prev = stack[stack.length - 1];
prev.next = prev.next.next;
return dummy.next;
}
第三种方式:
思路与算法
我们也可以在不预处理出链表的长度,以及使用常数空间的前提下解决本题。
由于我们需要找到倒数第 nnn 个节点,因此我们可以使用两个指针 first和 second 同时对链表进行遍历,并且 first 比 second超前 n个节点。当 first遍历到链表的末尾时,second就恰好处于倒数第 n个节点。
具体地,初始时 first 和 second均指向头节点。我们首先使用 first对链表进行遍历,遍历的次数为 n。此时,first 和 second 之间间隔了 n−1个节点,即 first比 second 超前了 n 个节点。
在这之后,我们同时使用 first 和 second 对链表进行遍历。当 first 遍历到链表的末尾(即 first 为空指针)时,second恰好指向倒数第 n个节点。
根据方法一和方法二,如果我们能够得到的是倒数第 n个节点的前驱节点而不是倒数第 n个节点的话,删除操作会更加方便。因此我们可以考虑在初始时将 second指向哑节点,其余的操作步骤不变。这样一来,当 first遍历到链表的末尾时,second 的下一个节点就是我们需要删除的节点。
总结:
用快慢指针,快指针比慢指针多走n步,然后两个指针一起走,直到快指针走到尽头。这个时候慢指针就刚刚好走到倒数第n个结点。
但是删除结点需要找到前驱结点,所以我们一开始new一个哑结点。让慢指针指向哑结点。然后按照上述的方式删除结点即可。
复杂度分析
- 时间复杂度:O(L),其中 LLL 是链表的长度。
- 空间复杂度:O(1)。
function removeNthFromEnd(head, n) {
let dummy = new ListNode(0, head);
let second = dummy;
let first = dummy.next;
for(let i = 0; i < n; i++){
first = first.next;
}
while(first){
first = first.next;
second = second.next;
}
second.next = second.next.next;
return dummy.next;
}