一杯咖啡的时间吃透一道算法题——2.两数相加(使用链表)

一、解题前置知识------链表操作(可跳过)

1. 什么是链表

链表是一种线性数据结构,它由一系列节点组成,每个节点包含两部分:

  • 数据 (data): 存储实际的值。
  • 指针 (next): 指向下一个节点,表示链表中下一个元素的位置。

与数组不同,链表中的元素不必存储在连续的内存位置中。 这使得链表在插入和删除元素时更加灵活,因为不需要移动大量的元素。

2. JavaScript 中的链表实现

应题目要求,我们使用函数的方式来创建链表,链表格式如下

js 复制代码
 function ListNode(val, next) {
    this.val = (val===undefined ? 0 : val)
    this.next = (next===undefined ? null : next) 
}

3. 链表的基本操作

以下是一些常见的链表操作,以及它们在 JavaScript 中的实现方法:

  • 创建链表:初始化头节点

    csharp 复制代码
    let head = null; // 空链表

    为何let head = null 为一个空链表?这是一个很有意思的问题

    笔者认为这里的head是不是空链表主要是看head的使用场景,这里是链表的场景所以称之为空链表。

    let head = null; 表示空链表的原因是:

    1.head 是链表的入口点。

    2.null 表示"没有"或"不存在"。

    3.当 headnull 时,链表没有起始节点,因此被认为是空的。

  • 在链表头部添加节点 (prepend):

    ini 复制代码
    function prepend(head, val) {
      const newNode = new ListNode(val);
      newNode.next = head;
      return newNode;
    }
    
    // 示例:
    head = prepend(head, 1);
    head = prepend(head, 2);

    1.const newNode = new ListNode(val);

    这行代码使用 ListNode 函数创建一个新的节点。新节点的 val 属性被设置为传入的 val 值,而 next 属性默认初始化为 null (如果 ListNode 函数的定义是像你提供的那样)。 此刻,我们创建了一个孤立的节点,它还没有连接到链表上。

    2.newNode.next = head;

    这行代码将新创建的节点 (newNode) 的 next 指针设置为当前的 head。 简而言之,newNodenext 指针现在指向链表的第一个节点 (原来的 head 所指向的节点)。 这实际上将 newNode 放在了链表的 "前面"。

    3.return newNode;

    这行代码返回 newNode。 在调用 prepend 函数时,我们需要更新 head 变量,让它指向这个新的头节点。 这就是为什么在调用时我们使用 head = prepend(head, 1); 这样的语句。

  • 在链表尾部添加节点 (append):

    ini 复制代码
    function append(head, val) {
      const newNode = new ListNode(val);
    
      if (!head) {
        return newNode;
      }
    
      let current = head;
      while (current.next) {
        current = current.next;
      }
      current.next = newNode;
      return head;
    }
    
    // 示例:
    head = append(head, 3);

    1.const newNode = new ListNode(val);

    创建一个新的节点,其 val 属性被设置为传入的 val 值,next 属性默认为 null

    2.if (!head) { return newNode; }

    这是一个重要的边界情况处理 。 如果 headnull,这意味着链表当前是空的。 在这种情况下,新节点 newNode 将成为链表的第一个(也是唯一一个)节点,因此函数直接返回 newNode 作为新的 head

    3.let current = head;

    如果链表不为空,我们就需要找到链表的最后一个节点。 current 变量用于从链表的头部开始遍历。

    4.while (current.next) { current = current.next; }

    这是一个循环,用于遍历链表,直到找到最后一个节点。 current.next 检查当前节点的 next 指针是否为 null。 如果 current.next 不是 null,说明 current 不是最后一个节点,所以我们将 current 移动到下一个节点 current = current.next。 当循环结束时,current 将指向链表的最后一个节点(即 current.nextnull 的节点)。

    5.current.next = newNode;

    这是添加新节点的核心步骤。 将最后一个节点(current)的 next 指针指向新创建的节点 newNode。 这样,newNode 就成为了链表的新的最后一个节点。

    6.return head;

    函数返回 head注意: 只有在链表一开始为空的时候,head 才会被更新!如果链表一开始不为空,我们只是在尾部添加了一个节点,head 仍然指向原来的头节点。

  • 插入节点 (insert):

    ini 复制代码
    function insert(head, val, position) {
      if (position < 0) {
        console.log("Invalid position");
        return head;
      }
    
      if (position === 0) {
        return prepend(head, val);
      }
    
      const newNode = new ListNode(val);
      let current = head;
      let previous = null;
      let index = 0;
    
      while (current && index < position) {
        previous = current;
        current = current.next;
        index++;
      }
    
      if (index < position) {
        console.log("Position out of range");
        return head;
      }
    
      newNode.next = current;
      previous.next = newNode;
      return head;
    }
    
    // 示例:
    head = insert(head, 10, 1);

    1.校验位置有效性 (`position < 0`)

    函数首先检查 position 是否小于 0。如果是,说明插入位置无效,函数输出错误信息并返回原始的 head,不做任何修改。这是处理一种异常情况。

    2.处理在头部插入的情况 (`position === 0`)

    如果 position 等于 0,意味着需要在链表的头部插入新节点。 此时,函数直接调用我们之前讨论过的 prepend 函数来完成插入操作,并返回新的 head。 这样做的好处是代码复用,同时简化了 insert 函数的逻辑。

    3.创建新节点 (`const newNode = new ListNode(val);`)

    创建一个值为 val 的新节点。 这个节点将要被插入到链表中的指定位置。

    ``4.初始化 current, previous 和 `index```

    current:指向当前正在遍历的节点,初始化为 head

    previous:指向 current 节点的前一个节点,初始化为 null

    index:记录当前遍历的节点的位置,初始化为 0。

    5.遍历链表到目标位置 (`while (current && index < position)`)

    这个 while 循环遍历链表,直到到达要插入的位置。 注意循环的两个条件:

    (1)current:确保 current 不为 null,即没有到达链表的末尾。

    (2)index < position:确保还没有到达目标位置。

    在循环内部:

    previous = current;:将 previous 更新为 currentcurrent = current.next;:将 current 移动到下一个节点。 index++;:增加 index 的计数。

    循环结束后,current 将指向目标位置的节点(或者 null,如果目标位置超出了链表的范围),previous 将指向目标位置的前一个节点。

    6.检查位置是否超出范围 (`index < position`)

    如果在遍历结束后,index 仍然小于 position,这意味着目标位置超出了链表的范围(例如,链表只有 3 个节点,但要插入到位置 5)。 在这种情况下,函数输出错误信息并返回原始的 head,不做任何修改。

    7.插入新节点

    newNode.next = current;:将新节点的 next 指针指向 current。这意味着 newNode 将插入到 current 之前。 previous.next = newNode;:将 previous 节点的 next 指针指向 newNode。这意味着 previous 节点现在指向 newNode,从而将 newNode 插入到链表中。

    8.返回 `head`

    返回链表的 head。 由于只有当 position 为0时head会发生变化,其他时候都是返回原head,函数会正确地返回链表的头部。

  • 删除节点 (deleteNode):

    ini 复制代码
    function deleteNode(head, position) {
      if (!head) {
        return null;
      }
    
      if (position < 0) {
        console.log("Invalid position");
        return head;
      }
    
      if (position === 0) {
        return head.next;
      }
    
      let current = head;
      let previous = null;
      let index = 0;
    
      while (current && index < position) {
        previous = current;
        current = current.next;
        index++;
      }
    
      if (!current) {
        console.log("Node not found at position");
        return head;
      }
    
      previous.next = current.next;
      return head;
    }
    
    // 示例:
    head = deleteNode(head, 1);

    1.if (!head) { return null; }

    处理链表为空的情况。 如果 headnull,说明链表是空的,没有节点可以删除,函数直接返回 null

    2.if (position < 0) { ... }

    处理无效的位置。 如果 position 是负数,说明位置无效,函数输出错误信息并返回原来的 head,不做任何修改。

    3.if (position === 0) { return head.next; }

    处理删除头节点的情况。 如果 position 是 0,说明要删除的是头节点。 在这种情况下,函数直接返回 head.next,即将链表的第二个节点作为新的头节点。 相当于从链表中移除了原来的头节点。 注意: 这种情况下,原来的头节点并没有被显式地释放内存。

    4.let current = head; let previous = null; let index = 0;

    初始化三个变量:

    current:指向当前正在遍历的节点,初始值为 headprevious:指向 current 的前一个节点,初始值为 null (因为头节点没有前一个节点)。 index:记录当前遍历到的节点的位置,初始值为 0。

    5.while (current && index < position) { ... }

    这个 while 循环遍历链表,直到到达要删除的节点的位置。

    current && index < position:确保 current 不为 null (即没有到达链表尾部) 并且 index 小于目标 position

    在循环内部:

    (1)previous = current;:将 previous 更新为 current

    (2)current = current.next;:将 current 移动到下一个节点。

    (3)index++;:增加 index 计数。

    循环结束后,current 会指向要删除的节点,而 previous 会指向要删除节点的前一个节点。

    6.if (!current) { ... }

    在遍历结束后,需要检查是否找到了要删除的节点。 如果 currentnull,说明遍历到了链表的末尾,但是仍然没有到达指定的位置 (即 position 大于等于链表的长度),这意味着要删除的节点不存在。 函数输出错误信息并返回原始的 head,不进行任何删除操作。

    7.previous.next = current.next;

    如果找到了要删除的节点,这一步是删除节点的核心操作。 让 previous 节点的 next 指针指向 current 节点的 next 指针。 这相当于将 current 节点从链表中"跳过",从而删除了该节点。 关键: 这一步并没有显式地释放 current 节点占用的内存空间。

    8.return head;

    返回链表的 head。在大多数情况(除了删除的是头节点),head 不会改变。

  • 查找节点 (search):

    ini 复制代码
    function search(head, val) {
      let current = head;
      let index = 0;
    
      while (current) {
        if (current.val === val) {
          return index;
        }
        current = current.next;
        index++;
      }
    
      return -1;
    }
    
    // 示例:
    const index = search(head, 3);
    if (index !== -1) {
      console.log("Node found at index:", index);
    } else {
      console.log("Node not found");
    }

    1.let current = head;

    初始化 current 指针,指向链表的头部 headcurrent 指针用于遍历链表。

    2.let index = 0;

    初始化 index 变量,用于记录当前遍历到的节点的索引。 链表的第一个节点的索引为 0。

    3.while (current) { ... }

    这个 while 循环遍历链表,直到到达链表的末尾(current 变为 null)。 循环条件 current 用于判断当前节点是否有效。如果 currentnull,说明已经遍历到了链表的末尾,循环结束。

    4.if (current.val === val) { return index; }

    在循环内部,检查当前节点的值 current.val 是否等于要查找的值 val。 如果相等,说明找到了目标节点,函数立即返回该节点的索引 index

    5.current = current.next;

    如果当前节点的值与目标值不匹配,将 current 指针移动到链表的下一个节点。

    6.index++;

    index 变量增加 1,以反映当前遍历到的节点的索引。

    7.return -1;

    如果 while 循环结束,说明已经遍历了整个链表,但没有找到目标节点。 在这种情况下,函数返回 -1,表示目标节点不存在于链表中。

  • 反转链表 (reverseList):

    ini 复制代码
    function reverseList(head) {
      let previous = null;
      let current = head;
      let next = null;
    
      while (current) {
        next = current.next;
        current.next = previous;
        previous = current;
        current = next;
      }
    
      return previous;
    }
    
    //示例
    head = reverseList(head);

    1.let previous = null;

    初始化 previousnullprevious 变量将用于存储当前节点 current 的前一个节点。 在反转后的链表中,当前节点的前一个节点实际上是原始链表中的后一个节点。 因为 head 节点会变成 tail 节点,所以 tail 节点的 previous 应该是 null。

    2.let current = head;

    初始化 currentheadcurrent 变量将用于遍历链表。

    3.let next = null;

    初始化 nextnullnext 变量用于临时存储 current 的下一个节点,以便在反转 currentnext 指针后,仍然可以访问链表的剩余部分。

    4.while (current) { ... }

    这个 while 循环遍历链表,直到 current 变为 null,表示已经到达链表的末尾。

    5.next = current.next;

    在修改 current.next 之前,先将 current 的下一个节点保存到 next 变量中。 这是至关重要的一步,因为在下一步中会覆盖 current.next

    6.current.next = previous;

    这是反转链表的核心步骤。 将 currentnext 指针指向 previous。 这实际上将 current 节点从原始链表中分离出来,并将其插入到反转后的链表的头部。

    7.previous = current;

    previous 更新为 current。 在下一次迭代中,current 将成为下一个节点的 previous 节点。

    8.current = next;

    current 更新为 next。 移动到链表中的下一个节点。

    9.return previous;

    while 循环结束时,current 将为 null,而 previous 将指向反转后的链表的头部。 返回 previous

  • 打印链表 (printList):

    ini 复制代码
    function printList(head) {
      let current = head;
      let str = "";
    
      while (current) {
        str += current.val + " -> ";
        current = current.next;
      }
    
      str += "null";
      console.log(str);
    }
    
    // 示例:
    printList(head);

    1.let current = head;

    初始化 current 指针,指向链表的头部 headcurrent 指针用于遍历链表,就像你在 search 函数中所做的那样。

    2.let str = "";

    初始化一个空字符串 str。 这个字符串将用于构建链表的表示。

    3.while (current) { ... }

    这个 while 循环遍历链表,直到 current 指针变为 null,表示已经到达链表的末尾。

    4.str += current.val + " -> ";

    在循环内部,将当前节点的值 current.val 和字符串 " -> " 添加到 str 字符串中。 这创建了链表中节点之间的箭头表示。

    5.current = current.next;

    current 指针移动到链表的下一个节点。

    6.str += "null";

    while 循环结束后,将字符串 "null" 添加到 str 字符串中,表示链表的末尾。 这是链表表示的标准约定。

    7.console.log(str);

    使用 console.log() 函数将构建好的字符串 str 打印到控制台。

4. 链表的优点和缺点

  • 优点:

    • 动态大小: 链表的大小可以在运行时动态调整,不需要预先分配固定大小的内存。
    • 插入和删除效率高: 在已知节点位置的情况下,插入和删除操作的时间复杂度为 O(1)。
  • 缺点:

    • 需要额外的内存空间: 每个节点都需要额外的内存空间来存储指针。
    • 访问效率低: 访问链表中特定位置的节点需要从头节点开始遍历,时间复杂度为 O(n)。

5. 何时使用链表

  • 当需要频繁进行插入和删除操作,并且不需要频繁访问特定位置的元素时。
  • 当无法预先确定数据的大小时。

二、题目描述------2.两数相加

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

示例 1:

ini 复制代码
输入: l1 = [2,4,3], l2 = [5,6,4]
输出: [7,0,8]
解释: 342 + 465 = 807.

示例 2:

css 复制代码
输入: l1 = [0], l2 = [0]
输出: [0]

示例 3:

css 复制代码
输入: l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出: [8,9,9,9,0,0,0,1]

提示:

  • 每个链表中的节点数在范围 [1, 100]
  • 0 <= Node.val <= 9
  • 题目数据保证列表表示的数字不含前导零

三、解题方案

本人的拼尽全力只能想到两种方案

方案一暴力拆分

将两个链表的数组表示出来,加一起之和再转换回去

方案二不拆分,直接处理

采用进位的方式直接在原链表上进行相加

这里我们选择方案二进行解题

四、具体代码

js 复制代码
/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var addTwoNumbers = function(l1, l2) {
    let carry = 0; 
    let head = null; 
    let tail = null; 

    let current1 = l1;
    let current2 = l2;

    while (current1 || current2 || carry) {
        const digit1 = current1 ? current1.val : 0;
        const digit2 = current2 ? current2.val : 0;

        const sum = digit1 + digit2 + carry;
        const digit = sum % 10; 
        carry = Math.floor(sum / 10); 

        const newNode = new ListNode(digit);

        if (head === null) {
            head = newNode;
            tail = newNode;
        } else {
            tail.next = newNode;
            tail = newNode;
        }

        current1 = current1 ? current1.next : null;
        current2 = current2 ? current2.next : null;
    }

    return head;
};

详细解释

1. let carry = 0; : 初始化进位 carry 为 0。 进位用于处理两个数字相加超过 9 的情况。

2. let head = null; : 初始化结果链表的头节点 headnull。 这是新建链表的标准做法。

3. let tail = null; : 初始化结果链表的尾节点 tailnulltail 指针方便在链表末尾添加新节点,而无需每次都从头遍历。

4. let current1 = l1; : 初始化 current1 指针,指向链表 l1 的头部。

5. let current2 = l2; : 初始化 current2 指针,指向链表 l2 的头部。

6. while (current1 || current2 || carry) { ... } : 一个 while 循环,只要 l1l2 中还有节点或者 carry 不为 0,就继续循环。 这意味着即使一个链表已经遍历完,但另一个链表还有剩余节点或者有进位,都需要继续处理。

7. const digit1 = current1 ? current1.val : 0; : 获取 l1 当前节点的值。 使用三元运算符来处理链表 l1 已经遍历完的情况。 如果 current1null,则 digit1 为 0。

8. const digit2 = current2 ? current2.val : 0; : 获取 l2 当前节点的值。 使用三元运算符来处理链表 l2 已经遍历完的情况。 如果 current2null,则 digit2 为 0。

9. const sum = digit1 + digit2 + carry; : 计算当前位的和,包括 l1l2 的当前位的数字以及进位 carry

10. const digit = sum % 10; : 计算当前位的结果。通过将 sum 除以 10 取余数来获得。

11. carry = Math.floor(sum / 10); : 计算进位。通过将 sum 除以 10 并向下取整来获得。

12. const newNode = new ListNode(digit); : 创建一个新节点 newNode,其值为 digit

13. if (head === null) { ... } : 检查结果链表是否为空。 如果 headnull,说明这是结果链表的第一个节点。

14. head = newNode; : 设置 newNode 为结果链表的头节点。

14. tail = newNode; : 设置 newNode 为结果链表的尾节点。 因为链表只有一个节点,所以头和尾都是同一个节点。

15. else { ... } : 如果结果链表不为空,则执行此操作。

16. tail.next = newNode; : 将 newNode 添加到结果链表的尾部。

17. tail = newNode; : 更新 tail 指针,使其指向新的尾节点。

18. current1 = current1 ? current1.next : null; : 将 current1 移动到 l1 的下一个节点。 如果 l1 已经遍历完,则将 current1 设置为 null

19. current2 = current2 ? current2.next : null; : 将 current2 移动到 l2 的下一个节点。 如果 l2 已经遍历完,则将 current2 设置为 null

20. return head; : 返回结果链表的头节点。

举例解析

五、结语

再见!

相关推荐
不是吧这都有重名3 分钟前
[论文阅读]Deeply-Supervised Nets
论文阅读·人工智能·算法·大语言模型
homelook8 分钟前
matlab simulink双边反激式变压器锂离子电池均衡系统,双目标均衡策略,仿真模型,提高均衡速度38%
算法
小盐巴小严17 分钟前
正则表达式
javascript·正则表达式
Samuel-Gyx34 分钟前
前端 CSS 样式书写与选择器 基础知识
前端·css
什码情况1 小时前
星际篮球争霸赛/MVP争夺战 - 华为OD机试真题(A卷、Java题解)
java·数据结构·算法·华为od·面试·机试
天天打码1 小时前
Rspack:字节跳动自研 Web 构建工具-基于 Rust打造高性能前端工具链
开发语言·前端·javascript·rust·开源
天上路人1 小时前
采用AI神经网络降噪算法的通信语音降噪(ENC)模组性能测试和应用
人工智能·神经网络·算法
AA-代码批发V哥1 小时前
正则表达式: 从基础到进阶的语法指南
java·开发语言·javascript·python·正则表达式
字节高级特工1 小时前
【C++】”如虎添翼“:模板初阶
java·c语言·前端·javascript·c++·学习·算法
.Vcoistnt1 小时前
Codeforces Round 1024 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划·图论