# 链表
链表,是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
一般我们说链表都是指的是单向链表。
# 链表的一般数据结构定义
interface LinkedListNode<T> {
/**
* 链表的数据域
*/
data: T;
/**
* 链表的后继节点域
*/
next: LinkedListNode<T> | null;
}
# 链表的操作
对于链表的操作,我个人感觉最重要的两个操作就是初始化和遍历。对于链表的操作有些同学喜欢在头放一个空节点,这是属于个人的编程习惯,而我一般不喜欢(可不是出于节约内存的想法哦,哈哈哈),因此,选择一个适合你的习惯即可。以下的所有代码实现均不包含空的头结点。
# 初始化
初始化,即把一堆数据构造成链表。对于链表的初始化一般有两种操作:即头插法
和尾插法
。
头插法:用一个变量指向已有链表的头结点,每次新来的节点都插在头结点的前面,再让这个变量指向新插入的节点,因此头插法构建出的链表数据的顺序和输入的顺序是相反
的。
头插法插入节点的过程:
/**
* 以头插法初始化链表
* @param {Array<number>} arr 用于初始化链表的数据
*/
function initialize(arr) {
// 如果图中第一步
let head = null;
// 此例中无用,仅用于阐述问题
let tail = null;
arr.forEach((val) => {
const node = createNode(val);
// 如果是空链表的话,直接让头结点指针指向第一个节点
if (head == null) {
// 如图第二步
head = node;
// 此例中无用,仅用于阐述问题
tail = node;
} else {
//先让新来的节点指向head节点,如图第三步
node.next = head;
// 再让head指针指向最新的节点,如图第四步
head = node;
}
});
return head;
}
尾插法:用一个变量指向已有链表的尾结点,每次新来的节点都插在尾结点的后面,再让这个变量指向新插入的尾结点,因此尾插法构建出的链表数据的顺序和输入的顺序是相同
的。
尾插法插入节点的过程:
/**
* 以尾插法初始化链表
* @param {Array<number>} arr 用于初始化链表的数据
*/
function initialize(arr) {
// 如图第一步
let head = null;
let tail = null;
arr.forEach((val) => {
const node = createNode(val);
if (head == null) {
// 如果是空链表的话,直接让头结点指针和尾结点指针指向第一个节点,对应图中第一步
head = node;
tail = node;
} else {
// 让尾结点的后继指针指向新来的节点, 如图第三步
tail.next = node;
// 让尾节点指针指向最后一个节点,如图第四步
tail = node;
}
});
return head;
}
我个人编程习惯喜欢用第二种,但是即使是使用头插法,你也可以多用一个变量来记住链表的尾结点,这样的好处就是如果某个时刻你需要在尾部插入的话,可以直接用尾节点指针而不用再去遍历了。
# 遍历
链表的遍历几乎可以说是一种标准范式了,这是对于链表一定得掌握的知识。对于单向链表,只要给头指针就可以完成遍历。
编程技巧
在遍历链表时,我们有时候会申明一个前驱节点,这样可以使得在遍历的过程中,既能找到当前节点,又可以找到当前节点的前驱节点,在某些时候非常好用,这也是一个必须掌握的编程技巧。
需要注意的是在遍历链表的过程中不要修改头指针,因为一旦修改了头指针就找不回来了,万一需要用到头指针,那代码又得重新设计。
链表遍历的复杂度为O(n)
;
/**
* 遍历链表
* @param {Node<number>} head 链表头指针
*/
function traverse(head) {
let node = head;
// pre在本例中无用,仅用于说明这是一种编程技巧
let pre = null;
// 如果当前节点指向空 (对于空链表,开始就直接指向空)
while (node) {
console.log(node);
// 让pre滞后,这样可以永远保证pre指向node的前一个节点(如果node是null,pre指向最后一个节点,如果node是第一个节点或者链表是空表,pre指向null)
pre = node;
node = node.next;
}
}
# 插入
对于链表的操作操作一定要谨慎,否则容易丢失后继节点或者使得链表的节点指向表现非预期。
我们演示一下在表中部插入节点的场景,其流程如下:
伪代码描述即:newNode.next = node; pre.next = newNode;
这两行代码一定不能交换。
链表插入的平均时间复杂度为:O(n)
;
/**
* 在链表中指定的K位置插入节点, 如果K小于1,则插在头部,如果K大于链表的长度,则直接插在尾部
* @param {Node<number>} head 链表头
* @param {number} val 节点值
* @param {number} K 插入的位置,K为节点数,不是索引
*/
function insert(head, val, K) {
const newNode = createNode(val);
// 如果需要插在头部的话
if (K < 1) {
newNode.next = head;
head = newNode;
return head;
}
let node = head;
// 申明一个空指针,因为其滞后node一个表结点,主要是用来记录上一个节点
let pre = null;
// 申明一个计数器,用于标记已经遍历的节点的个数
let counter = 0;
let inserted = false;
while (node) {
counter++;
// 如果找到了合适的插入位置,插入完成以后就没有继续循环的必要了
if (counter === K) {
// 必须先用一个临时变量将其记住,否则会丢失后继节点
let nextNode = node.next;
// 插入新的节点
node.next = newNode;
newNode.next = nextNode;
// 标记插入完成
inserted = true;
break;
}
// 先把当前这个节点记住,然后向后迭代
pre = node;
node = node.next;
}
// 如果已经插入了的哈,就不用再管什么事儿了
if (inserted) {
return head;
}
// 如果K大于等于链表的长度的话,就直接插在链表尾部即可
if (counter < K && pre) {
// 此刻的node已经是null了,而pre指针指向链表的最后一个节点
pre.next = newNode;
} else {
// 如果链表是空表,之前的循环一次都没有执行的,那么直接让head指向新来的节点即可
head = newNode;
}
return head;
}
# 查找
查找主要分为按值查找或者按位置查找。 查找的思路和遍历类似。
查找的平均算法复杂度为O(N)
。
/**
* 根据索引查找链表节点
* @param {Node<number>} head 链表头结点
* @param {number} idx 目标索引
*/
function findIndex(head, idx) {
let node = head;
let counter = 0;
// 找到表尾没有找到目标索引 或者 找到了目标索引 结束循环
while (node && counter < idx) {
counter++;
node = node.next;
}
return counter === idx ? node : null;
}
/**
* 根据节点值查找节点
* @param {Node<number>} head 链表头结点
* @param {number} val 目标节点值
*/
function find(head, val) {
let node = head;
// 找到节点值或遍历到链表结束,终止循环
while (node && node.val !== val) {
node = node.next;
}
return node;
}
# 删除
链表的删除相对来说比较简单,直接拿掉特定节点即可,这一点,相对于数组来说有优势的,因为在数组删除元素后,需要把元素统统往前挪动一位,然后才能把 size 减少。
WARNING
当数据的每个单元是一个复杂结构的时候,这个拷贝时间的开销可是不能忽略的。
链表节点的删除过程:
/**
* 从链表中删除值为val的节点
* @param {Node<number>} head 链表的头结点
* @param {number} val 待删除的值
*/
function remove(head, val) {
if (!head) {
console.warn("can not remove element from empty linked list");
return;
}
let node = head;
let pre = null;
while (node) {
// 找到了目标节点,需要结束循环
if (node.value != val) {
break;
}
pre = node;
node = node.next;
}
// 如果pre存在的话,说明用户删除的不是头结点
if (pre) {
// 如图第二步
pre.next = node.next;
// 如图第三步
node.next = null;
node = null;
} else {
// 删除头结点时,首先得用临时变量把第二个节点先记下来(哪怕它不存在)
let nextHead = head.next;
// 解除头结点对第二个节点的引用
head.next = null;
// 让头结点指针指向下一个节点
head = nextHead;
}
return head;
}
# 结语
链表相对于数组还有一个优势是在内存足够的前提下,其长度是可以无限增长的。链表在初始化的的时候无需知道表长,而数组必须确定表长(此性质不考虑 JavaScript 语言),对于其它语言来说(C#,Java 等)数组的扩容代价相对较大,首先需要向系统申请更长的连续空间(在某些情况下是可能申请不到的),然后需要把旧数据拷贝到新数组里面去,然后再将原来的数组释放,而在每个数据项比较大的情况下,这个拷贝时间是不能被忽略的。
链表平均算法复杂度与数组的比较如下:
操作 | 数组 | 链表 |
---|---|---|
随机访问 | O(1) | O(N) |
插入(不考虑前置查找) | O(N) | O(1) |
查找 | O(N) | O(N) |
删除(不考虑前置查找) | O(N) | O(1) |
但是如果需要对其数据进行排序的话,有些排序算法是不能直接用的,在实际开发中我们需要根据需求选择链表还是数组。
常见的应用链表的场景,如流程引擎中编辑过程,如果使用数组的话,当用户频繁的更改关系,其操作变得相当笨重,而使用链表仅修改节点的前驱和 后继指针而已。
还有在查找节,多层链表结合二分查找,可以实现高效的跳(跃)(链)表