有环链表及以此为基础的一些问题

作者: iwts li 发布时间: 2018-10-26 23:01:28

链表有环及其延伸问题

首先,问题涉及的有环链表是指链表的尾节点不是null,而是指向链表中的其中一个节点,从而使得链表的其中一段是循环的,如果用图的话可以得到这样的数据结构:

那么基于这样的数据结构有一系列问题需要处理:

  1. 判定是否有环,也就是说判定一个链表是否是有环链表
  2. 判定是否有环之后,又可以要求返回到底是链表的哪一个节点开始进入环的。
  3. 环的长度是多少,链表的总长度是多少。
  4. 反向思考,如果链表是无环的,那么给出两个链表的话,如何判定两个链表是否相交。
  5. 如果相交的话交点是哪个节点。

接下来就给出解决这些问题的算法。

然后,这篇博文用的语言是C++,然后测试用的链表就是上图,一样的结构。

而关于反向思考判定两个链表相交以及交点的问题,就不再写了,只给出代码。

链表节点的定义以及创建链表、测试正确性的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Node {
    int val;
    struct Node *next;
}head,a,b,c,d,e,f;

void create_list() {
    a.val = 1;
    b.val = 2;
    c.val = 3;
    d.val = 4;
    e.val = 5;
    f.val = 6;
    head.next = &a;
    a.next = &b;
    b.next = &c;
    c.next = &d;
    d.next = &e;
    e.next = &f;
    f.next = &c;
}

void test_link_list() {
    Node *temp = head.next;
    for (int i = 0; i < 10; i++) {
        cout << temp->val << endl;
        temp = temp->next;
    }
}

这个。。。写的比较省事,不过也比较直白= =。结构体中val的值是指节点的编号,从1开始。

判定链表是否有环

如果是无环,那么在O(n)复杂度下遍历到null就说明无环。有环的情况下遍历是死循环的。

这个时候我们考虑利用遍历的速度差。例如高中物理经典问题“追及问题”,A、B两者的速度不同,那么在某个时间两者必定相遇。如果是在操场上赛跑,那么因为周期性,在n圈以后必定存在快的追及到慢的情况。

链表同理,如果同时从头开始用两个指针开始遍历,一个遍历速度慢(一次跳一个结点),一个遍历速度快(一次跳多个结点),那么只要保证:快速的能够整除慢速,也就是满足周期T的情况,那么一定能够在环上追及到。

那么,我们设定两个指针,最简单的方法慢指针每次跳1,快指针每次跳2,如果碰见null,说明链表无环,如果快指针指向结点与慢指针的一样,那么说明有环。这样在O(n)的时间复杂度内就可以得到解。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
int have_ring() {
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) return 1;
        slow = slow->next;
        if (fast->next == NULL) return 0;
        fast = fast->next->next;
    }
    return 0;
}

返回值是1说明有环,返回值是0说明无环。

快指针的速度问题

我个人认为快指针的速度是无关紧要的,对于一个固定的链表能在几次移动中判定是否有环,主要跟慢指针的速度有关,因为只有以最快的速度进环,才能保证相遇的可能。

而在环中,移动几次能够相遇又与慢指针的速度、环长有关。增加慢指针的速度,在一次就移动到相遇点的情况下,再快也至少要移动一次。

所以速度推荐慢指针移动1快指针移动2。

相遇点这个,因为在环上运动的话是周期性的,第一次两者相遇的话,那么下一次相遇必定还是在这个点。而如果交换两者移动的先后顺序到达的则是对面的点。

判定入环结点

既然判定有环,那么就能引申一系列环上操作,判定入环结点就是比较重要的。不过算法过程更偏向于数学推导验证。

还是上面的链表为例,图可参考最上面的。

我们假设链表的总长为L,链表头到入环点的距离为A,入环点到快慢指针追及点的长度为X,环长为M。如下图所示:

如果不论A这一段距离的话,从慢指针到达环上开始,到快慢指针相遇,因为慢指针绝对还没有绕环一周,也就是说X < M,如果快指针的速度是慢指针的两倍,那么慢指针移动长度2*X < M,也就是说快指针一定是没有超过两周。

而慢指针想要和快指针相遇,至少要绕环一周才可以。所以对于快指针而言,在环上的第二周碰到了慢指针。

这里分2种情况:

第一种:A < M。

此时,2*A < M,所以在慢指针进入环的时候,块指针一周还没有遍历完成,那么一定是在第二周与慢指针相遇,就像上图的情况,那么有:

\[\begin{align} L + X &= 2 \times (A + X) \\ L - A - X &= A \end{align}\]

这里可以看到,L - A - X是相遇点到入环点的距离,和A是相同的。

第二种:A >= M。

此时快指针至少跑一周了,此时我们考虑一下截取,即按照周期M将多出来的部分给截取掉。例如这样的图:

我们将其中多出来的P个结点,只要满足$2 \times P \bmod M = 0 $的情况,就可以想象成多出来的部分,慢指针还没有进环,而块指针在环上周期运动。

步数相抵消。那么就可以将P个结点截取,红线右边的部分就是上面的图了,就转换成了第一种情况。

同理,所有满足A >= M的链表都可以转换成A < M的情况。换而言之,都满足:L - A -X = A的情况。

既然满足这个条件,那么算法就非常简单了,设定两个指针,一个从head开始移动,另一个从相遇点开始移动,那么最终相遇的地方就是入节点了。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Node * get_ring_node() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast){
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
    }
    return p_head;
}

有部分代码是重复的,这里是为了展示= =实际上最好是在判定是否有环的时候,就返回这个相遇点,如果相遇点是null说明无环,否则为有环。当做参数传递进函数就可以省略一部分代码了。

环长与链表总长

上面得到了这样的表达式: \(L - A - X = A\) 那么我们求的实际上是L与L - A。

首先在上面函数求入环结点的时候,遍历的数量其实就是A,这样我们就得到了A值。

然后如果我们继续遍历,从入环口开始,直到相遇点,统计这个遍历的次数就可以得到X值。

这样通过表达式的变式: \(L = 2 \times A + X\) 就可以得到L了。那么L - A也就可以直接获得。

这里注意链表总长的定义:一般我们指的是结点的数量,我们实际上计算的时候入环结点是计算了2次的,所以答案应该把多出来的给减去。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int get_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此时slow指针是指向相遇点没有变的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return 2*a + x - 1;
}

同上,还是将多余的写出来了。

环上长度就基本跟上面差不多了,将最后的$2 \times a+x-1$换成$a+x$就可以了。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int get_ring_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此时slow指针是指向相遇点没有变的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return a + x;
}

两个链表是否相交及交点

也是一个据说很经典的题,可以百度搜,原理以及解法可以看博主的另一篇博客:

LeetCode-160 相交链表 题解

这里就不多赘述了。

代码的重复问题

可以看到,上面很多代码是重复了很多的,因为这一系列的问题有很多重合点。但是如果我们能将入环点、相遇点给记录下来的话,就可以节省非常多的代码。

只用将这两个指针存进全局变量即可。这也比较简单,就是加两句代码的事也就不再写了(主要是比较懒= =)。