LinkedList链表怎么查和改
数组靠连续内存和下标寻址,链表则把数据拆成一个个节点,再用指针串起来。它插入删除看起来很快,但查找和遍历并不占优势。这篇用图示讲清 LinkedList 的节点结构、单链表与双向链表、插入删除过程,再和 Array 做一次完整对比。
上一篇我们讲了 Array 数组为什么查得快改得慢。
数组的特点是连续内存、下标访问快,但中间插入和删除需要搬动后面的元素。
链表刚好走了另一条路。
它不要求所有数据挨着存,而是把数据拆成一个个节点,每个节点通过指针指向下一个节点。
看起来像是“插入删除很快”的数据结构,但这句话有一个前提:我们已经拿到了要操作的节点或位置。
如果还要从头找这个位置,查找本身仍然要 O(n)。
这篇就把 LinkedList 链表拆开看看,顺便和 Array 做一次正面对比。
链表到底是什么
链表也是一种线性表。
它和数组一样,数据之间是线性关系,但保存方式完全不同。
数组把元素连续摆放:
数组
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘
链表则是节点分散存放,再用引用或指针连接起来:
head
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ data: 10 │ next ───>│ data: 20 │ next ───>│ data: 30 │ next ───> null
└──────────┘ └──────────┘ └──────────┘
节点不一定在连续的内存地址上。
只要 next 能找到下一个节点,整条链就能连起来。
所以链表不需要像数组那样提前申请一大块连续空间,扩展时也不需要整体搬家。
节点里有什么
一个最基本的单链表节点通常包含两部分:
- 数据本身
- 指向下一个节点的引用
class Node<T> {
T value;
Node<T> next;
}
双向链表则会多一个 prev 引用:
class DoubleNode<T> {
T value;
DoubleNode<T> prev;
DoubleNode<T> next;
}
这就是链表的核心代价:数组把空间花在连续存储上,链表把空间花在指针或对象引用上。
链表的几种形式
单向链表
单向链表只有一个方向:从当前节点指向下一个节点。
head -> A -> B -> C -> null
它结构简单,额外空间开销小。
但如果想从 C 回到 B,就只能重新从头遍历,不能直接向前走。
双向链表
双向链表同时保存前驱和后继:
null <- A <-> B <-> C -> null
│ │ │
prev prev prev
从任意节点都可以向前或向后移动。
Java 的 LinkedList 底层就是双向链表思路,节点通常会保存前一个节点和后一个节点。
好处是删除一个已知节点时更方便,坏处是每个节点多占一份引用空间,修改指针时也要更小心。
循环链表
循环链表的尾节点不指向 null,而是重新指向头节点:
head -> A -> B -> C
▲ │
└─────────┘
循环链表适合轮询、环形队列和周期调度等场景。
但遍历时不能简单判断节点是否为 null,必须记录起点,避免无限循环。
链表怎么查找
链表最明显的缺点就是随机访问慢。
数组访问 Array[3] 时,可以通过起始地址和元素大小直接计算目标地址。
链表没有这个条件。
想找第 3 个节点,只能从 head 开始一个个沿着 next 走:
查找第 3 个节点
head
│
▼
节点0 -> 节点1 -> 节点2 -> 节点3
访问 访问 访问 找到
因此链表按下标查找的时间复杂度是 O(n)。
双向链表可以根据目标位置选择从头走还是从尾走,平均能减少一部分遍历距离,但数量级仍然是 O(n),不会变成 O(1)。
这也是为什么 LinkedList.get(index) 不适合被放进大循环里反复调用。
for (int i = 0; i < list.size(); i++) {
// LinkedList 按下标 get,可能反复遍历
process(list.get(i));
}
如果只是顺序遍历,应该使用迭代器或增强 for,让链表沿着节点指针向后走一次。
链表怎么插入
链表插入的核心不是搬动数据,而是修改指针。
假设现在有:
A -> B -> C
想在 B 和 C 之间插入 N:
第一步:N.next 指向 C
第二步:B.next 指向 N
结果:
A -> B -> N -> C
图示如下:
插入前:
B ─────────> C
调整指针:
B ───────┐ C
│ ▲
▼ │
N ──┘
插入后:
B -> N -> C
如果已经拿到了 B 节点,插入动作只需要修改有限几个引用,时间复杂度可以做到 O(1)。
但如果只知道“我要插在第 1000 个位置”,还是要先遍历找到第 999 个节点。
所以更准确的说法是:
已知节点位置的插入:O(1)
按下标寻找位置再插入:O(n)
头部插入
头部插入通常非常简单:
插入前:
head -> A -> B -> C
创建 N:
N.next = head
head = N
插入后:
head -> N -> A -> B -> C
只要保存了 head 指针,头部插入就是 O(1)。
尾部追加
如果链表同时保存 tail 指针,尾部追加也可以做到 O(1):
追加前:
head -> A -> B -> C <- tail
C.next = N
tail = N
追加后:
head -> A -> B -> C -> N <- tail
如果没有 tail,每次追加都要从 head 走到最后,时间复杂度就是 O(n)。
链表怎么删除
删除操作也是修改指针。
假设删除 B:
删除前:
A -> B -> C
让 A 直接指向 C:
A.next = C
删除后:
A -> C
如果已经拿到了 A,也就是被删除节点的前驱,删除动作是 O(1)。
双向链表如果已经拿到了 B 自己,也可以通过 B.prev 和 B.next 直接完成摘除:
删除前:
A <-> B <-> C
调整:
A.next = C
C.prev = A
删除后:
A <-> C
但如果只有一个值,比如“删除值为 30 的节点”,还是需要从头找,查找时间是 O(n)。
删除时要处理边界
链表删除很容易漏掉这些情况:
- 删除空链表
- 删除头节点
- 删除尾节点
- 链表只有一个节点
- 删除的值不存在
- 删除连续重复值中的一个或全部
使用哨兵节点可以减少头节点的特殊判断:
哨兵 -> 第一个节点 -> 第二个节点 -> null
哨兵不保存业务数据,只负责让头部和中间节点拥有相似的操作流程。
代码少几个 if,维护起来就轻松一些。
LinkedList 和 Array 怎么选
数组和链表各有自己的优势。
可以先看一张复杂度对比表:
| 操作 | Array | LinkedList |
|---|---|---|
| 按下标读取 | O(1) | O(n) |
| 头部插入 | O(n) | 已知头节点时 O(1) |
| 中间插入 | O(n) | 已知节点位置时 O(1) |
| 尾部追加 | 有容量时接近 O(1) | 有 tail 时 O(1) |
| 中间删除 | O(n) | 已知节点时 O(1) |
| 顺序遍历 | 通常很快 | 需要沿指针访问 |
| 扩容 | 可能复制整体数据 | 通常按节点增加 |
但只看大 O 还不够。
## Array的优势
Array 使用连续内存,主要优势是:
- 按下标读取非常快
- 顺序遍历对 CPU 缓存友好
- 每个元素不需要额外保存 next 指针
- 内存布局简单,额外开销小
- 适合批量读取、排序和随机访问
上一篇[Array数组为什么查得快改得慢](/post/153.html)已经详细讲过连续内存和扩容,这里不再重复展开。
## LinkedList的优势
LinkedList 的优势主要在结构调整:
- 已知节点位置时插入删除只需要调整指针
- 头部插入和删除简单
- 不需要整体申请一块连续空间
- 可以自然实现队列、双端队列和循环结构
- 节点可以在运行过程中逐个加入
但“节点逐个加入”不等于完全不消耗内存。
每个节点仍然要保存对象本身和指针引用,频繁创建节点也会带来对象分配和垃圾回收成本。
## LinkedList的缺点
链表的缺点也很明显:
- 按下标访问慢
- 节点可能分散在内存中,缓存局部性差
- 每个节点有额外指针和对象开销
- 指针修改多,代码更容易出边界 bug
- 找到位置本身需要遍历
- 实际性能不一定比数组更好
尤其是 Java 中的 `LinkedList`,它并不是“插入删除万能快”。
如果业务经常按下标读取,或者主要是顺序遍历,`ArrayList` 往往更合适。
如果只是需要队列或双端队列语义,也可以优先考虑接口和场景匹配的容器,而不是因为名字里有 List 就直接选 LinkedList。
# 链表为什么不一定更快
链表的理论优势是“修改指针”,但现代计算机的实际性能还受到 CPU 缓存影响。
数组元素挨着存,CPU 读取一个元素时,附近的数据可能一起进入缓存。
链表节点分散在内存中,遍历每个节点都可能发生指针跳转。
```text
数组遍历:
内存A -> 内存A+1 -> 内存A+2 -> 内存A+3
连续、容易命中缓存
链表遍历:
地址A -> 地址K -> 地址P -> 地址D
分散、可能频繁跳转
所以即使两者的遍历复杂度都是 O(n),数组的实际耗时也可能更低。
理论复杂度是选型的起点,不是性能结论的终点。
我个人的习惯是:默认优先选择数组或 ArrayList,只有当业务确实有大量节点插入删除、并且能拿到操作位置时,才考虑链表。
LinkedList 适合哪些场景
链表比较适合下面这些场景:
- 需要频繁从头部加入或移除元素
- 已经持有节点或迭代器位置,需要快速插入删除
- 实现队列、双端队列、LRU 链表结构
- 节点之间有明显的前后关系
- 不需要频繁随机访问
例如 LRU 缓存通常会把哈希表和双向链表组合起来:
HashMap:通过 key 快速找到节点
Double LinkedList:维护最近使用顺序
访问节点 -> 摘除节点 -> 放到链表头部
淘汰节点 -> 删除链表尾部
这里哈希表负责定位,链表负责调整顺序,两者配合后才能同时做到快速查找和快速移动。
单独使用 LinkedList,通常无法做到按 key O(1) 定位。
常见实现错误
自己实现链表时,最容易错的是指针更新顺序。
插入节点时,应该先保存后继关系,再修改前驱指针,避免把原链路弄丢。
错误:先覆盖 B.next,再试图寻找原来的 C
结果:C 丢失,链表断开
正确:
1. 先让 N.next 指向 C
2. 再让 B.next 指向 N
双向链表还要同时维护 prev 和 next,只改一个方向会造成正向遍历正常、反向遍历断裂。
另外,遍历链表时一定要有终止条件:
单链表:节点走到 null 就结束
循环链表:节点再次回到起点就结束
如果判断写错,程序可能陷入死循环。
小结
LinkedList 链表的核心是:节点分散存储,依靠指针连接,修改连接关系而不是整体搬动数据。
它的优势:
- 已知节点位置时插入删除快
- 头部操作简单
- 不要求一整块连续内存
- 适合队列、双端队列和链式结构
它的缺点:
- 按下标查找慢,通常是 O(n)
- 节点有额外指针和对象开销
- 内存不连续,遍历缓存局部性较差
- 找位置的成本经常抵消插入删除的优势
一句话对比:Array 擅长连续访问和随机读取,LinkedList 擅长已知位置的结构调整。
别因为链表的插入删除是 O(1),就默认它比数组快。
先看业务到底是查得多,还是改得多;再看操作时是否真的拿到了节点位置;最后结合数据规模和实际压测做决定。
