链表算法题的技巧总结

0 阅读4分钟

反转链表

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;
}

哑节点的好处

  1. 便于初始化 在创建链表时,直接让头指针指向一个哑节点可以简化初始化过程,避免对空链表的特殊处理。

  2. 简化边界处理 在链表操作中,避免对头尾节点的单独判断,统一处理逻辑。

  3. 方便链表操作 在双端队列或某些链表操作中,哑节点可以作为始终存在的参照点,使得插入和删除操作总是有固定的前或后节点,无需担心空指针异常。

  4. 优化循环终止条件 在循环遍历链表时,哑节点可以作为一个明确的起始或结束标志,使得循环的终止条件更加简洁明了。

合并有序链表

例如,按从小到大合并两个有序链表。

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 ,该指针可以指向链表中的任何节点或空节点,现在要求复制这份链表。

实现步骤

  1. 复制节点并插入到原节点后面:遍历原链表,对于每一个节点,在其后面插入一个与其值相同的节点,形成一个交织链表。这样做的好处是新节点紧挨着原节点,方便后续步骤中 random 指针的复制。

  2. 复制 random 指针:再次遍历交织链表,为每个新节点设置正确的 random 指针。由于新节点就在原节点后面,我们可以很容易地找到原节点的 random 指针所指向的节点的下一个节点,即新节点的 random 指针应该指向的节点。

  3. 拆分链表:最后,将交织链表拆分成两个独立的链表。原链表保持不变,新链表由原链表中每个节点后面插入的节点构成。

/**
 * 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;
};

总结

本文总结了链表算法题中常用的几种技巧:

  1. 反转链表:通过三个指针(prev, cur, next)实现链表的反转,时间复杂度O(n),空间复杂度O(1)。

  2. 快慢指针

    • 检查链表是否有环
    • 寻找链表中间节点
    • 寻找倒数第n个节点
  3. 哑节点:简化链表操作的边界条件处理,使代码更加简洁。

  4. 合并有序链表:使用哑节点和双指针技巧合并两个有序链表,对于多个有序链表可以结合分治策略。

  5. 复制随机链表:通过三次遍历实现,先复制节点并插入原节点后,再复制随机指针,最后拆分链表。

掌握这些技巧可以帮助我们更高效地解决各类链表相关的算法问题,提高代码的可读性和性能。