【LeetCode选讲·第七期】「删除链表的倒数第 N 个结点」「两数相加」

164 阅读5分钟

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;
}

当然,此种方法过于「投机取巧」,它也不是我们今天的主角。

快慢指针

下面介绍本题的正统解法「快慢指针」。

  • 设指针fastslow,使它们都指向链表头部节点Head
  • 保持指针slow不动,移动指针fast向前走n步;
  • 同时移动指针fastslow并保持两者距离一致,直至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 两数相加

解决了上面的这道「链表」题,下面让我们回过头来做一道仍然与链表有关的「模拟」题。

题目链接:leetcode.cn/problems/ad…

哨兵技巧

本题模拟的是人工计算竖式加法的过程,且题目所给的数据从个位开始按次序排好,这极大降低了解题的难度。

我们通过本题主要需要学会的是链表题目中的一个常用技巧——哨兵节点。

我们可以在链表的边界(一般是头部或尾部)添加一个「哨兵」节点(也叫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;
}

分析

我们知道,为了避免L1L2next指针都为null之后ans链表仍然会创建一个新的尾部节点,我们不得不将代码tempNode = tempNode.next = new ListNode()(下记作代码A)紧随于while(L1 !== null || L2 !== null)之后。

但是显而易见的是,如果我们直接这么做,头部节点的处理便会出现问题。在前述的第一份代码中,我们在定义ans的时候便创建了返回答案的头部节点,因而我们应该先往头部节点而不是它的next里写入数据,这与代码A便冲突了!为了解决这个问题,我们不得不引入额外的标记变量flag

在使用了哨兵技巧后,我们将声明ans创建的节点作为「哨兵」节点,进而使得while循环中的代码完全实现了复用,避免了对头部边界进行特殊化处理!

写在文末

我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以实战锻炼我们的前端应用开发能力。

我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!

QQ图片20220701165008.png