1、前言
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。由于链表不是连续的线性数据结构,采用链表的程序在内存比较小的机器上运行时,相对于数组有较大的优势。
2、定义
对于链表,我们常常这样定义,其中value用于表示链表节点的值,next指针用于指示后继节点(命名随意,只要能够存储节点值和标记下一个节点即可),若不存在(null
)则代表链表结束。
interface Node<T> {
// 节点值
value: T;
// 下一个节点的引用
next: Node<T> | null;
}
3、链表的操作
对于链表的操作,我个人感觉最重要的两个操作就是初始化
和遍历
,从LeetCode的题目来看,半数以上的题都是这样的。对于链表的操作有些同学喜欢在头放一个空节点,这是属于个人的编程习惯,而我一般不喜欢(可不是出于节约内存的想法哦,哈哈哈),因此,选择一个适合你的习惯即可。本文的所有代码实现均不包含空的头结点。
3.1、初始化
首先聊聊初始化,即把一堆数据构造成链表。对于链表的初始化一般有两种操作:即头插法
和尾插法
。
头插法:用一个变量指向已有链表的头结点,每次新来的节点都插在头结点的前面,再让这个变量指向新插入的节点,因此头插法构建出的链表数据的顺序和输入的顺序是相反
的。
头插法插入节点的过程: 算法实现如下:
/**
* 以头插法初始化链表
* @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;
}
我个人编程习惯喜欢用第二种,但是即使是使用头插法,你也可以多用一个变量来记住链表的尾结点,这样的好处就是如果某个时刻你需要在尾部插入的话,可以直接用尾节点指针而不用再去遍历了。
3.2、链表的遍历
链表的遍历几乎可以说是一种标准范式了,这是对于链表一定得掌握的知识。对于单向链表,只要给头指针就可以完成遍历。
在遍历链表时,我们有时候会申明一个前驱节点,这样可以使得在遍历的过程中,既能找到当前节点,又可以找到当前节点的前驱节点,在某些时候非常好用,这也是一个必须掌握的编程技巧。
需要注意的是在遍历链表的过程中不要修改头指针,因为一旦修改了头指针就找不回来了,万一需要用到头指针,那代码又得重新设计。
链表的遍历过程:
链表遍历的复杂度为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;
}
}
3.3、在指定位置插入节点
对于链表的操作操作一定要谨慎,否则容易丢失后继节点或者使得链表的节点指向表现非预期。
我们演示一下在表中部插入节点的场景,其流程如下:
伪代码描述即: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;
}
3.4、查找
查找主要分为按值查找或者按位置查找。 查找的思路和遍历类似,因此此处就不再赘述其算法流程。
查找的平均算法复杂度为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;
}
3.5、删除
链表的删除相对来说比较简单,直接拿掉特定节点即可,这一点,相对于数组来说有优势的,因为在数组删除元素后,需要把元素统统往前挪动一位,然后才能把size
减少,当数据的每个单元是一个复杂结构的时候,这个时间的开销可是不能忽略的
。
链表节点的删除过程: 删除过程中还需要考虑一些边界情况,对于空表无序任何操作;如果删除头结点,需要修改链表头结点指针;
另外还需要注意的是,链表删除节点的时候,这些语句的顺序是不能交换顺序的,如果交换了顺序就不符合预期了,读者可以自行体会一下。
链表删除时我们若不考虑查找的时间复杂度的话,其时间复杂度为O(1)。
算法实现如下:
/**
* 从链表中删除值为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;
}
4、扩展
本文不阐述双向链表和循环链表,如果你能够掌握上述知识的话,对于双向链表和循环链表也不在话下,有兴趣的读者可以自行查阅资料。
本节选取一些高频面试题向大家分析思路及解法。
4.1、反转链表
第206题。
这道题其实考察你对头插法和尾插法的理解,可以在时间复杂度为O(N)的情况下实现。
我们在遍历链表的过程中,使用头插法构建新的链表,当原始链表遍历完成的时候,新链表也构建完成,即完成链表反转。
算法实现如下:
/**
* 对指定链表进行反转
* @param {Node<number>} head 待反转链表头节点
*/
function reverseList(head) {
let reverseList = null;
let node = head;
while (node) {
let nextNode = node.next;
node.next = null;
if (reverseList === null) {
reverseList = node;
} else {
node.next = reverseList;
reverseList = node;
}
node = nextNode;
}
return reverseList;
}
4.2、对链表进行插入排序
第147题
对于数组的排序因为我们可以直接通过下标访问到数组元素,链表相对数组来说,就稍微要复杂一些了。本文以插入排序简单阐述一下链表排序的一种实现方式。
插入排序的思路跟我们打扑克很相似,在摸牌的过程中,我们第一张牌摸上来的时候,是可以直接插入的,也就是说第一张牌自认为有序,后面我们每摸上来一张牌,我们都需要从最后一张牌开始比较,如果待插入的牌比当前选择的牌小的话,说明这个牌还不能插在这个位置,我们得把当前选择的牌往后错一位,然后继续向前比,重复这个过程,直到找到合适的位置。算法流程可以参考这里
但是对于链表来说,这个过程是反的。
首先,对链表进行遍历这是肯定必不可少的,那么先拿出我们的链表遍历范式代码。
一边遍历原链表,一边构建新链表。
因为链表节点的插入,不像数组那么麻烦,数组需要错位,而链表是可以直接插入的,但需要在新表中找到合适的插入位置,这必须要从新表的头开始找。
这个算法的时间复杂度和数组的插入排序平均时间复杂度是一样的,即O(N²)。
这个算法过程本文就不给出执行的过程图了,如果有任何疑问可以直接查看LeetCode的官方讲解或者联系作者。
算法实现如下:
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* 对链表进行直接插入排序
* @param {ListNode} head
* @return {ListNode}
*/
function insertionSortList(head) {
if (!head) {
return head;
}
// 待构建的新表
let newHead = null;
let node = head;
while (node) {
let nextNode = node.next;
node.next = null;
if (newHead === null) {
newHead = node;
} else {
let sortNode = newHead;
let preSortNode = null;
// 在新表中找到合适的插入位置
while (sortNode && sortNode.val <= node.val) {
preSortNode = sortNode;
sortNode = sortNode.next;
}
// 如果插在第一个节点上的话
if (preSortNode === null) {
node.next = newHead;
newHead = node;
} else {
let preNext = preSortNode.next;
preSortNode.next = node;
node.next = preNext;
}
}
node = nextNode;
}
// 最后返回新表即可完成排序
return newHead;
}
4.3、K 个一组翻转链表
第25题
据说这道题是某一年微软的面试原题。可以看到,这是一道困难难度的题,千万别被吓破了胆,当你真正的理解了链表,困难其实也就那样,哈哈哈(膨胀)。
这道题同样还是考查链表的插入操作,但是难度就在于引入了翻转条件。我们先考虑思考一些边界条件,首先:如果传入的链表是空的或者以K=1为一组进行翻转的话,是啥事儿也不用做。如果翻转完n组件之后,还剩下一些节点,按题目要求原样输出即可。
接着考虑一下实现思路,对链表的遍历是必不可少的,我们需要在遍历的时候增加一个计数器(记为groupCounter
),当计数器为0的时候,我们需要初始化一个变量(记为groupStartNode
)以后方便记住开始反转的链表头节点,如果计数器在某个时刻等于K的话,说明这个时刻,从groupStartNode
到当前这个节点的链表都可以进行反转,我们已经知道如何反转一个链表,为了简化问题,先把当前节点的后继节点断开,当然,为了防止后继节点丢失,断开之前得先用一个变量nextNode
记住它,反转完了之后,再通过它给接回来,然后还要记得把groupCounter
和groupStartNode
初始化,这样一组反转就完成了。如果链表已经遍历完成了,groupCounter
还是小于K, 即一组也不需要反转,也符合题意。
算法流程如下:
算法实现如下:
/**
* 以K为一组将链表翻转
* @param {Node<number>} head 待翻转的链表表头
* @param {number} k 翻转的每组大小
* @returns {Node<number>} 被翻转的链表
*/
var reverseKGroup = function(head, k) {
// 链表如果是空 或者 如果 反转节点是1 自然是不需要翻转的
if (!head || k == 1) {
return head;
}
let newHead = null;
let pre = null;
let node = head;
let groupHead = head;
let groupCounter = 0;
// 开始对链表进行遍历
while (node != null) {
groupCounter++;
// 说明当前满足K个一组了,需要对其进行反转了。
if (groupCounter === k) {
let nextNode = node.next;
node.next = null;
// 转化为和题目1完全一样的过程
const reverse = reverseList(groupHead);
// 是否是第一组翻转
if (!newHead) {
newHead = reverse;
} else {
pre.next = reverse;
}
// 因为翻转完成之后,pre刚好就是新链表的最后一个节点,即剩余待翻转链表的前一个节点
pre = groupHead;
// 之前为了简化问题,之前我们有断开的操作,现在可以把它接回来了。
groupHead.next = nextNode;
// 下一个节点即是下一轮开始翻转的节点
groupHead = nextNode;
// 因为已经完成了翻转,计数器需要归零
groupCounter = 0;
node = nextNode;
} else {
node = node.next;
}
}
return newHead || head;
};
/**
* 反转链表
* @param {Node<number>} head 链表表头
* @returns
*/
function reverseList(head) {
let reverseHead = null;
while (head != null) {
let next = head.next;
head.next = reverseHead;
reverseHead = head;
head = next;
}
return reverseHead;
}
总结
链表相对于数组还有一个优势是在内存足够的前提下,其长度是可以无限增长的。链表在初始化的的时候无需知道表长,而数组必须确定表长
(此性质不考虑JavaScript语言),对于其它语言来说(C#,Java等)数组的扩容代价相对较大,首先需要向系统申请更长的连续空间(在某些情况下是可能申请不到的),然后需要把旧数据拷贝到新数组里面去,然后再将原来的数组释放,而在每个数据项比较大的情况下,这个拷贝时间是不能被忽略的
。
链表平均算法复杂度与数组的比较如下:
操作 | 数组 | 链表 |
---|---|---|
随机访问 | O(1) | O(N) |
插入(不考虑前置查找) | O(N) | O(1) |
查找 | O(N) | O(N) |
删除(不考虑前置查找) | O(N) | O(1) |
虽然文中大部分都在谈链表相对于数组的优势,但是如果需要对其数据进行排序的话,有些排序算法是不能直接用的,因此在实际开发中我们需要根据需求决定选择用链表还是数组存储数据。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。