Skip to content

数组与链表:连续内存与指针跳转,一切线性结构的两个原型 ​

栈、队列、哈希表、图的邻接表、甚至 B+ 树的叶子链,底层几乎都是这两样东西拼出来的:一块连续内存,或者一串靠指针串起来的节点。把这两者的取舍讲透,后面的结构就都是它们的组合与变形。

面试问「数组和链表的区别」,只答「一个连续一个不连续」拿不到分。真正要说清的是:为什么数组随机访问是 O(1) 而链表是 O(n)、为什么链表「插入 O(1)」这句话有前提、动态数组扩容凭什么还能算 O(1)、以及 CPU 缓存怎么让「理论复杂度相同」的两种写法差出几十倍。

数组连续、链表跳转:地址与 cache 的差别


一、地址怎么来的:随机访问的本质 ​

数组是一块连续内存。声明 int a[8],编译器拿到的是首地址,第 i 个元素的地址就是 首地址 + i * sizeof(int)。一次乘加算出地址,一次访存拿到值——这就是随机访问 O(1) 的全部内容,和数组多长没关系。

链表没有这个公式。node->next 只知道下一个节点在哪,要拿第 i 个,只能从头 next 跳 i 次。跳转的地址是运行时才知道的,编译器算不出来。所以链表的「随机访问」是 O(n),本质是地址不可计算,不是「慢一点」。

cpp
struct ListNode {
    int val;
    ListNode* next;
    ListNode(int v) : val(v), next(nullptr) {}
};

一个节点在 64 位机上:int val 4 字节 + 对齐填充 4 字节 + next 指针 8 字节 = 16 字节。存一个 int 要付 16 字节,其中 12 字节是「结构开销」。数组存 8 个 int 就是 32 字节,紧挨着。这个差别不只是省内存,下面会看到它直接决定缓存命中。


二、增删:O(1) 这句话的前提 ​

「链表插入删除 O(1),数组 O(n)」是半句话。完整版本是:

  • 数组在中间插入/删除是 O(n),因为要把后面的元素整体挪位。在末尾追加(不扩容时)是 O(1)。
  • 链表插入/删除节点本身是 O(1)——但前提是你已经拿到了那个位置的指针。如果只给你一个下标或一个值,得先 O(n) 找过去,找的这一步把 O(1) 吃掉了。

所以链表真正比数组快的场景是:你手上已经有节点指针,要在它旁边增删。LRU 缓存把节点指针存在哈希表里,删除时 O(1) 摘链,就是吃这个红利;反过来「删除链表里第 k 个」并不比数组占便宜。

数组中间删除,挪动是逃不掉的:

删除下标 2(值 30),后面每个往前挪一格
[10,20,30,40,50]  →  [10,20,40,50,_]
                        ↑ 40、50 各挪一次,O(n)

有一个常被忽略的技巧:如果不要求保持顺序,数组删除可以 O(1)——把最后一个元素搬到被删位置,再砍掉尾巴。这在「集合语义」的场景(不在乎次序)里很常用。


三、动态数组:扩容为什么还能算 O(1) ​

std::vector、Java ArrayList、Go 的 slice 都是「动态数组」:底层还是连续内存,装满了就申请一块更大的、把旧数据拷过去、释放旧的。单次扩容是 O(n) 拷贝,可 push_back 平均却是 O(1),这靠的是倍增 + 均摊。

设每次满了扩成原来的 2 倍。从 0 push 到 n 个,触发的拷贝总量是 1 + 2 + 4 + ... + n ≈ 2n,是 O(n) 的总拷贝分摊到 n 次插入,每次均摊 O(1)。若改成「每次 +1」扩容,总拷贝是 1+2+...+n = O(n²),均摊就退化成 O(n)——这就是为什么增长因子必须是「乘」不是「加」。

增长策略n 次 push 总拷贝均摊单次
×2(或 ×1.5)O(n)O(1)
+1(固定增量)O(n²)O(n)

倍数取 2 还是 1.5 有讲究:1.5 倍的好处是「释放的旧块有机会被后续更大的申请复用」(几次之后累计释放的空间可能凑得出下一次的需求),内存碎片友好,GCC 的 libstdc++ vector 用 2 倍,MSVC 用 1.5 倍,都是工程取舍,不影响均摊 O(1) 的结论。

一个高频坑:扩容会让所有指向元素的指针/引用/迭代器失效,因为底层内存换了地方。vector 里存了元素地址、又在循环里 push_back,是经典 use-after-free。reserve 预留容量能避免中途扩容,也能避免这类失效。链表没有这个问题——节点地址一旦分配就不动。


四、缓存:把「复杂度相同」拉开几十倍的东西 ​

CPU 访问内存不是一次取一个字节,是一次取一整条 cache line(常见 64 字节)。数组连续存放,取第一个元素时,它后面的十几个元素被顺带拉进缓存,接着遍历几乎全部命中。链表节点是 new 出来的,散落在堆各处,每跳一个 next 大概率是一次 cache miss,要去主存等几十到上百个周期。

所以「遍历一个数组」和「遍历一个等长链表」,理论都是 O(n),实测数组常常快一个数量级。这也是为什么工程里默认容器是数组(vector),而不是链表。链表只在「频繁在已知位置增删、且很少遍历」时才值得。

顺带一个反直觉结论:在现代 CPU 上,「在有序数组中间插入」即使要挪 O(n) 个元素,也常常比「维护一个有序链表逐个 next 找插入点」更快——因为挪动是连续内存的顺序拷贝,硬件预取喜欢它;而链表找位置是一串随机跳转。别只按大 O 选结构,也要看访问模式是不是顺序的。


五、执行走查:反转单链表 ​

链表题的地基是「反转」。给 1 -> 2 -> 3 -> null,原地反成 3 -> 2 -> 1 -> null。核心是三个指针来回倒手,一次改一根 next。

cpp
ListNode* reverse(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* cur  = head;
    while (cur) {
        ListNode* next = cur->next; // 1. 先存住后继,否则改完就丢
        cur->next = prev;           // 2. 当前节点掉头指向前驱
        prev = cur;                 // 3. prev 前移
        cur  = next;                // 4. cur 前移
    }
    return prev; // cur 走到 null 时,prev 是新头
}

拿 1 -> 2 -> 3 走一遍,看每行执行后的状态:

迭代next执行 cur->next=prev 后的链prevcur
进入前-1→2→3→∅∅1
第 1 轮21→∅(2→3 仍在)12
第 2 轮32→1→∅23
第 3 轮∅3→2→1→∅3∅
退出-返回 prev=3--

第一行 next = cur->next 是整段的命门:一旦先执行了 cur->next = prev,原来的后继就再也找不到了。**改一行验证:**把前两句顺序调换(先 cur->next=prev 再 next=cur->next),第一轮之后 next 拿到的就是 prev(走回头),链表从此断裂成环或丢尾。链表题几乎所有 bug 都出在「改指针的顺序」和「改之前有没有存住要用的指针」。


六、双链表、循环链表、哨兵 ​

单链表只能往后走,删一个节点得知道它的前驱,所以「只给待删节点指针、要求 O(1) 删除」在单链表上做不到(除非把后继的值拷过来再删后继,这是 trick)。双链表每个节点多存一个 prev,拿到任意节点就能 O(1) 摘除,代价是每节点多一个指针、每次增删多维护一根链。LRU、std::list、内核的 list_head 都是双向循环链表。

**哨兵节点(dummy head)**是链表工程的标配:在真正的头前面挂一个不存数据的假节点。好处是「删头节点」和「删中间节点」变成同一套代码,不用为「被删的是不是头」写特判。写链表题时先加一个 dummy->next = head,返回 dummy->next,能消掉一大半边界 bug。

循环链表把尾节点的 next 指回头(双向的话头的 prev 指向尾),适合「轮转」语义:约瑟夫环、时间轮、round-robin 调度。判空和遍历终止条件从「next == null」变成「next == head」,是常见改错点。


七、对应到语言和工程 ​

需求C++Java说明
默认线性容器std::vectorArrayList连续内存,随机访问 O(1),遍历 cache 友好
两端进出std::dequeArrayDeque分段数组,不是链表;头尾 O(1)
频繁中间增删(有指针)std::listLinkedList双链表;实际很少用,遍历慢

几条工程经验:

  • std::vector 是 90% 场景的正确默认。std::list 教科书地位高,生产里因为 cache 不友好、每节点一次 new,大多数时候反而更慢,用之前先问「我真的会在已知位置频繁增删、且很少遍历吗」。
  • Java 的 LinkedList 同理,ArrayList 几乎总是更好;LinkedList 唯一略有优势的是「当队列用」,但那也不如 ArrayDeque。
  • deque(双端队列)常被误当成链表,它其实是「一段段连续数组 + 索引块」,随机访问也接近 O(1),两端增删 O(1),是「既要数组的遍历性能、又要两端进出」的折中。
  • Go 的 slice 是动态数组,append 触发扩容时底层数组会换地址,多个 slice 共享底层数组时的「一个 append 改了另一个」是同一类扩容陷阱。

八、面试收口 ​

  • 随机访问谁快? 数组 O(1)(地址可算),链表 O(n)(必须逐跳)。
  • 插入删除谁快? 链表在「已持有节点指针」时 O(1);否则要先 O(n) 找,不占便宜。数组中间增删 O(n),末尾均摊 O(1)。
  • 动态数组为什么均摊 O(1)? 倍增扩容,n 次插入总拷贝 O(n)。固定增量会退化成 O(n²)。
  • 理论一样为什么实测差很多? cache line + 预取,数组顺序遍历命中率高,链表逐跳几乎次次 miss。
  • 链表高频题的套路: 哨兵节点消边界、快慢指针找环/找中点、三指针反转、双链表 + 哈希做 LRU。快慢指针找环见下一层:slow 每次一步、fast 每次两步,相遇即有环,再从头和相遇点同速前进,相遇处是环入口。

数组承上启下:std::vector 是栈和邻接表的底座,见「栈与队列」和「图」;数组 + 哈希函数是哈希表的直接实现,见「哈希表」;数组按完全二叉树下标规则解释就是堆,见「堆与优先队列」。