朋友面完试跟我吐槽,又被问到同一个基础题:平时写代码,顺序表和链表到底什么时候用哪种?他很自然地背:数组随机访问快、插入删除慢;链表随机访问慢、插入删除快。面试官再追问一句“那 Java 的 LinkedList 为什么在业务代码里很少被当首选”,他就沉默了。别急着背结论,线性表这块地基只要扎稳,顺序表的自动扩容、链表遍历为什么慢、单链表逆序怎么画、以及栈和队列为什么偏爱数组底座,全都能串起来想明白。这篇不准备写成教科书,我想用实际代码和踩过的坑把顺序表与链表掰开揉碎讲一遍,适合正在啃数据结构、准备机试和面试、或者写业务写了两年却始终没想清楚选型的同学。
1. 先承认自己对“线性表”的理解可能一直是模糊的
1.1 “线性”两个字,约束的是元素之间的关系
教材里给线性表的定义很简短:由 n 个同类型数据元素构成的有限序列。看到这句话,很多人只是记住了“序列”这个词,根本没有深想约束在哪里。其实线性表只描述一件事:元素之间是排着队的前驱后继关系。第 i 个元素前面最多有一个直接前驱,后面最多有一个直接后继,谁也不能同时出现在两个位置。
这和数学里的“集合”完全是两种逻辑,集合里 1、2、3 和 3、2、1 没有区别,但线性表里有区别。一首歌单的播放顺序换一下,含义就变了。目录、排行榜、成绩单都是线性表,它们共同的特点是“存在一个位置次序”。你听到“链表”觉得复杂,是因为你把逻辑线性结构和物理存储结构混在一起想了。链表只是实现“线性关系”的一种物理手段,它在内存里并不连续,但逻辑上仍然要让每个节点都觉得自己有一条明确的前驱后继链。
正因为有“有限”和“有次序”这两个约束,后面所有复杂度分析才成立。顺序表和链表要解决的是同一个问题:如何把一组逻辑上连续的数据,用一种物理上可行的方案存下来,并且支持遍历、查找、插入、删除这些基本操作。谁的存储方案好,谁就能在特定场景下表现更优。想清楚这一步,再去看 ArrayList、LinkedList,就不再是背 API,而是看它内部选择了哪种线性表实现。
1.2 顺序表和链表的内存人格截然不同
顺序表最典型的代表是数组,不管底层是 C 的 int a[N]、Java 的 ArrayList,还是 Python 的 list,核心思想都是“找一块连续内存,把元素一个个码放好”。这种存储方式像一个编号固定的宿舍楼,所有房间按顺序排列,你只要知道某个学生的学号,就能按编号快速找到他。但宿舍的容量是固定的,想塞进更多学生时,整栋楼可能装不下,只能去更大的楼重新分配房间,再把所有行李一次性搬过去。
链表的画风完全相反。每一份数据单独住在一个“小单间”,单间并不要求挨在一起,每个房间里会贴一张纸条,写下一个房间的地址。想找第 100 个房间,必须从第一个房间的纸条开始,一路问路问过去。但好处也很明显,中间某个人搬走了,不需要把后面所有人都往前挪,只让前面那个人手里纸条的地址改成下一个人就行,邻居几乎无感。
这个类比可以直接映射到计算机底层。数组地址连续,查找时 CPU 能通过一次简单的地址计算跳到目标位置;链表节点是分散的,查找时要通过 next 指针一个个跳,每次跳都可能要去内存里读一个新的地址。插入删除的差异也来自同一套逻辑:数组中间插入,物理上后面的元素必须挪位;链表中间插入,只要改前后两个节点的指针指向。很多人在这一步就以为自己懂了线性表,实际上还差得远,因为接下去还要回答一个问题:顺序表扩容时整栋楼搬家,为什么还能宣称大部分操作是 O(1)?
1.3 一张第一印象表
在深入代码之前,先用一张表把顺序表和链表的轮廓画出来,后续再一一展开验证:
| 比较维度 | 顺序表(数组) | 链表 |
|---|---|---|
| 物理存储 | 一段连续内存 | 分散节点 + next 指针 |
| 按下标访问第 k 个元素 | O(1),地址直接算 | O(n),从头开始走 |
| 尾部追加元素 | 均摊 O(1),偶尔扩容 | 需要先走到尾部,除非额外维护 tail |
| 已知前驱位置后插入 | O(n),后续元素要搬 | O(1),只改指针 |
| 额外空间浪费 | 可能浪费容量 space | 每个节点多一个指针字段 |
| 遍历时的缓存命中率 | 高 | 低,节点地址不连续 |
这张表本身没有特别高深的地方,但很多争议就藏在“已知前驱位置”这个前缀里。真实业务里,调用方未必总能拿到前驱节点,更多时候你需要先遍历找到那个位置,所以链表的 O(1) 插入经常名不副实。接下来从顺序表开始逐个拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表的快,从 CPU 看见的东西说起
2.1 arr[k] 那一刻:CPU 只做一次加法和一次乘法
顺序表最让人舒服的地方是随机访问。不管是 C 的数组、Java 的 ArrayList,还是 Python 的 list,只要你给一个下标 k,内部就是通过地址公式定位内存地址:
code复制第 k 个元素的地址 = 数组首地址 + k × sizeof(元素类型)
拿 C 语言举例:
c复制#include <stdio.h>
int main(void) {
int a[5] = {10, 20, 30, 40, 50};
printf("%d\n", a[3]); // 输出 40
return 0;
}
看起来只是简单的一个数组下标,实际上 a[3] 在编译后会被转换成类似 *(a + 3) 的指针运算。假设 a 的首地址是 0x1000,sizeof(int) 是 4 字节,那么 a[3] 的地址就是 0x1000 + 3 × 4 = 0x100C。CPU 直接去 0x100C 读 4 个字节就拿到了 40。
这带来的直接结果是:访问第 1 万个元素和访问第 3 个元素,成本几乎一样。因为无论 k 多大,CPU 都只需要做一次乘法加一次加法,然后访问一次内存。链表做不到这一点,它没有“用编号反推地址”的能力,第 k 个节点必须从头一个 next 一个 next 地走 k 次。
你可以想象成电影院座位。连续座位的影厅,票上写着“第 3 排第 7 座”,你进场后根据排数编号能直接数过去;但有一种票只在上面写“下一张票在我旁边座位”,这种票要找到目标座位,只能从入口开始一个个传。顺序表就是前者,随机访问是刻在存储结构里的天赋。
2.2 尾部 add 的隐藏成本:扩容搬家为什么可以被接受
顺序表刚创建时,底层数组是有容量的。Java 的 ArrayList 默认初始容量是 10,Python 的 list 初始也有一定预留空间。问题在于,用户不断往里 add,容量终归会被用完。容量不够时,顺序表不能把新元素硬塞到不存在的内存空间里,只能做两件事:申请一块更大的连续内存,把旧元素全部复制过去,再释放旧数组。
Java 的 ArrayList 扩容逻辑简化后是这样的:
java复制private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容为原来的 1.5 倍
elementData = Arrays.copyOf(elementData, newCapacity);
}
每次扩容是 O(n) 的,因为要复制整个旧数组。可业界依然说“尾部 add 均摊复杂度是 O(1)”,原因在于扩容并不是每次都发生。假设初始容量是 1,每次容量翻倍,那么从一个元素扩充到 8 个元素的过程中,第 1 次扩容复制 1 个,第 2 次复制 2 个,第 3 次复制 4 个,总共复制 7 个元素。等数组容量变成 16 时,你会发现之前的所有复制次数的总和也大致接近 n。把复制成本摊到每一次 add 操作头上,单次成本是常数级别。
这也是为什么要用倍数扩容而不是固定加几个容量。如果每次容量只增加 1,那每插入一个元素都要把旧数据复制一遍,多次 add 的总成本就会变成 O(n²),这是完全不可接受的。Java 用 1.5 倍、Python 用大约 1.125 倍左右的增量策略,目标都是同一个:让扩容次数对数级减少,从而把均摊成本压到常数。写业务代码的人不关心这些细节,但底层框架一定关心。
2.3 头部和中间 add 就没那么幸运了
尾部 add 看起来很美,因为不需要搬动已有元素。可一旦插入位置在头部或中间,顺序表就要付出真实的搬移成本。比如你要在 index = 2 的位置插入一个元素 x,为了让这个位置腾出来,下标从 2 开始的所有元素都必须往后挪一位。
用一段简化逻辑表达:
java复制public void add(int index, int value) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException();
}
// 为保证数组不越界,这里实际会先检查容量并扩容
for (int i = size - 1; i >= index; i--) {
elementData[i + 1] = elementData[i]; // 从后往前搬
}
elementData[index] = value;
size++;
}
注意搬移必须从后往前。如果从前往后,第一个元素会先被覆盖,后面就全乱了。这段代码每次循环只搬移一个元素,所以插入位置越靠前,需要搬移的元素越多。头插时所有元素都要往后错一位,复杂度是 O(n);在正中间插入时,平均也要搬一半元素,复杂度依然是 O(n)。
Java 的 ArrayList 在真正实现时用的是 System.arraycopy,这是 JVM 层的批量内存复制,比手写循环快很多,但复杂度的量级没有变化:只要是搬移 n 个元素,终究是 O(n)。所以你在业务代码里如果天天调用 `list.add(0,
