从顺序表里“删掉”一个元素,听起来就是 list.remove(i) 一行调完的事,但真要你实现底层逻辑,尤其是笔试手撕代码或者面试被问到“链表和顺序表删除有什么区别”时,很多人当场就卡住了。顺序表删除的关键不在“删”这个动作本身,而在“删完之后数组里的元素怎么处理”,以及“怎么让下标和长度保持正确”。这篇我就把 算法2-3 从顺序表 list 中删除第 i 个元素这件事从原理到实现完整拆开讲一遍,适合正在学数据结构的学生、准备算法面试的开发者,以及想补基础的程序员。
1. 删除先想清楚:顺序表是什么,list 在这里指什么
1.1 顺序表的底层就是一块连续的数组
顺序表(Sequence List / Sequential List)在日常生活中最常见的形态就是数组,或者严格说是“基于数组实现的线性表”。它的特点是:逻辑上相邻的元素,物理内存上也相邻。比如 int a[5] = {10, 20, 30, 40, 50},a[1] 和 a[2] 在内存里就是紧挨着的,中间不需要额外指针去连。
这里必须区分一个概念:题目里说的“list”不一定特指 Java 里的 List 接口,也可能泛指一切顺序存储的线性表。在很多教材和考研题里,list 就是一个顺序表结构体,比如 C 语言里常写的:
c复制#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SqList;
data 存元素,length 记录当前列表中元素个数。删除第 i 个元素,就是在 data 数组里把下标 i-1 位置的那个元素干掉,然后让后面的元素全部往前挪一位,最后 length 减一。这个“往前挪一位”的动作,才是顺序表删除的核心。
1.2 删除的实质是“覆盖 + 长度缩减”,不是从内存里抹掉
很多人会把“删除”理解为把某个内存地址的数据置空或者释放,这是从高级语言角度产生的误解。顺序表删除的实质非常简单:用后一个元素的值覆盖前一个元素的位置,一整条元素链整体前移,最后把逻辑长度减 1。被“删除”的那个位置仍然存着旧值,但由于 length 已经变小,程序不会再访问到它,相当于被逻辑删除。
举个例子:
code复制数组: [10, 20, 30, 40, 50] length = 5
删除第 3 个元素(即 30),过程:
1. 用 40 覆盖 30: [10, 20, 40, 40, 50]
2. 用 50 覆盖原来 40 的位置: [10, 20, 40, 50, 50]
3. length 减 1,变为 4
4. 最终有效元素:[10, 20, 40, 50]
你会发现,数组最后一个位置还残留着 50 的副本,但这不重要。只要 length = 4,遍历时只访问前四个位置,这个残留值就是“不可见”的。就像你删除手机里一张照片,相册 App 不再显示它,但硬盘里的物理数据可能还在,只是索引被移除了。顺序表的 length 就是那个“索引”,这是理解删除算法的第一把钥匙。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 删除第 i 个元素的算法思路,难点全在“边界”和“移动方向”
2.1 合法下标范围:i 从 1 开始还是从 0 开始?
教材里的“第 i 个元素”默认 i 从 1 开始计数,所以第一个元素是第 1 个,对应数组下标 0;最后一个元素是第 length 个,对应数组下标 length - 1。删除第 i 个元素,实际操作的数组下标是 i - 1。
合法性判断必须同时考虑上下界:
c复制// 位置 i 合法条件
if (i < 1 || i > list.length) {
// 不合法,直接返回失败
}
这里最容易犯的错是只判断 i < 1 或者只判断 i > list.length,漏掉另一边。如果你删除第 0 个元素,程序会把下标 -1 当成操作位置,轻则返回错误结果,重则造成越界访问。在实际工程里,ArrayList 之类的高级容器会直接抛 IndexOutOfBoundsException,但你自己实现顺序表时,最好用返回值表明是否删除成功,而不是用异常流程控制业务逻辑。
2.2 元素移动:为什么必须从前往后覆盖?
删除第 i 个元素时,需要把第 i+1 个元素移到第 i 个位置,第 i+2 个移到第 i+1 个位置,依此类推。写成循环就是:
c复制for (int j = i - 1; j < list.length - 1; j++) {
list.data[j] = list.data[j + 1];
}
注意,这里的循环是从前向后移动的,也就是先覆盖被删除位置,再逐步覆盖后面的位置。为什么不能从后往前?你可以试想一下,如果从最后一个元素开始往前移动,比如先把最后的 data[length-1] 赋给 data[length-2],那确实不会丢失元素,但后续每次都要把前面的旧值搬过来,最终效果一样,只是分支条件更别扭。更关键的是,如果设计成从后往前,你极容易在边界处理上把自己绕晕,而且还要额外注意最后一个元素其实是“多余的”,没有必要参与移动。主流教材和标准库实现都采用从前往后覆盖,这个方向是经过验证最简化、最不容易出错的。
移动完成后,别忘了两件事:
- 把
list.length减 1,否则表长没变化,遍历时会多输出一个残留在末尾的旧值。 - 如果顺序表是用动态数组实现的,即使数组容量没变,也不需要在删除后立即缩容。缩容是性能优化话题,不属于删除算法本身的职责。
2.3 Java 的 List.remove(i) 和“算法2-3”有什么区别?
很多同学学到这里会困惑:我明明一直在用 list.remove(i),为什么还要手写一遍?差别在于 ArrayList.remove(i) 内部已经把“校验下标、移动元素、修改size、手动置空”都封装好了。但你理解算法时,必须能自己还原它内部的移动逻辑。
看一下 ArrayList 的 remove(int index) 核心伪代码:
java复制public E remove(int index) {
rangeCheck(index); // 越界检查
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // 手动置空,帮助 GC
return oldValue;
}
这里 numMoved 表示需要移动的元素个数。删除位置越靠前,需要移动的元素越多;删除最后一个元素时 numMoved = 0,不需要移动任何数据,只需要 size 减一。这个细节在面试里经常被追问,可以用来区分一个人是“背过 API”还是“真懂原理”。
所以当你手写“算法2-3”时,本质上就是在用最朴素的方式实现 ArrayList.remove 的数组移动部分。理解了这些,你甚至能自己预见 LinkedList.remove(i) 为什么不用移动数组——因为链表是通过修改指针来删除,但代价是需要从头遍历到第 i 个节点。
3. 完整代码实现:C 语言、Java、Python 三种姿势
3.1 C 语言版本:教科书式实现,严格符合“算法2-3”
C 语言最接近底层,适合用来理解指针和内存操作。我用动态内存分配实现一个顺序表,核心删除函数如下:
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef struct {
int *data; // 指向动态数组
int length; // 当前长度
int capacity; // 容量
} SeqList;
// 初始化顺序表
void initList(SeqList *list, int capacity) {
list->data = (int *)malloc(sizeof(int) * capacity);
list->length = 0;
list->capacity = capacity;
}
// 插入一个元素到末尾,便于测试
bool append(SeqList *list, int value) {
if (list->length >= list->capacity) {
return false;
}
list->data[list->length++] = value;
return true;
}
// 算法2-3:删除第 i 个元素,i 从 1 开始
bool deleteElement(SeqList *list, int i, int *deletedVal) {
if (i < 1 || i > list->length) {
return false;
}
if (deletedVal != NULL) {
*deletedVal = list->data[i - 1];
}
// 从第 i 个元素开始,后面的元素依次前移
for (int j = i - 1; j < list->length - 1; j++) {
list->data[j] = list->data[j + 1];
}
list->length--;
return true;
}
void printList(SeqList *list) {
for (int i = 0; i < list->length; i++) {
printf("%d ", list->data[i]);
}
printf("\n");
}
int main() {
SeqList list;
initList(&list, 10);
for (int value = 10; value <= 50; value += 10) {
append(&list, value);
}
printf("原始列表:");
printList(&list);
int val = 0;
if (deleteElement(&list, 3, &val)) {
printf("删除第 3 个元素:%d\n", val);
}
printf("删除后列表:");
printList(&list);
return 0;
}
运行输出:
code复制原始列表:10 20 30 40 50
删除第 3 个元素:30
删除后列表:10 20 40 50
注意这里我增加了一个 deletedVal 指针参数,用来把被删除的元素值带出来。这不是必须的,但能提高函数的实用性。很多教材只返回 bool 表示成功失败,但实际工程中,你往往需要知道被删掉的是哪个值。如果不需要,调用时传 NULL 即可,C 语言没有重载,这种参数设计是种常见妥协。
3.2 Java 版本:自己实现一个极简顺序表
如果你已经习惯了用 ArrayList,手写一个内部含 remove 逻辑的顺序表会更有体感。我写一个 int 版本的简化容器:
java复制public class SimpleSeqList {
private int[] data;
private int size;
public SimpleSeqList(int capacity) {
data = new int[capacity];
size = 0;
}
public void add(int value) {
data[size++] = value;
}
// 删除第 i 个元素,i 从 1 开始
public boolean remove(int i) {
if (i < 1 || i > size) {
return false;
}
// 从 i-1 位置开始,覆盖式左移
for (int j = i - 1; j < size - 1; j++) {
data[j] = data[j + 1];
}
data[--size] = 0; // 可选,避免对象引用残留在数组中(如果元素是对象则更有意义)
return true;
}
public int get(int index) {
return data[index];
}
public int size() {
return size;
}
public void print() {
for (int i = 0; i < size; i++) {
System.out.print(data[i] + " ");
}
System.out.println();
}
public static void main(String[] args) {
SimpleSeqList list = new SimpleSeqList(10);
for (int value = 10; value <= 50; value += 10) {
list.add(value);
}
list.print();
System.out.println("删除第3个元素: " + list.remove(3));
list.print();
}
}
在 Java 版本里,data[--size] = 0 这一步是我建议加上的。对于 int[] 无所谓,但如果数组中存的是对象,删除后不把最后一个引用置空,会导致这个对象仍然被数组引用,垃圾回收器无法回收它,造成内存泄漏。ArrayList 源码里正是这么干的。
3.3 Python 风格的顺序表删除:细节藏在切片和 del 里
Python 的 list 虽然也是顺序表,但底层是 PyObject 指针数组,删除一个元素直接用 del list[i-1] 或者 list.pop(i-1)。你真要展开,Python 内部的 list_ass_slice 函数同样要做“搬移指针”的操作。所以原理上没有任何区别,只是语言帮你隐藏了循环。如果你非要用 Python 手工模拟:
python复制def delete_at_index(lst, i):
if i < 1 or i > len(lst):
return False
for j in range(i - 1, len(lst) - 1):
lst[j] = lst[j + 1]
lst.pop() # 同时缩减长度并移除尾部残留引用
return True
lst = [10, 20, 30, 40, 50]
print("删除前:", lst)
delete_at_index(lst, 3)
print("删除后:", lst)
输出一样是 [10, 20, 40, 50]。但 Python 写法中有个细节:即使不调用 pop(),只把前面的元素覆盖完,列表长度也不会自动变小。len(lst) 不会因为末尾重复值而减少,所以必须 pop() 或 del lst[-1]。这也从侧面印证了顺序表删除的核心两件事:搬移元素 + 缩减逻辑长度。
4. 复杂度、批量删除和链表对比:顺序表删除的“账”要算清楚
4.1 为什么顺序表删除是 O(n)?最好、最坏、平均都要会算
删除第 i 个元素时,需要移动 length - i 个元素。如果删除最后一个元素(i = length),移动 0 个元素,时间复杂度 O(1),这是最好情况。如果删除第一个元素(i = 1),移动 length - 1 个元素,这是最坏情况 O(n)。平均情况呢?假设每个位置被删除的概率相等,即 1/n,那么期望移动次数为:
code复制E = Σ (n - i) * (1/n) (i从1到n)
= (1/n) * (0 + 1 + 2 + ... + (n-1))
= (n-1)/2
忽略常数,所以平均时间复杂度同样是 O(n)。这个推导过程在面试里很常见,背结论容易,能现场推出来才是真掌握了。
O(n) 到底意味着什么?如果数据量是 1000,删除一个中间元素大约要搬运 500 个元素;如果是 100 万,就要搬运 50 万个。所以顺序表删除不适合频繁在中间位置操作,这是它的结构性短板,不是代码写得不够好。
4.2 在 Java 中连续删除多个元素,千万别一个个 remove
这是一个非常实战的场景。比如你有一个 ArrayList<Integer>,想删除所有值为 2 的元素。新手最容易写:
java复制for (int i = 0; i < list.size(); i++) {
if (list.get(i) == 2) {
list.remove(i);
}
}
这个代码有两个坑:一是 remove 时元素前移,会导致索引错位,漏删;二是即使改成从后往前遍历,每个 remove 仍然是 O(n),总体 O(n²)。而且 ArrayList 删除元素还会数组拷贝。
正确做法有两种:
- 使用迭代器的
remove()方法,内部保持一致性,但仍是一个一个删,O(n²)。 - 把保留元素写回前面,最后一次性截断,也就是“覆盖 + 缩短”的思想,能达到 O(n)。
举个例子,删除所有偶数:
java复制int writeIndex = 0;
for (int readIndex = 0; readIndex < list.size(); readIndex++) {
if (list.get(readIndex) % 2 != 0) {
list.set(writeIndex++, list.get(readIndex));
}
}
list.subList(writeIndex, list.size()).clear();
这本质上就是顺序表删除算法的延展。你在实现过滤器时,用“双指针覆盖”代替“逐个删除”,性能会好很多。这也是我在实际代码 review 中反复强调的一个点。
4.3 顺序表 vs 链表:删除操作不能只看“移动元素数量”
很多人认为链表删除是 O(1),因为只需要修改指针。这个说法有前提:必须是已经知道要删除哪个节点,也就是你已经持有该节点的引用(指针)。但如果只知道“删除第 i 个元素”,链表仍然需要从头开始遍历到第 i 个节点,查找本身是 O(n)。再加上修改指针的 O(1),总体还是 O(n)。因此,从复杂度的角度看,顺序表和链表在“删除第 i 个元素”这件事上都是 O(n),只是造成 O(n) 的原因不同:
- 顺序表:要移动后续所有元素。
- 链表:要遍历到目标节点。
这也是面试中常用来“打脸”背答案者的点。如果继续追问“什么时候链表删除更有优势”,答案是:已知前驱节点,删除某个给定节点。例如双向链表删除当前节点,只需要 prev.next = next; next.prev = prev,那是真正的 O(1)。要是业务里频繁按“值”删除,而且你能在 O(1) 内定位到节点(配合哈希表),那链表的优势才能体现出来。
所以选择顺序表还是链表,不看单个操作,要看整体操作模式:查多改少用顺序表,高频在头部/指定已定位节点插入删除用链表。实际项目里,ArrayList 的使用率远高于 LinkedList,因为绝大多数场景都是“遍历为主、偶尔删除”,顺序表的缓存局部性好,整体更快。
5. 面试和笔试中,顺序表删除的高频考点与防坑指南
5.1 手写代码最典型的 5 个错误
我见过太多候选人在这一题上翻车,下面这些错误几乎每次都能踩到:
| 错误类型 | 错误示例 | 后果 | 正确做法 |
|---|---|---|---|
| 下标范围漏判 | 只判断 i < 1 |
删除不存在的尾部位置造成越界 | 必须同时判断 i > length |
| 循环边界错误 | j < list.length 写成 j <= list.length |
访问 data[length] 越界 |
循环到 length - 1 停止 |
| 忘记减长度 | 移动完元素但没执行 length-- |
末尾残留脏数据,输出结果错误 | 每次删除后必须 length-- |
| 从后往前移动搞混 | 尝试从 length 往前赋值 |
覆盖链条断裂,数值错乱 | 明确从前往后覆盖 |
| 对 i 的语义理解错误 | 把第 i 个元素当成下标 i | 删除错对象 | 数组下标是 i-1,链表第几个节点语义同理 |
这 5 个错误覆盖了 90% 的新手问题。我建议你写完之后,立刻用三个测试用例验证:删除第一个元素、删除最后一个元素、删除非法位置(比如 0 或 length+1)。这三个边界过完,基本能保证实现正确。
5.2 顺序表删除的常见变体题思路
很多面试题、考研题不会直接考“删除第 i 个元素”,而是套了一层壳。但核心还是相同的“覆盖 + 缩短”逻辑。列举几个高频变体:
- 删除有序顺序表中所有值等于 x 的元素:用双指针,一个慢指针
slow表示下一个可写入位置,一个快指针fast扫描所有元素。若data[fast] != x,则data[slow++] = data[fast]。最后把length设为slow。这个思路就是7.0的扩展版,时间复杂度 O(n),空间 O(1)。 - 从顺序表中删除重复元素(有序):同样双指针,保留第一个元素后,只写入与上一个不同值的元素。这是 LeetCode 26 题“删除有序数组中的重复项”的裸身,也是很多公司笔试第一题。
- 删除给定区间内的元素:比如删除下标
[s, t]之间的所有元素。可以先用两个指针找到区间位置,再把区间后的元素整体前移。这里的一个小技巧是不要一个个删,直接计算要移动多少元素,用一次循环或一次System.arraycopy解决。
做这些变体题时,你要始终记住顺序表删除的“总成本”是移动元素,所以凡是能“一次扫描、原地覆盖”的场景,就不要真的去多次调用删除。毕竟删除一次 O(n),删 k 次就是 O(kn),而用双指针覆盖一次就搞定,明显更优。
5.3 工程代码里那些“不写也不会错,但写了更专业”的细节
如果你不只是要应付考试,而是想在工程里写出更地道的代码,下面几个细节值得注意:
- 删除对象元素后,把尾部引用置空。这是为了帮助垃圾回收,
ArrayList源码中删除或clear时都会执行elementData[--size] = null。如果你的容器元素是对象,别漏了这一步。 - 不要用魔法数字表示成功/失败。可以定义枚举
DeleteStatus { SUCCESS, INDEX_OUT_OF_RANGE }或返回Optional,让调用方明确知道发生了什么。C 语言中如果不想用指针带回删除值,至少返回bool并配合统一的日志。 - 并发环境下,删除操作要考虑数组可见性。Java 里可以用
volatile int size或者直接用并发容器CopyOnWriteArrayList。但要注意CopyOnWriteArrayList的删除是 O(n) 而且每次写操作都复制整个数组,不适合大列表频繁删除。 - 动态扩容的顺序表,删除后是否缩容?这是一个经典的工程取舍。
ArrayList默认不缩容,因为缩容涉及重新分配数组和拷贝,如果用户删除后又添加,频繁扩容/缩容反而浪费。如果你自己实现顺序表,建议设置缩容阈值,比如说当size < capacity / 4时缩小到一半,避免内存浪费。
这些细节不需要背,而是在写代码时多问一句“编译器 / 运行时 / 维护者会不会因为这个写法产生困惑”,慢慢就能形成肌肉记忆。
我在实际开发中处理订单列表、缓存队列这类数据时,经常要在“直接改数组”和“用容器封装”之间做选择。我的体会是:算法2-3 从顺序表 list 中删除第 i 个元素虽然基础,但它背后覆盖了“数据结构选型、内存布局、边界条件、复杂度估算”一整条知识链。新手一开始就把这个知识点吃透,后面学链表、栈、队列、字符串匹配都会轻松很多。特别是当你亲手调试过越界崩溃,或者定位过因为漏掉 length-- 而出现脏数据的问题,你就会理解为什么教科书上每个细节都不多余。
