news 2026/4/22 17:54:57

理解链表算法:从基础操作到高级应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
理解链表算法:从基础操作到高级应用

理解链表算法:从基础操作到高级应用

链表是算法面试中最高频的考点之一,其「非连续存储」的特性决定了它的解题思路和数组有本质区别——指针操作、边界处理、虚拟头节点等技巧贯穿始终。本文将从链表的核心解题思想出发,拆解合并、分解、指针技巧、运算四大类经典题型,结合LeetCode高频题给出最优解,并标注面试中最容易踩坑的易错点。

一、核心解题思想:先掌握这3个通用技巧

在开始刷题前,先牢记链表题的「三板斧」,能解决80%的问题:

1. 虚拟头节点(Dummy Node)

适用场景:需要创建/修改链表(如合并、删除、分解),避免处理「头节点为空」的边界情况。

核心价值:让「删除头节点」和「删除中间节点」、「创建新链表头」和「拼接后续节点」的逻辑完全统一。

示例:合并两个链表时,用dummy = new ListNode(-1)作为占位符,最终返回dummy.next即可。

2. 双指针技巧

链表的绝大多数经典问题(中点、倒数k、环、相交)都依赖双指针,核心是通过指针的「步长差」或「路径差」实现目标:

  • 快慢指针:慢指针走1步,快指针走2步(中点、环检测);
  • 前后指针:前驱指针记录「待删除节点的前一个节点」(删除重复、倒数k);
  • 互换指针:遍历完A链表接B链表,遍历完B链表接A链表(链表相交)。

3. 栈/堆的辅助使用

  • 栈:解决「正序链表逆序操作」(如445题两数相加II,正序链表转逆序取数);
  • 最小堆(优先队列):解决「多链表合并」(如合并k个升序链表、有序矩阵找第k小)。

二、经典题型拆解(附最优解+易错点)

(一)链表的合并:从2个到k个,再到矩阵/数组的「伪合并」

合并类问题的核心是「筛选最小值/符合条件的值,按序拼接」,从基础的2个链表合并,可延伸到k个链表、有序矩阵等场景。

1. 合并两个升序链表(LeetCode 21)

题目要求:将两个升序链表合并为一个新的升序链表。

核心思路:双指针「拉拉链」——两个指针分别遍历两个链表,每次选值更小的节点接入新链表,遍历完一个后直接拼接另一个的剩余部分。

JavaScript

/** * @param {ListNode} l1 - 升序链表1 * @param {ListNode} l2 - 升序链表2 * @returns {ListNode} 合并后的升序链表 */ function mergeTwoLists(l1, l2) { // 虚拟头节点:避免处理l1/l2为空的情况 const dummy = new ListNode(-1); let p = dummy; // 新链表的尾指针 let p1 = l1, p2 = l2; // 核心:选更小的节点接入新链表 while (p1 !== null && p2 !== null) { if (p1.val <= p2.val) { p.next = p1; p1 = p1.next; } else { p.next = p2; p2 = p2.next; } p = p.next; // 尾指针后移 } // 拼接剩余节点(剩余部分本身有序) p.next = p1 === null ? p2 : p1; return dummy.next; }

易错点

  • 忘记拼接剩余节点,导致结果缺失部分链表;
  • 尾指针p未后移,始终覆盖dummy.next,最终只保留最后一个节点。

2. 合并k个升序链表(LeetCode 23)

题目要求:合并k个升序链表,返回合并后的升序链表。

核心思路:最小堆筛选最小值——用堆存储各链表的当前节点,每次取堆顶(最小值)接入新链表,再将该链表的下一个节点入堆。

JavaScript

/** * @param {ListNode[]} lists - k个升序链表数组 * @returns {ListNode} 合并后的链表 */ var mergeKLists = function(lists) { const k = lists.length; if (k === 0) return null; // 定义最小堆(按节点值排序) class MinHeap { constructor() { this.heap = []; } push(node) { this.heap.push(node); this.swim(this.heap.length - 1); } pop() { const min = this.heap[0]; const last = this.heap.pop(); if (this.heap.length > 0) { this.heap[0] = last; this.sink(0); } return min; } // 上浮:维护小顶堆 swim(idx) { while (idx > 0) { const parent = Math.floor((idx - 1) / 2); if (this.heap[parent].val > this.heap[idx].val) { [this.heap[parent], this.heap[idx]] = [this.heap[idx], this.heap[parent]]; idx = parent; } else break; } } // 下沉:维护小顶堆 sink(idx) { while (idx * 2 + 1 < this.heap.length) { let minIdx = idx * 2 + 1; const right = idx * 2 + 2; if (right < this.heap.length && this.heap[right].val < this.heap[minIdx].val) { minIdx = right; } if (this.heap[idx].val < this.heap[minIdx].val) break; [this.heap[idx], this.heap[minIdx]] = [this.heap[minIdx], this.heap[idx]]; idx = minIdx; } } isEmpty() { return this.heap.length === 0; } } const dummy = new ListNode(-1); let p = dummy; const minHeap = new MinHeap(); // 初始化堆:各链表的第一个节点入堆 for (let i = 0; i < k; i++) { if (lists[i] !== null) minHeap.push(lists[i]); } // 循环取堆顶,拼接链表 while (!minHeap.isEmpty()) { const minNode = minHeap.pop(); p.next = minNode; p = p.next; // 该链表的下一个节点入堆 if (minNode.next !== null) minHeap.push(minNode.next); } return dummy.next; };

易错点

  • 堆的比较逻辑写错(如写成大顶堆),导致取到最大值;
  • 忘记将「当前节点的下一个节点」入堆,堆很快为空,只合并了各链表的第一个节点;
  • 未处理lists中包含null的情况,入堆时报错。

3. 延伸:有序矩阵中第K小的元素(LeetCode 378)

核心思路:将「矩阵的每一行」视为「升序链表」,复用「合并k个链表」的堆思路——每行一个指针,堆存储「行索引+当前值」,每次取最小值后将该行下一个值入堆。

js

/** * 通用优先队列实现(支持大顶堆/小顶堆,基于完全二叉树+数组存储) * @param {Function} compareFn - 比较函数,决定堆类型: * 返回值是负数的时候,第一个参数的优先级更高 * - 小顶堆(默认):(a,b) => a - b(返回负数则a优先级高) * - 大顶堆:(a,b) => b - a(返回负数则b优先级高) */ class PriorityQueue1 { constructor(compareFn = (a, b) => a - b) { this.compareFn = compareFn; // 自定义比较函数(核心:替代硬编码比较) this.size = 0; // 堆的有效元素个数(≠queue.length,避免数组空洞) this.queue = []; // 物理存储数组(逻辑完全二叉树) } // 入队:添加元素并上浮堆化 enqueue(val) { // 1. 把新元素放到数组末尾(完全二叉树的最后一个节点) this.queue[this.size] = val; // 2. 上浮:维护堆的性质(从新元素位置向上调整) this.swim(this.size); // 3. 有效元素个数+1(先swim再++,因为swim需要当前索引) this.size++; } // 出队:移除并返回堆顶元素,最后一个元素补位后下沉堆化 dequeue() { // 边界:空队列返回null if (this.size === 0) return null; // 1. 保存堆顶元素(要返回的值) const peek = this.queue[0]; // 2. 最后一个元素移到堆顶(完全二叉树补位) this.queue[0] = this.queue[this.size - 1]; // 3. 下沉:维护堆的性质(从堆顶向下调整) this.sink(0); // 4. 有效元素个数-1(堆大小减小) this.size--; // 可选:清空数组空洞(非必需,但更优雅) this.queue.length = this.size; return peek; } // 获取堆顶元素(不出队) head() { return this.size === 0 ? null : this.queue[0]; } // 获取父节点索引 parent(idx) { return Math.floor((idx - 1) / 2); } // 获取左子节点索引 left(idx) { return idx * 2 + 1; } // 获取右子节点索引 right(idx) { return idx * 2 + 2; } // 交换两个节点的值 swap(idx1, idx2) { [this.queue[idx1], this.queue[idx2]] = [this.queue[idx2], this.queue[idx1]]; } // 上浮(swim):从idx向上调整,维护堆性质 swim(idx) { // 循环:直到根节点(idx=0)或当前节点不小于父节点 while (idx > 0) { const parentIdx = this.parent(idx); // 核心:用compareFn替代硬编码比较 // compareFn(a,b) < 0 → a优先级更高(应上浮) if (this.compareFn(this.queue[idx], this.queue[parentIdx]) >= 0) { break; // 当前节点优先级不高于父节点,停止上浮 } // 交换当前节点和父节点 this.swap(idx, parentIdx); // 继续向上检查 idx = parentIdx; } } // 下沉(sink):从idx向下调整,维护堆性质 sink(idx) { // 循环:直到没有左子节点(完全二叉树,左子不存在则右子也不存在) while (this.left(idx) < this.size) { const leftIdx = this.left(idx); const rightIdx = this.right(idx); // 找到“优先级更高”的子节点(小顶堆找更小的,大顶堆找更大的) let priorityIdx = leftIdx; // 右子节点存在,且右子优先级更高 → 切换到右子 if (rightIdx < this.size && this.compareFn(this.queue[rightIdx], this.queue[leftIdx]) < 0) { priorityIdx = rightIdx; } // 当前节点优先级 ≥ 子节点 → 停止下沉 if (this.compareFn(this.queue[idx], this.queue[priorityIdx]) <= 0) { break; } // 交换当前节点和优先级更高的子节点 this.swap(idx, priorityIdx); // 继续向下检查 idx = priorityIdx; } } // 辅助:判断队列是否为空 isEmpty() { return this.size === 0; } } /** * @param {number[][]} matrix * @param {number} k * @return {number} */ var kthSmallest = function(matrix, k) { const rows = matrix.length const cols = matrix[0].length // 每一行,都有一个指针,pArr[0]表示第0行的指针,pArr[1]表示第1行的指针, let pArr = new Array(rows).fill(0) // 返回值 let res // 这里因为需要存储第几行的信息,所以queue队列里存储的不单单是val 还有row的信息,这样的话,需要重新写compareFn const pq = new PriorityQueue1(([row1,val1],[row2,val2])=>val1-val2) // 每行的第一个元素进队 for(let row=0;row<rows;row++){ pq.enqueue([row,matrix[row][0]]) } // 当队存在且k>0的时候 说明需要继续 while(pq.size>0 && k>0){ // 出队的是当前最小的 const [curRow,curVal] = pq.dequeue() // 值存下 res = curVal // 循环k次就能获取到k小的值 k-- const nextCol = pArr[curRow]+1 if(nextCol<cols){ pArr[curRow] = nextCol pq.enqueue([curRow,matrix[curRow][nextCol]]) } } return res };

易错点

  • 堆中仅存储值,未记录行索引,无法找到下一个要入堆的值;
  • 忽略矩阵单行/单列的边界情况。

(二)链表的分解:按条件拆分,删除重复

分解类问题的核心是「用两个虚拟头节点分别存储符合/不符合条件的节点,最后拼接」。

1. 分隔链表(LeetCode 86)

题目要求:将链表分隔为两部分,小于x的节点在前,大于等于x的节点在后,保持原有相对顺序。

核心思路:两个虚拟头节点分别存储「小于x」和「大于等于x」的节点,遍历原链表后拼接。

JavaScript

var partition = function(head, x) { // 两个虚拟头节点:分别存储小于x和大于等于x的节点 const p1Dummy = new ListNode(-1); const p2Dummy = new ListNode(-1); let p1 = p1Dummy, p2 = p2Dummy; let p = head; while (p !== null) { if (p.val < x) { p1.next = p; p1 = p1.next; } else { p2.next = p; p2 = p2.next; } p = p.next; } // 关键:断开p2的尾节点,避免链表成环 p2.next = null; // 拼接两个链表 p1.next = p2Dummy.next; return p1Dummy.next; };

易错点

  • 未断开p2.next,若原链表末尾属于「大于等于x」的部分,会导致链表成环;
  • 拼接时错误拼接p2Dummy而非p2Dummy.next,引入无效的虚拟头节点。

2. 删除排序链表中的重复元素II(LeetCode 82)

题目要求:删除链表中所有重复的节点,只保留原链表中没有重复出现的节点。

核心思路:前驱指针+跳过重复项——前驱指针记录「最后一个不重复的节点」,遍历指针找到所有连续重复节点后,前驱指针跳过这些节点。

JavaScript

var deleteDuplicates = function(head) { if (head === null) return null; const dummy = new ListNode(-1); dummy.next = head; let prev = dummy; // 前驱指针:最后一个不重复的节点 let p = head; // 遍历指针 while (p) { // 找到所有连续重复的节点 if (p.next && p.val === p.next.val) { while (p && p.next && p.val === p.next.val) { p = p.next; } // 跳过所有重复节点 prev.next = p.next; p = p.next; } else { // 无重复,前驱指针后移 prev = prev.next; p = p.next; } } return dummy.next; };

易错点

  • 内层循环未判空p && p.next,导致p.next.val报错;
  • 无重复时忘记移动前驱指针prev,prev始终停留在虚拟头节点,最终结果缺失部分节点;
  • 仅删除重复节点中的一个,而非全部跳过。

(三)双指针经典:中点、倒数k、环、相交

这类问题的核心是「通过指针的步长/路径设计,一次遍历完成目标」,避免先遍历统计长度的二次遍历。

1. 链表的中间节点(LeetCode 876)

题目要求:返回链表的中间节点,偶数长度返回第二个中间节点。

核心思路:快慢指针——慢指针走1步,快指针走2步,快指针到末尾时,慢指针即为中间节点。

JavaScript

var middleNode = function(head) { if (head === null) return null; let slow = head, fast = head; // 循环条件:fast和fast.next都不为空 while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } return slow; };

易错点

  • 循环条件漏写fast或fast.next,导致fast.next.next报错;
  • 混淆偶数长度的返回值(要求返回第二个中间节点,快慢指针的逻辑天然满足)。

2. 删除链表的倒数第N个节点(LeetCode 19)

核心思路:快慢指针拉开N步距离——快指针先前进N步,然后快慢指针同步前进,快指针到末尾时,慢指针指向倒数第N个节点的前驱。

JavaScript

var removeNthFromEnd = function(head, n) { if (head === null) return null; const dummy = new ListNode(-1); dummy.next = head; let slow = dummy, fast = dummy; // 快指针先前进n步 for (let i = 0; i < n; i++) { fast = fast.next; } // 同步前进,快指针到末尾时停止 while (fast.next) { slow = slow.next; fast = fast.next; } // 删除倒数第n个节点 slow.next = slow.next.next; return dummy.next; };

易错点

  • 未使用虚拟头节点,删除倒数第L个节点(头节点)时出错;
  • 快指针前进n步时未判空,n超过链表长度时报错;
  • 循环条件写成fast !== null,导致慢指针位置错误。

3. 环形链表II(LeetCode 142)

题目要求:判断链表是否有环,若有则返回环的入口节点。

核心思路:快慢指针分两步——

  1. 判环:慢1快2,相遇则有环;
  2. 找入口:相遇后慢指针回头部,快慢均走1步,再次相遇即为入口。
JavaScript

var detectCycle = function(head) { if (head === null || head.next === null) return null; let slow = head, fast = head; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; // 相遇则找入口 if (slow === fast) { slow = head; // 慢指针回头部 while (slow !== fast) { slow = slow.next; fast = fast.next; // 快指针改为走1步 } return slow; } } return null; };

原理推导

设a=表头到入口的距离,b=入口到相遇点的距离,c=相遇点回到入口的距离:

  • 快指针路程:2(a+b) = a + b + n*(b+c) → a = (n-1)*(b+c) + c;
  • 当n=1时,a=c,因此慢指针回头部后,同步走必然在入口相遇。

易错点

  • 相遇后快指针未改为走1步,仍走2步,无法找到入口;
  • 判环时循环条件漏写fast.next,导致报错;
  • 忽略单节点成环的情况(如head.next = head)。

4. 相交链表(LeetCode 160)

题目要求:找到两个单链表相交的起始节点,无相交则返回null。

核心思路:互换指针——p1遍历完A接B,p2遍历完B接A,总路程均为A+B,相交则在交点相遇,否则同时到null。

JavaScript

var getIntersectionNode = function(headA, headB) { if (headA === null || headB === null) return null; let p1 = headA, p2 = headB; while (p1 !== p2) { p1 = p1 === null ? headB : p1.next; p2 = p2 === null ? headA : p2.next; } return p1; };

易错点

  • 担心无限循环:无需担心,p1/p2总路程相等,最终必相遇(要么交点,要么null);
  • 遍历到末尾时未切换到另一个链表,而是直接返回null。

(四)链表运算:两数相加(逆序/正序)

链表运算的核心是「模拟手工运算」,结合栈处理正序链表的逆序操作。

1. 两数相加(LeetCode 2)

题目要求:两个逆序存储数字的链表,返回相加后的逆序链表。

核心思路:模拟手工加法——遍历链表,逐位相加,处理进位。

JavaScript

var addTwoNumbers = function(l1, l2) { if (l1 === null && l2 === null) return null; const dummy = new ListNode(-1); let p = dummy; let p1 = l1, p2 = l2; let isNeedPlusOne = false; // 进位标记 while (p1 || p2 || isNeedPlusOne) { let val = isNeedPlusOne ? 1 : 0; if (p1) { val += p1.val; p1 = p1.next; } if (p2) { val += p2.val; p2 = p2.next; } isNeedPlusOne = val >= 10; val = val % 10; p.next = new ListNode(val); p = p.next; } return dummy.next; };

2. 两数相加II(LeetCode 445)

题目要求:两个正序存储数字的链表,返回相加后的正序链表。

核心思路:栈逆序取数 + 反向构建链表——先将正序链表入栈,逆序取数相加,再反向构建正序链表。

JavaScript

var addTwoNumbers = function(l1, l2) { if (l1 === null && l2 === null) return null; // 正序链表入栈,逆序取数 const stack1 = [], stack2 = []; let p1 = l1, p2 = l2; while (p1) { stack1.push(p1.val); p1 = p1.next; } while (p2) { stack2.push(p2.val); p2 = p2.next; } let head = null; let isNeedPlusOne = false; // 核心:逆序相加 + 反向构建链表 while (stack1.length || stack2.length || isNeedPlusOne) { let val = isNeedPlusOne ? 1 : 0; if (stack1.length) val += stack1.pop(); if (stack2.length) val += stack2.pop(); isNeedPlusOne = val >= 10; val = val % 10; // 反向构建:新节点作为头节点 const newNode = new ListNode(val); newNode.next = head; head = newNode; } return head; };

易错点

  • 循环条件漏写isNeedPlusOne,漏掉末尾进位(如999+1=1000);
  • 反向构建链表时next指向写反(如head.next = newNode),导致链表断裂;
  • 栈取数用shift()而非pop(),取数顺序错误。

三、面试高频易错点总结(避坑指南)

场景

常见错误

正确做法

虚拟头节点

返回head而非dummy.next

始终返回dummy.next,避免头节点被删除/修改

链表拼接

未断开尾节点的next

拼接前将尾节点next置为null,避免成环

快慢指针

循环条件漏写fast.next

判环/中点时用while (fast && fast.next)

堆/栈

堆的比较逻辑写错(大顶堆/小顶堆混淆)

小顶堆用a.val - b.val,大顶堆用b.val - a.val

反向构建链表

next指向写反

正确逻辑:newNode.next = head; head = newNode

进位处理

进位判断在取余之后

先判断val >= 10,再取余val % 10

四、总结

链表题的核心是「指针操作+边界处理」,掌握以下3点即可应对绝大多数面试题:

  1. 虚拟头节点:解决头节点边界问题;
  2. 双指针:快慢/前后/互换指针,一次遍历完成目标;
  3. 栈/堆辅助:处理逆序/多链表合并场景。

刷题时建议按「合并→分解→双指针→运算」的顺序,每道题先想清楚「指针该怎么动」,再动手写代码,同时标注易错点——面试中不仅要写对代码,更要能讲清「为什么这么写」和「避免了哪些坑」。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 14:00:44

Mysql索引优化实战:从 320ms 到 130ms 的慢 SQL 改造

前言&#xff1a;我们项目中&#xff0c;经常遇到需要索引优化的地方&#xff0c;即我们常见的慢查询&#xff0c;那么从一个实际的案例出来&#xff0c;分析慢查询中会经过哪些步骤&#xff0c;哪些环节是我们需要注意的&#xff0c;同时&#xff0c;在整个链路分析中&#xf…

作者头像 李华
网站建设 2026/4/18 3:52:53

Unity DOTS核心概念之 Component(组件)

目录 前言 一、Component 的核心定义与设计原则 1.1 核心定义 1.2 两大黄金法则 二、ECS 组件的三大核心类型 三、基础组件:IComponentData 3.1 定义方式 3.2 内存布局与性能优势 3.3 常用操作 四、分组组件:ISharedComponentData 4.1 核心原理 4.2 定义与使用示例…

作者头像 李华
网站建设 2026/4/18 4:20:31

Unity DOTS核心概念之 System(系统)

目录 前言 一、System 的核心定义与设计准则 1.1 核心定义 1.2 三大核心设计准则 二、System 的核心类型与定义方式 2.1 核心类型分类 2.2 基础 System 定义(ISystem 接口) 2.2.1 最小化 System 模板 2.2.2 关键说明 三、System 的生命周期与执行时机 3.1 完整生命…

作者头像 李华
网站建设 2026/4/19 0:01:29

ABB 3BSE004192R1 压力传感器

孙13665068812ABB 3BSE004192R1 压力传感器&#xff1a;工业自动化中的精确压力测量核心在现代工业自动化系统中&#xff0c;对过程参数的精确、可靠监测是确保生产效率、产品质量、设备安全和能源优化的基石。压力&#xff0c;作为众多关键过程变量之一&#xff0c;其准确测量…

作者头像 李华
网站建设 2026/4/18 2:12:41

AI原生应用领域:AI代理的边缘计算应用

AI原生应用领域&#xff1a;AI代理的边缘计算应用关键词&#xff1a;AI代理、边缘计算、AI原生应用、端侧智能、分布式系统、实时性、隐私保护摘要&#xff1a;在AI技术与物联网高速发展的今天&#xff0c;"AI原生应用"正从概念走向落地。本文将聚焦AI代理与边缘计算…

作者头像 李华
网站建设 2026/4/17 21:18:23

基于SpringBoot和Vue的员工信息管理系统

文章目录详细视频演示项目介绍技术介绍功能介绍核心代码系统效果图源码获取详细视频演示 文章底部名片&#xff0c;获取项目的完整演示视频&#xff0c;免费解答技术疑问 项目介绍 基于Spring Boot的员工信息管理系统是一款专为企业人力资源部门设计的高效、安全、易用的数字…

作者头像 李华