链表算法精讲:从基础到高频面试题的深度解析

0 阅读9分钟

引言:为什么链表仍是程序员的必修课?

在数组、哈希表、树等数据结构大行其道的今天,链表(Linked List)似乎显得“古老”而“低效”。然而,翻开 LeetCode、Codeforces 或大厂面试题库,链表相关题目依然高频出现——206. 反转链表、141. 环形链表、160. 相交链表、234. 回文链表…… 这些经典问题不仅考察编码能力,更检验对指针操作、空间优化与算法思想的理解。

本文将结合你提供的完整代码实现(已通过 LeetCode 测试),系统讲解链表的核心概念与八大高频操作。我们将跳过冗余语法,直击算法本质,用清晰的逻辑和图解思维,助你彻底掌握链表这一“简单却不平凡”的数据结构。


一、链表是什么?——动态结构的优雅

1.1 定义与内存模型

链表是一种线性但非连续存储的数据结构。它由一系列节点(Node)组成,每个节点包含:

  • 数据域val):存储实际值;
  • 指针域next):指向下一个节点的内存地址。
function ListNode(val, next) {
  this.val = (val === undefined ? 0 : val);
  this.next = (next === undefined ? null : next);
}

关键特性

  • 动态扩容:无需预先分配固定内存;
  • 插入/删除 O(1) :只需修改相邻节点指针;
  • 不支持随机访问:查找需从头遍历,O(n)。

1.2 与数组的对比

特性数组链表
内存布局连续非连续(堆中分散)
访问元素O(1)(索引)O(n)(遍历)
插入/删除O(n)(需移动)O(1)(已知位置)
空间开销仅数据数据 + 指针(8字节/节点)

💡 适用场景:频繁插入删除、未知数据规模、内存碎片化环境。


二、核心思想:双指针与虚拟头节点

在深入具体问题前,先掌握两大通用技巧

2.1 双指针(Two Pointers)

  • 快慢指针fast 每次走两步,slow 走一步 → 用于找中点、判环。
  • 同步指针:两指针以相同速度遍历 → 用于合并、比较。

2.2 虚拟头节点(Dummy Head)

  • 创建一个 dummy = new ListNode(0)dummy.next = head
  • 避免边界判断(如删除头节点),最终返回 dummy.next

三、八大高频链表操作详解

以下按本人做题顺序展开,每题包含:问题描述 → 核心思想 → 代码解析 → 复杂度分析


1. 反转链表(LeetCode 206)

🔍 问题

206. 反转链表 - 力扣(LeetCode)

输入:1→2→3→4→5→NULL
输出:5→4→3→2→1→NULL

💡 思想:迭代反转指针方向

  • 维护 prev(已反转部分)、cur(当前节点)、next(暂存下一节点)。
  • 每次将 cur.next 指向 prev,然后整体右移。

🧠 代码解析

var reverseList = function(head) {
  let prev = null;   //定义一个空指针
  let cur = head;      //定义一个指针指向头节点位置
  while (cur) {
    const next = cur.next; // 保存当前cur指针指向的下一节点
    cur.next = prev;       // 反转指针
    prev = cur;            // prev 前进
    cur = next;            // cur 前进
  }
  return prev; // 新头节点
};

⏱️ 复杂度

  • 时间:O(n),遍历一次
  • 空间:O(1),仅用常数变量

2. 回文链表(LeetCode 234)

🔍 问题

234. 回文链表 - 力扣(LeetCode)

判断链表是否为回文:1→2→2→1true

💡 思想:快慢指针 + 后半段反转

  1. 快慢指针找中点fast 走两步,slow 走一步 → slow 停在前半段尾。
  2. 反转后半段:从 slow.next 开始反转。
  3. 双指针比较p1 从头,p2 从反转后头,逐值对比。
  4. 恢复链表(可选):再次反转后半段,保持原结构。

🧠 代码解析

// 找前半段尾节点
const endOfFirstHalf = (head) => {
  let fast = head, slow = head;    //起初位置均为head
  while (fast.next && fast.next.next) {   
  //while判断条件,fast.next必须放前面,不然会引发错误
    fast = fast.next.next;
    slow = slow.next;
  }
  return slow;
};

var isPalindrome = function(head) {
  if (!head) return true;
  
  // 反转后半段
  const firstHalfEnd = endOfFirstHalf(head); //拿到前半段的末尾
  const secondHalfStart = reverseList(firstHalfEnd.next); //通过对后半段的head进行reverse操作
  
  // 比较
  let p1 = head, p2 = secondHalfStart;
  let result = true;  //判断回文
  while (result && p2) {    
    if (p1.val !== p2.val) result = false;  //一旦不同就变为false并跳出循环
    p1 = p1.next;     //p1右移
    p2 = p2.next;    //p2右移
  }
  
  // 恢复链表
  firstHalfEnd.next = reverseList(secondHalfStart); //恢复链表避免错误
  return result;
};

⏱️ 复杂度

  • 时间:O(n)
  • 空间:O(1)

优势:无需额外数组,空间最优。


3. 环形链表(LeetCode 141)

🔍 问题

141. 环形链表 - 力扣(LeetCode)

判断链表是否有环:1→2→3→4→5→2(5 指回 2)→ true

💡 思想:Floyd 判圈算法(龟兔赛跑)

  • slow 每次走 1 步,fast 走 2 步。
  • 若有环,fast 必会追上 slow;若无环,fast 先到 null

🧠 代码解析

var hasCycle = function(head) {
  let fast = head, slow = head;
  while (fast && fast.next) {
    fast = fast.next.next; // 快指针
    slow = slow.next;      // 慢指针
    if (fast === slow) return true; // 相遇即有环
  }
  return false;
};

⏱️ 复杂度

  • 时间:O(n)
  • 空间:O(1)

📌 数学证明:若环长为 L,当 slow 入环时,fast 已在环内某处。二者相对速度为 1,最多 L 步必相遇。


4. 相交链表(LeetCode 160)

🔍 问题

160. 相交链表 - 力扣(LeetCode)

找两个链表的相交节点:
listA = [4,1,8,4,5], listB = [5,0,1,8,4,5] → 交于 8

💡 思想:双指针消除长度差

  • pA 遍历完 A 后跳到 B 头,pB 遍历完 B 后跳到 A 头。
  • 二者路径长度均为 lenA + lenB,若相交必在交点相遇;否则同时为 null

🧠 代码解析

var getIntersectionNode = function(headA, headB) {
  let pA = headA, pB = headB;
  while (pA !== pB) {
    pA = pA === null ? headB : pA.next;
    pB = pB === null ? headA : pB.next;
  }
  return pA; // 相交节点或 null
};

⏱️ 复杂度

  • 时间:O(m + n)
  • 空间:O(1)

巧妙之处:无需计算长度,自动对齐。


5. 排序链表(LeetCode 148)

🔍 问题

148. 排序链表 - 力扣(LeetCode)

对链表排序:4→2→1→31→2→3→4

💡 思想:扔进数组排序后返回

  • 将链表之间的关系全部打碎后扔进数组中
  • 将数组利用sort排序
  • 遍历一遍数组,还原为有序链表

🧠 代码补充


var sortList = function(head) {
    if(head===null)return null; //判断链表是否为空
    const arr=[];  //初始化数组
      while(head)
      {
        arr.push(head) //将该节点扔进数组
        const next=head.next;    //保存下一个节点
        head.next=null;       //解除该节点与下一个节点间的指针
        head=next;           //头节点变为下一个节点
      }
   arr.sort((a,b)=>a.val-b.val);    //sort排序
   for(let i=0;i<arr.length-1;i++)     // 还原链表
   {
    arr[i].next=arr[i+1];
   }
       return arr[0];    //返回头节点
};

⏱️ 复杂度

  • 时间:O(n log n)
  • 空间:O(n)

优势:可根据长度利用sort选择最快的排序方法。


6. 合并两个有序链表(LeetCode 21)

🔍 问题

21. 合并两个有序链表 - 力扣(LeetCode)

合并:1→2→4 + 1→3→41→1→2→3→4→4

💡 思想:双指针 + 虚拟头

  • 比较两链表当前节点,小者接入结果链。
  • 使用 dummy 简化头节点处理。

🧠 代码解析

var mergeTwoLists = function(list1, list2) {
  const dummy = new ListNode(0);  //哨兵节点
  let cur = dummy;
  
  while (list1 && list2) {
    if (list1.val <= list2.val) {
      cur.next = list1;      //指向小的
      list1 = list1.next;       //链表1头节点变成原先的下一个节点
    } else {
      cur.next = list2;     
      list2 = list2.next; 
    }
    cur = cur.next;        //指针右移
  }
  
  // 接上剩余部分
  cur.next = list1 ?? list2; // ??运算符表示取非空的
  return dummy.next;
};

⏱️ 复杂度

  • 时间:O(m + n)
  • 空间:O(1)

7. 合并 K 个有序链表(LeetCode 23)

🔍 问题

23. 合并 K 个升序链表 - 力扣(LeetCode)

合并:[1→4→5, 1→3→4, 2→6]1→1→2→3→4→4→5→6

💡 思想:分治归并

  • 将 K 个链表两两合并,每轮数量减半,共 log K 轮。
  • 每轮总操作数为 O(N)(N 为总节点数)。

🧠 代码解析

var mergeTwoLists=function(list1,list2){
    const dummy=new ListNode();
    let cur=dummy;
    while(list1&&list2)
    {
       if(list1.val>list2.val)
       {
          cur.next=list2;
          list2=list2.next;
       }
     else 
     {
         cur.next=list1;
         list1=list1.next;
     }
  cur=cur.next;
    }
     cur.next=list1??list2
    return dummy.next;
}

var mergeKLists = function(lists) {
  if (lists.length === 0) return null;
  
  // 分治:step=1,2,4... 两两合并
  for (let step = 1; step < lists.length; step *= 2) {
    for (let i = 0; i < lists.length - step; i += step * 2) {
      lists[i] = mergeTwoLists(lists[i], lists[i + step]);   
      //根据步长合并为一个链表,放在list[0]位置
    }
  }
  return lists[0];
};

⏱️ 复杂度

  • 时间:O(N log K)
  • 空间:O(1)

🔁 替代方案:最小堆(优先队列),时间同为 O(N log K),但需 O(K) 空间。


8. K 个一组翻转链表(LeetCode 25)

🔍 问题

25. K 个一组翻转链表 - 力扣(LeetCode)

每 K 个节点反转:1→2→3→4→5, K=3 → 3→2→1→4→5

💡 思想:局部反转 + 链接

  1. 检查剩余节点 ≥ K。
  2. 反转子链 [head, tail]
  3. 将反转后的子链接回原链。

🧠 代码解析

const myReverse = (head, tail) => {
    // 反转后,原 tail 的 next 应该指向原 tail.next(即子链表之后的部分)
    // 所以把 prev 初始化为 tail.next,作为反转后的“终止指针”
    let prev = tail.next;
    let p = head; // 当前处理的节点

    // 循环条件:当 prev 指向 tail 时,说明 head 到 tail 的所有节点都已反转完毕
    // (因为每次循环 prev 都会前移,最终 prev 会变成原 tail)
    while (prev !== tail) {
        const nex = p.next;   // 保存下一个节点,防止断链
        p.next = prev;        // 将当前节点的 next 指向前一个节点(反转指针)
        prev = p;             // prev 前移:当前节点成为下一轮的“前一个节点”
        p = nex;              // p 前移:处理下一个节点
    }

    // 反转完成后:
    // - 原 tail 成为新头
    // - 原 head 成为新尾
    return [tail, head];
};
var reverseKGroup = function(head, k) {
    // 创建虚拟头节点(hair),统一处理头节点可能被改变的情况
    const hair = new ListNode(0);
    hair.next = head;
    
    // pre 指向当前待处理子链表的前一个节点(即上一组的尾节点)
    let pre = hair;

    // 只要还有待处理的节点(head 不为空),就继续循环
    while (head) {
        // 初始化 tail 为 pre,准备向后走 k 步找到当前组的尾节点
        let tail = pre;

        // 向后移动 k 次,检查是否还有至少 k 个节点
        for (let i = 0; i < k; ++i) {
            tail = tail.next;
            // 如果中途遇到 null,说明剩余节点不足 k 个,直接返回结果
            if (!tail) {
                return hair.next;
            }
        }
        // 保存下一组的起始节点(即当前组 tail 的下一个节点)
        const nex = tail.next;
        // 对 [head, tail] 这一段子链表进行反转
        // 反转后,head 变成新尾,tail 变成新头
        [head, tail] = myReverse(head, tail);
        // 将反转后的子链表重新接回主链表:
        // 1. 上一组的尾节点(pre)指向新头(原 tail)
        pre.next = head;
        // 2. 新尾(原 head)指向下一组的起始节点(nex)
        tail.next = nex;
        // 更新 pre 和 head,准备处理下一组
        pre = tail;          // pre 移动到当前组的尾部(即新 tail)
        head = tail.next;    // head 指向下一组的开头
    }
    // 返回真实头节点(跳过虚拟头)
    return hair.next;
};

⏱️ 复杂度

  • 时间:O(n)
  • 空间:O(1)

四、总结:链表问题的解题心法

问题类型核心技巧代表题目
单链操作迭代反转、虚拟头反转链表、K组反转
双链协作双指针对齐、合并相交链表、合并链表
结构探测快慢指针环检测、找中点
分治策略归并、分组排序、合并K链

🌟 终极建议

  1. 画图! 链表问题务必手动画指针变化;
  2. 边界先行:空链表、单节点、K=1 等;
  3. 善用 dummy:避免头节点特殊处理;
  4. 流式思维:把链表看作“数据流”,指针是“游标”。

结语

链表虽“简单”,却是理解指针操作、内存管理与算法设计的绝佳载体。掌握上述八大模式,不仅能攻克面试,更能培养出对数据流动的直觉。正如《算法导论》所言:“抽象数据类型的威力,在于其操作的纯粹性。