Theory · Data Structure · Array & Linked List

数组与链表

顺序存储 vs 链式存储:寻址公式 · 动态数组均摊扩容 · 双指针 · 反转与判环 —— 一切线性结构的起点

数组

连续内存 + 寻址公式 → O(1) 随机访问;动态数组用均摊扩容换长度自由

链表

分散节点 + 指针 → O(1) 已知位置插删;代价是缓存不友好与 O(n) 定位

工程真相

绝大多数场景数组赢——缓存局部性是现代 CPU 的一等公民

开场定位:线性结构是所有后续 deck 的地基——栈/队列是受限的线性表,哈希拉链法用链表,跳表是链表加索引。三个卡片分别是数组的核心、链表的核心、以及反直觉但重要的工程结论。

Why It Matters

先看一个需求:待办清单要能"取第 k 条",也要能"中间插一条"

场景:10 万条待办,两个操作

取第 5 万条(翻页要显示它);② 在第 5 万条后面插一条(用户新建)。听起来都是小事——但数据在内存里只有两种摆法,而每种摆法只擅长其中一个。这就是本 deck 全部矛盾的来源。

摆法 A · 挨着放

10 万条排成一条连续的内存带。取第 5 万条:拿公式算一下地址就到了,跟总共多少条无关;中间插一条:它后面的5 万条要全部往后挪一格

摆法 B · 分开放,每块带张"路条"

每条待办单独占一小块内存,块里存一个"下一条在哪"的地址。中间插一条:改两个地址就完事;取第 5 万条:只能从第 1 条顺着路条走 5 万步

结论:没有"两个都快"的摆法

摆法 A 就是数组,摆法 B 就是链表。后面所有线性容器——栈、队列、哈希表的桶、LRU——都是在这两块地基上加工出来的,所以它们必须先讲。

操作挨着放(数组)分开放(链表)
取第 k 条一次计算到位顺着走 k 步
中间插一条挪动后面 n−k 条改两个地址
末尾追加通常直接写(偶尔搬家)改一个地址
整体顺序扫一遍:数据挨着,缓存顺带预取慢:每跳一次都可能等主存
一个反直觉的现实数字:CPU 读一次 L1 缓存约 1ns,跑到主存取一次约 100ns,差 100 倍。链表"改两个地址"省下的搬移其实是 memcpy(极快),而它每次遍历都要付缓存缺失的代价(极慢)——渐进复杂度赢了,实际时间常常输。这就是为什么四门主流语言的默认线性容器全是动态数组。
本 deck 路线:先讲两种摆法各自的机制(寻址公式 / 指针 + 扩容),再练两套手上功夫(数组三板斧 / 链表 dummy 与快慢指针),最后回答那个真正的面试题:什么时候链表才划算
动机页:先用"取第 k 条 + 中间插一条"把矛盾摆出来,术语一律延后(先叫"挨着放/分开放")。右侧 1ns vs 100ns 是全 deck 最重要的工程数字,为后面"数组 vs 链表"正面对决页埋线。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(细节后面展开)
内存地址 address内存里每个字节的门牌号,用 0x1000 这样的十六进制数表示
连续内存 contiguous一段门牌号挨着的内存;数组就住在这样一段里
下标 index元素在数组里的第几号位(从 0 数),本身不是地址
寻址公式由下标算出地址的算式:地址 = 起点 + 下标 × 每个元素多大
节点 node链表里的一小块内存:装一个值 + 一个"下一个在哪"的地址
指针 pointer值就是一个内存地址的变量;节点里的 next 就是指针
缓存行 cache lineCPU 从内存搬数据的最小单位,通常 64 字节——一次搬来一整块
均摊 amortized把偶发的大开销摊到多次操作上算平均,得到的"每次成本"
容量 capacity已经申请好的格子数(cap),区别于已经用了几格(len)
哨兵 sentinel / dummy特意加在头部的假节点,用来消灭"头节点特殊处理"的边界代码

前置知识:不确定就先看这两篇

复杂度分析 → O(1) / O(n) 怎么读,"均摊"到底摊的是什么
Go slice 底层 → 本 deck 的 Go 例子都以它为准(想深挖再去)

最小心智模型(记住这一句)

两种结构的唯一区别是:"下一个元素在哪"这个问题怎么回答。
· 数组用「算」——地址可以由下标推导出来,所以能一步跳到任意位置;
· 链表用「存」——地址写在上一个节点里,所以必须一步一步走过去。

后面所有变体都挂在这一句上

动态数组 = 数组 + "格子不够就换更大的一段连续内存";双向链表 = 节点里多存一个"上一个在哪";循环链表 = 最后一个的 next 指回第一个;跳表 = 给链表补几层索引,让它也能"跳"。

阅读提示:术语不用背,忘了回来查这一页。真正要背下来的只有两处:寻址公式,以及倒数第三页的那张速查表。
前置页:十个术语先定义再使用,尤其把"下标不是地址""缓存行一次搬 64 字节"两个零背景读者必卡的点讲开。最小心智模型「算 vs 存」是全 deck 的统一动作,后面每种变体都回指它。

Two Ways Of Lining Up

同一个序列,两种物理形态

把开场那两种摆法画出来就是下面两排:上排"挨着放"=数组(地址靠),下排"分开放"=链表(地址靠)。

数组连续存储与链表分散存储的内存布局对比 数组把 a0 到 a4 连续放在一段内存里,靠下标乘元素大小算地址;链表的节点分散在内存各处,每个节点带一个 next 指针指向下一个节点,访问第 i 个元素必须沿指针走 i 步。 ARRAY · 连续 a₀a₁a₂a₃a₄ 0x10000x10080x10100x10180x1020 addr(a[i]) = base + i × elem_size 下标即偏移量 → 随机访问 O(1) LINKED LIST · 分散 + 指针 a₀ next a₁ next a₂ next a₃ next a₄ next 节点地址无规律 到第 i 个必须走 i 步
这页是全 deck 的地图:数组用"地址可计算"换随机访问;链表用"地址在节点里"换插删自由。上排地址连续递增正好是寻址公式的直观展示;下排强调节点地址无规律。

Array · Contiguous Memory

O(1) 随机访问的全部秘密:一个乘法一个加法

寻址公式是数组的"灵魂三行代码"

addr(a[i]) = base + i × size——不依赖 i-1 的地址,也不需要任何遍历,所以下标访问是严格 O(1)、与数组长度无关。

连续内存的两个红利

CPU 缓存预取:cache line 通常 64B,读 a[0] 时 a[1..7] 已顺带进缓存,顺序扫描近似免费;② 指针算术通用:多维数组、切片、字符串底层全是它。

连续的代价

中间插入/删除要整体搬移 O(n);扩容要另找一块更大的连续内存并整体搬迁——这就是动态数组和均摊分析的故事(复杂度 deck)。

静态数组 vs 动态数组

静态:长度编译期固定(Go 的 [N]T 是值类型,赋值即拷贝);动态:长度运行期可变,本质是"结构体包着底层数组"(下一页 Go slice 视角)。

操作复杂度原因
下标访问 a[i]O(1)寻址公式直接算
线性查找O(n)逐个比较
尾部追加均摊 O(1)偶发扩容搬迁
中间插入/删除O(n)整体搬移保顺序
头插O(n)全体右移
为什么二分查找只能在数组上做?二分每步要"跳到中点"——只有 O(1) 随机访问才付得起这个跳跃。链表二分是伪命题,但跳表靠空间换索引让链表也能"跳"(跳表 deck)。
核心记忆点:寻址公式 = 加法 + 乘法,这是 O(1) 的来源;连续内存带来缓存预取红利,这是工程上数组碾压链表的根源。二分与随机访问的绑定关系是高频追问。

Dynamic Array · Amortized Growth

动态数组:长度自由的数组,代价是偶发搬迁

三要素:指针 + 长度 + 容量

所有语言的动态数组(Go slice / C++ vector / Java ArrayList / Python list)都是同一个抽象:指向底层数组的指针 + len(已用)+ cap(已分配)。len<cap 时追加零成本;len==cap 时触发扩容。

扩容:按倍数增长,均摊 O(1)

翻倍或 1.5 倍 → n 次追加总搬迁 < 2n~3n → 均摊 O(1)(证明见 复杂度 deck)。固定增量扩容是均摊 O(n)——面试的反面教材题。

缩容与抖动

删除到 1/4 才缩到 1/2(hysteresis 滞回):如果"一半就缩、满了就扩",在临界大小反复 push/pop 会触发 thrashing 抖动——每次操作都 O(n)。Java HashMap、vector 的 shrink 都留了缓冲带。

预分配:把搬迁消灭在源头

已知规模时先 make([]T, 0, n) / new ArrayList(n),n 次 append 从"均摊 O(1) + 多次分配"变成"严格 O(1) + 一次分配"。高性能代码的肌肉记忆。

语言扩容策略备注
Go slice<256 翻倍;≥256 渐进 ≈1.25×roundupsize 按内存规格对齐,实际 cap 常与直觉不符
C++ vector1.5×(libc++/MSVC)或 2×(libstdc++)标准只保证几何级数,具体倍率看实现
Java ArrayList1.5×(oldCap + oldCap≫1)grow() 里位运算右移一位
Python list≈1.125× 带超额预算listresize 的增长模式 new_allocated ≈ size + size/8
面试标准句:"扩容是均摊 O(1),倍增系数越大均摊常数越小但内存浪费越多——1.5 倍是空间/搬迁折中,还可能让释放的旧块更容易被下次复用。"(1.5 vs 2 的权衡是加分项)
动态数组四件事:三要素结构、倍增均摊 O(1)、缩容滞回防抖动、预分配消灭搬迁。各语言倍率表建议只记 Go 和"几何级数"这个公共本质——细节版本敏感,以源码为准。

Go Slice · Header + Backing Array

Go 的动态数组:slice header 是一把钥匙

Go slice 头部结构与底层数组的关系 slice 是一个含 Data 指针、Len、Cap 三个字段的结构体;Data 指向底层数组,Len 是已用元素个数,Cap 是底层数组总容量,超出 Cap 追加会触发扩容换新数组。 SLICE HEADER · reflect.SliceHeader(24B) Data *T 0x2040 Len int 3 Cap int 8 Data BACKING ARRAY · 底层数组(8 槽) v₀v₁v₂ 01234567 len = 3 cap = 8 len 内追加 → 原地写;超出 cap → growslice 换新数组(详见 slice deck)
Go 岗位专属页:slice 头部 24 字节三元组。看图说话——蓝括号是 len(已用)、灰虚括号是 cap(已分配)。关键推论:slice 按值传递时拷贝的是 header,底层数组共享;这解释了"子切片改值影响原切片"和"append 有时不同步"两大玄学。

Array Patterns · LeetCode

数组题三板斧:双指针、前缀和、差分

套路适用信号代表题
对撞双指针有序 + 两端向中间收缩两数之和 II(167)、盛水(11)、反转字符串(344)
快慢同向指针原地读写分离、去保留删除重复项(26)、移动零(283)、移除元素(27)
滑动窗口连续子数组/子串 + 单调性无重复最长子串(3)、长度最小子数组(209)
前缀和区间和/计数查询多次区域和检索(303)、和为K的子数组(560)
差分区间批量加减后一次汇总航班预订(1109)、拼车(1094)
识别技巧:"连续子数组"想窗口/前缀和;"原地删除/去重"想快慢指针;"有序数组两数/三数"想对撞——信号词到套路的映射要条件反射。滑动窗口详见 滑动窗口 deck
// 快慢指针:原地删除有序数组重复项 (LC 26)
func removeDuplicates(a []int) int {
    slow := 1
    for fast := 1; fast < len(a); fast++ {
        if a[fast] != a[slow-1] {
            a[slow] = a[fast] // slow 只写不读
            slow++
        }
    }
    return slow
}
// 前缀和:pre[i] = a[0..i-1] 之和 (LC 303)
func preSum(a []int) []int {
    pre := make([]int, len(a)+1)
    for i, v := range a {
        pre[i+1] = pre[i] + v
    }
    return pre // sum[i..j] = pre[j+1]-pre[i]
}
三板斧覆盖了 LeetCode 数组 medium 题的大半。代码模板两段都是"背下来就能写"级别:快慢指针的关键是 slow 只写不读;前缀和的关键是 pre 长度 n+1、sum[i..j] 用左闭右开差分。

Linked List · Pointer Chasing

单链表 / 双链表 / 循环链表:一张结构图

单链表、双向链表与循环链表的结构对比 单链表每个节点只有 next 指针单向相连,尾部指向空;双向链表每个节点有 prev 和 next 两个指针可以双向走;循环链表尾节点的 next 指回头节点形成环。 SINGLY · 单链表 1|next2|next3|next 只能单向走 · 删节点需先拿前驱 DOUBLY · 双向链表 p|1|np|2|np|3|n prev+next · 拿到节点即可 O(1) 自删(LRU 基建) CIRCULAR · 循环链表 1|next2|next3|next 尾 next → 头,成环 约瑟夫问题 · 环形缓冲 · 从任意节点遍历全表
三种形态各配一句存在理由:单链表省内存最常见;双向链表买到"O(1) 自删 + 反向遍历",LRU 必用;循环链表解决"回到开头"的语义(轮转调度、约瑟夫)。Go 标准库 container/list 就是带头节点的双向循环链表。

Operations · Sentinel Trick

链表操作:O(1) 的前提、边界与 dummy 哨兵

"O(1) 插删"的完整前提

插入/删除在已知位置指针处是 O(1);但删除单链表节点需要前驱——找到前驱本身要 O(n)。双链表自带 prev 才做到"拿到节点就 O(1) 自删"。

边界全在"头"上

删头、头插、删到空——链表题 80% 的 bug 在头节点特判。dummy 哨兵节点统一了"头和中间":在 dummy 后面做操作,永远不用单独处理头,返回 dummy.Next。

常见操作复杂度(单链表)

头插 O(1) · 尾插 O(1)(带尾指针)/ O(n)(不带)· 查找 O(n) · 删给定节点 O(n)(找前驱)/ O(1)(换值法,见 QA10)。

内存特征

每节点多付 1~2 个指针(8~16B);节点分散导致分配开销与碎片;Go 里 type Node struct{ Val int; Next *Node } 逃逸到堆,GC 压力比连续数组大。

// dummy 哨兵:删除链表所有 val 节点 (LC 203)
func removeElements(head *ListNode,
    val int) *ListNode {
    dummy := &ListNode{Next: head}
    cur := dummy
    for cur.Next != nil {
        if cur.Next.Val == val {
            cur.Next = cur.Next.Next // 跳过即删除
        } else {
            cur = cur.Next
        }
    }
    return dummy.Next // 头被删也不怕
}
链表题三件套(面试官视角):① 纸上画指针变化图再写代码;② 用 dummy 消灭头边界;③ 过一遍空表 / 单节点 / 头尾三个用例。做到这三条,链表题基本不翻车。
两个必考点:O(1) 插删的"已知位置"前提(多数人答不满分);dummy 哨兵为什么能消灭头边界。LC 203 这段代码建议现场默写,它是所有"删节点"题的骨架。

Reverse · Fast & Slow Pointers

两大基本功:反转链表与快慢指针

// 反转链表 · 三指针迭代 (LC 206)
func reverseList(head *ListNode) *ListNode {
    var prev *ListNode
    cur := head
    for cur != nil {
        next := cur.Next // 1. 先存后继
        cur.Next = prev  // 2. 掉头
        prev = cur       // 3. 双双前进
        cur = next
    }
    return prev // 新头
}
// 递归版:reverse(head) = 接在已反转的
// head.Next 之后;时间 O(n)、栈深 O(n)——
// 长链表在 Go 里会栈溢出,首选迭代。
递归语义:"先把 head 后面的全反转好,再把 head 接到新尾后面"——写得出递归版说明真懂指针,答得出栈深 O(n) 说明懂工程。

快慢指针三连:中点 / 判环 / 倒数第 k

中点(LC 876):slow 一步 fast 两步,fast 到尾时 slow 在中点(偶数个返回第二个中点,方便断链)。
判环(LC 141):环内 fast 相对 slow 每轮近 1 步,必相遇(不会跳过)。
找环入口(LC 142):相遇后一头一 slow 同速走,再相遇即入口。
倒数第 k(LC 19):fast 先走 k 步,再同步走。

Floyd 判环推导(面试要会写)

设头到入口 a、入口到相遇点 b、环长 L:slow 走 a+b,fast 走 a+b+nL;由 fast=2×slow 得 a = (n−1)L + (L−b)——头出发的指针和相遇点出发的 slow 同速前进,恰在入口汇合。

为什么"相对速度 1"是关键

只要相对速度与环长互质成立(速度差 1 最显然),fast 逐步逼近 slow 不存在"跳过":每轮距离减 1,减到 0 必相遇。速度差为 2 时在偶数环长上才可能跳过——这也是判环永远用 1:2 的原因。

链表算法的两大母题:反转(指针操作基本功)和快慢指针(空间 O(1) 的定位技术)。Floyd 推导要求能黑板写出 a=(n−1)L+(L−b);"相对速度 1 不跳过"是深刻追问,答出来直接加分。

Pointer Gymnastics · Visualized

指针怎么转、环怎么判:一图看懂

反转链表指针变化与 Floyd 快慢指针判环 左图展示反转链表:链表 1 接 2 接 3 接空,通过逐个掉转 next 指针变成 3 接 2 接 1 接空;右图展示带环链表上快慢指针从头部出发进入环内相遇,利用距离关系 a 等于环长的整数倍减 b 找到环入口。 REVERSE · 反转前 123 next := cur.Next // 1 存后继 cur.Next = prev // 2 掉头 prev, cur = cur, next // 3 前进 REVERSED · 反转后 123 prev 最终停在新头(原尾 3) 时间 O(n) · 空间 O(1) · 每条箭头只改一次 FLOYD · 快慢指针判环 12345 5.next → 3 成环 head 入口(3) 相遇点(5) 设:head→入口 = a · 入口→相遇 = b · 环长 L = b + c slow = a+b · fast = a+b+nL · fast = 2×slow ⇒ a = (n−1)·L + (L−b) = (n−1)·L + c 从 head 与相遇点各放一个同速指针,必在入口相遇 时间 O(n) · 空间 O(1),无需哈希集合记访过的节点
左图对齐代码三步:存后继、掉头、前进——每条箭头只改一次,prev 停在新头。右图对齐公式:蓝虚线是 5.next 指回 3 的成环边,方框里三行推导就是 Floyd 判环全部数学。这两张图建议自己动手在纸上复现一遍。

LeetCode Shortlist

必刷题单:按套路分组

套路题目(编号 · 难度)要点
快慢同向26 删除有序数组重复项 E · 283 移动零 E · 27 移除元素 Eslow 写 fast 读,读写分离
对撞指针167 两数之和 II M · 11 盛最多水的容器 M · 344 反转字符串 E · 125 验证回文串 E两端收缩,跳过不可能组合
前缀和/差分303 区域和检索 E · 560 和为 K 的子数组 M · 1109 航班预订 M560 还要用哈希计前缀出现次数
链表反转206 反转链表 E · 92 反转链表 II M · 25 K 个一组翻转 H25 = 分组反转 + 组内 206 + 接回
快慢指针876 中间节点 E · 141 判环 E · 142 环入口 M · 19 删倒数第 N M142 要会 a=(n−1)L+c 推导
合并/删除21 合并有序链表 E · 203 删指定值 E · 237 删给定节点 M · 160 相交链表 E160 用"互换轨道"消除长度差
刷法建议:206 / 141 / 21 三道模板题各默写 3 遍直到 5 分钟内 bug-free,再进入组合题(25 / 19 / 142)。链表面试的本质是"指针操作不出错",手熟比聪明重要。
题单按套路分组,先模板后组合。25(K 个一组)是链表题天花板:dummy + 分组定位 + 组内反转 + 组间接尾,四大技巧一次练全。

Array vs Linked List

正面对决:数组为什么几乎总是赢

维度数组 / 动态数组链表
内存布局连续,一次分配分散,每节点一次分配
随机访问O(1) 寻址公式O(n) 指针追踪
已知位置插删O(n) 搬移O(1)(单链表删需前驱)
缓存局部性:64B cache line 顺带预取,顺序扫描近内存带宽:指针追踪 ~100ns 主存访问,可能逐节点 miss
额外空间扩容预留 ≤ 1/2(倍增)每节点 8~16B 指针 + 分配器开销
二分/ SIMD 友好可二分、可向量化均不可
数量级直觉:L1 命中 ~1ns,主存 ~100ns。链表"O(1) 插删"省下的搬移是 n 次内存拷贝(memcpy 极快),付出去的却是每次遍历的指针追踪 miss——渐进复杂度赢了,实际时间输了

链表真正的用武之地

① 需要 O(1) 自删 + 迭代器稳定:LRU(双链表+哈希);② 频繁头尾操作:deque 内部也常用块状链;③ 函数式/不可变结构共享尾节点。Go 标准库 container/list、Java LinkedList 实战中都很少被选。

本页是面试杀手锏页:复杂度表上链表占优,但缓存局部性让数组实际更快——能讲清"渐进 vs 实测"的差距就是高分答案。LRU 是链表最正当的应用场景,记住"双链表+哈希"这个组合。

Across Languages

四门语言里的线性结构对照表

语言动态数组链表要点
Goslice(header + 底层数组)container/list(双向循环)[N]T 是值类型;slice 语义见 slice deck
JavaArrayList(1.5× 扩容)LinkedList(双链表,也实现 Deque)官方推荐 ArrayDeque 做队列/栈——LinkedList 很少是对的选择
C++std::vector(1.5×/2×)std::list(双)· forward_list(单)vector 是默认容器;list 只在稳定迭代器/O(1) 拼接时用
Pythonlist(动态数组)无内置链表;collections.deque(块状双向)deque 栈/队列双修;链表要手写或用第三方
共识:四大语言的"默认线性容器"全是动态数组。链表类型要么边缘化(LinkedList)、要么工具化(container/list、deque)。面试可以直说:"工程上我默认数组,除非要 O(1) 自删(LRU)或 O(1) 拼接(合并中间段)——这是实测驱动的选择,不是背书。"
对照表的价值在"默认容器都是动态数组"这个横向结论。Go 岗位重点记 container/list 是带头节点的双向循环链表,PushBack/PushFront/Remove 都是 O(1)。

Cheat Sheet

一页带走:复杂度、选型、模板、坑

① 操作复杂度对照

操作数组链表
按下标取第 k 个O(1) 寻址公式O(k) 顺着走
查找某个值O(n)(有序可二分 O(log n))O(n),不可二分
尾部追加均摊 O(1)O(1)(带尾指针)
已知位置插/删O(n) 搬移O(1),单链表需先有前驱
头部插/删O(n)O(1)
额外空间扩容预留 ≤ 1/2每节点 8~16B 指针

② 选型:什么时候才该用链表

默认选择动态数组(slice / vector / ArrayList)——缓存友好,四门语言的默认容器
需要 O(1) 自删双向链表 + 哈希LRU 的标准结构
需要 O(1) 拼接大段链表(改两个指针);数组要整体拷贝
迭代器/元素地址必须稳定链表(扩容不会搬迁节点)
要二分 / 向量化 / 顺序扫只能数组

③ 四段模板,各记一句

快慢同向指针slow 只写不读,fast 负责扫——原地去重/移零
前缀和pre 长度 n+1,sum[i..j] = pre[j+1]-pre[i]
反转链表三步固定:存后继 → 掉头 → 双双前进,prev 是新头
dummy 哨兵结果头不确定就上 dummy,最后 return dummy.Next
Floyd 判环1:2 速度必相遇;入口用 a=(n−1)L+c,头与相遇点同速再走

④ 五个必踩的坑

cap 内 append子切片 append 会覆盖原切片元素;扩容后才彻底分离
缩容抖动"满就扩、半就缩"会在临界点反复搬迁;要留滞回缓冲带
递归反转爆栈栈深 O(n),长链表首选迭代版
改 next 前没存后继断链,后面全丢;顺序永远是"先存再改"
LC 237 换值删不能删尾节点,且删的是"值"不是那个对象
一句话背下来:数组用地址换来随机访问,链表用地址换来插删自由;而现代 CPU 偏爱"挨着放",所以除非要 O(1) 自删或 O(1) 拼接,默认就用动态数组
速查页是"可检索性优于一次性"的落点:回看只看这一页。四块按使用顺序排——先复杂度、再选型、再模板、最后避坑;末尾一句把第 3 页的"算 vs 存"心智模型收回来。

Interview QA · Part 1

高频追问:数组与 slice 的坑

先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的翻回上一页速查表。

1 · 数组随机访问为什么是 O(1)?

寻址公式

连续内存 + addr = base + i×size:地址一步算出,与 n 无关。链表要沿指针走 i 步,所以是 O(i)。

2 · 动态数组 append 的真实代价?

均摊 O(1)

len<cap 时 O(1);写满触发扩容 O(n) 整体搬迁。倍增下 n 次 append 总代价 < 3n → 均摊 O(1)(证明见复杂度 deck 第 9/10 页)。

3 · Go slice 的扩容策略?

<256 翻倍≥256 渐进

旧 cap <256 直接翻倍;≥256 走渐进增长(约 1.25× 平滑过渡,Go 1.18+),再经 roundupsize 按 size class 对齐——所以实际 cap 常与估算不符。详见 slice deck

4 · s2 := s1[:2] 之后改 s2[0]、append s2,s1 变吗?

共享底层数组分离点

改 s2[0]:变(同一底层数组)。append:若 len<cap 仍写进原数组(覆盖 s1 的元素,最阴险);cap 满则 growslice 新数组,之后彻底分离。判据永远是 header 里的 Data 指针指向谁。

5 · 链表插入删除 O(1) 的前提是什么?

已知位置前驱

前提是已拿到插入/删除位置的指针。单链表删除还需前驱,定位前驱要 O(n);双链表自带 prev 才是真正"拿到节点即 O(1) 自删"。

6 · 工程上为什么数组几乎总赢链表?

cache line 64B预取

数组顺序访问顺带把后续数据装进缓存(预取器友好), miss 率极低;链表节点分散,指针追踪一次 ~100ns 主存延迟。省下的搬移是 memcpy(快),付出的是 cache miss(慢)。

7 · 反转链表的递归写法有什么问题?

栈深 O(n)

递归深度 = 链表长度,百万节点在 Go 里直接爆栈(Go 无尾调用优化)。迭代三指针 O(1) 空间才是工程首选;递归版价值在"证明你懂指针语义"。

8 · Floyd 判环为什么 slow/fast 一定相遇?

相对速度 1

进环后 fast 每轮比 slow 多走 1 步,两者距离每轮减 1,从"环长"一路减到 0,不存在跳过。速度差为 2 以上时才可能跨过对方(这也是为什么判环固定用 1:2)。

前八题偏数组和原理:寻址、均摊、slice 四连(扩容/共享底层数组是 Go 岗必考)、链表 O(1) 前提、缓存、递归栈深、Floyd。第 4 题的"cap 内 append 覆盖原切片"是最冷门的送命题。

Interview QA · Part 2

高频追问:链表操作与选型

同样先自答再对照。这一页的题都要"结论 + 一句边界/代价"才算完整——只答结论会被继续追问。

9 · 快慢指针怎么找中点?偶数个返回哪个?

LC 876

slow 每步 1、fast 每步 2,循环条件 fast != nil && fast.Next != nil:奇数个停在正中,偶数个停在第二个中点——正好方便左断右反转(回文链表题的标准起手)。

10 · 只给你要删的节点(不给头),怎么删?

LC 237 换值法

把 next 节点的值拷进来,再删 next:node.Val = node.Next.Val; node.Next = node.Next.Next。限制:不能是尾节点(无值可换);语义上删的是"值"不是"那个对象"。

11 · dummy 哨兵到底解决什么问题?

消灭头边界

头节点可能被删/被换/被插——有了 dummy,头与中间节点走同一套代码,返回 dummy.Next。凡是"结果头不确定"的题(删、合并、分组反转)一律 dummy 起手。

12 · 双链表比单链表贵在哪、值在哪?

+8B/节点O(1) 自删

每节点多一个 prev(8B/64 位机)。换来:O(1) 自删与删前驱、O(1) 反向遍历、O(1) 在尾操作(带尾指针)。LRU 必须双链表——命中的节点要原地摘下移到头部。

13 · 两个有序链表怎么合并?复杂度?

LC 21

双指针逐节点摘小者接入 dummy 链,O(m+n) 时间 O(1) 空间。它是归并排序的子过程——数组版归并因搬移用辅助数组,链表版天然 O(1) 空间。递归写法优雅但栈深 O(m+n)。

14 · 循环链表在真实系统里出现过吗?

环形缓冲

环形缓冲区(音频/网络包)逻辑上是循环数组;Linux 内核 list_head 是双向循环链表(非空环,省去头尾特判);约瑟夫问题、时间轮定时器都是环语义。Go container/list 也是带头节点的双向循环。

15 · 链表题总出错,有什么通用检查法?

画图三边界

① 每次改 next 前在纸上画箭头图;② 三个边界用例过一遍:空表、单节点、头/尾操作;③ 检查是否断链(先把 next 存下来再改)。面试官说"可以画图"时,画——那是送分提示。

后七题偏链表实操:中点、换值删、dummy、双链表性价比、合并、循环链表落地(Linux list_head 是亮点素材)、调试方法论。第 10 题必须主动说出"不能删尾节点"这个限制才完整。

Related & References

相关知识点与参考

数据结构系列(本分类)

栈与队列 →(受限线性表,含单调栈)
哈希表 →(拉链法用的就是链表)
跳表 →(给链表加索引,找回 O(log n))
堆与优先队列 →(TopK 场景对比)
LRU 缓存 →(双链表 + 哈希的正当场景)

算法与 Go 系列

复杂度分析 →(扩容均摊 O(1) 的证明)
二分与对撞指针 →(有序数组的黄金搭档)
排序算法 →(合并有序链表 = 归并子过程)
Go slice 底层 →(growslice 全流程)

参考来源(本 deck 结论可溯源至下列一手材料)

CLRS ch10 · Elementary Data Structures数组/链表的形式化定义与操作代价
go.dev/blog/slices · go.dev/ref/specGo 官方:slice 语义、共享底层数组、append 行为
src/runtime/slice.go · growslice<256 翻倍 / ≥256 渐进 1.25× / roundupsize 对齐(对照本仓库 slice deck)
U. Drepper · What Every Programmer Should Know About Memorycache line / 预取 / 局部性的硬件依据
LeetCode 题单(206/141/142/21/25/84 等 · 完整清单见第 11 页)套路与代表题解对照
收尾:数据结构系列四条链接 + 算法/Go 系列四条。参考里 Go slice 的扩容数字与仓库已有 slice deck 完全一致,两边互链不冲突。