leetcode 之链表

151 阅读5分钟

“链表” 考察频率较高!

链表基础

链表存储数据时,不需要使用地址连续的存储单元,而是通过“链”建立起元素之间的逻辑关系,对链表的插入、删除不需要移动元素,只需要修改指针即可

因为 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. 反转链表:递归 / 迭代

题目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外的其他所有节点”这一子问题的求解思路和大问题完全一样。当递归到终止条件时,如下,我们需要反转节点:

image.png

先增加一条后面节点指向前面节点的指针,

head.next.next = head

image.png

再取消原来的前面节点到后面节点的指针,

head.next = null

image.png

通过上面两步即可实现单个节点的指针方向反转。

递归解法比较难理解,可以借助动画。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

题目21. 合并两个有序链表

  1. 链表是以头节点的形式给出的,
  2. 比较节点大小时,不要忘记 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. 两两交换链表中的节点:两步递归

题目24. 两两交换链表中的节点

该题是 "K个一组翻转链表" 的特殊情况,美团常考。

image.png (1)如上图,我们要将原链表转换成目标链表形式,首先用 head 表示旧链表的头节点,新链表的第二个节点,用 newHead 表示新链表的头节点,旧链表的第二个节点,用 newHead.next 表示下一组旧链表的头节点。

image.png (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)。

总结

  1. 链表问题一般通过 递归/迭代、双指针 解决。
  2. 相交链表:快慢指针;倒数第k个节点:前后指针
  3. 合并两个有序链表、两两交换链表节点:递归(画图)