Skip to content

2015·41 链表按绝对值去重:从两两比较到值域标记

本文属于「从暴力解到最优解」专题。题面白送了一个条件「|data| ≤ n」,而暴力解从头到尾没碰它——这正是它慢的原因。这道题和 2018·41 用的是同一味药:值域标记数组,空间换时间。

题目

用单链表保存 m 个整数,结点的结构为 data | link,且 |data| ≤ n(n 为正整数)。现要求设计一个时间复杂度尽可能高效的算法,对于链表中 data 的绝对值相等的结点,仅保留第一次出现的结点而删除其余绝对值相等的结点。例如,若给定的单链表 head 如下:

head ─→ 21 → -15 → -15 → -7 → 15 → NULL

则删除结点后的 head 为:

head ─→ 21 → -15 → -7 → NULL

要求:

(1) 给出算法的基本设计思想。 (2) 使用 C 或 C++ 语言,给出单链表结点的数据类型定义。 (3) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释。 (4) 说明你所设计算法的时间复杂度和空间复杂度。

总分 15 分。题面写了「时间复杂度尽可能高效」——最优性分存在,判据是时间。另外注意 (2) 是独立一问:结点定义按题面写 data + link,字段名别顺手写成 next——这问和用什么算法无关,暴力解最优解通吃。

第一步:把暴力解写对

去重的最直接想法:对每个保留下来的结点,向后扫一遍,把绝对值和它相同的结点全删掉。外层 p 从首结点走到尾,内层拿 p 的绝对值向后逐个比对。

单链表删结点有个绕不开的基本功:必须借助前驱——摘掉一个结点要改的是它前一个结点的 link。所以内层用 pre 指向「待检结点的前驱」,删除时改 pre->link 并 free,不删时 pre 才前进:

c
typedef struct ListNode {
    int              data;         // 整数值,可正可负
    struct ListNode *link;         // 后继指针(题面字段名 link)
} ListNode;

#include <stdlib.h>

void dedup(ListNode *head, int n) {
    // 外层:p 依次指向每个保留下来的结点(首结点在 head->link)
    for (ListNode *p = head->link; p != NULL; p = p->link) {
        int v = p->data;
        if (v < 0) v = -v;                       // 当前结点的绝对值

        // 内层:从 p 之后扫到尾,删掉所有绝对值等于 v 的结点
        ListNode *pre = p;                       // pre 是待检结点的前驱
        while (pre->link != NULL) {
            int w = pre->link->data;
            if (w < 0) w = -w;
            if (w == v) {
                ListNode *tmp = pre->link;       // 摘除并释放
                pre->link = tmp->link;
                free(tmp);
            } else {
                pre = pre->link;                 // 不删才前进
            }
        }
    }
}

拿题面的例子走一遍:p = 21,后面没有绝对值 21 的,不删;p 前进到 -15,向后删掉 -15 和 15;p 前进到 -7,后面已空。结果 21 → -15 → -7 ✓。三连重复 5 → -5 → 5 也对:p = 5 一轮删掉后面两个 ✓。

复杂度:外层每个保留结点都触发一轮向后全扫,最坏(全不重复)时间 O(m2);只有 p、pre、tmp 几个指针,空间 O(1)

注意一个细节:这份代码的参数 n 从头到尾没用上。先记下这笔账。

它值多少分

按正确性/最优性两个维度(详见总纲):

  • 正确性:去重语义对(保留第一次出现)、比较前取了绝对值、用前驱指针摘除并 free、逻辑自洽——正确性维度的主体分拿得到。(2) 问的结点定义、(4) 问照实写 O(m2) 时间 O(1) 空间,也都是正常得分。
  • 最优性:题面要求「时间复杂度尽可能高效」,标答是 O(m) 单趟;朴素两两比较 O(m2) 达不到,最优性档拿不到。

这道 15 分的题,暴力解保住大头。但本题的最优解只比暴力解多一个数组,升级成本是全专题里最低的一档——值得练到能直接写出最优版。

暴力解的浪费在哪

用总纲的「找浪费三问」审一遍:

有没有重算已知的东西?——有。「绝对值 15 出现过吗」这个问题,第一次遇到 -15 时答案就已经知道了;可暴力解没把它记下来,后面每次都靠重新扫链来回答。同一个问题被全表扫描反复回答,每遍只带走一个 bit。

有没有放着性质不用?——有,而且是题面白送的。「|data| ≤ n」意味着绝对值的取值范围只有 0..n 这 n + 1 个数——值可以直接当数组下标用。暴力解把 data 当成任意整数处理,参数 n 一次都没用上。408 题面不写废话:给了值域约束,就是在提示你开桶。

两条浪费指向同一个对策——总纲的第二类手段,空间换时间:把「见过哪些绝对值」记在一个以值为下标的数组里,查询和标记都是 O(1),向后扫描就彻底不需要了。

智慧化改造:值域标记数组

开一个长度 n + 1 的标记数组 seen[0..n],从前往后扫一遍链表:取每个结点的绝对值 v,seen[v] 已置位说明出现过,摘除该结点;否则置位并保留。整条链只扫一遍。

(2) 问的结点定义和 (3) 问的算法一起给出:

c
typedef struct ListNode {
    int              data;         // 整数值,可正可负,|data| <= n
    struct ListNode *link;         // 后继指针(题面字段名 link)
} ListNode;

#include <stdlib.h>

void dedup(ListNode *head, int n) {
    char *seen = (char *)calloc(n + 1, sizeof(char));   // 下标 0..n,calloc 自动清 0
    if (seen == NULL) return;

    ListNode *pre = head;                     // pre 指向"已保留链"的尾结点(首轮是头结点)
    while (pre->link != NULL) {
        int v = pre->link->data;
        if (v < 0) v = -v;                    // 取绝对值

        if (seen[v]) {
            // 已出现过:摘除 pre->link 并释放,pre 不动
            ListNode *tmp = pre->link;
            pre->link = tmp->link;
            free(tmp);
        } else {
            // 首次出现:标记并把 pre 推进
            seen[v] = 1;
            pre = pre->link;
        }
    }

    free(seen);
}

复杂度:每个结点最多被访问一次(要么标记后跳过,要么被摘除释放),时间 O(m);辅助数组 n + 1 个字节,空间 O(n)。这就是标准答案——正确性、最优性两个维度全部拿满。

从暴力解升级过来,五个细节决定这 15 分能拿多满:

  • 取绝对值别丢。桶的下标是 |data|,漏了取绝对值会把 -15 和 15 当两个值都保留,去重语义直接错。
  • 删除后 pre 不前进。摘掉 pre->link 后,新的 pre->link 是被删者的后继,下一轮接着检查它。如果删完也让 pre 前进,-15 → -15 → -15 这种连续重复会被漏掉——这是本题最阴的坑。
  • free 的顺序:先 tmp = pre->link,再改 pre->link = tmp->link,最后 free(tmp)。先 free 再读 tmp->link 是 use-after-free。
  • calloc 不是 malloc。seen 必须初值全 0;malloc 不清零会读到内存残留值,把没出现过的绝对值误判成出现过。这个坑和 2018·41 的标记数组一模一样。
  • 收尾 free(seen)。辅助数组用完释放,别留内存泄漏。

两版对照跑一遍

用题面同款输入 21 → -15 → -15 → -7 → 15(n 取 21),期望结果 21 → -15 → -7。先看两两比较版——每个保留结点都触发一轮向后扫描:

加载可视化中...

再看值域标记版——单趟扫过去,每个结点只看一眼:

加载可视化中...

演示为不带头结点的 JavaScript 等价版本(首结点必为首次出现、不会被删,故先标记首结点再从它出发;JS 由垃圾回收代劳,没有 free);考卷上请按题面用带头结点写法(上文 C 代码)。

等价方法与升级空间

  • 通用哈希表(开链法或开放定址法):思路和标记数组相同,也能做到线性时间,但空间是 O(m)。题面既然给了「|data| ≤ n」这个有限值域,直接拿值当下标的桶就是更简单、更快的哈希——本题按桶法作答更贴题意。如果哪天题面不给值域约束,哈希表才是那个正确的替身。
  • 同一味药的另一道真题2018·41 未出现的最小正整数——同样是「值域有限 → 值当下标 → 标记数组换掉重复扫描」。两道题放在一起做,「看到值域约束就想开桶」这个反射就建立起来了。

下一步

相关真题(1题)

2017Q11选择题2分

链式存储排序:链式存储对各排序算法效率的影响