图 1:本文封面,连续方格对指针链——结构差异决定一切。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「ArrayList 和 LinkedList 有什么区别」是 Java 集合面试的开胃题,标准答案人人会背:数组查询快、链表插入快。但这句话只说对了一半,而且是最容易误导人的一半。
想象面试现场:「ArrayList 和 LinkedList 怎么选?什么场景用哪个?」
第一步,从底层结构说起。
ArrayList 底层是一块连续的可扩容数组:元素挨着排,按下标访问就是基地址加偏移,一步到位;容量不够时换一块更大的数组整体搬过去,所以尾部追加是「摊还常数时间」——JDK 官方文档的原话,add 操作摊还 O(1),而 get、set、size 是严格的常数时间。LinkedList 底层是双向链表:每个节点是堆上独立的对象,带着数据和前后两根指针;想访问第 N 个元素,只能从离它最近的一头沿线走过去——官方文档也写明了,带下标的操作会从开头或结尾遍历,挑近的一边。
第二步,把「插入快」这句半对的话拆开。
LinkedList 的插入确实只改两根指针,O(1)。但请注意:这是「已经站在目标位置」的代价。真实场景里的「在中间插入」包含两步——先走到那个位置(O(n)),再插入(O(1))。链表省掉的只是搬移元素的那一步,找位置的一步它照样要走,而且走得比数组慢得多。ArrayList 在中间插入要把后续元素整体右移,同样是 O(n),但数组的搬移是连续内存的整块拷贝,常数因子极小。两边真正的分野只剩头部插入:链表改一根指针,数组要把全部元素挪一遍。
还有一个常被忽略的维度:内存。链表的每个节点除了数据还要存两根指针,外加对象头的开销,同样存一百万个元素,链表的内存占用是数组的数倍;更要命的是缓存局部性——数组元素在内存里连续,CPU 预取一拿一批;链表节点散落各处,每次跟随指针都可能是一次缓存未命中。现代硬件上,这个差异常比大 O 记号更决定真实速度。
第三步,用实验把直觉钉死。
下面用 Node.js 22.15.0 复现「连续内存 vs 指针链」的差异(Java 里也是同一套物理原理):二十万个元素的顺序求和、两千次随机访问、三万次头部插入,数组与链表正面打擂。直接运行(node array-vs-linked.js)即可:
'use strict';
const N = 200000;
const arr = Array.from({ length: N }, (_, i) => i);
let head = null;
for (let i = N - 1; i >= 0; i--) head = { val: i, next: head }; // 与数组同序的链表
function time(label, fn) {
fn(); // 预热
const t0 = process.hrtime.bigint();
fn();
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(`${label}: ${ms.toFixed(1)} ms`);
}
time('数组顺序求和', () => {
let s = 0;
for (let i = 0; i < arr.length; i++) s += arr[i];
return s;
});
time('链表顺序求和', () => {
let s = 0;
for (let p = head; p; p = p.next) s += p.val;
return s;
});
time('数组随机访问 2000 次', () => {
let s = 0;
for (let k = 0; k < 2000; k++) s += arr[Math.floor(Math.random() * N)];
return s;
});
time('链表随机访问 2000 次', () => {
let s = 0;
for (let k = 0; k < 2000; k++) {
const idx = Math.floor(Math.random() * N);
let p = head;
for (let i = 0; i < idx; i++) p = p.next;
s += p.val;
}
return s;
});
time('数组头部插入 30000 次', () => {
const a = [];
for (let i = 0; i < 30000; i++) a.unshift(i);
return a.length;
});
time('链表头部插入 30000 次', () => {
let h = null;
for (let i = 0; i < 30000; i++) h = { val: i, next: h };
return h.val;
});
笔者机器上的实测输出(毫秒数随机器波动,量级稳定):
数组顺序求和: 0.1 ms
链表顺序求和: 0.3 ms
数组随机访问 2000 次: 0.1 ms
链表随机访问 2000 次: 195.2 ms
数组头部插入 30000 次: 48.9 ms
链表头部插入 30000 次: 0.2 ms
三组数字各讲一个道理:顺序遍历同为 O(n),链表仍慢三倍,指针跳跃的常数因子摆在那里;随机访问差了三个数量级,O(1) 对 O(n) 的正面碾压;头部插入则是链表唯一的保留项目,200 多倍的差距同样真实。
图 2:结构差异、四维对照与最终结论。
选型结论与易错点。
工程默认选 ArrayList:随机访问、遍历、尾部追加全是它的主场,内存还省。LinkedList 的赢面只剩一个窄场景——高频头部插入且几乎不随机访问;而即便这个场景,Java 里通常也有更优解:ArrayDeque,数组实现的循环队列,头尾操作都是 O(1),缓存还友好。JDK 文档对 ArrayList 的评价可以当结论背下来:它的常数因子比 LinkedList 低。
易错点有三:背「插入快」却忘了找位置本身是 O(n);忽略每个节点两根指针加对象头的内存膨胀;以及把「理论复杂度」当成全部,无视缓存局部性这种硬件层因素。这套思维在 AI 工程里同样通用:特征列表、批次缓冲区的容器选型是数据侧日常,向量计算和批处理拼的也是顺序访问的局部性。回到面试现场,建议先讲结构、再拆「插入快」的半对之处、补上内存与缓存两个维度,最后给出「默认数组、头部插入用队列」的选型口诀。你在项目里真的用对过一次 LinkedList 吗?是什么场景?欢迎聊聊。