从数组到链表:用“指针思维”把链表一次讲清楚

74 阅读4分钟

从数组到链表:用“指针思维”把链表一次讲清楚

链表是数据结构世界里非常基础但又极具代表性的存在。它看似简单:一个个节点通过指针连起来。但正是“指针”的存在,让链表能在插入、删除上显得游刃有余,也让许多面试题变得耐人寻味。

一、为什么要学链表?把概念具象化

想象一列火车,由一个个车厢连接起来:

  • 每节车厢里装着货物 —— 节点的 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
}

此处的关键是要确保 prevNodeprevNode.next 都存在,直接跳过被删除的节点即可。

四、经典例题

4.1删除排序链表中的重复元素

给定一个已排序的链表,删除所有重复的元素,使每个元素只出现一次。返回已排序的链表。

image.png

思路:利用当前节点 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合并两个有序链表

image.png

核心想法一句话: 像拉拉链一样,每次从两个链表头部选更小的那个,接到新链表后面。

我们需要做的三件事:

  1. 创建一个“假头节点” 。使用哑节点(dummy)可以让删除头节点和删除中间节点统一处理,避免额外分支判断。 (这非常重要)
  2. 用一个指针 cur,始终指向新链表的最后一个节点
  3. 比较 list1.vallist2.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
}

最后

学会链表,关键在于把“指针的操作”变成你可预见的动作:每次指针赋值都要问自己——我丢失了哪里?我想要哪个节点指向哪个节点?

链表不是为了让你记住各种指针操作的套路,而是为了让你养成“每一步都能预判后果”的编程直觉。多练、画图、复盘——你会看到它从难题变成手到擒来的工具。

希望这篇文章能帮助到你学习排序~