前端数据结构与算法学习之链表(js 实现)

489 阅读11分钟

链表的概念

根据百科的描述:

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

它大致长这样: 单向链表 图片来源:wikipedia

链表有很多种结构,比如:单向链表、双向链表、循环链表、块状链表等,本文内容只会涉及单向、双向和循环这三种。

链表有时候看代码会不是很容易理解,建议在学习的同时多在草稿纸上画画图可以辅助理解。

单向链表

根据百科的描述:

链表中最简单的一种是单向链表,它包含两个域,一个信息域和一个指针域。这个链接指向列表中的下一个节点,而最后一个节点则指向一个空值。

一个单向链表的节点被分成两个部分。第一个部分保存或者显示关于节点的信息,第二个部分存储下一个节点的地址。单向链表只可向一个方向遍历。

单向链表

需要了解一下这几个名词:

头结点:单链表的第一个结点

尾结点:单链表的最后一个结点

当前结点:遍历链表时使用的一个中间变量保存的当前指向的结点

哨兵结点(或哑结点):没有实际意义,用于在执行链表操作时,用来方便操作的一个结点

定义单向链表结点

因为一个链表是由一个个结点组成的,定义链表的结点我们可以使用构造函数或者 class 来定义:

// 函数定义,定义方式与 leecode 一致,方便做题
function ListNode(val, next) {
  this.val = val === undefined ? 0 : val;
  this.next = next === undefined ? null : next;
}

// class 定义
class ListNode {
  constructor(val, next) {
    this.val = val === undefined ? 0 : val;
    this.next = next === undefined ? null : next;
  }
}

// 使用
let list = new ListNode(1);
// 向 list 后面添加一个结点
list.next = new ListNode(2);
// 这时 list 长这样:{val: 1, next: {val: 2, next: null}}

创建单向链表

有了链表结点的构造函数,接下来为了方便咱们后面学习链表的算法,需要提前准备好一个链表, 创建链表的方法有两种,分别是头插法和尾插法。

为了方便随机生成数字,使用一个工具函数

// 生成一个 [0, 100] 范围的随机数
function generateNumber() {
  return Math.floor(Math.random() * 101);
}

创建链表之:头插法

头插法,顾名思义就是在链表头部插入结点的方法,这样得到的链表是一个倒序的。

/**
 * 创建一个随机长度的链表,并返回头结点,这个方法需要一个哑结点来帮助
 * h -> N(n) -> ... -> N(2) -> N(1)
 * 实现方式:使用一个哑结点作为头结点,在头结点后面插入一个新结点,所以看起来是倒序的,又叫头插法
 */
function createListFromHead() {
  // 链表长度
  let len = generateNumber();
  console.log("创建了一个长度为:", len, "的链表");
  // 创建头结点,这个头结点将作为哑结点来使用
  let head = new ListNode(-1);
  while (len > 0) {
    let newNode = new ListNode(generateNumber());
    // h -> N(n) -> ... -> N(2) -> N(1)
    // 在头结点后面插入一个新结点
    // 方法是:1、先把 head 后面的结点放到新结点后面;2、再把 新结点放到 head 结点后面。
    newNode.next = head.next;
    head.next = newNode;
    len--;
  }
  // 返回的时候需要把哑结点去掉
  return head.next;
}

创建链表之:尾插法

尾插法,顾名思义就是在链表尾部插入结点的方法,这样得到的链表是一个正序的。

/**
 * 创建一个随机长度的链表,并返回头结点
 * N1 -> N2 -> ... -> Nn
 * 实现方式:从头到尾依次添加结点,所以是顺序的,又叫尾插法
 */
function createListFromTail() {
  // 链表长度
  let len = generateNumber();
  console.log("创建了一个长度为:", len, "的链表");
  // 设定一个用于保存头结点的 head 变量
  let head = null;
  // 设定一个尾结点,它将一直作为尾结点来使用,可以看做是临时的结点
  let end = null;
  while (len > 0) {
    let newNode = new ListNode(generateNumber());
    // 初始头和尾相同
    if (head === null) {
      head = newNode;
      end = head;
    } else {
      // 1、把新结点放到尾结点后面;
      end.next = newNode;
      // 2、把 newNode 赋值给 end 后,下一次循环到 1 时,就相当于这一次的 newNode.next
      // 于是就通过这种引用依次从头结点一直延续到尾结点。
      // 想简单点的话,就把这步操作想成把 end 指针挪到新结点。
      end = newNode;
    }
    len--;
  }
  return head;
}

将一个结点插入到链表

上面那两个方法适用于快速创建一个链表,实际使用的并不是很多,更多的操作是将一个结点插入到链表中。

一般要将一个结点插入到链表中,需要知道要插入的位置,如果不知道待插入的位置,那么你还得想法去找到这个位置,比如搜索某个结点在链表中的位置,这个后面会讲到。

/**
 * 向单链表中的指定位置插入一个节点
 * @param {*} head 链表
 * @param {*} newNode 待插入的节点
 * @param {*} index 要插入的位置,下标从 0 开始计数
 */
function insert(head, newNode, index) {
  // 如果是空链表,就直接把新结点赋值给空链表就可以了
  if (head === null) {
    // 空链表只能从第 0 个位置开始插入,否则就直接返回原链表不作任何处理
    if (index !== 0) {
      return head;
    }
    head = newNode;
    return head;
  }
  // 向链表头插入结点
  if (index === 0) {
    // 链表的这种换来换去的操作几乎成了链表的标配
    // 把新结点指向头结点,这时就成了以新结点为头的链
    newNode.next = head;
    // 这时再把头结点换成新结点就可以了
    head = newNode;
    return head;
  }
  // 在其他位置插入的逻辑
  // currentNode 几乎也成了链表的标配,用来记录当前遍历到哪结点了
  let currentNode = head;
  // 用来设定 currentNode 位置的
  let count = 0;
  // 开始从头结点往下找目标位置,要找到目标结点的前一个结点
  while (currentNode.next !== null && count < index - 1) {
    currentNode = currentNode.next;
    count++;
  }
  // 上述查找步骤完成后,这时,count 和 currentNode 就来到目标结点的前一位了
  if (count === index - 1) {
    // 把新结点的指针指向目标结点(因为 currentNode 是目标的前一位,所以 currentNode.next 指向的就是目标结点)
    newNode.next = currentNode.next;
    // 这时把当前结点的指针指向新结点,插入操作就完成了
    currentNode.next = newNode;
  }
  return head;
}

上面这个插入的方法是基于链表头不是哑结点的方式来操作的,如果你的链表头有一个哑结点,那么上述的代码就要做一下相应的修改,其实跟上面的代码大同小异的,如果你有兴趣,可以自己写一下。

有了插入链表的方法,那么创建链表我们就可以使用这个方法来做了。

使用 insert 方法创建链表

// 空链表
let head = null;
for (let i = 0; i < 100; i++) {
  const newNode = new ListNode(i + 1);
  head = insert(head, newNode, i);
}
// 这时 head 就是一个具有 100 个结点的链表了

遍历链表

我们现在得到了一个链表,那么如何遍历这个链表把每个结点是数据都输出出来呢?

/**
 * 打印链表,从传入的头结点开始依次往后遍历链表中各结点的数据
 * @param {*} head 链表,无哑结点
 */
function printList(head) {
  // 看到没,卡伦特.努得又来帮忙了,海德先到一边稍息
  let currentNode = head;
  const result = [];
  while (currentNode !== null) {
    result.push(currentNode.val);
    // 遍历链表就是把 currentNode 不断往后移动
    currentNode = currentNode.next;
  }
  console.log("链表一共有", result.length, "个结点", "结果为:");
  console.log(result);
}

知道如何遍历链表后,接着咱们再来找一下链表中的某个结点。

查找链表中的某个结点

首先来个简单的,查找指定值所在的结点

/**
 * 在单链表中查找指定值所在的结点,并返回它
 * @param {*} head 单链表
 * @param {*} val 要查找的值
 * @returns 返回以目标结点为头结点的链表
 */
function searchNode(head, val) {
  let currentNode = head;
  while (currentNode !== null && currentNode.val !== val) {
    currentNode = currentNode.next;
  }
  return currentNode;
}

再加点难度,找到指定值所在的结点后,再看看它的位置是多少

/**
 * 在单链表中查找指定值所在的结点位置
 * @param {*} head 单链表
 * @param {*} val 要查找的值
 * @returns 未找到返回 -1,找到返回以 0 计数的结点位置
 */
function searchPosition(head, val) {
  let currentNode = head;
  // 找位置就需要使用一个计数器来做辅助
  let count = 0;
  while (currentNode !== null && currentNode.val !== val) {
    currentNode = currentNode.next;
    count++;
  }
  if (currentNode === null) {
    return -1;
  }
  return count;
}

再换种方式,假设我们已经知道结点的位置,但是不知道结点的内容,这个也简单:

function searchNode(head, index) {
  let currentNode = head;
  let count = 0;
  while (count < index) {
    currentNode = currentNode.next;
    count++;
  }
  return currentNode;
}

加油,再学完咋删除链表中的结点就差不多该完事了。

删除链表中的某个结点

删除结点有两种情况,一种是知道要删除的位置,一种是知道结点的值(需要结点中的值各不相同才行)。

接下来为大家介绍的是知道要删除的位置来删除结点,另外一种情况也是大同小异的。

/**
 * 删除单链表指定位置的一个结点
 * @param {*} head 链表(不带哑结点)
 * @param {*} index 要删除的位置,从0计数
 * @returns ListNode
 */
export function deleteByPosition(head, index) {
  if (head === null) return head;
  if (index === 0) {
    head = head.next;
    return head;
  }
  // 回想一下插入结点的操作,康特还是很有用的
  // 这一步是把 currentNode 变成待删除结点的前一个结点
  let count = 0;
  let currentNode = head;
  while (currentNode.next !== null && count < index - 1) {
    currentNode = currentNode.next;
    count++;
  }
  // 这时 currentNode 就是目标位的前一位了,currentNode.next 指向的就是要删除的结点
  if (count === index - 1 && currentNode.next !== null) {
    // 找到要删除的结点
    let deletedNode = currentNode.next;
    // 把 currentNode 的指针指向删除位的下一位,就实现了删除目标结点的操作
    currentNode.next = deletedNode.next;
    // 把已被孤立出来的模板结点的内存释放掉(js 中可不做这步,因为会自动 gc)
    deletedNode = null;
  }
  return head;
}

小结

咱们现在已经学完了链表的数据结构以及一些最基本算法:创建链表、插入、遍历、查找、删除。

接下来的内容将会以上述基本算法为依托来进一步加深问题难度,咱们再学习一下要如何来求解这些问题。

翻转单链表

翻转链表就是要把链表里面的结点的指向改成与原来相反的方向,比如原来的链表是:A -> B -> C -> D -> null 这样的顺序,翻转后就会变成:D -> C -> B -> A -> null

/**
 * 翻转单链表,一般翻转链表都是原地翻转,没有开辟额外的存储空间
 * @param {*} head
 */
export function reverseSingleList(head) {
  if (head === null) return head;
  // 先把头结点分离出来,这时 currentNode 就是头结点了
  let currentNode = head.next;
  head.next = null;
  // 使用一个变量来保存下一个结点(相对 currentNode 来说)
  let nextNode = null;
  // 如果你画图的话,会看到 head、currentNode 和 nextNode 是一个整体右移的过程,一边移动一边就把链表翻转完成了
  // 当前遍历的结点为 null 就说明已经翻转完成了
  while (currentNode !== null) {
    nextNode = currentNode.next;
    // 把当前结点的指针指向 head,这样 第一,第二 两个结点的指向就翻转过来了,
    // 这时就分成了两条链,一条是 nextNode 做为牵头结点的剩下还没翻转的链,
    // 另一条是 head 和 currentNode 所在的已经翻转过的链
    currentNode.next = head;
    // 这时 卡伦特.努得 宣布在这边的任务已完成,于是把老大的的位置还给 海德
    head = currentNode;
    // 接着 卡伦特.努得 又跑去另一边当老大继续搞事情,真不让人省心
    currentNode = nextNode;
  }
}

链表中环的检测

有一个问题是这样描述的:

给定一个这样的链表:A -> B -> C -> D -> E -> B,请问如何检测这个链表是否有环,也就是尾结点指向了另一个结点,这就叫链表有环。

更特殊的有:A -> B -> C -> D -> E -> A,即尾结点指向了头结点,这个链表就构成了一个循环链表。

对于这个问题,有的同学会这样回答:我遍历整个链表,把链表中的值存到一个临时变量中,如果某次遍历的时候发现当前的值等于临时变量的值,就说明有环了。这个思路也对,但不是最好的办法。

解决这个问题,可以利用在操场跑圈的办法来解决,两位同学同时朝一个方向跑,其中一位跑的快,另一位跑的慢,如果快的那位同学追上了慢的那位同学,就说明这个操场是环形的了。

function hasCycle(head) {
  // 两位同学同时跑
  let fast = head;
  let slow = head;
  // 因为快的同学每次要跑两步,所以如果链表是没有环的,那么终止条件有两种情况:
  // 第一种是 fast 在倒数第一个位置,那么 fast.next === null,就说明没环
  // 第二中是 fast 在倒数第二个位置,那么进入最后一次循环后,fast === null,也说明没环
  while (fast !== null && fast.next !== null) {
    // 快的同学每次跑两步
    fast = fast.next.next;
    // 慢的同学每次跑一步
    slow = slow.next;
    // 这里判断的不是值,是指针
    if (fast === slow) {
      return true;
    }
  }
  return false;
}

合并两个有序链表

有两个递增排序的链表,现在需要合并这两个链表并使新链表中的节点仍然是递增排序的。

示例,给定两个链表:

L1: 1->2->4

L2: 1->3->4

合并后,返回:1->1->2->3->4->4

/**
 * 合并两个有序链表
 * @param {ListNode} L1
 * @param {ListNode} L2
 * @return {ListNode}
 */
function mergeTwoLists(l1, l2) {
  // 我们依然使用两个变量来代表当前遍历到的结点
  let curL1 = l1;
  let curL2 = l2;
  // 对于这道题使用一个哑结点来作为头结点会相对容易一些
  let head = new SingleListNode(-1);
  // 借鉴上面创建链表的尾插法,使用一个临时的指针来向尾部持续添加结点
  // h -> N1 -> N2 -> ...
  //          [end↑]
  let end = head;
  // 判断条件不难得出,只要一个链不为空就得再进入循环执行里面的逻辑
  while (curL1 !== null || curL2 !== null) {
    // 终止条件之一,当 l1 链遍历完了,就把 l2 链剩余的结点都放到主链的后面
    if (curL1 === null) {
      end.next = curL2;
      return head.next;
      // 终止条件之二,当 l2 链遍历完了,就把 l1 链剩余的结点都放到主链的后面
    } else if (curL2 === null) {
      end.next = curL1;
      return head.next;
    } else if (curL1.val < curL2.val) {
      // 使用尾指针添加结点
      end.next = curL1;
      // 把尾指针依次向后挪动位置
      end = end.next;
      // 遍历
      curL1 = curL1.next;
    } else {
      end.next = curL2;
      end = end.next;
      curL2 = curL2.next;
    }
  }
}

删除链表倒数第 n 个结点

给定一个链表:A -> B -> C -> D -> E -> null 输入 2,则表示要删除这个链表的倒数第二个结点也就是 D 所在的这个结点。 返回结果:A -> B -> C -> E -> null

我们最容易想到的办法就是先遍历链表得到长度,然后 长度 - n 就是要删除的那个结点,再执行删除操作即可。

但是这需要两次遍历的操作,我们可以这样来考虑优化一下解法:

两个同学在操场跑 100 米,假设 A、B 两同学的速度是一样的,那么当 A 跑了 50 米的时候 B 开始跑,那么 A 到终点的时候 B 刚好跑了 50 米。而 50 米这个点就可以看作是待删除的结点了。

这个思路在解决链表有没有环的时候也用到过,大家习惯称这种思路叫:快慢指针。也有的叫龟兔赛跑。

/**
 * 删除链表倒数第 n 个结点,利用哑结点帮助化简逻辑
 * @param {ListNode} head
 * @param {number} n
 * @return {ListNode}
 */
function removeNthFromEnd(head, n) {
  if (head === null || n <= 0) return head;
  // 在头结点加入哑结点,主要目的是帮助解决删除恰好是第一个结点的问题,不加哑结点删除第一个节点会复杂一些
  let dummy = new ListNode(-1);
  dummy.next = head;
  // 设定快慢指针
  let fast = dummy;
  let slow = dummy;
  let count = 0;
  // 先让快指针遍历前 n 个结点
  while (fast.next !== null && count < n) {
    fast = fast.next;
    count++;
  }

  // 当 fast 指针遍历到最后一个结点的时候,
  // slow 指针正好遍历到待删除结点的前一位
  while (fast.next !== null) {
    fast = fast.next;
    slow = slow.next;
  }

  let deletedNode = slow.next;
  slow.next = deletedNode.next;
  deletedNode = null;
  // 返回的时候不需要哑结点
  return dummy.next;
}

求链表的中间结点

这道题也容易想到两次遍历的做法,但是使用快慢指针会更好。

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
function middleNode(head) {
  let fast = head;
  let slow = head;
  while (fast !== null && fast.next !== null) {
    // 快指针每次走两格,慢指针每次走一格
    fast = fast.next.next;
    slow = slow.next;
  }

  return slow;
}

双向链表

基本算法跟单向链表差不多,只是多了个前驱结点。

/**
 * 构造双向链表的函数
 * @param {*} val Number
 * @param {*} prior DoubleListNode
 * @param {*} next DoubleListNode
 */
function DoubleListNode(val, prior, next) {
  this.val = val === undefined ? 0 : val;
  this.prior = prior === undefined ? null : prior;
  this.next = next === undefined ? null : next;
}

循环链表

单链表把尾指针指向头指针后就是循环链表了。

双向链表就是尾指针指向头,头指针指向尾。

具体的双向链表和循环链表的题就不带大家做了,有兴趣的可以去找来做做。