反转链表
var reverseList = function(head) {
let prev = null;
let cur = head;
while (cur) {
const next = cur.next; // 保存下一个节点
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
};
快慢指针
场景一:检查是否有环
快慢指针同时出发,慢指针一次走一步,快指针一次走两步,如果慢指针追上快指针,说明有环。
var hasCycle = function(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
return true;
}
}
return false;
};
场景二:找到中间节点
快慢指针同时出发,慢指针一次走一步,快指针一次走两步,当快指针移动到链表的末尾时,慢指针恰好到链表的中间。
const endOfFirstHalf = (head) => {
let fast = head;
let slow = head;
while (fast.next && fast.next.next) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
场景三:找到倒数第 n 个节点
快指针先走 n 步,然后快慢指针同时前进,当快指针移动到链表的末尾时,慢指针指向的节点就是倒数第 n 个节点的前一个节点。
const nthFromEnd = (head, n) => {
let res = new ListNode();
res.next = head;
let fast = res, slow = res;
for (let i = 0; i <= n; ++i) {
// 要≤,因为res是个哑节点
fast = fast.next;
}
while (fast) {
fast = fast.next;
slow = slow.next;
}
return slow.next;
}
哑节点(哨兵节点/虚拟头节点)
创建哑节点 dummy,最终返回 dummy.next
function xxx(x) {
const dummy = new ListNode();
let current = dummy;
// ……
return dummy.next;
}
哑节点的好处
-
便于初始化 在创建链表时,直接让头指针指向一个哑节点可以简化初始化过程,避免对空链表的特殊处理。
-
简化边界处理 在链表操作中,避免对头尾节点的单独判断,统一处理逻辑。
-
方便链表操作 在双端队列或某些链表操作中,哑节点可以作为始终存在的参照点,使得插入和删除操作总是有固定的前或后节点,无需担心空指针异常。
-
优化循环终止条件 在循环遍历链表时,哑节点可以作为一个明确的起始或结束标志,使得循环的终止条件更加简洁明了。
合并有序链表
例如,按从小到大合并两个有序链表。
function mergeTwoLists(l1, l2) {
const dummy = new ListNode();
let current = dummy;
while (l1 && l2) {
if (l1.val < l2.val) {
current.next = l1;
l1 = l1.next;
} else {
current.next = l2;
l2 = l2.next;
}
current = current.next;
}
current.next = l1 || l2;
return dummy.next;
}
合并多个有序链表时,就可以使用分治策略,分解为能用上面代码解决的分组,最后合并得到结果。
复制随机链表
一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random ,该指针可以指向链表中的任何节点或空节点,现在要求复制这份链表。
实现步骤
-
复制节点并插入到原节点后面:遍历原链表,对于每一个节点,在其后面插入一个与其值相同的节点,形成一个交织链表。这样做的好处是新节点紧挨着原节点,方便后续步骤中 random 指针的复制。
-
复制 random 指针:再次遍历交织链表,为每个新节点设置正确的 random 指针。由于新节点就在原节点后面,我们可以很容易地找到原节点的 random 指针所指向的节点的下一个节点,即新节点的 random 指针应该指向的节点。
-
拆分链表:最后,将交织链表拆分成两个独立的链表。原链表保持不变,新链表由原链表中每个节点后面插入的节点构成。
/**
* function _Node(val, next, random) {
* this.val = val;
* this.next = next;
* this.random = random;
* };
*/
/**
* @param {_Node} head
* @return {_Node}
*/
var copyRandomList = function(head) {
if (!head) {
return null;
}
// 每个旧节点后面追加一个一模一样的新节点
let cur = head;
while (cur) {
const newNode = new _Node(cur.val);
newNode.next = cur.next;
cur.next = newNode;
cur = newNode.next;
}
// 设置新节点的 random
cur = head;
while (cur) {
const newRandom = cur.random ? cur.random.next : cur.random;
cur.next.random = newRandom;
cur = cur.next.next;
}
// 拆分
cur = head;
const res = cur.next;
let newCur = res;
while (cur) {
cur.next = newCur.next;
cur = cur.next;
if (cur) {
newCur.next = cur.next;
newCur = newCur.next;
}
}
return res;
};
总结
本文总结了链表算法题中常用的几种技巧:
-
反转链表:通过三个指针(prev, cur, next)实现链表的反转,时间复杂度O(n),空间复杂度O(1)。
-
快慢指针:
- 检查链表是否有环
- 寻找链表中间节点
- 寻找倒数第n个节点
-
哑节点:简化链表操作的边界条件处理,使代码更加简洁。
-
合并有序链表:使用哑节点和双指针技巧合并两个有序链表,对于多个有序链表可以结合分治策略。
-
复制随机链表:通过三次遍历实现,先复制节点并插入原节点后,再复制随机指针,最后拆分链表。
掌握这些技巧可以帮助我们更高效地解决各类链表相关的算法问题,提高代码的可读性和性能。