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)来看

  1. 返回了5这个节点
  2. reverseList(4)中
  3. p为5
  4. head.next.next = head 相当于 5 -> 4
  5. 现在节点情况为 4 -> 5 -> 4
  6. head.next = null,切断4 -> 5 这一条,现在只有 5 -> 4
  7. 返回(return)p为5,5 -> 4
  8. 返回上一层reverseList(3)
  9. 处理完后返回的是4 -> 3
  10. 依次向上

复杂度分析

  • 时间复杂度: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 两个指针。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值