跳转至

AC213-找出两个单链表的共同后缀起始位置

假定采用带头结点的单链表保存单词,当两个单词有相同的后缀时,可共享相同的后缀存储空间。设 str1 和 str2 分别指向两个单词所在链表的头结点,请设计一个时间上尽可能高效的算法,找出这两个链表的共同后缀的起始位置。(2012 年统考题)

代码实现

C
// 函数 find:找出两个单链表的共同后缀起始位置
// 假设两个链表在某个位置开始共享相同的后缀
LinkList find(LinkList str1, LinkList str2) {
    // 初始化指针和长度计数器
    LinkList p = str1->next, q = str2->next; // p、q 分别指向两个链表的第一个数据节点
    int len1 = 0, len2 = 0; // len1、len2 分别记录两个链表的长度

    // 计算第一个链表的长度
    while (p != NULL) {
        p = p->next;
        len1++;
    }

    // 计算第二个链表的长度
    while (q != NULL) {
        q = q->next;
        len2++;
    }

    // 重新将指针指向链表的起始位置
    p = str1->next;
    q = str2->next;

    // 对齐两个链表的起始位置
    // 如果第一个链表更长,则将 p 指针向前移动 (len1 - len2) 个位置
    for (; len1 > len2; len1--) {
        p = p->next;
    }

    // 如果第二个链表更长,则将 q 指针向前移动 (len2 - len1) 个位置
    for (; len2 > len1; len2--) {
        q = q->next;
    }

    // 同步移动两个指针,寻找第一个相同的节点(即共同后缀的起始位置)
    while (p != NULL && q != NULL) {
        if (p == q) { // 比较指针地址,而不是节点数据
            return p; // 找到共同后缀的起始位置
        }
        p = p->next;
        q = q->next;
    }

    // 如果没有找到共同后缀,返回 NULL
    return NULL;
}

代码逻辑解析

1. 问题理解

  • 两个单词链表可能共享相同的后缀部分
  • 需要找到这个共享部分的起始位置
  • 关键:比较的是节点的地址,不是节点的数据

2. 算法思路

这是一个经典的"寻找两个链表相交点"问题,采用"对齐后同步移动"的策略:

步骤1:计算长度 - 分别遍历两个链表,计算各自的长度

步骤2:对齐起点 - 让较长链表的指针先走 |len1 - len2| 步 - 使得两个指针距离各自链表尾部的距离相等

步骤3:同步移动 - 两个指针同时向前移动 - 当遇到第一个相同地址的节点时,即为共同后缀的起始位置

3. 关键点说明

  • 地址比较if (p == q) 比较的是指针地址,不是节点内容
  • 共享后缀:意味着从某个节点开始,两个链表完全重合
  • 对齐策略:通过让长链表先走,确保同步移动时能同时到达交点

时间复杂度分析

  • 第一次遍历:O(m) + O(n),其中 m、n 分别为两个链表的长度
  • 对齐操作:O(|m - n|)
  • 同步移动:O(min(m, n))
  • 总体复杂度O(max(m, n))

空间复杂度分析

  • 只使用了常量级别的额外变量(p, q, len1, len2)
  • 空间复杂度为 O(1)

较难理解部分的说明

为什么比较地址而不是数据?

C
1
2
3
4
5
// 错误的理解:比较数据内容
if (p->data == q->data) // ❌ 可能有多个节点数据相同

// 正确的理解:比较节点地址  
if (p == q) // ✅ 只有共享节点的地址才相同

当两个链表共享后缀时,它们实际上指向同一个内存地址的节点序列。

图解说明

假设两个链表:

Text Only
1
2
3
4
str1: A -> B -> C -> D -> E -> F -> NULL
                    |
str2: X -> Y -> Z -> D -> E -> F -> NULL

步骤演示:

Text Only
步骤1:计算长度
str1长度 = 6 (A,B,C,D,E,F)
str2长度 = 6 (X,Y,Z,D,E,F)

步骤2:对齐(长度相等,无需对齐)
p -> A
q -> X

步骤3:同步移动
比较 A ≠ X,移动 p->B, q->Y
比较 B ≠ Y,移动 p->C, q->Z  
比较 C ≠ Z,移动 p->D, q->D
比较 D == D,找到交点!返回 D 的地址

再看一个长度不同的例子:

Text Only
1
2
3
4
str1: A -> B -> C -> D -> E -> NULL  (长度5)
              |
str2: X -> Y -> C -> D -> E -> NULL  (长度5)

Text Only
步骤1:计算长度
len1 = 5, len2 = 5

步骤2:对齐
由于长度相等,无需对齐

步骤3:同步移动
p->A, q->X: A≠X, 移动
p->B, q->Y: B≠Y, 移动  
p->C, q->C: C==C, 找到交点!

如果长度不同:

Text Only
1
2
3
4
str1: A -> B -> C -> D -> E -> F -> G -> NULL  (长度7)
                    |
str2: X -> Y -> Z -> D -> E -> F -> G -> NULL  (长度7)

Text Only
1
2
3
4
5
6
步骤1:len1=7, len2=7
步骤2:对齐(实际代码中的 for 循环)
str1 比 str2 长 2 个节点,所以 p 先走 2 步
p 从 A 开始,走 2 步后指向 C
现在 p->C, q->X,距离尾部都是 5 个节点
步骤3:同步移动找到交点 D

延伸知识点

1. 其他解法

方法二:双指针技巧(无需计算长度)

C
1
2
3
4
5
6
7
8
LinkList find_intersection(LinkList headA, LinkList headB) {
    LinkList p = headA, q = headB;
    while (p != q) {
        p = (p == NULL) ? headB : p->next;
        q = (q == NULL) ? headA : q->next;
    }
    return p;
}

方法三:使用栈

C
// 将两个链表分别压入栈中,然后同时弹出比较
// 时间复杂度 O(m+n),空间复杂度 O(m+n)

2. 实际应用场景

  • 内存管理:检测循环引用
  • 版本控制:寻找两个分支的共同祖先
  • 图论算法:寻找两条路径的交点
  • 数据库:检测循环外键引用

3. 相关问题变体

变体1:判断两个链表是否相交

C
1
2
3
4
5
6
7
8
9
bool isIntersect(LinkList headA, LinkList headB) {
    if (!headA || !headB) return false;

    LinkList p = headA, q = headB;
    while (p->next) p = p->next;
    while (q->next) q = q->next;

    return p == q; // 比较尾节点地址
}

变体2:计算交点到尾部的距离

C
1
2
3
4
5
6
7
8
int getTailDistance(LinkList intersection) {
    int count = 0;
    while (intersection) {
        count++;
        intersection = intersection->next;
    }
    return count;
}

4. 边界情况处理

C
1
2
3
4
5
6
7
8
9
LinkList find_robust(LinkList str1, LinkList str2) {
    // 边界检查
    if (!str1 || !str2 || !str1->next || !str2->next) {
        return NULL;
    }

    // 原算法...
    // 添加适当的 NULL 检查
}

这种方法体现了算法设计中的数学对称性思想:通过让较长链表先走,创造出对称的条件,然后同步移动找到交点。这是解决链表相交问题的经典高效算法。