链表是我们在大学课程中必学的一种数据结构,它通过指针将所有的结点连接起来,在 javascript 中我们通常使用对象来实现链表,一个结点的构造函数长这样

class ListNode {
  constructor(val) {
    this.val = val || null
    this.next = null
  }
}

一个完整的链表结构如下所示

image-20220406202003549

基础链表操作

链表在一般的考核中主要涉及结点的增删改查,我们将以下面这个链表来进行基本的链表操作演示。

const node1 = new ListNode(1)
const node2 = new ListNode(3)
const node3 = new ListNode(5)
const node4 = new ListNode(11)
const node5 = new ListNode(23)

node4.next = node5
node3.next = node4
node2.next = node3
node1.next = node2

/**
 * @description: 遍历链表
 * @param {ListNode} head
 * @return {string}
 */
function linkedListToString(head) {
  let cur = head
  let str = ''
  while (cur) {
    str += `val: ${cur.val}|next =>`
    cur = cur.next
  }
  return str + 'null'
}

console.log(linkedListToString(node1));
// val: 1|next =>val: 3|next =>val: 5|next =>val: 11|next =>val: 23|next =>null

查找结点

查找结点的过程相对简单,通过循环不断地遍历列表来查找符合要求的结点

例如,统计链表中数值大于10 的结点数

/**
 * @description: 统计大于某个值的结点数
 * @param {number} n
 * @return {number}
 */
function countMoreThan(n) {
  let cur = node1
  let cnt = 0

  while (cur) {
    if (cur.val > n)
      cnt++
    cur = cur.next
  }
  return cnt
}
console.log(countMoreThan(10));

查找链表的过程就是不断地通过 next 指针来推进链表

增加结点

增加一个结点需要做的事情是将要插入位置的两侧通过指针操作关联起来,如下图所示

image-20220406203926423

通过这个例子来说明一下具体操作:在有序链表中正确的位置插入一个新的结点,数值为 6

/**
 * @description: 向链表中添加新的结点
 * @param {ListNode} oldList 原来的链表
 * @param {ListNode} newNode 要插入的结点
 * @return {void}
 */
function addNode(oldList, newNode) {
  let cur = oldList
  while (cur.next) {
    if (cur.val < newNode.val && cur.next.val > newNode.val) {
      newNode.next = cur.next
      cur.next = newNode
      break
    }
    cur = cur.next
  }
}
let newNode = new ListNode(6)
addNode(node1, newNode)
console.log(linkedListToString(node1));
// val: 1|next =>val: 3|next =>val: 5|next =>val: 6|next =>val: 11|next =>val: 23|next =>null

要注意操作链表的顺序,一定要先把后置位的元素添加到新元素之后,如果先把新元素赋值前置位的 next,后续的结点就会丢失

删除结点

删除节点的操作更为简单,只需要将要删除结点的 next 指针赋值给上一个结点的 next 指针,这样该元素就无法被访问到,最终会被垃圾回收♻️自动回收了

image-20220406214646058

例如,删除链表结点中可以被 5 整除的结点

/**
 * @description: 删除可以被 n 整除的元素
 * @param {ListNode} head 头结点
 * @param {number} n 除数
 * @return {void}
 */
function deleteNodeByEntire(head, n) {
  let cur = new ListNode()
  cur.next = head

  while (cur.next) {
    if (cur.next.val % n === 0) {
      cur.next = cur.next.next
    }
    cur = cur.next
  }
}

deleteNodeByEntire(node1, 5)
console.log(linkedListToString(node1))
// val: 1|next =>val: 3|next =>val: 11|next =>val: 23|next =>null

在进行删除类的操作时需要添加一个前置元素,不然第一个元素会操作不到

修改结点

修改结点可以看做是是删除操作+添加操作

image-20220406214527045

将数值为 5 的元素替换为 7

/**
 * @description: 替换值为 n 的结点
 * @param {ListNode} oldList 原链表
 * @param {number} n 目标数值
 * @param {ListNode} newNode 新结点
 * @return {void}
 */
function replaceNodeByValue(oldList, n, newNode) {
  let cur = new ListNode()
  cur.next = oldList

  while (cur.next) {
    if (cur.next.val === n) {
      newNode.next = cur.next.next
      cur.next = newNode
      break
    }
    cur = cur.next
  }
}

const newNode = new ListNode(7)
replaceNodeByValue(node1, 5, newNode)
console.log(linkedListToString(node1));
// val: 1|next =>val: 3|next =>val: 7|next =>val: 11|next =>val: 23|next =>null

链表进阶操作

快慢指针

使用两个指针操作链表,常见的操作有删除链表中倒数第 n 个结点

删除的过程如下

image-20220407192920219

  1. 创建两个指针同时指向 head
  2. 将 fast 指针向后移动 n 个结点,此时 fast 和 slow 之间的差值是 n
  3. 同时向后移动 fast 和 slow,二者之间的距离始终保持 n,直到 fast 移动到最后一个结点
  4. 删除 slow.next 结点,因为slow 和 fast 之间的差值为 n,所以倒数第 n 个元素是 slow 的下一个元素

代码实现如下:

/**
 * @description: 删除倒数第 n 个结点
 * @param {ListNode} list 链表
 * @param {number} n 倒数第 n 个
 * @return {void}
 */
function deleteLastNnd(list, n) {
  let head = new ListNode()
  head.next = list
  let fast = head
  let slow = head

  while (n !== 0) {
    fast = fast.next
    n--
  }

  while (fast.next) {
    fast = fast.next
    slow = slow.next
  }

  slow.next = slow.next.next
}

deleteLastNnd(node1, 3)
console.log(linkedListToString(node1));
// val: 1|next =>val: 3|next =>val: 11|next =>val: 23|next =>null

多指针

多指针的情况最常出现在反转链表中,例如,将指定链表进行反转

过程如下

image-20220407200742847

  1. 创建三个指针,分辨指向三个元素(pre 开始指向null,因为链表的最后是 null)
  2. 将 cur.next 指向 pre,此时 pre 和 cur 已经反转
  3. 将 pre指向当前的 cur,作为下一次循环的 pre,将cur指向 next 作为下一次循环的 cur

只需要不停地循环直到最后一个节点,就可以将整个链表反转

代码实现如下:

/**
 * @description: 反转链表
 * @param {ListNode} list 要反转的链表
 * @return {ListNode} 反转后的链表
 */
function reverse(list) {
  let pre = null
  let cur = list

  while (cur) {
    let next = cur.next
    cur.next = pre
    pre = cur
    cur = next
  }

  return pre
}

console.log(linkedListToString(reverse(node1)));
// val: 23|next =>val: 11|next =>val: 5|next =>val: 3|next =>val: 1|next =>null

进阶:反转部分链表

将链表中 m-n 部分的链表进行反转(0<m,n<list.length)

过程如下

image-20220407205221556

  1. 遍历至第 m-1 结点,将当前节点标记为 ledftHead 用于拼接反转列表,然后将后面的结点依次标记为 pre(同时标记为 start,用于拼接n 结点之后的链表) 、 cur、next
  2. 开始反转,循环到 n 结点
  3. 将 leftHead 的 next 指向 pre,start 的 next 指向 cur

代码实现如下:

/**
 * @description: 反转部分链表
 * @param {ListNode} list 要操作的链表
 * @param {number} m 开始位置
 * @param {number} n 结束位置
 * @return {ListNode} 反转后的链表
 */
function partReverse(list, m, n) {
  let head = new ListNode()
  head.next = list

  let p = head
  let pre, cur, start, leftHead
  for (let i = 0; i < m - 1; i++) {
    p = p.next
  }
  leftHead = p
  start = leftHead.next
  pre = start
  cur = pre.next

  for (let i = m; i < n; i++) {
    let next = cur.next
    cur.next = pre
    pre = cur
    cur = next
  }

  leftHead.next = pre
  start.next = cur

  return head.next
}

console.log(linkedListToString(partReverse(node1, 2, 4)));
// val: 1|next =>val: 11|next =>val: 5|next =>val: 3|next =>val: 23|next =>null

环形链表

环形链表是一种特殊的链表,将最后一个节点与之前的节点连接起来,永远不会遍历到最后一个节点

image-20220407221325630

判断一个链表是否为环形链表的代码如下:

/**
 * @description: 判断是否为环形链表
 * @param {ListNode} list 链表
 * @return {boolean} 是否是环形链表
 */
function isCircleLinkedList(list) {
  let cur = list
  while (cur) {
    if (cur.flag) {
      return true
    } else {
      cur.flag = true
      cur = cur.next
    }
  }

  return false
}

// console.log(isCircleLinkedList(node1)); // false
node5.next = node1
console.log(isCircleLinkedList(node1)); // true
// 不能同时执行两次,第一次会全打上 flag 标记

进阶一点的题目会让你找出环形链表的起点

image-20220407221429224

只要掌握了环形链表的特性,这类题目的方法都是相同的,判断为环形链表的节点便是环形链表的起点

/**
 * @description: 寻找环形链表的起点
 * @param {ListNode} list 要搜索的链表
 * @return {ListNode | null} 环形链表起点或者 null
 */
function searchCircleStart(list) {
  let cur = list
  while (cur) {
    if (cur.flag) {
      return cur
    } else {
      cur.flag = true
      cur = cur.next
    }
  }

  return null
}

// console.log(searchCircleStart(node1)); // null
node5.next = node2
console.log(searchCircleStart(node2).val); // 3

还可以使用快慢指针来判断是否存在环形链表

设置两个指针 fast 和 slow,fast 步长为 2,slow 步长为 1,则移动 n 次之后,fast 走过的路程为 2n,slow 走过的路程为 n,两个指针的差值为 n,如果此时二者相遇,说明环形链表的长度为 n,如果 fast 的 next 为 null 则不存在环形链表。

统计链表中的环形链表长度,不存在环形链表返回 0

/**
 * @description: 统计链表中环形链表的长度
 * @param {ListNode} list 要操作的链表
 * @return {number} 环形链表长度
 */
function countCircleLength(list) {
  let head = new ListNode()
  head.next = list

  let slow = head,
    fast = head

  let count = 0
  while (slow && fast) {
    if (slow === fast && slow.val) {
      return count
    } else {
      count++
      slow = slow.next
      fast = fast.next.next
    }
  }

  return count
}

node5.next = node2
console.log(countCircleLength(node1)); // 4

前端小白