1. 从一道课后题说起:顺序表删除到底考什么
“从顺序表 list 中删除第 i 个元素”这个题目,乍一看特别简单,就是教科书里那种“算法2-3”的级别。但如果你真的在代码里写过几年的增删改查,回头再看这道题,会发现它远不止“把元素往前挪一位”这么单薄。它背后牵涉到顺序表这个数据结构的本质、参数合法性的边界处理、时间复杂度的计算方式,以及在实际工程中 List 接口和数组实现之间的微妙关系。
这个题目通常出现在《数据结构》课程的线性表章节,是顺序表(Sequence List)操作的三大基本方法之一:插入、删除、查找。删除第 i 个元素,教科书标准描述是:如果表长为 n,删除第 i 个位置上的元素,需要把第 i+1 到第 n 个元素依次向前移动一个位置,表长减一。就这么几句话,但里面藏着至少三个关键决策:怎么判断 i 是否合法、移动元素是从前向后还是从后向前、删除后是否要释放空间。这篇博文就围绕这三个问题展开,同时把 C 语言、Java 的 ArrayList、Python 的 list 串起来对比,帮助你把这块知识彻底吃透。
适合谁看?正在学数据结构的学生、准备算法面试的开发者、以及那些写了几年业务代码但对底层集合实现始终有点模糊的人。如果你只需要“会用 ArrayList.remove(i)”那当然不用看这些,但如果你想搞明白“为什么有时候 remove 性能很差”“为什么数组删除要从后往前删”“为什么明明删了元素内存却没变小”,这篇值得你花十分钟读完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表的内存布局与删除操作的设计逻辑
2.1 连续存储带来的“移动代价”
顺序表的底层是一块连续的内存空间,数组就是最典型的实现。它的特点是:逻辑上相邻的元素,物理地址也相邻。这意味着你可以通过下标直接计算地址,O(1) 时间就能访问任意元素,这是顺序表最大的优势。
但优势的另一面就是代价。因为物理连续,所以当你删除中间某个元素时,后面的元素必须整体向前挪动,才能维持“逻辑相邻、物理也相邻”的特性。如果允许中间空一个位置,那就不再是顺序表,而是一种稀疏结构,代价是查找时无法用下标直接定位。
我常跟人打一个比方:顺序表就像一列春运硬座车厢,每个人都必须紧挨着坐。如果第 5 号座位的乘客下车了,后面所有人都得往前挪一个座位,列车员才能保证没有空位。链表则是每个人手里拿着下一站的地址纸条,有人下车只需要改一下前一个人的纸条就行,但找人得从头一个个问过去。
回到删除算法本身,第 i 个元素被删掉后,从第 i+1 个到最后一个元素都得搬家。平均下来,删除一个元素要移动 (n-1)/2 个元素,时间复杂度是 O(n)。这就是顺序表删除操作“贵”的根源,也是面试官最喜欢追问的点。
2.2 教科书算法的核心步骤拆解
算法的标准描述是:
- 判断删除位置 i 是否合法(1 ≤ i ≤ L.length)。
- 取出被删除元素 e = L.data[i-1]。
- 从 i 到 L.length-1 的下标位置,将后一个元素前移一位。
- 表长减一。
这里有几个细节需要特别强调。
第一个细节是下标换算。教材里说的“第 i 个元素”,在 C 语言数组中对应的下标是 i-1,因为数组从 0 开始。很多初学者在这里绕晕,写出来代码越界或者删错了人。
第二个细节是移动的起点和终点。起点是被删除元素的后一个位置,终点是最后一个元素。循环写成 for (j = i; j < L.length - 1; j++) { L.data[j-1] = L.data[j]; } 是一种常见写法,也可以用 for (j = i-1; j < L.length - 1; j++) { L.data[j] = L.data[j+1]; },两种写法结果一样,核心就是“从前往后覆盖”。
第三个细节是删除最后一个元素时,循环体一次都不执行,只是表长减一。这个边界情况很多人会忽略,但它往往能帮你快速验证算法的边界处理是否健壮。
2.3 为什么“先判断合法性”是删除的第一道防线
在写删除代码时,第一步不是移动元素,而是检查参数。这个检查看起来像是防御性编程的惯性动作,但它在数据结构层面是有明确意义的。
顺序表的长度为 length,容量为 capacity,合法的删除位置是 1 ≤ i ≤ length。如果 i 超出这个范围,有两种可能:要么是调用方传错了参数(比如把 0 当成第一个位置),要么是表本身就是空的。无论是哪种情况,直接访问 data[i-1] 都会造成数组越界,轻则读到脏数据,重则直接段错误。
我在实际开发中见过不少因为删除参数越界导致的线上事故。比如某个服务从列表里删除配置项,传入的 index 是前端传来的,前端状态没同步好,传了一个已经超出长度的下标,后端没做校验,直接操作数组越界,最后把内存里的其他数据破坏了。这个 bug 排查了很久,根因就是算法 2-3 里那句“判断 i 是否合法”没被执行。
所以,这不仅是教科书上的固定流程,更是一条在真实工程中屡试不爽的安全底线。无论你用的是什么语言,无论你面对的是数组还是 ArrayList,删除之前先判断参数范围,永远不亏。
3. 完整代码实现与多语言对比
3.1 C 语言版:教科书的标准答案
C 语言版本的顺序表通常自己定义结构体,data 数组 + length 字段。删除操作的实现如下:
c复制#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SeqList;
int ListDelete(SeqList *L, int i, int *e) {
// 1. 合法性校验
if (L->length == 0) {
return 0; // 表为空,无法删除
}
if (i < 1 || i > L->length) {
return 0; // i 超出范围
}
// 2. 取出被删除的元素
*e = L->data[i-1];
// 3. 从第 i 个位置开始,后面的元素依次前移
for (int j = i; j < L->length; j++) {
L->data[j-1] = L->data[j];
}
// 4. 表长减一
L->length--;
return 1; // 删除成功
}
这个版本是教材里最典型的写法,用返回值表示删除是否成功,用指针参数 e 带回被删除的元素值。有个细节值得注意:删除后 data[length] 的位置还残留着旧值,但已经没有意义了,因为 length 已经减一,下次插入时这个位置会被直接覆盖。这也是顺序表的一个特点,删除并不真正清空数据,只是逻辑上不再访问它。
如果你在写嵌入式或者系统级代码,这种 C 语言版本是标配。但要注意,实际工程中很少用固定大小的数组来定义顺序表,更多用动态扩容的方式,这就要引入容量 capacity 的概念,当 length == capacity 时插入需要扩大内存。
3.2 Java 版:ArrayList 的 remove 方法是怎么实现的
Java 开发者天天在用 ArrayList.remove(int index),但很多人没读过它的源码。其实 ArrayList 的本质就是动态数组,也就是一个自动扩容的顺序表。它的 remove 方法内部逻辑和算法 2-3 一模一样,核心代码是这样的:
java复制public E remove(int index) {
rangeCheck(index); // 范围检查,越界会抛 IndexOutOfBoundsException
modCount++;
E oldValue = elementData(index); // 取出被删元素
int numMoved = size - index - 1;
if (numMoved > 0) {
// 关键:底层调用 System.arraycopy 做批量前移
System.arraycopy(elementData, index+1, elementData, index, numMoved);
}
elementData[--size] = null; // 清空最后一个引用,帮助 GC
return oldValue;
}
看到了吗?核心逻辑就是:算需要移动的元素个数,然后把后面的元素批量复制到前面。和 C 语言版本唯一的不同是,它用了 System.arraycopy 这个 native 方法做批量移动,性能比手动 for 循环高不少,还有就是 elementData[--size] = null 这一步,把最后一个位置置空,让 GC 可以回收被删对象。
这个细节值得好好品味。C 语言的数组存的是 int,删了就删了,没有引用问题。但 Java 的数组存的是对象引用,如果 size 减一后不把最后一个引用置空,这个对象就一直被数组引用着,GC 永远回收不了。这就是顺序表删除在“有垃圾回收的语言”里特有的注意点。
另外,Java 的 ArrayList.remove(Object o) 重载是另一种操作,它接收的是要删除的元素值而不是位置,内部会先遍历找到这个元素的下标,然后再调用 remove(index)。这个方法的复杂度是 O(n) 查找 + O(n) 移动,整体还是 O(n)。
3.3 Python 版:del list[i] 和 pop(i) 的底层逻辑
Python 的 list 虽然是动态数组,但语法层面提供了非常优雅的删除方式:pop(i) 和 del list[i]。它们的核心逻辑和算法 2-3 完全一致,只是 pop(i) 会返回被删除的值,而 del 不返回。
python复制# pop(i) 的本质
def pop(self, index=-1):
if index < 0:
index += len(self) # Python 支持负数索引
if index < 0 or index >= len(self):
raise IndexError("pop index out of range")
result = self[index]
# 后面元素整体前移
self[index:len(self)-1] = self[index+1:len(self)]
# 长度减一(数组尾部收缩)
self.resize(len(self)-1)
return result
Python 的优势在于切片操作让代码看起来很简洁,但底层依然是“把后半段复制到前半段”。这个操作在 CPython 的实现里也是 memmove 级别的批量内存操作,本质上没跳出顺序表删除的框架。
还有一个经常被忽视的点:pop() 不带参数时删除的是最后一个元素,这时不需要移动任何元素,时间复杂度是 O(1)。这也是为什么 Python 内置的 list 非常适合模拟栈结构的原因——尾部操作是 O(1) 的。
4. 高频踩坑与排查技巧
4.1 边界条件踩坑清单
顺序表删除的边界条件是面试和上机考试最爱出题的地方。我把常见的坑整理成一张表:
| 场景 | 正确行为 | 易犯错误 |
|---|---|---|
| 空表删除 | 返回失败/抛异常 | 直接访问 data[0],越界 |
| i = 0 | 如果接口约定从 0 开始,删除第一个元素 | 和“第 i 个元素”混淆,差一错误 |
| i = length | 删除最后一个元素,无需移动 | 误以为越界,或循环写错导致越界 |
| i = length+1 | 越界,返回失败 | 没有校验就访问 data[length],越界 |
| 删除后原最后一个位置 | 逻辑上不可访问,长度减一 | 误以为数据被清空了 |
我见过太多人在 i = length 这个边界上纠结。实际上如果删除的是最后一个元素,移动循环体一次都不需要执行,直接 length-- 完事。如果你写出 for (j = i; j < length; j++),当 i == length 时循环条件不成立,自动跳过,这实际上是正确的。
4.2 从后往前删除的陷阱
有一个场景特别容易踩坑:删除顺序表中所有满足某个条件的元素。很多人的第一反应是正序遍历,遇到就删:
c复制for (i = 0; i < L.length; i++) {
if (条件成立) {
ListDelete(&L, i+1, &e); // 删除后长度变了
// 问题:删了当前元素,i 还继续增加,会跳过下一个元素
}
}
这个写法有 bug。删除一个元素后,后面的所有元素都前移了一位,但你还在继续往后遍历,结果就是跳过了原本紧跟在被删元素后面的那个元素。
正确的做法有两种。第一种是每次删除以后 i-- 回退一次,抵消前移造成的下标偏移;第二种是改成从后往前遍历:
c复制for (i = L.length - 1; i >= 0; i--) {
if (条件成立) {
ListDelete(&L, i+1, &e);
}
}
从后往前删的好处是,删除后元素前移只影响后面的部分,而你已经遍历过后面了,不会再回去,所以不会漏。这个反向遍历的技巧在很多算法题里都能用上,比如删除有序数组中的重复元素、清空联系人列表等场景。
4.3 为什么 ArrayList.remove(i) 在循环里慢到令人抓狂
如果你在 Java 里用 for (int i = 0; i < list.size(); i++) { if (...) list.remove(i); } 这种写法,不仅可能跳元素,性能也是灾难级的。每次 remove 都是 O(n) 的数组移动,n 次删除就是 O(n²),在列表很大的时候直接卡死。
这个问题的工程解法是使用迭代器:
java复制Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
Integer val = it.next();
if (val 满足条件) {
it.remove(); // 迭代器提供的安全删除方法
}
}
迭代器的 remove 方法会维护一个“上一次访问的位置”的内部状态,删除后自动调整游标位置,不会跳过元素。但注意,它的底层依然是数组移动,只是解决了遍历逻辑的问题。如果你想更快,干脆倒序 remove,或者先收集要删除的下标,最后统一倒序删除。
再进一步,用 Java 8 的 removeIf 是最优雅的方案:
java复制list.removeIf(val -> val 满足条件);
它在 JDK 内部做了一次高效的批量删除,把所有要保留的元素压缩到前面,然后一次性截断尾部,整体时间复杂度是 O(n) 而不是 O(n²)。这个方法的底层思想实际上是对顺序表删除的一种优化变种:先标记,再统一前移。
4.4 i 的语义差异:1-based 和 0-based 引发的血案
这是顺序表删除中最典型的“差一错误”(off-by-one error)来源。教科书的伪代码通常用“第 i 个元素”,i 从 1 开始计数;但所有编程语言里数组下标都是从 0 开始。所以当你把教材上的算法翻译成代码时,必须记住一次 i-1 的换算。
很多上机考试专门在这里挖坑:给的输入是 1-based 的位置,你直接当下标用了,结果删错了人。或者在写测试用例时用小数据测试通过了,一旦数据量变多,边界处就开始出问题。
我的建议是:在写任何删除函数的接口注释中,明确标注参数 i 的语义是从 1 开始还是从 0 开始。如果是从 1 开始,内部第一件事就做 i-- 换算。宁可让内部多一步换算,也不要让外部调用方去记“下标要不要减一”这种容易出错的事。
5. 复杂度分析:删除一个元素到底有多贵
5.1 时间复杂度的严密推导
删除第 i 个元素时,需要移动的元素个数是 n-i 个(从第 i+1 个到第 n 个,一共 n-i 个)。最好情况是删除最后一个元素,移动次数为 0,时间复杂度 O(1);最坏情况是删除第一个元素,移动次数为 n-1,时间复杂度 O(n)。
在等概率假设下,删除任意位置的概率都是 1/n,那么平均移动次数是:
code复制(1/n) * Σ(i=1 到 n) (n-i)
= (1/n) * (n-1 + n-2 + ... + 0)
= (1/n) * (n-1)n/2
= (n-1)/2
所以平均时间复杂度是 O(n)。这个推导过程要熟练,面试官经常会让你现场推一遍。
5.2 空间复杂度:O(1) 但有时候不是
顺序表删除操作本身只用了常数个辅助变量(j 和 e),所以空间复杂度是 O(1)。但这里有个细节:对于像 Java ArrayList 这样的动态数组,size 减一后容量并没有变。
也就是说,你 remove 掉 100 万个元素,ArrayList 内部数组的长度还是原来的容量,内存并没有被释放。这个现象在生产环境中经常造成“内存看起来没降下来”的困惑。解决办法是调用 trimToSize() 方法把容量收缩到当前 size,或者新建一个 ArrayList 把元素 copy 过去。
C 语言版本如果用的是 malloc 动态分配的空间,删除元素后通常也不会调用 realloc 缩容,因为缩容本身也是 O(n) 的操作,频繁缩容反而效率更差。业界常见做法是“缩容阈值设为容量的 25%”,也就是当 size 降到 capacity 的四分之一以下时,才把容量减半,这样摊销下来成本可控。
6. 从算法 2-3 到工程实践:List 接口的删除语义差异
6.1 不同语言对“删除第 i 个元素”的 API 设计差异
顺序表删除在不同语言里有不同的 API 设计,但内核逻辑一致。我整理了一个对比表:
| 语言 | API | 越界表现 | 返回被删元素 | 对应复杂度 |
|---|---|---|---|---|
| C(手写) | ListDelete(&L, i, &e) | 返回错误码 | 通过指针带回 | O(n) |
| C++(vector) | erase(iterator) | 未定义行为 | 返回下一个迭代器 | O(n) |
| Java(ArrayList) | remove(int index) | 抛 IndexOutOfBoundsException | 返回被删元素 | O(n) |
| Python(list) | pop(index) | 抛 IndexError | 返回被删元素 | O(n) |
| JavaScript(Array) | splice(index, 1) | 不报错,返回空数组 | 返回被删元素数组 | O(n) |
注意到没有?JavaScript 的 splice 在 index 越界时不抛异常,而是什么也不做。这种设计差异会直接影响代码的健壮性,跨语言开发时特别容易踩坑。
还有一点,C++ 的 vector::erase 接收的是迭代器而不是下标,这是一种更泛化的设计,表面上看是“删除指定位置的元素”,内部同样是数组元素的前移。和 vector 相比,Java 的 ArrayList 和 Python 的 list 在 API 设计上更接近下标直接操作。
6.2 顺序表删除的几个经典变体
除了最简单的删除第 i 个元素,还有几个高频变体,面试题里很常见:
变体一:删除有序顺序表中所有值重复的元素。 要求时间复杂度 O(n),空间复杂度 O(1)。思路是用双指针:一个指针 i 指向新表的尾部,另一个指针 j 遍历原表,发现新元素就放到 i+1 的位置。这本质上是“移动代替删除”的思路。
c复制int DeleteDuplicates(SeqList *L) {
if (L->length <= 1) return 0;
int i = 0; // 新表的最后一个位置
for (int j = 1; j < L->length; j++) {
if (L->data[i] != L->data[j]) {
i++;
L->data[i] = L->data[j];
}
}
L->length = i + 1;
return 1;
}
变体二:删除顺序表中所有值为 x 的元素。 同样是双指针,覆盖写。这比每次删除都移动元素要高效得多,因为每个元素最多被移动一次,整体 O(n)。
变体三:反转顺序表。 本质上也是靠交换元素实现的,但也可以用“头尾同时向中间移动”的方式,和删除没有直接关系,但经常被放在一起考。
这些变体有一个共同的内核:顺序表的删除并不一定非要“立即移动所有后面的元素”。在某些场景下,先把后续元素按需覆盖到前面,最后再统一修改 length,整体的效率会高很多。这就是为什么你需要理解算法 2-3 的原理,而不仅仅是会调 API。
6.3 如何用“标记+压缩”优化批量删除
在 Java 的 removeIf 源码里,JDK 团队就是用了上面说的“标记+压缩”的思路,而不是对每个满足条件的元素单独做一次删除。这个思路值得单独说透。
假设你要从一个包含 100 万个元素的 ArrayList 中删除 80 万个元素。如果逐个删除,最坏情况下移动次数是 n+(n-1)+(n-2)+... 大概是千万级别的操作;如果用双指针覆盖写,只需要遍历一次数组,每个元素最多移动一次,操作次数是百万级别。这个差距在数据量大的时候非常明显。
具体做法是:两个指针 i 和 j 从 0 开始同步前进,j 负责扫描原数组,i 指向新表的写入位置。如果 data[j] 不需要删除,就写入 data[i],i 加一;如果需要删除,就跳过。最后新的 length 就是 i 的值。
java复制int i = 0;
for (int j = 0; j < size; j++) {
if (!shouldRemove(data[j])) {
data[i++] = data[j];
}
}
size = i;
这种写法的好处是每个元素至多被复制一次,真正把 O(kn) 变成了 O(n)。很多公司面试题“删除数组中的指定元素”考的就是这个思路。掌握了它,算法 2-3 的单次删除只是批量删除的一个特例而已。
7. 个人实操体会与最后的建议
做这么多年开发,我最大的体会是:像“从顺序表中删除第 i 个元素”这种基础算法,看着简单,其实是最容易暴露基本功的地方。很多人写业务代码的时候各种框架用得飞起,一让他手写一个 list 的删除函数,就漏洞百出。根源在于大家都习惯了调用现成的 API,对底层的数据搬移缺乏直觉。
我建议每个学数据结构的人,都亲手用 C 语言实现一遍顺序表的插入、删除、查找,并且把自己的实现和 Java 的 ArrayList 源码、Python 的 list 实现去对照。你会发现,不管是多高级的语言,底层都逃不出这几个基本操作。这种直觉一旦建立起来,再看那些所谓的高性能组件、缓存淘汰算法、数据库页管理,都会觉得亲切很多。
最后分享一个小技巧:当你写任何和“下标删除”有关的代码时,先把三件事写清楚——参数 i 是从 0 开始还是从 1 开始、删除后元素向前移动的方向是从前向后还是从后向前、删除后是否需要处理尾部的脏数据。把这三个问题想明白,这个函数基本就不会写错。我在实际项目里就是用这个方法,把很多同事容易写错的边界处理问题在代码评审阶段就拦截了下来。
