打开第二章配套习题集,第4道大题通常写得很简短:“设计一个算法,删除线性表中所有值在[s, t]范围内的元素,要求时间复杂度O(n),空间复杂度O(1)。”很多同学看到“线性表”这行字就会心一笑——不就是遍历一遍,遇到范围内的元素删掉吗?可真拿到真题答题卡上动手写代码,一半以上的人会在三种地方失分:边界条件考虑不全、顺序表忘记更新长度、链表删除后忘记移动前驱指针。
这篇文章就围绕这道“删除值在指定范围内元素”的题展开,把顺序表和单链表两条解题路线都走一遍。我会把每一行代码“为什么这么写”讲清楚,再列出容易丢分的细节和可以直接拿来测试的用例。不管你是考研复习、期末突击,还是单纯想把线性表的基础操作打牢,这篇文章都值得完整看完。
1. 一道看似送分的大题,到底在考哪些底层能力
1.1 先还原这道题的“标准长相”
不同教材、不同习题集对这道题的描述会有一点差异,但核心意思完全一致。
题目原文大致是这样:已知线性表L,表中元素为整型,设计一个尽可能高效的算法,删除表中所有值在给定范围[s, t]( s < t )之间的元素。如果使用顺序表,则要求时间复杂度 O(n) ,空间复杂度 O(1) ;如果使用链表,通常默认带头结点,也要求一趟遍历完成删除。
这道题在“第二章 线性表”的习题里非常典型,因为它同时覆盖了顺序表和链表的实现思路、参数合法性判断、边界元素处理、遍历过程中指针/下标的移动规则,以及时间和空间复杂度的估算能力。很多学校的期末考试、考研初试都会直接拿这类题考手写代码。
在有些版本里,范围会写成开区间 (s, t) ,而有些版本写成闭区间 [s, t] 。还有的会换一种问法,比如“删除值在s到t之间的所有元素”,这时候你就要在代码里决定用 < 还是 <= 。我后面会专门讲这个细节。
1.2 考点拆解:读题越快的同学,越容易在条件上翻车
这道题表面上只考了一个“删除”操作,但顺着题目往下挖,至少有四层能力被考察了。
第一层:你知不知道顺序表的删除操作本质是“覆盖”,而不是单独把某个位置“清空”。要删除下标 i 的元素,正确做法是把 i 之后的所有元素依次往前移动一位。知道了这一点,你才能理解为什么这道题可以用一个“记录保留元素位置”的下标来一趟遍历完成。
第二层:你能不能处理链表的删除。单链表没有随机访问能力,你不能用下标定位,必须从头开始通过前驱指针后驱指针逐步寻找。删除一个结点,核心操作是修改前驱结点的 next 指针,而不是直接操作被删除的结点。
第三层:你对边界条件的敏感度。范围 [s, t] 里包含端点吗?s 和 t 相等时应该返回什么?空表怎么办?表里的元素全都落在范围内怎么办?这些都不是代码主流程解决的问题,但是阅卷时最容易扣分的地方。
第四层:你能不能写出真正 O(n) 时间、 O(1) 空间的代码。有些同学一看到“删除范围内元素”,第一反应是每删除一个元素就调用一次普通删除操作,然后重复移动大量元素。这样写在最坏情况下是 O(n²) 的复杂度,不符合题目要求,甚至直接判错。
所以这道题的真正价值不在于“会不会遍历”,而在于你能不能根据不同的存储结构,设计出只遍历一次就能完成去留筛选的算法。
1.3 一个容易混淆的前提:删除范围是值域,不是下标范围
我先说一个最常见的误解。题目描述里的“值在[s, t]范围内”,指的是元素的数值大小落在某个区间上,比如删除所有值在[10, 20]之间的元素。这里的 [10, 20] 是元素值范围,不是数组下标的范围。
有同学会把题意理解错,写成:
c复制for (i = s; i <= t; i++) { /* 删除下标从s到t的元素 */ }
这是完全错误的理解。顺序表里的“值”和“位置”是两个维度。s 和 t 给出的是数值上下界,和元素存的位置没有关系。比如表里元素是 [5, 100, 13, 2, 30] ,删除值在 [10, 20] 之间的元素,应该只删掉 13 ,而不是把位置 10 到位置 20 的元素全删掉。
理解这一点非常重要。它可以帮你避免在后续写判断条件时,下意识把数组下标带入到范围判断里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表实现:用“保留”代替“删除”,一次遍历完成原地覆盖
2.1 第一反应为什么会错
顺序表的结构体定义一般是这样的:
c复制#define MaxSize 50
typedef struct {
ElemType data[MaxSize];
int length;
} SqList;
初学者看到这道题,第一反应往往是:遍历顺序表,只要发现当前元素 data[i] 落在 [s, t] 内,就调用“删除第 i 个元素”的常规操作。
标准删除操作的写法是:
c复制bool ListDelete(SqList &L, int i, ElemType &e) {
if (i < 1 || i > L.length) return false;
e = L.data[i - 1];
for (int j = i; j < L.length; j++)
L.data[j - 1] = L.data[j];
L.length--;
return true;
}
如果用这种思路做这道题,代码会写成两层循环:外层循环找范围内元素,内层循环移动元素。假设表长为 n ,每次删除的平均移动次数接近 n 次。如果范围内元素很多,最坏情况是几乎每个元素都触发一次删除,总移动次数会达到 n² 的量级。这既不符合题目的时间复杂度要求,手写代码过程也很冗长,容易乱套。
而且这种“边遍历边删除”还有一个隐蔽问题:删除当前元素后,后一个元素会往前移动一位,如果你还按原来的 i++ 继续遍历,就很有可能跳过一个本该被检查的元素。这也是为什么使用普通删除思路时,代码里总是会出现“删除后 i 要减 1”这样的补救逻辑。补救本身并不复杂,但在考场上多一个特殊分支,就多一点出错概率。
2.2 推荐解法:快慢下标 + 计数器,一行核心逻辑搞定
真正推荐的写法不是“主动删除范围元素”,而是“保留非范围元素”。
我们可以这样想:遍历一次顺序表,把不需要删除的元素全部保留下来。既然顺序表删除的本质是覆盖,那我们可以把“保留的元素”依次放到表的前面去,最后将表的长度更新为保留元素的个数。这样一次遍历就完成了删除,而且全程只需要一个额外变量。
完整代码可以这样写:
c复制bool DeleteRange(SqList &L, ElemType s, ElemType t) {
// 参数合法性判断
if (L.length == 0) return false;
if (s >= t) return false;
int k = 0; // k 表示“下一个保留元素应该存放的位置”,也是新表的长度
for (int i = 0; i < L.length; i++) {
// 当前元素不在范围内,说明需要保留
if (L.data[i] < s || L.data[i] > t) {
L.data[k] = L.data[i];
k++;
}
}
L.length = k;
return true;
}
这个算法的核心是 L.data[k] = L.data[i] 这一步。为了更好理解,我习惯把 k 叫作“慢下标”,把 i 叫作“快下标”。快下标负责遍历原表的所有元素,判断每个元素是否应该保留;慢下标负责记录下一个可写位置。
为什么可以放心地让快下标往慢下标位置覆盖?关键前提是:慢下标 k 永远不会超过快下标 i 。因为 k 每写一次只加 1 ,而 i 每遍历一次也加 1 ,如果遇到删除元素,k 不加,i 继续走,所以 k <= i 恒成立。这意味着每次赋值时,把 data[i] 写到 data[k] ,而 data[k] 要么是自己的位置,要么是一个已经被遍历过、并且已被判定为不需要保留的旧位置。无论哪种情况,都不会覆盖还没有处理过的元素。
举个例子,假设顺序表 L 的初始内容为:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| data | 3 | 15 | 7 | 30 | 9 | 20 |
删除范围 [10, 25] 之间的元素,也就是值 15 和 20 需要删除,保留 3、7、30、9。
遍历过程如下:
- i=0 时,data[0]=3,不在范围内,保留。k=0,data[0]=data[0],k 变为 1。
- i=1 时,data[1]=15,在范围内,跳过,k 仍为 1。
- i=2 时,data[2]=7,不在范围内,保留。data[1]=data[2],也就是把 7 写到下标 1 的位置。k 变为 2。
- i=3 时,data[3]=30,不在范围内,保留。data[2]=data[3],k 变为 3。
- i=4 时,data[4]=9,不在范围内,保留。data[3]=data[4],k 变为 4。
- i=5 时,data[5]=20,在范围内,跳过,k 仍为 4。
最终 k=4,把 length 置为 4 。此时数组前四个位置依次是 3、7、30、9,后面的元素不影响,因为顺序表的有效长度已经变成了 4 。这个结果正好就是把 15 和 20 删掉后的样子。
这个算法的时间复杂度很好算:整个循环只遍历了一次表,O(n) ;额外只用了一个整数变量 k ,空间复杂度 O(1) ,完全满足题目要求。
2.3 另一种思路:用删除个数做错位,本质和快慢下标一样
除了快慢下标,很多教材里还出现过另一种写法:先从头到尾统计并移动,最后统一缩短表长。或者用变量 d 记录已经删除的元素个数,遍历时如果当前元素需要保留,就让它往前移动 d 个位置。
核心代码如下:
c复制bool DeleteRange(SqList &L, ElemType s, ElemType t) {
if (L.length == 0 || s >= t) return false;
int d = 0; // 已经被删除的元素个数
for (int i = 0; i < L.length; i++) {
if (L.data[i] >= s && L.data[i] <= t) {
d++;
} else {
L.data[i - d] = L.data[i];
}
}
L.length -= d;
return true;
}
这里 d 的含义是“已经累积删除的数量”。如果当前元素需要保留,它应该往前移动 d 个位置,因为前面已经删掉了 d 个元素。如果当前元素需要删除,d 加 1,这个元素本身不需要移动。
你对比一下会发现,k 写法和 d 写法本质上属于同一类思路:都是在一次遍历中完成元素的“筛选—前移—定长”。只不过 k 写法更直观,直接记录下一个保留位置;d 写法更偏重“删除计数”。我个人在讲题时推荐先用 k 写法,因为它和后面的链表双指针写法可以共用同一套思维模型,不容易换题之后乱掉。
2.4 如果真的允许使用辅助空间,代码会变成什么样
有些复习资料会先在基础题里讨论“如果允许用辅助数组,你会怎么做”,然后引出“空间复杂度 O(1) 是不是必须”的问题。这里也简单说一下。
如果允许使用一个临时数组 B ,那思路更加直接:
c复制int m = 0;
for (int i = 0; i < L.length; i++) {
if (L.data[i] < s || L.data[i] > t) {
B[m++] = L.data[i];
}
}
for (int i = 0; i < m; i++) {
L.data[i] = B[i];
}
L.length = m;
时间仍然是 O(n) ,但空间复杂度变成了 O(n) ,因为需要额外的 B 数组。考研或者期末考试一旦题目明确写了“空间复杂度 O(1)”,这种写法就不能用。但如果题目没有写,辅助空间版本通常也能得分,只是不够优。
这里也体现出一种很常见的思维误区:有些同学认为只要写出了“正确”的代码就行了,不看复杂度要求。实际上在这种大题里,第一问可能是“设计算法”,第二问可能直接是“说明你的算法时间复杂度和空间复杂度”。如果你给出的算法是 O(n) 空间,哪怕功能正确,也拿不到满分。所以看到题目,第一步不是写代码,而是先圈出时间和空间限制。
3. 单链表实现:前驱指针走一遍,free是收尾工作的灵魂
3.1 带头结点版:pre和p双指针配合
链表版本的题目一般会强调“带头结点的单链表 L ”。带头结点最大的好处是:头结点固定存在,即使表为空,也有一个结点占位,删除第一个数据结点时不需要单独修改头指针。
结构体定义:
c复制typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkList;
带头结点的单链表删除指定范围元素的代码可以这样写:
c复制void DeleteRange(LinkList &L, ElemType s, ElemType t) {
if (s >= t) return; // 范围不合法,直接返回
LNode *pre = L; // pre 始终指向当前结点的前驱
LNode *p = L->next; // p 指向当前要检查的结点
while (p != NULL) {
if (p->data >= s && p->data <= t) {
// 当前结点需要删除
pre->next = p->next;
LNode *q = p;
p = p->next;
free(q);
} else {
// 当前结点需要保留,双指针整体后移
pre = p;
p = p->next;
}
}
}
这段代码最核心的要点是:只有当当前结点需要保留时,pre 和 p 才一起向后移动;如果当前结点被删除,pre 不需要动,只有 p 向后移动。
为什么要这样处理?因为删除一个结点后,p 原本指向的结点已经被释放了,而 pre 指向的结点的 next 已经指向了 p 的下一个结点。此时如果还把 pre 向后移动,就会跳过下一个结点,导致下一个结点永远得不到检查。
我们用一个具体例子走一遍。链表数据为:3 -> 15 -> 7 -> 30 -> 9 -> 20,删除值在 [10, 25] 内的元素。
初始 pre 指向头结点,p 指向结点3。
- 结点3的值是3,不在范围内。pre指向3,p指向15。
- 结点15的值在范围内。pre->next = p->next,也就是让3的next指向7。把p结点15记录下来,p指向7,free(15)。此时链表变成 3 -> 7 -> 30 -> 9 -> 20,pre仍然指向3。
- 结点7的值是7,不在范围内。pre指向7,p指向30。
- 结点30不在范围内。pre指向30,p指向9。
- 结点9不在范围内。pre指向9,p指向20。
- 结点20在范围内。pre->next = p->next,即9的next指向NULL。把p指向NULL,free(20)。最终链表为 3 -> 7 -> 30 -> 9,成功。
可以看到,两个保留结点之间如果夹着若干个删除结点,整个过程是非常平滑的。pre 始终停留在最后一个保留结点上,p 不断向前试探,一旦发现新结点是可保留的,pre 就更新到这个新结点上。
3.2 不带头结点的链表如何处理
考试偶尔也会出“不带头结点”的版本。这时删除第一个结点和删除中间结点不一样。删除第一个结点需要修改头指针本身,所以如果函数参数是 LinkList &L,可以使用引用,也可以传二级指针。
我不太建议在不带头结点的链表里用额外的“dummy node”技巧来回避这道题,因为这样反而会让代码多绕一层。直接分情况讨论是最稳妥的:
c复制void DeleteRange(LinkList &L, ElemType s, ElemType t) {
if (s >= t) return;
// 先处理头结点开头就连环在范围内的情况
while (L != NULL && L->data >= s && L->data <= t) {
LNode *q = L;
L = L->next;
free(q);
}
if (L == NULL) return;
// 处理中间结点
LNode *pre = L;
LNode *p = L->next;
while (p != NULL) {
if (p->data >= s && p->data <= t) {
pre->next = p->next;
LNode *q = p;
p = p->next;
free(q);
} else {
pre = p;
p = p->next;
}
}
}
这里先单独处理链表头部的连续范围内结点,是一个容易忽略的边界问题。如果上来就直接用 pre 和 p 两个指针从头遍历,pre 一开始指向 L,而 L 本身是需要被删除的结点,这时候修改 pre->next 会非常棘手,因为你并没有一个真正的“头结点前驱”。
3.3 链表本身有序时,还能再快一步
如果题目额外说明“线性表是有序的”,删除指定范围这一题还有一个更优的写法。由于元素有序,值在 [s, t] 范围内的结点在链表中必然是一段连续的区间。我们只需要先找到第一个值大于等于 s 的结点,然后从那里一直删除到值大于 t 为止。
头结点版本的有序链表代码思路:
c复制void DeleteRangeSorted(LinkList &L, ElemType s, ElemType t) {
if (L->next == NULL || s >= t) return;
LNode *pre = L; // pre 指向待删除区间的前驱
LNode *p = L->next;
// 找到第一个值 >= s 的结点
while (p != NULL && p->data < s) {
pre = p;
p = p->next;
}
// 删除值在 [s, t] 之内的连续结点
while (p != NULL && p->data <= t) {
pre->next = p->next;
LNode *q = p;
p = p->next;
free(q);
}
}
这个版本其实比无序版更好理解:因为有序,你不需要考虑“删除结点中间是否还会插入保留结点”这种复杂情况,待删除的结点全部连在一起,找到起点后,一路删到 value > t 就可以停止。
时间方面,即使是最坏情况,也只是先扫描一段小于 s 的结点,再扫描一段范围内的结点,总体仍是 O(n)。空间仍然是 O(1)。
3.4 为什么链表的边界条件比顺序表更多
很多同学在顺序表上能把代码写对,一到链表就频繁报错,核心问题不是语法,而是对“指针到底指谁”没有建立清晰的动态想象。
顺序表的删除靠“覆盖”,不需要释放空间,length 减一就行。链表的删除靠“修改指针指向”,但你还要额外处理三个问题:被删除结点的内存是否释放、前驱指针是否保留、下一轮要检查的结点是谁。
另外,链表代码里通常会把被删除的结点先保存到临时变量 q 中,再让 p 后移,最后 free(q)。这个顺序不能随意改变。如果先 free(p) 再执行 p = p->next,你的 p 已经变成了野指针,再去取 p->next 就会访问非法内存。这种错误在笔试里不一定能直观暴露,但在真实运行环境里会直接崩溃。
早期学链表时我也犯过类似的错:看着代码觉得“既然都把 pre->next 改好了,那直接 free(p),然后让 p 继续走不就行了?”问题在于 p=p->next 这句必须发生在 free 之前,因为一旦释放 p 所指向的内存,p->next 就不再是一个安全可读的值。
4. 那些让代码扣分的边界条件和测试用例
4.1 五类测试用例,帮你把一段代码测到底
一个算法题如果只看主流程,很容易觉得自己写对了。但真正拿分的关键在于能不能通过边界测试。对于本题,我建议你至少准备五类测试数据。
第一类:空表。如果是顺序表,L.length == 0 ;如果是带头结点的链表,L->next == NULL 。此时算法应该安全返回,不能出现越界访问或空指针解引用。
第二类:没有任何元素落在范围内。比如表是 [1, 2, 3, 4],范围是 [10, 20]。此时顺序表的 k 会一路增加,最终 k == 原 length,即每个元素都“被保留”。链表版本中,pre 和 p 会一路同步后移。最终表不变,程序自然结束。
第三类:全部元素都落在范围内。例如 [10, 12, 15, 18],范围 [5, 20]。对顺序表来说,k 始终为0,最终 length == 0,表变成空表。对链表来说,第一个结点的删除就会让头结点直接指向空,后续所有结点也被一一删除。
第四类:元素值正好等于范围的边界。如果范围是 [s, t],那么值等于 s 或值等于 t 的元素都要删除;如果范围是 (s, t),那么值等于 s 或 t 的元素需要保留。这是非常容易出错的点。
第五类:重复值。比如 [2, 5, 5, 5, 8],删除值等于5的元素。重复值的处理最能检验你是否每次删除后都正确移动了指针/下标。
你可以把这段测试代码在本地 IDE 里跑一遍:
c复制#include <stdio.h>
#include <stdlib.h>
#define MaxSize 20
typedef struct {
int data[MaxSize];
int length;
} SqList;
void PrintList(SqList L) {
for (int i = 0; i < L.length; i++) {
printf("%d ", L.data[i]);
}
printf("\n");
}
bool DeleteRange(SqList &L, int s, int t) {
if (L.length == 0 || s >= t) return false;
int k = 0;
for (int i = 0; i < L.length; i++) {
if (L.data[i] < s || L.data[i] > t) {
L.data[k] = L.data[i];
k++;
}
}
L.length = k;
return true;
}
int main() {
SqList L = {{3, 15, 7, 30, 9, 20}, 6};
DeleteRange(L, 10, 25);
PrintList(L); // 期望输出:3 7 30 9
return 0;
}
4.2 参数合法性检查放在哪里最安全
这道题里有两个参数 s 和 t 。虽然在题目中已经说了 s < t,但作为独立算法,最好还是加上合法性判断。
顺序表版本里,如果 s >= t,意味着区间为空或者区间上下界颠倒,这时任何删除操作都没有意义。更极端一点,如果 t 特别大,超过所有元素值,或者 s 特别小,小于所有元素值,这都不影响算法的正确性,因为你多余的元素最终会被保留下来。
另一个值得加在最前面的判断是空表。顺序表用 L.length == 0 判断,链表用 L->next == NULL 判断。
我见过有些同学把判断放在 for 循环里面,比如写 if (L.length == 0) ...,虽然结果也不错,但因为顺序表版本里面 length 在最后才修改,所以其实在循环开始前判断一次就够了。判断越早,越能避免后续对空表做无意义的赋值操作。
链表版本还要注意,在访问 p->data 前必须确认 p != NULL。很多空指针异常不是因为忘了判断,而是把 p = p->next 和 p != NULL 的先后顺序搞错了。
4.3 一个隐藏扣分点:范围判断写反或漏等号
范围判断是整个算法最核心的一行代码。在顺序表版本中,保留条件是:
c复制if (L.data[i] < s || L.data[i] > t) // 保留
删除条件等价的写法是:
c复制if (L.data[i] >= s && L.data[i] <= t) // 删除
这两种写法都可以,但你要特别关注题目说的是闭区间 [s, t] 还是开区间 (s, t) 。闭区间时,>= 和 <= 都带等号;开区间时,应该写 > s && < t,这样值正好等于 s 或 t 的元素才会被保留。
我见过不少代码把范围写成了 if (L.data[i] > s && L.data[i] < t),然后拿去处理闭区间题,结果边界值全部被错误保留。这种错很隐蔽,因为测试数据里如果边界值出现得少,你可能根本意识不到。
区间开闭为什么要单拎出来说?因为很多同学以为这只是一个符号问题,大不了审题时看一眼就知道了。但实际考试中,命题人经常故意在题末加一句:“设删除值落在开区间 (s,t) 内的元素”。这句话在整页试卷中非常不显眼,如果不养成“圈条件”的习惯,很可能默认成闭区间就写了。所以我建议你平时刷题时,不管是教材题还是习题集,拿到题目先把“范围”用笔圈出来,再明确写出“本题删除时是否保留 s 和 t 处的元素”。
4.4 基于一段错误代码的复盘
我假设一位同学写出了下面这段顺表代码,你试着找找问题:
c复制void DeleteRange(SqList &L, int s, int t) {
for (int i = 0; i < L.length; i++) {
if (L.data[i] >= s && L.data[i] <= t) {
for (int j = i; j < L.length - 1; j++) {
L.data[j] = L.data[j + 1];
}
L.length--;
i--; // 删除后回退一位
}
}
}
这段代码的功能其实是正确的,但它的时间复杂度是 O(n²)。比如表里全部元素都要删除时,外层循环每删除一个元素,内层循环就要把后面的所有元素整体前移。理论上看起来简单、直观,可如果题目明确要求“尽可能高效”,这种解法就拿不到高分。
更麻烦的是,如果删除后没有执行 i--,代码会跳过下一个元素,直接漏删。这也是很多初学者写普通删除法时最容易踩的坑。
从这段错误代码里,我想给你一个习惯:写代码前先问自己一句,这个算法的总移动次数是多少?如果每一次删除都可能带动 O(n) 范围内的移动,那总时间往往就是 O(n²)。这时你就该换一种只移动一次的覆盖思路,也就是用第 2.2 节里的快慢下标方法。
5. 跳出这一题:删除类题目背后是同一套“去留规则”模型
5.1 这个题目的“亲戚”非常多
在“线性表”这个章节里,和本题类似的题目可以列出一长串:
- 删除顺序表中所有值等于 x 的元素。
- 从有序顺序表中删除所有值重复的元素,使表中所有元素的值均不同。
- 从顺序表中删除给定值在 s 和 t 之间的所有元素,要求空间复杂度为 O(1)。
- 从链表中删除绝对值相等的结点,只保留第一个出现的结点。
- 从链表中删除所有值为 x 的结点。
- 将线性表中所有小于 x 的元素放在所有大于等于 x 的元素之前。
你会发现,这些题目最后都能归纳成一个动作:遍历一遍线性表,根据某个“去留规则”判断当前元素是保留还是删除,然后利用覆盖或指针修改完成操作。
以“删除顺序表中所有值等于 x 的元素”为例,使用快慢下标可以写成:
c复制void DeleteX(SqList &L, ElemType x) {
int k = 0;
for (int i = 0; i < L.length; i++) {
if (L.data[i] != x) {
L.data[k++] = L.data[i];
}
}
L.length = k;
}
这几乎就是“删除指定范围元素”的特例,只是把 L.data[i] < s || L.data[i] > t 换成了 L.data[i] != x。
再比如“从有序顺序表中删除重复元素”,代码也可以套同一个模型:
c复制void DeleteDuplicates(SqList &L) {
if (L.length == 0) return;
int k = 0;
for (int i = 1; i < L.length; i++) {
if (L.data[i] != L.data[k]) {
L.data[++k] = L.data[i];
}
}
L.length = k + 1;
}
这里的 k 不再只是“计数器”,还充当了“当前结果表中最后一个保留元素的下标”。这种通过比较当前元素与结果表末尾元素的方法,也是顺序表去重非常经典的写法。
5.2 把这些题打通后,你会获得一个稳定的解题套路
你会发现,不管题目换成什么删除条件,解法套路几乎是固定的。
第一步,先确认存储结构。顺序表和链表本质上都是线性表,但实现的细节完全不同。顺序表关键在于 length 和数组下标,链表关键在于 next 指针和动态内存。
第二步,写出“去留规则”。到底什么条件下的元素保留?什么条件下的元素删除?用代码表达成一个布尔条件。
第三步,设计遍历结构。顺序表用快慢下标;链表用 pre 和 p 双指针,必要时加 dummy node。所有需要删除的元素在遍历过程中要么被跳过,要么被释放。
第四步,最后统一收尾。顺序表更新 length;链表则不需要额外更新长度,但你得检查头指针是否被改动。
这个方法对很多变形题都有效。比如把“删除范围”改成“删除最小值”,你只需要先遍历一次找到最小值,再第二次遍历删除所有等于最小值的元素。虽然某些变体要遍历两次,但整体思路保持一致。
有些同学会担心“链表里要不要保留 dummy node”的问题。我的建议是:如果题目明确是带头结点,那就直接用头结点,不需要再设 dummy node;如果题目没有头结点,而你又不希望在头指针上分情况讨论,那么可以临时构造一个 dummy node 指向原链表的第一个结点,处理完后再释放 dummy node。这是一种合法的技巧,本质上是在把“没有头结点”的链表临时改造成“带头结点”的链表。
5.3 最后分享一个我自己带人复习时的习惯
每次讲完这类删除题,我都会让同学在一张白纸上画出如下过程图:先画一排方框代表顺序表,然后用不同颜色标出需要保留的元素和需要删除的元素;再用一个箭头代表 k,一个箭头代表 i,手动模拟完整遍历过程。链表同理,画出结点方框和 next 箭头,每删除一个结点就划掉一个方框。
不要觉得画图浪费时间。很多边界条件,比如“删除后 pre 不动、p 继续走”“k 始终不大于 i”,如果只在脑子里想,很容易出错;一旦画出来,指针的移动顺序就变得非常直观。手写代码能力建立在动手模拟的基础上,而不是建立在背代码上。
如果你现在正在刷王道、天勤或严蔚敏教材后面的习题,遇到“删除值在指定范围内元素”这类题目,不要满足于把答案代码看一遍。我建议你把代码先抄一遍,再把代码合上,像刚才那样画出表和指针的移动过程,最后再自己独立写一遍。这三步走下来,这道题才算真正消化了。以后遇到“删除所有值为x的元素”“删除重复元素”“删除绝对值相同元素”等变形题,你也会发现它们全都长得差不多,因为它们的本质都是同一个道理:在线性表的一次遍历中,判断每个元素的去留,并通过覆盖或指针修改把保留的元素组织成最终结果。
