Skip to content

2009·42 倒数第 k 个结点:从两遍扫描到双指针

本文属于「从暴力解到最优解」专题。这是全专题里暴力解和最优解离得最近的一道题:暴力解已经是 O(n) 时间、O(1) 空间,最优性档卡的只剩「扫两遍还是扫一遍」。也正因为差距小,这次升级几乎是白捡的——而它练出来的双指针,是后面一串链表题的地基。

题目

已知一个带有表头结点的单链表,结点结构为:

┌──────┬──────┐
│ data │ link │
└──────┴──────┘

假设该链表只给出了头指针 list。在不改变链表的前提下,请设计一个尽可能高效的算法,查找链表中倒数第 k 个位置上的结点(k 为正整数)。若查找成功,算法输出该结点的 data 域的值,并返回 1;否则,只返回 0。要求:

(1) 描述算法的基本设计思想。 (2) 描述算法的详细实现步骤。 (3) 根据设计思想和实现步骤,采用程序设计语言描述算法(使用 C、C++ 或 Java 语言实现),关键之处请给出简要注释。

总分 15 分。题面写了「尽可能高效」——按总纲的分类,最优性分存在;同时注意另一个硬约束:不改变链表,任何版本都只能读、不能写。

第一步:把暴力解写对

「倒数第 k 个」换算成正数很简单:链长为 n 时,倒数第 k 个就是正数第 n − k + 1 个。麻烦在于单链表不自带长度——那就先扫一遍数出来:

  • 第一遍:从首结点走到尾,数出长度 n;
  • 判断:若 k > n,链不够长,返回 0;
  • 第二遍:从首结点再出发,走 n − k 步,落点就是倒数第 k 个。
c
typedef struct LNode {
    int           data;
    struct LNode *link;
} LNode, *LinkList;

int findKthFromTail(LinkList list, int k) {
    // ① 第一遍:数出链表长度 n(从 list->link 起,不含头结点)
    int n = 0;
    for (LNode *p = list->link; p != NULL; p = p->link) n++;

    if (k > n) return 0;               // 链长不足,查找失败

    // ② 第二遍:从首结点走 n - k 步,到达正数第 n-k+1 个
    LNode *p = list->link;
    for (int i = 0; i < n - k; i++) p = p->link;

    printf("%d", p->data);             // 输出倒数第 k 个结点的 data
    return 1;
}

拿链 1 → 2 → 3 → 4 → 5 → 6、k = 2 走一遍:第一遍数出 n = 6;6 ≥ 2 不越界;第二遍走 4 步落在值为 5 的结点上,输出 5 返回 1 ✓。k = 7 时 n = 6 < 7,直接返回 0 ✓。k = n = 6 时走 0 步,落在首结点 ✓。

复杂度:两遍各走一次链,时间 O(n)(约 2n 次指针移动);只用 p、n、i 几个标量,空间 O(1);全程只读不写,「不改变链表」满足。

它值多少分

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

  • 正确性:这份解能正确定位倒数第 k 个结点、k 越界时返回 0、成功时输出 data 返回 1、不改动链表——正确性维度的主体分都拿得到。题库解析里也明说了:先求长度再走 n − k 步的两遍扫描「也对」。
  • 最优性:题面「尽可能高效」在这道题上的判据是扫描遍数——双指针一遍扫描是标答,两遍扫描在最优性上按「扫一遍 vs 扫两遍」丢一小档。

这是全专题里暴力解最保值的一道:渐进复杂度已经和最优解持平,丢的只是最优性里很小的一档。复杂度那问照实写「两遍扫描、时间 O(n)、空间 O(1)」——分析与实现一致本身就是得分点。

暴力解的浪费在哪

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

有没有重复扫描?——有。整条链被走了两遍,而第一遍走完只带走一个数字 n,沿途看到的所有结点位置信息全部扔掉了,第二遍只能从头再来。

有没有放着性质不用?——有,而且就是解题钥匙。「倒数第 k 个」的本质是「距离链尾恰好 k 个结点」。要判断这件事,其实不需要知道总长 n——只要有另一个游标在前面 k 步替你探路:探路的到了链尾,你就正好在倒数第 k 个。「先数 n 再算 n − k」是绕了远路,把一个相对位置问题做成了绝对位置问题。

对策就是总纲的第一类手段——一遍扫描时多记一点:多带一个指针,把「距离」直接编码在两个指针的间隔里。

智慧化改造:双指针一遍扫描

让 fast、slow 都从首结点出发:fast 先走 k 步,然后两人同步前进、始终保持 k 个结点的间距。当 fast 越过链尾(NULL)时,slow 距链尾正好还差 k——它就是倒数第 k 个。若 fast 在先走的 k 步里就撞上 NULL,说明链长 < k,返回 0。

数学上验证一下:链长 n,fast 先走 k 步后,两人再同步走 n − k 步 fast 到达 NULL,此时 slow 从首结点走了 n − k 步,指向正数第 n − k + 1 个,正是倒数第 k 个。

c
typedef struct LNode {
    int           data;
    struct LNode *link;
} LNode, *LinkList;

int findKthFromTail(LinkList list, int k) {
    LNode *fast = list->link;          // 跳过头结点,从首结点开始
    LNode *slow = list->link;
    int i = 0;

    // ① fast 先走 k 步;若途中遇 NULL,说明链表长度 < k
    while (fast != NULL && i < k) {
        fast = fast->link;
        i++;
    }
    if (i < k) return 0;               // 长度不足,查找失败

    // ② 两指针同步前进,fast 到 NULL 时 slow 即倒数第 k 个
    while (fast != NULL) {
        fast = fast->link;
        slow = slow->link;
    }

    printf("%d", slow->data);          // 输出倒数第 k 个结点的 data
    return 1;
}

复杂度:fast 总共走完整条链一次(前 k 步 + 后 n − k 步 = n 步),时间 O(n) 单趟;只有 fast、slow、i 三个标量,空间 O(1)。这就是标准答案。

从两遍扫描升级过来,四个细节决定这 15 分能拿多满:

  • fast 先走 k 步,不是 k − 1 步。间距必须恰好是 k:fast 到 NULL 那一刻 slow 才落在倒数第 k 个上。先走 k − 1 步会让 slow 停在倒数第 k + 1 个,差一个结点。
  • 越界判定藏在先走的循环里。循环条件写 fast != NULL && i < k,走完检查 i < k 就返回 0。漏掉这个判定,链长不足时会带着错误状态进入同步循环,逻辑全乱——这是本题最常见的丢分点。
  • list->link 起步,不是 list。题面是带头结点的链表,头结点不存数据;从 list 起算会整体错位一个结点。
  • 只读不写。「不改变链表」是题面硬约束,fast、slow 只是游标,不能改 link 域、不能 malloc/free。两遍扫描版天然满足,双指针版也一样——升级不引入任何写操作。

两版对照跑一遍

同一条链 1 → 2 → 3 → 4 → 5 → 6,k = 2,答案是值为 5 的结点。先看两遍扫描版——注意链被完整走了两轮:

加载可视化中...

再看双指针版——fast 先拉开 k 的间距,然后两个指针齐步走,一遍到位:

加载可视化中...

演示为不带头结点的 JavaScript 等价版本,成功时直接返回查到的 data 值;考卷上请按题面用带头结点写法(上文 C 代码),输出 data 并返回 1/0。

等价方法与升级空间

  • 栈法:把所有结点依次压栈,再弹 k 次,弹出的就是倒数第 k 个。定位正确,拿正确性分;但辅助栈占 O(n) 空间,按算法欠最优在最优性上丢分——它比两遍扫描丢得更多,因为两遍扫描至少空间是 O(1) 的。
  • 双指针的一套家族:本题的「fast 提前 k 步」和「fast 走 2 步 slow 走 1 步找中点」「快慢指针判环」是同一个技巧的三个变体。找中点那个变体正是 2019·41 链表重排三步原地解的第一步——这道 2009 年的题,练的是后面十几年反复出现的基本功。

下一步

相关真题(1题)

2017Q11选择题2分

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