“链表” 考察频率较高!
链表基础
链表存储数据时,不需要使用地址连续的存储单元,而是通过“链”建立起元素之间的逻辑关系,对链表的插入、删除不需要移动元素,只需要修改指针即可。
因为 JS 没有提供内置的链表,因此我们需要学会从头实现此数据结构。对单链表来说,链表节点的结构由两部分组成:数据data和指向其后继的指针next。双链表节点由三部分组成: 数据data、前驱指针prior、后继指针next。
单链表只有后继指针,所以当插入或删除某一节点时,必须先通过遍历的方式找到其前驱节点,因此单链表的增删时间复杂度O(n),如果已给定前驱节点,那么时间复杂度就是O(1)。而双链表要增删某节点时,可通过q = p->prior的方式获取其前驱节点,不必再进行遍历,所以双链表的增删时间复杂度为O(1)。单链表和双链表的查找时间复杂度都是O(n)。
1. 与数组区别
- 内存空间是否连续。(数组栈存储、链表堆存储)
- 查找:数组可以根据下标快速查找、时间复杂度 O(1);链表则需要遍历查找、时间复杂度 O(n)。
- 增删:数组在插入和删除时会有大量元素的移动补位,而链表只需改变指针指向即可。(数组增删的时间复杂度 O(n),链表如果是单链表、已知其前驱节点情况下增删的时间复杂度O(1))
2. 生成链表节点、创建链表
class ListNode {
constructor (data) {
this.data = data;
this.next = null;
}
}
const generateList = (nums) => {
let head = new ListNode(nums[0]); // 保存头节点,用来返回
let curr = head; // 注意
for (let i = 1; i < nums.length; i++) {
curr.next = new ListNode(nums[i]);
curr = curr.next;
}
return head;
}
const head = generateList([1, 2, 3, 4, 5]);
console.log(head); // 1 -> 2 -> 3 -> 4 -> 5
// 反转链表时
const head = generateList([1, 2, 3, 4, 5]);
let newHead = reverseList(head);
console.log(newHead); // 5 -> 4 -> 3 -> 2 -> 1
3. 链表删除某个节点
node.next = node.next.next;
4. 链表反转节点
// 迭代法,先储存 prev、curr、next 三个节点
cur.next = prev
// 递归法
node.next.next = node;
node.next = null;
206. 反转链表:递归 / 迭代
迭代和递归都需掌握。
1. 迭代:prev、curr、next
假设链表为1 -> 2 -> 3 -> null,反转后得到的链表应该为3 -> 2 -> 1 -> null。(因为每个节点都由val 和 next组成,因此尾结点指向null,头节点就正常指向)
在遍历链表时,保存 prev / curr / next 三个节点,将当前节点的next指针指向它的前一个节点,由于单向链表,节点没有引用其前一个节点,所以要事先存储其前一个节点。最后 「返回新的头引用」。
const reverseList = function(head) {
let prev = null; // 当前节点的前一个节点,初始为空
let curr = head; // 当前节点,初始为头节点
while (curr) {
let next = curr.next; // 储存当前节点的下一个节点
curr.next = prev; // 反转
// 更新prev和curr,继续下一个循环
prev = curr;
curr = next;
}
return prev; // 注意,当退出循环时 prev 是头节点
};
( 通过 while 循环先将 head 指向 null,然后逐步改变 prev 和 curr,因为单链表只有一个 next 指针,只能指向一个节点,所以不用考虑需要切断之前的连接,因为改变next指针时就已经切断了之前的连接 )
时间复杂度:O(n)。
空间复杂度:O(1)。
2. 递归:两步反转
我们将大问题拆分成两个子问题:头节点head、除head外的其他所有节点。然后对“除head外的其他所有节点”这一子问题的求解思路和大问题完全一样。当递归到终止条件时,如下,我们需要反转节点:
先增加一条后面节点指向前面节点的指针,
head.next.next = head
再取消原来的前面节点到后面节点的指针,
head.next = null
通过上面两步即可实现单个节点的指针方向反转。
递归解法比较难理解,可以借助动画。newHead 在多层递归中始终不变,为尾结点!
const reverseList = function(head) {
if (head === null || head.next == null) return head; // 当链表为空表或只有一个节点时,直接返回 head 即可
const newHead = reverseList(head.next); // 注意 newHead
// 反转
head.next.next = head;
head.next = null;
return newHead; // 注意
};
时间复杂度:O(n)。
空间复杂度:O(n)。
21. 合并两个有序链表:递归 + 4个return
- 链表是以头节点的形式给出的,
- 比较节点大小时,不要忘记 val 属性。
const mergeTwoLists = function(L1, L2) {
if (!L1) {
return L2;
} else if (!L2) {
return L1;
} else if (L1.val < L2.val) {
L1.next = mergeTwoLists(L1.next, L2);
return L1; // 注意,是头节点
} else {
L2.next = mergeTwoLists(L1, L2.next);
return L2; // 注意
}
}
时间复杂度:O(m + n)。
空间复杂度:O(m + n)。
1. 考虑去重
用一次遍历的方法判断。需要创建一个虚拟头节点,然后判断cur.next和cur.next.next的值是否相等,相等则继续下一层循环判断并更新节点,不相等则直接更新节点。
let dummy = new ListNode(0, head); // 创建虚拟头节点,指向head
let cur = dummy;
while (cur.next && cur.next.next) {
if (cur.next.val === cur.next.next.val) { // 有重复值
let x = cur.next.val; // 储存相等的值
while (cur.next && cur.next.val === x) { // 二次循环
cur.next = cur.next.next;
}
} else { // 无重复值
cur = cur.next;
}
}
2. 合并多个有序链表
借助合并两个链表的思路,先两两合并,然后再使用“归并法”自底向上合并。
24. 两两交换链表中的节点:两步递归
该题是 "K个一组翻转链表" 的特殊情况,美团常考。
(1)如上图,我们要将原链表转换成目标链表形式,首先用 head 表示旧链表的头节点,新链表的第二个节点,用 newHead 表示新链表的头节点,旧链表的第二个节点,用 newHead.next 表示下一组旧链表的头节点。
(2)先用
head.next = swapPairs(newHead.next)来改变旧链表的指针指向,因为 swapPairs 函数返回的是头节点,因此 swapPairs(newHead.next) 就代表下一组链表的头节点。
(3)再用newHead.next = head来反转每组链表内部的指向,最后返回新的头节点 newHead。
const swapPairs = function(head) {
if (!head || !head.next) return head; // 递归终止条件
let newHead = head.next; // 反转后新链表的头节点
// 反转
head.next = swapPairs(newHead.next);
newHead.next = head;
return newHead;
}
时间复杂度:O(n)。
空间复杂度:O(n)。
总结
- 链表问题一般通过 递归/迭代、双指针 解决。
- 相交链表:快慢指针;倒数第k个节点:前后指针。
- 合并两个有序链表、两两交换链表节点:递归(画图)。