链表的概念
根据百科的描述:
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
它大致长这样:
图片来源: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;
}
循环链表
单链表把尾指针指向头指针后就是循环链表了。
双向链表就是尾指针指向头,头指针指向尾。
具体的双向链表和循环链表的题就不带大家做了,有兴趣的可以去找来做做。