删除链表倒数第 n 个结点

133 阅读5分钟

删除链表倒数第 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;


}