List
很多真实场景会直接使用顺序容器或链式结构:
ArrayList:底层用连续数组保存元素引用,强调随机访问(random access)和缓存局部性(cache locality)这些例子共同指向一个抽象:元素之间只有顺序关系
顺序表通常具有更好的缓存局部性;链表插入删除的局部操作快,但指针跳转更容易造成缓存未命中。
线性表是一个有限序列:
\[ L=(e_1,e_2,\ldots,e_n) \]
当 \(n=0\) 时,\(L\) 是空表。
当 \(n>0\) 时:
| 线性表 | 元素 | 顺序含义 |
|---|---|---|
| 人员名单 | 人员记录 | 登记顺序或编号顺序 |
| 考试列表 | 考试 1、考试 2、考试 3 | 时间顺序 |
| 星期 | 周一、周二、……、周日 | 周期中的固定顺序 |
| 文本行 | 字符 | 字符出现顺序 |
线性表允许相同元素重复出现,因为位置也是信息的一部分。
| 操作 | 含义 | 常见返回 |
|---|---|---|
Create() |
创建空表 | 表对象 |
IsEmpty() |
判断是否为空 | boolean |
Length() |
返回元素个数 | int |
Find(k) |
返回第 k 个元素 |
元素或失败 |
Search(x) |
查找元素 x 的位置 |
下标或失败 |
Delete(k) |
删除第 k 个元素 |
被删元素或失败 |
Insert(k, x) |
在位置 k 处插入 x |
成功或失败 |
若每个数组槽位占固定宽度 w,并且数组首地址是 base,则第 \(i\) 个物理单元的位置为:
\[ \operatorname{location}(i)=base+i\times w \]
因此,按下标访问是 \(O(1)\)。
查找元素仍然需要逐个比较,顺序查找最坏为 \(O(n)\)。
在元素等概率出现在任意位置时,成功查找的平均比较次数为:
\[ ACN=\frac{1+2+\cdots+n}{n}=\frac{n+1}{2} \]
找不到元素时,需要比较全部 \(n\) 个元素。
删除下标 \(i\) 的元素时,右侧元素需要左移。
若表长为 \(n\),删除位置等概率,则平均移动次数为:
\[ \frac{(n-1)+(n-2)+\cdots+1+0}{n}=\frac{n-1}{2} \]
删除的时间复杂度是 \(O(n)\)。
在下标 \(i\) 处插入时,需要从尾部开始右移。
若允许插入到 \(0,1,\ldots,n\) 共 \(n+1\) 个位置,平均移动次数为:
\[ \frac{n+(n-1)+\cdots+1+0}{n+1}=\frac{n}{2} \]
插入的时间复杂度是 \(O(n)\)。
| 维度 | 结论 |
|---|---|
| 按下标访问 | \(O(1)\),非常快 |
| 顺序扫描 | 内存连续,缓存友好 |
| 中间插入 | 需要移动元素,\(O(n)\) |
| 中间删除 | 需要移动元素,\(O(n)\) |
| 容量变化 | 静态数组容量固定;动态数组扩容有额外代价 |
static class SequentialList {
private static final int DEFAULT_CAPACITY = 8;
private String[] data;
private int size;
SequentialList() {
data = new String[DEFAULT_CAPACITY];
}
int size() {
return size;
}
String get(int index) {
checkElementIndex(index);
return data[index];
}
void insert(int index, String value) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException(index);
}
ensureCapacity(size + 1);
for (int i = size; i > index; i--) {
data[i] = data[i - 1];
}
data[index] = value;
size++;
}
String remove(int index) {
checkElementIndex(index);
String removed = data[index];
for (int i = index; i < size - 1; i++) {
data[i] = data[i + 1];
}
data[--size] = null;
return removed;
}
private void checkElementIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException(index);
}
}
private void ensureCapacity(int required) {
if (required <= data.length) {
return;
}
int newCapacity = Math.max(required, data.length * 2);
String[] expanded = new String[newCapacity];
for (int i = 0; i < size; i++) {
expanded[i] = data[i];
}
data = expanded;
}
}SequentialList 用连续数组保存元素,逻辑长度由 size 单独维护。
data.length 是物理容量,size 是当前有效前缀长度;未使用槽位不属于顺序表。
size=0,因此此时所有槽位都只是备用容量。
size() 直接返回逻辑元素数,不需要扫描数组。
get 验证下标后直接读取 data[index],所以随机访问是 O(1)。
insert 在需要时扩容,再把后缀整体右移一格并写入新值;中间插入的主要代价是搬移元素,因此是 O(n)。
remove 保存目标值后把右侧元素左移填洞,并清掉旧尾槽;中间删除同样是 O(n)。
checkElementIndex 保证只有 0..size-1 能作为现有元素下标,不能把备用容量当成数据。
ensureCapacity 只在容量不足时申请更大的数组并复制有效前缀;元素顺序和 size 都保持不变。
单链表的每个节点包含两个部分:
data:元素值next:指向下一个节点Node 把元素与链接字段放在同一个结点中;结点何时接入主结构由后续链接操作决定。
next 关系里。
Node(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
最后一个节点的 next 是 null。
before 是已经定位好的前驱节点,value 是要插入的新元素;因此这里不需要再从表头搜索位置。
before 为 null,就没有可改写的 next 字段,因此立即拒绝调用。
value 的新节点。此时主链还没有改变,新节点只是先在链外准备好。
node.next 保存旧的 before.next;这样下一步改写前驱链接时,后半段链表不会丢失。
before → oldNext 变成 before → node → oldNext。
O(1)。
before 是待删位置的前驱,因此真正的删除目标就是 before.next。
next;这和“前驱后面没有节点”是两个不同边界。
before.next 保存为 victim;若它不存在,说明前驱已经在链尾,结构保持不变。
victim 的后继,主链于是绕过待删节点;这次绕接才是真正的结构删除。
victim.next,让被移出的节点不再保留对原链后半段的结构引用。
O(1)。
头结点是不存放有效数据的哨兵(sentinel)节点。
它的作用是统一边界情况:
header.next == nullheader 后插入header 后的节点zeroth() 可以返回头结点位置static class SinglyLinkedList<T> {
protected final Node<T> header = new Node<>(null, null);
protected int size;
int size() {
return size;
}
void addFirst(T value) {
header.next = new Node<>(value, header.next);
size++;
}
List<T> toList() {
ArrayList<T> result = new ArrayList<>(size);
for (Node<T> p = header.next; p != null; p = p.next) {
result.add(p.data);
}
return result;
}
}SinglyLinkedList 集中保存这一结构的核心状态;类内操作都必须共同维护同一表示不变量。
header 永远存在但不算元素;把“表头前驱”固定下来后,空表与首节点操作都能用同一套链接规则。
size(…) 只报告当前逻辑规模,不应触发结构遍历或修改。
size 只统计真实节点,哨兵不计入;因此长度可以常数时间读取,而不必每次遍历链表。
addFirst(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
header.next 指向它;已知哨兵时只改常数个引用。
toList(…) 按链上顺序复制元素形成结果列表,不改变原链结构。
header.next 沿链逐个访问真实节点;链表没有随机下标跳转,因此完整快照天然是 O(n)。
find(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
next 顺序推进;单链表没有可直接跳到第 i 个节点的地址计算。
null;最坏情况下每个节点检查一次,时间为 O(n)。
boolean remove(T value) {
Node<T> before = super.header;
while (before.next != null && !Objects.equals(before.next.data, value)) {
before = before.next;
}
if (before.next == null) {
return false;
}
Node<T> victim = before.next;
before.next = victim.next;
victim.next = null;
super.size--;
return true;
}remove(…) 从当前结构移除一个目标;操作结束时其余元素仍必须满足原来的表示不变量。
before 始终停在“下一节点尚待判断”的前驱位置;这样找到目标时已经同时拿到了删除所需的前驱。
before.next==null,说明整条链都没有目标;此时结构必须保持不变并返回失败。
victim、断开旧链接并同步 size;搜索是 O(n),真正改链是 O(1)。
相邻节点之间有两条方向相反、彼此平行的结构边:上方 next 向右,下方 prev 向左。变量指针与结构边分开表示。
before 是插入位置左边的节点,value 是新元素。插入后必须同时恢复 next 和 prev 两个方向的一致性。
before,先建立 node.prev。
node.next;现在新节点知道左右邻居,但邻居还没有反过来指向它。
prev 改指向新节点。必须在覆盖 before.next 前做,否则原右邻居会丢失。
next 指向新节点;此时两个方向都经过新节点,局部双向不变量恢复。
true。已知位置时只修改常数个引用,因此是 O(1)。
先读完仍需使用的旧指针,再覆盖邻居的字段;否则旧后继可能丢失,甚至把新节点错误地连回自己形成自环(self-loop)。
victim 是已经定位好的待删节点。它自己的 prev、next 直接给出两个邻居,所以不需要从表头寻找前驱。
header 或其他链表的节点都不能删除;继续改指针会破坏当前链表的边界或所有权。
victim.prev 直接给出左邻居;这一阶段先确认正向链接将从左邻居跨过 victim。
victim.next 直接给出右邻居;反向链接也必须跨过 victim,否则从右向左仍会走回被删节点。
next 接到右邻居,正向链已经不再经过 victim。
prev 接到左邻居;两条方向都完成后,剩余链表重新满足双向一致性。
prev、next 和 owner,把它明确标成已经脱离链表的节点。
O(1)。
| 已知条件 | 单链表 | 双向链表 |
|---|---|---|
只知道待删节点 victim |
无法从 victim 直接得到前驱,通常需从头查找 |
victim.prev 立即给出前驱 |
| 指针重连 | 找到前驱后为 \(O(1)\) | 已知 victim 时直接为 \(O(1)\) |
| 代价 | 每个节点只存 next |
每个节点额外存 prev,修改时必须维护双向一致性 |
若采用带表头结点的循环双向链表,首尾删除也能复用同一组重连语句;前提是哨兵节点与两条方向的不变量始终成立。
有 \(n\) 个人围成一圈,编号为 \(1,2,\ldots,n\)。给定报数间隔 \(m\):
算法要回答两个问题:出列顺序是什么?最后幸存者是谁?
约瑟夫问题的关键是三个操作语义:
循环链表把“最后一个人的后继是第一个人”直接写进结构不变量,因而适合表达这个问题。
约瑟夫问题示例:\(n=8, m=3\)
static List<Integer> josephus(int n, int m) {
if (n <= 0 || m <= 0) {
throw new IllegalArgumentException("n and m must be positive");
}
Node<Integer> tail = new Node<>(n, null);
tail.next = tail;
for (int value = n - 1; value >= 1; value--) {
tail.next = new Node<>(value, tail.next);
}
Node<Integer> before = tail;
int remaining = n;
ArrayList<Integer> order = new ArrayList<>(n);
while (remaining > 1) {
int advances = (m - 1) % remaining;
for (int step = 0; step < advances; step++) {
before = before.next;
}
Node<Integer> victim = before.next;
order.add(victim.data);
before.next = victim.next;
victim.next = null;
remaining--;
}
order.add(before.data);
return order;
}josephus(…) 在循环结构中反复计数并删除命中结点,直到只剩最终幸存者。
1..n 连成 1→2→…→n→1;报数因此可以一直沿 next 循环前进。
before 定位待删者前驱,remaining 控制终止,order 保存真实出列顺序。
(m-1)%remaining 步;整圈空转被取模消掉,但淘汰位置不变。
before.next 直接给出本轮目标,不再搜索前驱。
victim、清理其链接并减少人数;剩余节点仍成环,直到只剩幸存者。
order 完整描述淘汰过程和最终结果。
多项式可以用数组表示,也可以用链表表示。
数组适合最高次数不大且稠密的多项式:
\[ p(x)=3x^8-5x^3+3x-1 \]
链表适合最高次数很大但非零项很少的多项式:
\[ p(x)=10x^{1000}+5x^{14}+1 \]
每个节点保存一个非零项:
Term 把多项式的一项系数与指数绑定在一起;链表顺序由指数决定。
next 只负责串联下一项。
Term(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
next 链接决定,因此“项内容”和“链表结构”可以分开维护。
static Term addPolynomial(Term a, Term b) {
Term header = new Term(0, -1);
Term tail = header;
while (a != null && b != null) {
if (a.exponent == b.exponent) {
int sum = a.coefficient + b.coefficient;
if (sum != 0) {
tail.next = new Term(sum, a.exponent);
tail = tail.next;
}
a = a.next;
b = b.next;
} else if (a.exponent > b.exponent) {
tail.next = new Term(a.coefficient, a.exponent);
tail = tail.next;
a = a.next;
} else {
tail.next = new Term(b.coefficient, b.exponent);
tail = tail.next;
b = b.next;
}
}
for (Term p = a != null ? a : b; p != null; p = p.next) {
tail.next = new Term(p.coefficient, p.exponent);
tail = tail.next;
}
return header.next;
}addPolynomial(…) 按指数合并两个有序项链表;相同指数合并系数,不同指数保持顺序。
tail 始终指向结果链最后一项,使每次输出都能直接接到尾部。
a 的指数更大,它必然是当前尚未输出项中的最高次项;复制后只推进 a,保持结果指数递减。
b 的指数更大则完全对称:先输出 b 当前项,再只推进 b。
tail 保持 O(1) 追加。
header.next 跳过哑元头结点;若所有同类项都抵消,真实结果头自然就是 null。
设两个多项式分别有 \(m\) 项和 \(n\) 项。
每一步至少推进 a 或 b 中的一个指针,因此总步数不超过 \(m+n\)。
\[ T(m,n)=O(m+n) \]
最坏情况是两个多项式指数交错,例如:
\[ A=a_5x^5+a_3x^3+a_1x+a_0 \]
\[ B=b_4x^4+b_2x^2+b_0 \]
给定一个单链表和正整数 k,要求找到倒数第 k 个结点。例如链表为 10 → 20 → 30 → 40 → 50 → 60,当 k=3 时,答案是 40。
思路:快慢两个指针。让 fast 先走 k 步,再让 fast 和 slow 同时前进。两者始终相差 k 个结点;当 fast 到达表尾时,slow 正好指向倒数第 k 个结点。
static <T> Optional<Node<T>> kthFromEnd(Node<T> header, int k) {
if (header == null || k <= 0) {
return Optional.empty();
}
Node<T> lead = header.next;
for (int i = 0; i < k; i++) {
if (lead == null) {
return Optional.empty();
}
lead = lead.next;
}
Node<T> follow = header.next;
while (lead != null) {
lead = lead.next;
follow = follow.next;
}
return Optional.of(follow);
}kthFromEnd(…) 用两个相距 k 的指针一次扫描定位倒数第 k 个结点。
k 没有合法答案;通过检查后,才从首个真实节点开始建立双指针间距。
lead 前进 k 步;若途中到达表尾,说明链长不足 k。
k 个节点的间距;lead 到表尾时,follow 正好落在倒数第 k 个节点。
O(n)、额外空间是 O(1)。
header 和 free 是两个独立入口:前者串起当前逻辑数据,后者串起尚未使用的数组槽位。同一个固定数组因此同时包含“逻辑链成员”和“可复用槽位”两类状态。
观察同一个数组槽位怎样在 free 链与逻辑链之间切换身份:分配时从空闲链摘下,释放时再接回空闲链。
int allocate() {
if (freeHead == NULL) {
throw new NoSuchElementException("cursor space is full");
}
int index = freeHead;
CursorNode node = space[index];
freeHead = node.next;
node.next = NULL;
node.allocated = true;
return index;
}
void free(int index) {
if (index < 0 || index >= space.length) {
throw new IndexOutOfBoundsException(index);
}
CursorNode node = space[index];
if (!node.allocated) {
throw new IllegalStateException("cursor slot is not allocated");
}
node.data = null;
node.allocated = false;
node.next = freeHead;
freeHead = index;
}allocate(…) 从空闲链取走一个槽位;被分配槽不再属于空闲链,且新的空闲链入口必须保持可达。
freeHead 不会被当成一个不存在的数组下标。
freeHead、断开旧空闲链接并标记为已分配,因此整个操作是 O(1)。
free(…) 从当前结构移除一个目标;操作结束时其余元素仍必须满足原来的表示不变量。
allocate() 可直接复用它,回收同样只修改常数个字段。
假设某省有大量考生,每位考生都有一条 Student 记录;总分范围是 \([0,750]\),并且最小计分单位是 \(0.5\) 分。
例如某一小段数据是:
600.5, 599.0, 601.0, 600.5, 598.5, 601.5, 599.5, 600.0, 601.0, 599.0, 600.0, 600.5
目标是把所有考生记录按总分从高到低排列。
如果有几十万条记录,直接让所有考生彼此比较有些低效。这里有一个很强的先验条件:
分数的可能取值只有 \(0,0.5,1.0,\ldots,750\),一共 1501 种。
把分数乘以 2,就可以把所有合法分数映射成整数桶编号:\(2\times score\)。
例如:
598.5 → 桶 1197600.0 → 桶 1200600.5 → 桶 1201601.0 → 桶 1202因此整个算法只有三步:
此时不需要再对桶内部按分数排序:同一桶中的考生本来就是同分。
设考生人数为 \(n\),合法分数种类数为 \(S=1501\)。
分桶与收集的成本约为 \(O(n+S)=O(n+1501)\approx O(n)\)。
关键不是“桶排序(bucket sort)永远是线性的”,而是这个问题同时满足:
如果只需要“每个分数有多少人”,一个计数数组就足够;如果需要保留完整 Student 记录,每个桶可以保存一个动态序列或链表。
static List<Student> bucketSortByHalfPointScore(List<Student> students) {
final int maxScore = 1500;
List<LinkedList<Student>> buckets = new ArrayList<>(maxScore + 1);
for (int score = 0; score <= maxScore; score++) {
buckets.add(new LinkedList<>());
}
for (Student student : students) {
if (student.scoreTimesTwo() < 0 || student.scoreTimesTwo() > maxScore) {
throw new IllegalArgumentException("score out of range");
}
buckets.get(student.scoreTimesTwo()).addLast(student);
}
ArrayList<Student> result = new ArrayList<>(students.size());
for (int score = maxScore; score >= 0; score--) {
result.addAll(buckets.get(score));
}
return result;
}bucketSortByHalfPointScore(…) 按有限分值域分桶计数,再按桶序稳定收集结果。
scoreTimesTwo() 把成绩精确映射为整数桶编号;先检查 0..1500,防止非法成绩访问不存在的桶。
addLast 是 O(1),并保留同分考生原来的相对顺序。
O(n+1501)。
| 维度 | 顺序表 | 单链表 |
|---|---|---|
| 按下标访问 | \(O(1)\) | \(O(n)\) |
| 查找某值 | \(O(n)\) | \(O(n)\) |
| 已知位置后插入 | 平均移动 \(n/2\) | 改两条指针,\(O(1)\) |
| 已知前驱后删除 | 平均移动 \((n-1)/2\) | 改一条指针,\(O(1)\) |
| 空间局部性 | 好 | 较差 |
| 额外空间 | 少 | 每个节点多一个或多个指针 |
| 扩容 | 动态数组可能整体搬迁 | 单个节点动态分配 |
| 场景 | 更合适的结构 | 原因 |
|---|---|---|
| 频繁按下标访问 | 顺序表 | \(O(1)\) 定位 |
| 频繁中间插入删除且位置已知 | 链表 | 不需要移动大量元素 |
| 需要缓存友好扫描 | 顺序表 | 连续内存 |
| 容量变化剧烈且不需要随机访问 | 链表 | 节点按需分配 |
| 需要从当前节点访问前驱 | 双向链表 | 有 prev 指针 |
| 需要循环报数或轮转 | 循环链表 | 末尾自然回到开头 |
next 指针组织顺序,遍历、插入和删除的关键是维护当前位置及其前驱。before 指向待删除节点的前驱,删除 before.next。