一杯咖啡的时间吃透一道算法题——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; : 返回结果链表的头节点。

举例解析

五、结语

再见!

相关推荐
mmmmath_31 小时前
LeetCode.541.反转字符串II
数据结构·算法·leetcode
Navigator_Z2 小时前
LeetCode //MySQL - 1251. Average Selling Price
c语言·算法·leetcode
Captaincc2 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
醇氧2 小时前
MySQL 8.0 系统表损坏与引擎转换故障排查实战
数据结构·算法
大熊背3 小时前
《Color constancy by characterization of illumination chromaticity》之色度色域最大化算法(二)
算法·白平衡·色度·色温
钓鱼的肝3 小时前
梳理(1-5)
c++·经验分享·笔记·算法·青少年编程
HZZD_HZZD3 小时前
CSDN_批发市场水电漏损归因算法LAM的原理与落地
嵌入式硬件·物联网·算法
计算机魔术师3 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen4 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程