写给前端开发的链表介绍(js)

435 阅读4分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

链表初识

链表和数组相似,它们都是有序的列表、都是线性结构(有且仅有一个前驱、有且仅有一个后继)。不同点在于,链表中,数据单位的名称叫做“结点”,而结点和结点的分布,在内存中可以是离散的。

数组和链表的区别

数组在内存中是一段连续的内存空间。每个元素的内存地址可以根据其索引距离数组头部的距离计算出来。因此对数组来说,每一个元素都可以通过数组的索引下标直接定位。

而链表中的结点,则允许散落在内存空间的各个角落里。通过指针(next)来记录后继结点的位置。

在链表中,每一个结点的结构都包括了两部分的内容:数据域和指针域。JS 中的链表,是以嵌套的对象的形式来实现的:

{
  // 数据域
  val: 1,
  // 指针域,指向下一个结点
  next: {
      val:2,
      next: ...
  }
} 

要想访问链表中的任何一个元素,我们都得从起点结点开始,逐个访问 next,一直访问到目标结点为止。为了确保起点结点是可抵达的,我们有时还会设定一个 head 指针来专门指向链表的开始位置。

画一张图来总结一下:

link.jpeg

链表结点的创建、插入和删除

创建

创建一个 1 -> 2 的链表

需要一个构造函数
function ListNode(val, next) {
  this.val = (val===undefined ? 0 : val)
  this.next = (next===undefined ? null : next)
}


const node = new ListNode(1) 
node.next = new ListNode(2)

这样就创建了一个数据域为1,指向数据域为2的链表。

插入

1 -> 2 变成 1 -> 3 -> 2

const node3 = new ListNode(3) 

node3.next = node1.next
node1.next = node3

删除

1 -> 2 -> 3 变成 1 -> 3

node1.next = node1.next.next

update.jpeg

链表和数组的增删、访问操作

数组的访问

因为数组在内存中对应一串连续的空间地址,每个元素的内存地址可以根据其索引距离数组头部的距离计算出来,所以时间复杂度是 O(1)。

数组的增删

因为数组在内存中对应一串连续的空间地址,所以增加元素,后面的所有元素都要后移一位; 同理,删除元素,后面所有的元素都要前移一位,所以时间复杂度是 O(n)。

链表的访问

因为链表只能通过 next 一个一个去找,所以访问的时间复杂度是 O(n)

链表的增删

不管链表里面的结点个数 n 有多大,只要我们明确了要插入/删除的目标位置,那么我们需要做的都仅仅是改变目标结点及其前驱/后继结点的指针指向,所以增删的时间复杂度是 O(1)

小结一下:

操作数组链表
访问O(1)O(n)
插入O(n)O(1)
删除O(n)O(1)

相关 leetcode

合并两个有序链表

真题描述:将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例:

image.png

迭代法

不断用变量的旧值递推新值

const mergeTwoLists = function (l1, l2) {

  const head = new ListNode()         // 定义头结点,确保链表可以被访问到
  
  let cur = head                      // 定义 cur 指针,用来把 l1 和 l2 串起来
  
  while (l1 && l2) {                  // 遍历 l1 和 l2
    
    if (l1.val < l2.val) {            // 如果 l1 的值比较小,就串起 l1 ,反之串起 l2
      
      cur.next = l1                   // cur 指向 l1
      
      l1 = l1.next                    // l1 向前走一个结点
    } else {
      cur.next = l2                   // l2 和 l1 同理
      l2 = l2.next
    }
  }
  cur = cur.next                      // 每一轮遍历, cur向前走一个结点
  
  cur.next = l1 || l2                 // 处理 l1 和 l2 长度不同的情况

  return head.next                    // 返回起始结点
}

时间复杂度:O(n + m)

空间复杂度:O(1)

递归法

const mergeTwoLists = function(l1, l2) {
    if (l1 === null) {
        return l2;
    } else if (l2 === null) {
        return l1;
    } else if (l1.val < l2.val) {
        l1.next = mergeTwoLists(l1.next, l2);
        return l1;
    } else {
        l2.next = mergeTwoLists(l1, l2.next);
        return l2;
    }
};

时间复杂度:O(n + m)

空间复杂度:O(n + m)

虽然递归看似高大上且代码简洁,还是用迭代法比较好,空间复杂度更低。

删除排序链表中的重复元素

真题描述:给定一个已排序的链表的头 head , 删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表 。

示例:

输入: 1->1->2
输出: 1->2
输入: 1->1->2->3->3
输出: 1->2->3

解法

const deleteDuplicates = function (head) {

  let cur = head                       // 设定 cur 指针,初始位置为链表第一个结点
  
  while (cur && cur.next) {            // 遍历 cur
  
    if (cur.val === cur.next.val) {    // 若当前结点和它后面一个结点值相等(重复)
    
      cur.next = cur.next.next         // 删除后面那个节点(去重)
    } else {
      cur = cur.next                   // 若不重复,继续遍历
    }
  }
  return head
}

时间复杂度:O(n)

空间复杂度:O(1)

反转链表

真题描述:给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

示例:

输入:1 -> 2 -> 3 -> 4

输出:4 -> 3 -> 2 -> 1

解法

const reverseList = function (head) {

  let pre = null               // 定义两个指针: pre 和 cur ;pre 在前 cur 在后。
  let cur = head
  
  while(cur) {

    const temp = cur.next      // 临时变量 temp 记录 cur 的 next,因为做反转操作时 cur 的 next 会变化,但后面还需要用到最开始时 cur 的 next
   
    cur.next = pre             // 每次让 cur 的 next 指向 pre ,实现一次局部反转
    
    pre = cur                  // cur 和 pre 同时往前移动一个位置
    cur = temp
  }
  
  return pre                   // 返回pre
}

reverse.jpeg

很难受,直接写没写出来,看了答案后在脑海里想也想不出来,画出来才理解了,算法还是不能空想,容易卡壳,要把过程画出来。