js 链表常见问题——转
在前端大全公众号上看到的一篇文章,想选转载的但是找不到原作者和链接,只能写在标题上
整合的题目来自力扣网,上面有更详细的讲解,我也整理过来了一部分,有兴趣的可以去看看
最近想着学点东西,在极客上看到了一门讲数据结构与算法的课,虽然自己前端的技术也还没学到家,但是看到有说数据结构与算法很多学到的是一种思考的方式,并不侧重于前端后端,所以还是买了下来抽空看看
老师的代码虽然是用C语言写的,但依然受益匪浅,思路很清晰,有很多可以借鉴的地方,但是到底不是用JavaScript写的,所以我根据思路自己用JavaScript摸索着写了一个单循环链表解决约瑟夫环的问题,也就是上一篇,今天又在前端大全公众号上看到了一篇链表的文章,而且是用JavaScript写的,所以我就把它转载了过来,下面上内容
链表
链表和数组的底层存储结构不同,数组要求存储在一块连续的内存中,而链表是通过指针将一组零散的内存块串联起来。可见链表对内存的要求降低了,但是随机访问的性能就没有数组好了,需要 O(n) 的时间复杂度。
链表的实际操作上一篇也有一些描述,就不多说了
链表的结点结构由数据域和指针域组成,在 JavaScript 中,以嵌套的对象形式实现。
{
// 数据域
val: 1,
// 指针域
next: {
val: 2,
next: {
val: 3,
next: ...
}
}
}
1、合并两个有序链表
https://leetcode-cn.com/problems/merge-two-sorted-lists/
方法一:递归
思路
我们可以递归地定义两个链表里的 merge 操作(忽略边界情况,比如空链表等),两个链表头部值较小的一个节点与剩下元素的 merge 操作结果合并。
我们直接将以上递归过程建模,同时需要考虑边界情况。
如果 l1 或者 l2 一开始就是空链表 ,那么没有任何操作需要合并,所以我们只需要返回非空链表。否则,我们要判断 l1 和 l2 哪一个链表的头节点的值更小,然后递归地决定下一个添加到结果里的节点。如果两个链表有一个为空,递归结束。
var mergeTwoLists = function(l1, l2) {
if (l1 === null) {
return l2;
} else if (l2 === null) {
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(n+m),其中 n 和 m 分别为两个链表的长度。因为每次调用递归都会去掉 l1 或者 l2 的头节点(直到至少有一个链表为空),函数 mergeTwoList 至多只会递归调用每个节点一次。因此,时间复杂度取决于合并后的链表长度,即 O(n+m)。
- 空间复杂度:O(n+m),其中 n 和 m 分别为两个链表的长度。递归调用 mergeTwoLists 函数时需要消耗栈空间,栈空间的大小取决于递归调用的深度。结束递归调用时 mergeTwoLists 函数最多调用 n+m 次,因此空间复杂度为 O(n+m)。
方法二:迭代
思路
我们可以用迭代的方法来实现上述算法。当 l1 和 l2 都不是空链表时,判断 l1 和 l2 哪一个链表的头节点的值更小,将较小值的节点添加到结果里,当一个节点被添加到结果里之后,将对应链表中的节点向后移一位。
首先,我们设定一个哨兵节点 prehead ,这可以在最后让我们比较容易地返回合并后的链表。我们维护一个 prev 指针,我们需要做的是调整它的 next 指针。然后,我们重复以下过程,直到 l1 或者 l2 指向了 null :如果 l1 当前节点的值小于等于 l2 ,我们就把 l1 当前的节点接在 prev 节点的后面同时将 l1 指针往后移一位。否则,我们对 l2 做同样的操作。不管我们将哪一个元素接在了后面,我们都需要把 prev 向后移一位。
在循环终止的时候, l1 和 l2 至多有一个是非空的。由于输入的两个链表都是有序的,所以不管哪个链表是非空的,它包含的所有元素都比前面已经合并链表中的所有元素都要大。这意味着我们只需要简单地将非空链表接在合并链表的后面,并返回合并链表即可。
var mergeTwoLists = function(l1, l2) {
const prehead = new ListNode(-1);
let prev = prehead;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
prev.next = l1;
l1 = l1.next;
} else {
prev.next = l2;
l2 = l2.next;
}
prev = prev.next;
}
// 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
prev.next = l1 === null ? l2 : l1;
// prehead前加了一个哨兵节点,所以此时返回prehead.next为合并后的链表
return prehead.next;
};
复杂度分析
- 时间复杂度:O(n+m) ,其中 n 和 m 分别为两个链表的长度。因为每次循环迭代中,l1 和 l2 只有一个元素会被放进合并链表中, 因此 while 循环的次数不会超过两个链表的长度之和。所有其他操作的时间复杂度都是常数级别的,因此总的时间复杂度为 O(n+m)。
- 空间复杂度:O(1) 。我们只需要常数的空间存放若干变量。
2、环形链表
https://leetcode-cn.com/problems/linked-list-cycle/
方法一:双指针
思路
双指针法,使用快慢不同的两个指针遍历,快指针一次走两步,慢指针一次走一步,如果没有环,快指针会先到达尾部,返回 false,如果有环,则一定会相遇,返回true
const hasCycle = function(head) {
if (!head || !head.next) {
return false;
}
let fast = head.next;
let slow = head;
while (fast !== slow) {
if (!fast || !fast.next) {
return false;
}
fast = fast.next.next;
slow = slow.next;
}
return true;
};
复杂度分析
-
时间复杂度:O(n),让我们将 n 设为链表中结点的总数。为了分析时间复杂度,我们分别考虑下面两种情况。
-
链表中不存在环:
快指针将会首先到达尾部,其时间取决于列表的长度,也就是O(n)。 -
链表中存在环:
我们将慢指针的移动过程划分为两个阶段:非环部分与环形部分:- 慢指针在走完非环部分阶段后将进入环形部分:此时,快指针已经进入环中 迭代次数=非环部分长度=N
- 两个指针都在环形区域中:考虑两个在环形赛道上的运动员 - 快跑者每次移动两步而慢跑者每次只移动一步。其速度的差值为 1,因此需要经过 二者之间距离/速度差值 次循环后,快跑者可以追上慢跑者。这个距离几乎就是 “环形部分长度 K” 且速度差值为 1,我们得出这样的结论 迭代次数=近似于 “环形部分长度 K”.
因此,在最糟糕的情形下,时间复杂度为 O(N+K),也就是 O(n)。
-
-
空间复杂度:O(1),我们只使用了慢指针和快指针两个结点,所以空间复杂度为 O(1)。
方法二: 标记法
思路
遍历链表,通过标记判断是否有环,如果标记存在则有环
const hasCycle = function(head) {
while (head) {
if (head.flag) {
return true;
} else {
head.flag = true;
head = head.next;
}
}
return false;
}
复杂度分析
- 时间复杂度:O(N),对于含有 n 个元素的链表,我们访问每个元素最多一次
- 空间复杂度:O(N),对于含有 n 个元素的链表,个人认为是O(N),因为这是给每个节点增加了一个flag属性
3、反转链表
https://leetcode-cn.com/problems/reverse-linked-list/
方法一: 迭代
思路
在遍历列表时,将当前节点的 next 指针改为指向前一个元素。由于节点没有引用其上一个节点,因此必须事先存储其前一个元素。在更改引用之前,还需要另一个指针来存储下一个节点。不要忘记在最后返回新的头引用!
const reverseList = function(head) {
let prev = null;
let curr = head;
while (curr !== null) {
let next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
};
复杂度分析
- 时间复杂度:O(n),假设 n 是列表的长度,时间复杂度是 O(n)。
- 空间复杂度:O(1)
方法二:递归(毫不炫耀的说,这个我没看懂)
思路
不妨假设链表为1,2,3,4,5。按照递归,当执行reverseList(5)的时候返回了5这个节点,reverseList(4)中的p就是5这个节点,我们看看reverseList(4)接下来执行完之后,5->next = 4, 4->next = null。这时候返回了p这个节点,也就是链表5->4->null,接下来执行reverseList(3),代码解析为4->next = 3,3->next = null,这个时候p就变成了,5->4->3->null, reverseList(2), reverseList(1)依次类推,p就是:5->4->3->2->1->null
var reverseList = function(head) {
if (!head || !head.next) return head
const p = reverseList(head.next)
head.next.next = head
head.next = null
return p
};
假设链表是[1, 2, 3, 4, 5]从最底层最后一个reverseList(5)来看
- 返回了5这个节点
- reverseList(4)中
- p为5
- head.next.next = head 相当于 5 -> 4
- 现在节点情况为 4 -> 5 -> 4
- head.next = null,切断4 -> 5 这一条,现在只有 5 -> 4
- 返回(return)p为5,5 -> 4
- 返回上一层reverseList(3)
- 处理完后返回的是4 -> 3
- 依次向上
复杂度分析
- 时间复杂度:O(n),假设 nn 是列表的长度,那么时间复杂度为 O(n)。
- 空间复杂度:O(n),由于使用递归,将会使用隐式栈空间。递归深度可能会达到 n 层。
4、删除结点的倒数第 N 个节点
https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/
方法一:单指针遍历两次
思路
删除从列表开头数起的第 (L−n+1) 个结点,其中 L 是列表的长度。只要我们找到列表的长度 L,这个问题就很容易解决。
首先我们将添加一个哑结点作为辅助,该结点位于列表头部。哑结点用来简化某些极端情况,例如列表中只含有一个结点,或需要删除列表的头部。在第一次遍历中,我们找出列表的长度 L。然后设置一个指向哑结点的指针,并移动它遍历列表,直至它到达第 (L−n) 个结点那里。我们把第 (L−n) 个结点的 next 指针重新链接至第 (L−n+2) 个结点,完成这个算法
const removeNthFromEnd = function(head, n) {
let dummy = new ListNode(0);
dummy.next = head;
let length = 0;
let first = head;
while (first != null) {
length++;
first = first.next;
}
length -= n;
first = dummy;
while (length > 0) {
length--;
first = first.next;
}
first.next = first.next.next;
return dummy.next;
}
复杂度分析
- 时间复杂度:O(L),该算法对列表进行了两次遍历,首先计算了列表的长度L 其次找到第 (L−n) 个结点。 操作执行了 2L−n 步,时间复杂度为 O(L)。
- 空间复杂度:O(1),我们只用了常量级的额外空间
方法二:双指针遍历一次
思路
我们可以使用两个指针而不是一个指针。第一个指针从列表的开头向前移动 n+1 步,而第二个指针将从列表的开头出发。现在,这两个指针被 n 个结点分开。我们通过同时移动两个指针向前来保持这个恒定的间隔,直到第一个指针到达最后一个结点。此时第二个指针将指向从最后一个结点数起的第 n 个结点。我们重新链接第二个指针所引用的结点的 next 指针指向该结点的下下个结点。
const removeNthFromEnd = function(head, n) {
let dummy = new ListNode(0);
dummy.next = head;
let first = dummy;
let second = dummy;
for (int i = 1; i <= n + 1; i++) {
first = first.next;
}
while (first != null) {
first = first.next;
second = second.next;
}
second.next = second.next.next;
return dummy.next;
}
复杂度分析
- 时间复杂度:O(L),该算法对含有 L 个结点的列表进行了一次遍历。因此时间复杂度为 O(L)。
- 空间复杂度:O(1),我们只用了常量级的额外空间。
5、求链表的中间结点
https://leetcode-cn.com/problems/middle-of-the-linked-list/
方法一:数组
思路
链表的缺点在于不能通过下标访问对应的元素。因此我们可以考虑对链表进行遍历,同时将遍历到的元素依次放入数组 A 中。如果我们遍历到了 N 个元素,那么链表以及数组的长度也为 N,对应的中间节点即为 A[N/2]。
var middleNode = function(head) {
let A = [];
while (head !== null){
A.push(head)
head = head.next
}
return A[Math.trunc(A.length / 2)]
};
复杂度分析
- 时间复杂度:O(N),其中 N 是给定链表中的结点数目。
- 空间复杂度:O(N),即数组 A 用去的空间。
方法二:单指针法
思路
我们可以对方法一进行空间优化,省去数组 A。
我们可以对链表进行两次遍历。第一次遍历时,我们统计链表中的元素个数 N;第二次遍历时,我们遍历到第 N/2 个元素(链表的首节点为第 0 个元素)时,将该元素返回即可。
var middleNode = function(head) {
let n = 0
let current = head
while (head !== null){
n++
head = head.next
}
for (let k = 0; k < Math.trunc(n/2); k++) {
current = current.next
}
return current
};
复杂度分析
- 时间复杂度:O(N),其中 N 是给定链表中的结点数目。
- 空间复杂度:O(1),只需要常数空间存放变量和指针。
方法三:快慢双指针法
思路
我们可以继续优化方法二,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。
var middleNode = function(head) {
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
};
复杂度分析
- 时间复杂度:O(N),其中 N 是给定链表中的结点数目。
- 空间复杂度:O(1),只需要常数空间存放 slow 和 fast 两个指针。