顺序表删除这种基础算法,难度不高,但是细节能把人逼疯。我印象很深,当年在某个刷题社区里看到有人发帖,说一道“从顺序表list中删除第i个元素”的题,代码逻辑写来写去就是不对,要么删错位置,要么末尾元素残留。我点进去一看,问题就出在“下标”和“位序”的换算上,顺手帮他改了两行,提交直接通过。后来我辅导过不少学数据结构的同学,发现这几乎是共性问题。
今天我就借“算法2-3”这个经典题目,把顺序表删除操作从头到尾拆一遍。包括底层原理、参数合法性判断、C语言实现、Java和Python的工程对照,以及我实际踩过的坑。不管你是本科在读、考研复习,还是准备面试刷题,这篇都能让你对顺序表的删除操作有一个完整的、可落地的认识。
1. 为什么删除一个元素要“惊动”后面所有数据
1.1 顺序表的连续内存特性
顺序表(Sequence List)本质上就是一块连续的内存空间,数组是它的存储载体。你看 Java 的 ArrayList、C++ 的 vector、Python 的 list,底层都是这个套路:预先分配一块连续空间,元素按顺序码在里面,逻辑上相邻的元素,物理地址上也相邻。
这个特性带来一个直接后果:删除中间某个元素时,你不能只把那个位置“挖空”就完事。存储结构要求数据必须是连续排列的,如果中间留了个空洞,后面的元素就没有办法通过“首地址 + 偏移量”的方式直接计算出自己的地址。于是,唯一的办法就是把被删元素后面的所有元素整体往前移一位,把洞填上。
这一点看起来好像很机械,但理解它才是理解顺序表一切操作的基础。你问为什么顺序表删除不是 O(1),答案就在这——因为绝大多数情况下需要搬动数据。
1.2 删除的本质:把“洞”填上
假设顺序表长度是 length,里面存了 5 个元素:
code复制下标: 0 1 2 3 4
数据: [10] [20] [30] [40] [50]
现在要删除第 3 个元素,也就是数据 30,它在数组里的下标是 2。删除后,期望的结果是:
code复制下标: 0 1 2 3
数据: [10] [20] [40] [50]
怎么从第一个状态变成第二个状态?核心操作就是从下标 3 开始,把每个元素往前挪一位:
arr[2] = arr[3],把 40 挪到原来 30 的位置arr[3] = arr[4],把 50 挪到原来 40 的位置
最后把线性表的长度减 1,长度从 5 变成 4。注意,虽然数组底层 arr[4] 的位置上可能还存在一份残留的 50,但从逻辑上讲,这个元素已经不属于顺序表了,因为你通过 length 能访问到的数据只到下标 3 为止。
把删除看成一个“填洞”的过程,理解起来就轻松了很多:删除的核心不是抹掉目标元素,而是把后面所有的元素整体搬上来,让存储结构重新恢复连续。
1.3 删最后一个元素时发生了什么
这是很多初学者容易忽略的场景:如果删除的是最后一个元素,还需要搬移数据吗?
不需要。最后一个元素后面没有元素了,所以你只需要让 length 减 1,目标位置的旧数据就直接从逻辑上“消失”了。换句话说,末尾删除的时间复杂度是 O(1),而中间删除的最坏时间复杂度是 O(n)。
很多教材和网上的代码,不管删除哪个位置,都统一走同一套循环搬移,这是没问题的——因为当 i == length(删除最后一个)时,循环体一次都不会执行,搬移次数为 0。但如果你在写代码之前能先想清楚“删最后一个根本不用搬”,你写出来的循环边界会更稳。
我建议你在读代码的时候带着这个问题去读:如果执行删除最后一个元素,这个循环会不会越界?会不会一次都不执行?能回答上来,说明边界条件你心里有数了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “第 i 个元素”的坐标陷阱:从 1 开始还是从 0 开始
2.1 数学位序和物理下标的换算
普通人对“第几个”的理解是从 1 开始的,第一、第二、第三。但在 C 语言数组里,地址偏移是从 0 开始的,第一个元素的下标是 0。算法题里说的“删除第 i 个元素”,这里的 i 通常是逻辑位序,从 1 计数;而你在代码里真正访问数组时,则必须转换成数组下标。
所以,第 i 个元素的存储下标是 i-1,这是写删除函数时最需要绷紧的一根弦。
举个例子,一个顺序表存了 [10, 20, 30, 40, 50],题目要求删除第 3 个元素。如果你在数组下标上直接写 list[3],那你会发现你删掉的是 40,而不是 30。我和不少同学复盘过,很多人写错都挂在这里——题目里说的是位序,代码里却把它当成了下标。
2.2 参数合法性校验到底校验什么
删除函数的参数通常是两个:一个是顺序表本身的引用,一个是位序 i。在动手搬移数据之前,先要问自己一个问题:这个 i 合不合法?
一个合法的删除条件是:
code复制1 <= i <= L.length
这里包含两种非法情况:
i < 1,比如 i = 0 或负数。如果允许 i = 0,那你要删除的到底是谁?第 0 个元素在现实语义里是不存在的。i > L.length,比如表里有 5 个元素,你要求删除第 6 个,根本没那么多元素可删。
我见过有些代码写的是:
c复制if (i < 1 || i > L.length) {
return ERROR;
}
这段判断本身没问题,覆盖了 i 太小和 i 太大两种情况。但你有没有想过,如果顺序表是一个空表,L.length == 0,这时候 i 等于 1,能不能删?
不能删。因为 i > L.length 此时是 1 > 0,成立,会被拦下来。所以空表的情况下这个判断依然能正确处理,你不需要单独再写一个 if (L.length == 0)。这一点是不少网课或习题答案里没有专门提的,但它恰恰是你在写边界测试时应该覆盖的点。
2.3 返回被删元素:删除函数不只是一个“动作”
在教材风格的算法里,删除函数往往不只是把元素抹掉,还要把被删的元素值带出来。例如经典描述是:“删除顺序表 L 中第 i 个元素,并用 e 返回被删元素的值。”这有什么实际意义?
意义大了。你在一个用户管理系统里要把某个用户移除,移除动作发生之后,审计日志需要记录这个被删用户是谁;在撤销操作实现里,你也要留下被删的数据,才能支持恢复。所以删除操作通常不是一个“没有返回值”的 void 方法,而是一个“带回执”的操作。
C 语言里带出返回值有两种方式:
- 使用指针参数:
int ListDelete(SqList *L, int i, ElemType *e),通过*e = L->data[i-1]带出被删元素 - 使用引用参数(C++):
bool ListDelete(SqList &L, int i, ElemType &e)
不管哪种,共同点就是:表结构本身需要被修改,所以要么传地址,要么传引用。如果这里偷懒,传成了值拷贝,那你在函数里把 length 减 1,回到调用方后 length 还是原来的值,等于白删了。这属于 C 语言初学者最常见的“为什么我的链表/顺序表操作没反应”的原因之一。
3. 手把手写出可直接运行的删除代码
3.1 C 语言完整实现:教材风格的落地版
下面我直接用 C 语言把顺序表删除操作完整实现一遍,包括结构体定义、初始化、显示、删除和测试。你不光要看删除函数,还要连起来跑一遍,确认 main 函数里的输出是否符合预期。
c复制#include <stdio.h>
#include <stdlib.h>
#define MAXSIZE 100
#define OK 1
#define ERROR 0
typedef int Status; // 函数状态
typedef int ElemType; // 元素类型
typedef struct {
ElemType data[MAXSIZE];
int length;
} SqList;
// 初始化顺序表
void InitList(SqList *L) {
L->length = 0;
}
// 打印顺序表
void ShowList(SqList L) {
printf("当前顺序表: ");
for (int i = 0; i < L.length; i++) {
printf("%d ", L.data[i]);
}
printf("\n");
}
// 在第 i 个位置插入一个元素(辅助构造测试数据)
Status ListInsert(SqList *L, int i, ElemType e) {
if (i < 1 || i > L->length + 1 || L->length == MAXSIZE) {
return ERROR;
}
for (int j = L->length; j >= i; j--) {
L->data[j] = L->data[j - 1];
}
L->data[i - 1] = e;
L->length++;
return OK;
}
// 删除第 i 个元素,并用 e 返回被删元素的值
Status ListDelete(SqList *L, int i, ElemType *e) {
if (L->length == 0) {
printf("表为空,无法删除\n");
return ERROR;
}
if (i < 1 || i > L->length) {
printf("删除位置不合法\n");
return ERROR;
}
*e = L->data[i - 1]; // 先取出被删元素
// 把第 i 个元素之后的每个元素往前移一位
for (int j = i; j <= L->length - 1; j++) {
L->data[j - 1] = L->data[j];
}
L->length--; // 表长减 1
return OK;
}
int main() {
SqList L;
InitList(&L);
for (int i = 1; i <= 5; i++) {
ListInsert(&L, i, i * 10); // 插入 10 20 30 40 50
}
ShowList(L);
ElemType e;
Status flag = ListDelete(&L, 3, &e); // 删除第 3 个元素(30)
if (flag == OK) {
printf("删除成功,被删元素 = %d\n", e);
ShowList(L);
}
flag = ListDelete(&L, 1, &e); // 删除第 1 个元素
if (flag == OK) {
printf("删除头元素成功,被删元素 = %d\n", e);
ShowList(L);
}
flag = ListDelete(&L, 4, &e); // 此时长度只有 3,删除第 4 个应失败
if (flag == ERROR) {
printf("越界删除被拦截\n");
}
return 0;
}
运行结果如下:
code复制当前顺序表: 10 20 30 40 50
删除成功,被删元素 = 30
当前顺序表: 10 20 40 50
删除头元素成功,被删元素 = 10
当前顺序表: 20 40 50
删除位置不合法
越界删除被拦截
稍微解释下删除函数的两个关键动作:
*e = L->data[i - 1];这行必须在搬移数据之前执行。如果先搬移,目标位置的原始数据已经被覆盖了,你再去取 e,取到的就是后面那个元素的值。- 搬移循环从
j = i开始,把data[i]赋给data[i-1]吗?不是。这里i是位序,所以data[i]实际上是第 i+1 个元素。为了避免混淆,你可以换一种更符合数组思维的写法:
c复制// 更直观的数组下标版
int pos = i - 1; // 被删元素的下标
*e = L->data[pos];
for (int j = pos; j < L->length - 1; j++) {
L->data[j] = L->data[j + 1];
}
这种写法在后期维护时更不容易出错。因为一旦你定义了 pos = i - 1,循环边界就完全是数组下标的大小比较,不再需要反复心算“i 和 length 之间到底差多少”。如果你的代码要交给别人 review,这种写法也更友好。
3.2 Java 版本:手写数组扩容版思路
Java 初学者用 ArrayList 习惯了,可能感受不到底层发生了什么。但理解手写版本能帮你读懂 ArrayList 的源码。下面我用数组模拟一个固定容量的顺序表删除:
java复制public class MySeqList {
private int[] data;
private int length;
public MySeqList(int capacity) {
data = new int[capacity];
length = 0;
}
public void add(int value) {
if (length == data.length) {
// 实际 ArrayList 会扩容,这里简化为报错
throw new RuntimeException("容量已满");
}
data[length++] = value;
}
/**
* 删除第 i 个元素,i 从 1 开始
*/
public int delete(int i) {
if (length == 0) {
throw new RuntimeException("表为空");
}
if (i < 1 || i > length) {
throw new IndexOutOfBoundsException("删除位置越界: " + i);
}
int targetIndex = i - 1;
int deletedValue = data[targetIndex];
// 前移后续元素
for (int j = targetIndex; j < length - 1; j++) {
data[j] = data[j + 1];
}
length--;
// 把最后一个位置置零,避免内存泄漏的隐患
data[length] = 0;
return deletedValue;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(data[i]).append(" ");
}
return sb.toString();
}
public static void main(String[] args) {
MySeqList list = new MySeqList(10);
list.add(10);
list.add(20);
list.add(30);
list.add(40);
list.add(50);
System.out.println("删除前: " + list);
int removed = list.delete(3);
System.out.println("被删元素: " + removed);
System.out.println("删除后: " + list);
}
}
注意我在删除完成后多加了一行 data[length] = 0;。为什么要这么干?因为数组里存的是引用类型时,如果不把最后一个槽位置为 null,那个对象虽然逻辑上已经不属于这个顺序表了,但数组仍然持有它的引用,垃圾回收器无法回收,就会造成内存泄漏。虽然这个例子里用的是 int,不涉及这个问题,但我在教项目成员写类似代码时,一定会要求他们关注这个细节。这对面试时展示你的工程素养也很有帮助。
3.3 Python 版本:语言替你封装的删除
Python 里实现顺序表删除最直观的方式就是:
python复制data = [10, 20, 30, 40, 50]
# 删除第 3 个元素,下标为 2
data.pop(2)
但是如果你自己维护一个顺序表类,可能会这样实现:
python复制class SeqList:
def __init__(self, capacity=10):
self.data = [None] * capacity
self.length = 0
def add(self, value):
if self.length == len(self.data):
raise RuntimeError("容量已满")
self.data[self.length] = value
self.length += 1
def delete(self, i):
"""删除第 i 个元素,i 从 1 开始"""
if self.length == 0:
raise ValueError("表为空,无法删除")
if i < 1 or i > self.length:
raise IndexError(f"删除位置不合法: {i}")
pos = i - 1
deleted_value = self.data[pos]
for j in range(pos, self.length - 1):
self.data[j] = self.data[j + 1]
self.length -= 1
self.data[self.length] = None
return deleted_value
Python 原生的 list 其实是一种动态数组,pop 方法内部已经帮你处理了元素搬移和容量收缩,但在学习数据结构时,你依然需要亲手模拟一遍这个过程。不然你会把“语言兜底”误当成“算法本身很简单”,等到面试官让你手写数组删除时,边界条件就容易写崩。
4. 时间复杂度与数据搬移:这笔账必须算明白
4.1 平均移动次数怎么推算
删除第 i 个元素时,需要将第 i+1 到第 n 个元素全部前移一位,移动次数是 n - i。这里面有三个特殊场景:
- 删除表头元素(i = 1):移动次数为
n - 1 - 删除表尾元素(i = n):移动次数为 0
- 删除中间元素:移动次数介于二者之间
如果每个位置被删除的概率相等,都是 1/n,那么平均移动次数为:
code复制(1/n) * Σ(n - i),其中 i 从 1 到 n
= (1/n) * [0 + 1 + 2 + ... + (n-1)]
= (1/n) * [n(n-1)/2]
= (n-1)/2
结论是删除一个元素的平均时间复杂度是 O(n)。这个数字你应该熟,因为顺序表插入操作的平均移动次数是 n/2,删除是 (n-1)/2,两者在同一数量级。这也是为什么在高频插入和删除的场景下,人们会优先考虑链表而不是顺序表。
不过,这里有个反直觉的点值得单独说:虽然顺序表删除在“平均意义”上不如链表,但它在尾部操作时是 O(1),而且数据在内存里是连续的,CPU 缓存的友好度远高于链表。所以现实中的 ArrayList 并没有被 LinkedList 完全取代,Java 官方甚至在注释里建议大多数场景优先使用 ArrayList。算法复杂度只是选型的维度之一,不是全部。
4.2 删除代码的循环边界为什么容易写错
我观察到很多初学者在写删除循环时,会纠结到底写成:
c复制for (int j = i; j < L->length; j++) {
L->data[j - 1] = L->data[j];
}
还是写成:
c复制for (int j = i - 1; j < L->length - 1; j++) {
L->data[j] = L->data[j + 1];
}
这两段代码其实是等价的,但第二段明显更容易理解,因为所有下标都是真实的数组下标,不存在“位序与下标混着用”的换算。
用第二段的方式记忆:循环从被删元素的下一个位置开始跑,跑到最后一个有效元素为止,逐个往前挪。写成伪代码就是:
code复制for (j = 被删元素下标; j < 当前表长 - 1; j++) {
当前位置 = 下一位置;
}
这里的关键是循环终止条件是 j < length - 1。如果你写成了 j < length,最后一次循环会把 data[length](这是越界后的第一个位置,在 C 语言里属于未定义行为)赋给 data[length-1],而在 Java、Python 中会直接抛异常。这个错误非常隐蔽,因为很多 C 编译器不会当场报错,程序看起来还能跑,只是最后多了一个“幽灵元素”或者内存里的脏数据被搬了进来。
4.3 用“插入”反向记忆“删除”的搬移方向
顺序表插入和删除是一对镜像操作,把它们放在一起对比,更容易加深记忆:
| 操作 | 数据移动方向 | 移动数量 | 次数公式 |
|---|---|---|---|
| 在第 i 个位置插入元素 | 从最后一个元素开始向后移 | 第 i 个到第 n 个元素 | n - i + 1 |
| 删除第 i 个元素 | 从第 i+1 个元素开始向前移 | 第 i+1 个到第 n 个元素 | n - i |
插入时从后往前搬,删除时从前往后搬。这个方向千万别搞反。如果插入也从前往后搬,后面的元素会被前面的覆盖掉,数据就丢了。
为什么方向必须是这样的?你看,插入是在某个位置“腾出”一个空间来,你得先把最后一个元素挪到后面新位置,然后倒数第二个元素挪到原来的最后一个位置……依次倒着来。而删除是“填补”一个空洞,前面的空洞需要后面紧邻的元素来填,所以正向遍历即可。
5. 从教材走向工程:真实世界里的“删除”没那么纯粹
5.1 Java ArrayList 的底层是怎么删的
在 Java 的 ArrayList 源码里,remove(int index) 的核心逻辑是这样一段:
java复制int numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(elementData, index + 1,
elementData, index, numMoved);
}
elementData[--size] = null;
看到了吗?它先用 size - index - 1 计算出需要搬移的元素个数,然后调用 System.arraycopy 一次性批处理搬移。最后一步把数组末尾的引用置为 null,跟我前面在 Java 示例中写的 data[length] = 0 是一个道理。
这里面的关键点有两个:
- 它并没有用 for 循环逐个赋值,而是调用本地方法
arraycopy,这是因为 JVM 对数组块拷贝有专门优化,性能更好。 - 它把
--size和null清理放在了最后。先把 size 减 1,再利用减完之后的 size 作为索引把原末尾槽位置空。
你在手写顺序表删除时,可以借鉴的就是这个“先删后清”的顺序。很多同学写完删除后,只记得 length--,忘了清理引用。如果是元素类型是对象,这种代码在长期运行的服务器上就可能累积出内存泄漏。
5.2 逻辑删除与物理删除:很多业务系统选择了另一条路
教材里的删除是“物理删除”,也就是把数据从存储结构里抹掉。但在真实的业务系统里面,很多删除其实是“逻辑删除”。什么意思?
比如一个订单系统里,用户点了“删除订单”,你以为这条记录真的从数据库里消失了吗?大概率没有。它只是把 deleted 字段标记为 1,或者把 status 改为已删除状态。查询时统一带上 deleted = 0 的条件,这些数据就“看不见”了。
为什么业务系统更青睐逻辑删除?原因很简单:
- 物理删除后数据无法找回,一旦误删,恢复成本极高
- 业务上往往有审计需求,需要查看历史曾存在的记录
- 关联数据的存在性约束,比如删掉一个分类,那它下面的商品应该怎么处理?逻辑删除可以先保留,等人工确认
不过逻辑删除也有代价。每个查询都要额外带上删除标记条件,索引设计得更复杂,而且无法直接利用数据库的唯一约束来防止重复数据。要不要用逻辑删除,本质上是一个业务权衡,不是技术上的对错问题。
我之所以在这里提这个,是想把“顺序表删除”这个算法题放回更大的坐标里看:你学习的具体操作是数据搬移和长度调整,但你将来面对的真实系统里,“删除”可能更多是在标记、归档、软引用之间做选择。数据结构教材教你的是最底层的物理动作,它是地基,地基之上会长出各种业务策略。
5.3 删除与并发:单线程模型和真实世界的距离
教材里的顺序表删除通常假设单线程操作,没有人在你删到一半时同时访问这个表。但真实的高并发系统里,这基本不成立。举个例子,一个在线协作文档里,协作者 A 正在删除某个节点,同时协作者 B 正在读这个节点,如果没有锁或者版本号控制,B 拿到的数据可能是搬移执行到一半的中间状态。
Java 的 CopyOnWriteArrayList 就是专门解决这类问题的变体。它的删除思路已经和“物理移动”完全不同了:每次修改都拷贝一份新数组,在新副本上做删除操作,然后整体替换引用。写操作代价很高,但读操作不需要加锁,适合读多写少的场景。
看到这里,你应该能理解为什么面试官喜欢从“顺序表删除”这个小题目一路追问到 ConcurrentModificationException、fail-fast 机制、扩容策略。因为一个看似基础的删除操作,往上延伸可以覆盖整个并发编程和容器设计思想。基础题不是背答案用的,它是你知识体系的锚点。
6. 我在这道题上踩过的坑与验证方法
6.1 最容易被忽略的边界:传参方式不对,删了个寂寞
C 语言写顺序表删除时,最常见的坑就是把结构体直接传值,导致删除操作在函数内部生效,但回到调用方后数据纹丝不动。
c复制// 错误示范:传值,函数内修改不影响外部
Status ListDelete(SqList L, int i, ElemType *e) {
// ...
L.length--;
return OK;
}
// 调用后 L.length 仍然是原值
正确写法前面已经展示了,要么传结构体指针,要么在 C++ 里用引用:
c复制Status ListDelete(SqList *L, int i, ElemType *e)
排查这个问题有个非常简单的方法:在函数末尾打印一下 L->length,然后在调用方也打印一下 L.length。如果函数里变了、调用方没变,基本就是传参方式的问题。
6.2 被删元素的下标边界:i == length 时别越界
假设顺序表长度为 5,删除第 5 个元素(最后一个),此时 i = 5,对应的数组下标为 4。删除后长度为 4。如果循环判断条件没写对,极容易在访问 data[5] 或 data[length] 时越界。
我用一个自检清单来验证循环边界是否正确:
- 删除后表内元素是否全部往前移动了一位?最后两个元素是否相同?
- 被删位置的下一个元素,是否成功出现在了被删位置上?
length--之后,原最后一个位置是否还有残留数据可以被访问到?
针对最后一点,C 语言里你也不一定非要清理,但你必须在逻辑上明确:length 之外的数组元素没有任何意义。我见有些同学写测试代码时,是用 for (int i = 0; i < MAXSIZE; i++) 去打印整个数组,结果发现末尾有个重复数据,就以为删除函数写错了。其实不是函数错了,是你打印方式错了——顺序表的有效范围是 [0, length-1],出了这个范围的内容是未定义的,不能作为判断依据。
6.3 怎么快速验证删除函数的正确性
最好的验证方式就是数据可视化。每次删除后打印整个顺序表,看输出是否和预期一致。举一组测试用例,你可以直接照着测:
| 测试场景 | 操作 | 预期结果 |
|---|---|---|
| 删除中间元素 | 对 [10,20,30,40,50] 删除第 3 个 | [10,20,40,50] |
| 删除第一个元素 | 对 [10,20,30] 删除第 1 个 | [20,30] |
| 删除最后一个元素 | 对 [10,20,30] 删除第 3 个 | [10,20] |
| 只有一个元素时删除 | 对 [10] 删除第 1 个 | [] |
| 删除位置为 0 | 对 [10,20] 删除第 0 个 | 返回错误,表不变 |
| 删除位置大于长度 | 对 [10,20] 删除第 5 个 | 返回错误,表不变 |
能把这六组用例全部跑通,删除函数基本就稳了。我在做代码评审时,也习惯让作者先把这组用例跑通再谈其他。
另外提一个很有用的调试技巧:在删除函数入口和出口分别打印一次顺序表。入口打印确认“进来的数据是不是我预期的”,出口打印确认“删除后的结果是不是我预期的”。如果入口不对,说明调用方传参有问题;如果入口对、出口不对,说明删除函数内部有 bug。这种二分定位法能帮你省下大量调试时间。
6.4 一次真实的线上翻车经历:死循环背后的删除缺陷
最后分享一次我实际遇到的线上事故。有个内部系统用数组维护在线用户列表,用户断开连接时需要删除对应元素。最初的实现是:
java复制for (int i = 0; i < userList.size(); i++) {
if (需要删除) {
userList.remove(i);
}
}
看起来好像没问题,实际上隐藏了一个巨坑:remove(i) 会让后续元素集体前移,索引 i 对应的元素已经变成原来第 i+1 个元素,但循环结束后 i++ 会让它被跳过,漏删一个。更严重的是,如果连续两个元素都需要删除,删除第一个后,第二个被删除元素的索引已经变了,你在原索引上删除到的根本不是你以为的那个对象。
正确做法是倒序遍历:
java复制for (int i = userList.size() - 1; i >= 0; i--) {
if (需要删除) {
userList.remove(i);
}
}
倒序删除时,删除当前元素不会影响前面元素的索引,这样就不会漏掉任何需要删除的元素。另外也可以用迭代器的 remove 方法,它内部维护了预期修改计数,不会触发 ConcurrentModificationException。
这个故事和“顺序表删除第 i 个元素”看起来隔了一层,但核心还是同一个:顺序表删除会导致元素批量移动,索引是动态变化的,你在设计循环逻辑时永远要问自己一句:“我删了这个之后,下一步该看哪个位置?”
这几年的经验告诉我,凡是能随手写对顺序表删除边界的人,写代码的习惯通常都不差。因为边界意识、传参意识、复杂度意识、内存清理意识,全都在这一道小题里了。这也是为什么算法 2-3 这种看起来不起眼的题目,值得被反复咀嚼的原因。
如果你正在复习这本书,我的建议是不要停留在“能看懂代码”的层面,合上书自己默写一遍删除函数,再写十个测试用例去轰它。轰不塌,你才算真正会了。
