从数组到链表:用“指针思维”把链表一次讲清楚
链表是数据结构世界里非常基础但又极具代表性的存在。它看似简单:一个个节点通过指针连起来。但正是“指针”的存在,让链表能在插入、删除上显得游刃有余,也让许多面试题变得耐人寻味。
一、为什么要学链表?把概念具象化
想象一列火车,由一个个车厢连接起来:
- 每节车厢里装着货物 —— 节点的
val。 - 车厢之间通过钩子相连 —— 节点的
next指针。
如果你想在第 3 节车厢后插入一个新车厢,你只要把第 3 节车厢的钩子指向新车厢,再把新车厢的钩子指向原来第 4 节车厢。这个过程不需要搬动后面所有车厢——这就是链表插入操作直观且高效的来源。
对比数组:
数组在内存上是连续存储,支持按索引快速访问(O(1)),但在中间插入或删除时需要移动大量元素(O(n))。
链表则是离散存储,随机访问慢(O(n)),但插入/删除在已知位置时是常数时间(O(1))。
二、链表的基本结构
链表由节点组成,每个节点通常包含两个域:保存数据的 val 和指向下一个节点的 next。
function ListNode(val) {
this.val = val
this.next = null
}
// 示例:构造 1 -> 2 -> 3
const node1 = new ListNode(1)
node1.next = new ListNode(2)
node1.next.next = new ListNode(3)
把 ListNode 想象成车厢,next 是车厢间的钩子——指针是链表思想的核心。
三、三种必会操作:遍历、插入与删除
下面我们逐个讲:遍历、插入、删除。每个操作先说思路,再给出简洁实现。
3.1遍历(找到第 k 个节点)
思路:从头开始,沿 next 一个一个走,直到计数到 k 或者到达 null。
function getKthNode(head, k) {
let node = head
let i = 1
while (node && i < k) {
node = node.next
i++
}
return node // 若为 null,说明链表长度 < k
}
时间复杂度:O(n)。遍历是理解链表行为的第一步。
3.2插入(在某个位置插入新节点)
思路:找到要插入位置的前驱节点,把新节点的 next 指向 prev.next,再把 prev.next 指向新节点。
function insertAfter(prevNode, newNode) {
if (!prevNode) return
newNode.next = prevNode.next
prevNode.next = newNode
}
这就像把新车厢挂在两节车厢之间:先把新车厢钩上后面的车厢,再把前一节车厢的钩子换到新车厢上。
3.3删除(删除某个节点)
思路:需要前驱,直接把 prev.next 指到 prev.next.next。下面给出最常见的简洁实现,并在之后用力扣第 83 题作为具体例子来说明指针操作的细节与边界处理。
function deleteAfter(prevNode) {
if (!prevNode || !prevNode.next) return
prevNode.next = prevNode.next.next
}
此处的关键是要确保 prevNode 和 prevNode.next 都存在,直接跳过被删除的节点即可。
四、经典例题
4.1删除排序链表中的重复元素
给定一个已排序的链表,删除所有重复的元素,使每个元素只出现一次。返回已排序的链表。
思路:利用当前节点 cur,如果 cur.next 存在且 cur.val === cur.next.val,就把 cur.next 指向 cur.next.next(相当于删除了重复节点);否则就移动 cur = cur.next 继续检查。
function deleteDuplicates(head) {
let cur = head
while (cur && cur.next) {
if (cur.val === cur.next.val) {
// 跳过重复节点
cur.next = cur.next.next
} else {
// 值不同,继续向后移动
cur = cur.next
}
}
return head
}
通过这个例子,你可以更直观地感受“删除”操作中指针改变的细节:每一次 cur.next = cur.next.next 都像是在把车厢的钩子直接换到更远的车厢,从而把中间的一节或多节车厢摘掉。
4.2合并两个有序链表
核心想法一句话: 像拉拉链一样,每次从两个链表头部选更小的那个,接到新链表后面。
我们需要做的三件事:
- 创建一个“假头节点” 。使用哑节点(
dummy)可以让删除头节点和删除中间节点统一处理,避免额外分支判断。 (这非常重要) - 用一个指针
cur,始终指向新链表的最后一个节点 - 比较
list1.val和list2.val,谁小就接谁
var mergeTwoLists = function(list1, list2) {
// 1. 创建假头节点
let dummy = new ListNode(-1)
let cur = dummy
// 2. 当两个链表都还有节点时
while (list1 !== null && list2 !== null) {
if (list1.val <= list2.val) {
cur.next = list1 // 接上 list1
list1 = list1.next // list1 往后走
} else {
cur.next = list2
list2 = list2.next
}
cur = cur.next // 新链表指针往后走
}
// 3. 把剩余的直接接上
cur.next = list1 !== null ? list1 : list2
return dummy.next
}
最后
学会链表,关键在于把“指针的操作”变成你可预见的动作:每次指针赋值都要问自己——我丢失了哪里?我想要哪个节点指向哪个节点?
链表不是为了让你记住各种指针操作的套路,而是为了让你养成“每一步都能预判后果”的编程直觉。多练、画图、复盘——你会看到它从难题变成手到擒来的工具。
希望这篇文章能帮助到你学习排序~